diff --git a/chat.go b/chat.go index d102bc9..adc1875 100644 --- a/chat.go +++ b/chat.go @@ -393,6 +393,7 @@ func RunCompletion(ctx context.Context, response *Stream, request *openrouter.Ch } choice := chunk.Choices[0] + delta := choice.Delta if choice.FinishReason == openrouter.FinishReasonContentFilter { response.Send(ErrorChunk(errors.New("stopped due to content_filter"))) @@ -400,7 +401,7 @@ func RunCompletion(ctx context.Context, response *Stream, request *openrouter.Ch return nil, "", nil } - calls := choice.Delta.ToolCalls + calls := delta.ToolCalls if len(calls) > 0 { call := calls[0] @@ -416,14 +417,20 @@ func RunCompletion(ctx context.Context, response *Stream, request *openrouter.Ch break } - content := choice.Delta.Content + if delta.Content != "" { + buf.WriteString(delta.Content) - if content != "" { - buf.WriteString(content) + response.Send(TextChunk(delta.Content)) + } else if delta.Reasoning != nil { + response.Send(ReasoningChunk(*delta.Reasoning)) + } else if len(delta.Images) > 0 { + for _, image := range delta.Images { + if image.Type != openrouter.StreamImageTypeImageURL { + continue + } - response.Send(TextChunk(content)) - } else if choice.Delta.Reasoning != nil { - response.Send(ReasoningChunk(*choice.Delta.Reasoning)) + response.Send(ImageChunk(image.ImageURL.URL)) + } } } diff --git a/go.mod b/go.mod index 108450e..c0e8f7d 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/coalaura/plain v0.2.0 github.com/go-chi/chi/v5 v5.2.3 github.com/goccy/go-yaml v1.18.0 - github.com/revrost/go-openrouter v0.2.4-0.20250909110314-b8c4ee4c5861 + github.com/revrost/go-openrouter v0.2.4 golang.org/x/crypto v0.42.0 ) diff --git a/go.sum b/go.sum index 711ed5a..0037db4 100644 --- a/go.sum +++ b/go.sum @@ -28,6 +28,8 @@ github.com/revrost/go-openrouter v0.2.3 h1:ollIaPrgVWgqJyKbJGSX1jFs66eAWJs8Ojrxn github.com/revrost/go-openrouter v0.2.3/go.mod h1:ZH/UdpnDEdMmJwq8tbSTX1S5I07ee8KMlEYN4jmegU0= github.com/revrost/go-openrouter v0.2.4-0.20250909110314-b8c4ee4c5861 h1:4XU64nIgj6l9659KJx+FOaABvdhM3YrytCgD8XoKu90= github.com/revrost/go-openrouter v0.2.4-0.20250909110314-b8c4ee4c5861/go.mod h1:ZH/UdpnDEdMmJwq8tbSTX1S5I07ee8KMlEYN4jmegU0= +github.com/revrost/go-openrouter v0.2.4 h1:ts9VMZGj8C6688xIgBU9/Tyw2WBl55WfdVP2zG+EV98= +github.com/revrost/go-openrouter v0.2.4/go.mod h1:ZH/UdpnDEdMmJwq8tbSTX1S5I07ee8KMlEYN4jmegU0= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= diff --git a/static/css/chat.css b/static/css/chat.css index 1df2602..4a8ee13 100644 --- a/static/css/chat.css +++ b/static/css/chat.css @@ -334,6 +334,9 @@ body:not(.loading) #loading { margin-left: 12px; } +.message:not(.has-tool) .tool, +.message:not(.has-reasoning) .reasoning, +.message:not(.has-images) .images, .message:not(.has-text) .text, .message:not(.has-tags) .tags { display: none; @@ -405,6 +408,18 @@ body:not(.loading) #loading { border: 2px solid #ed8796; } +.images .image { + display: block; + font-size: 0; +} + +.images .image img { + max-width: 100%; + width: 420px; + border-radius: 6px; + border: 2px solid #363a4f; +} + .tool .result pre, .reasoning-text pre { background: #1b1d2a; @@ -433,8 +448,7 @@ body:not(.loading) #loading { .message.has-reasoning:not(.has-text):not(.errored) div.text, .message.has-tool:not(.has-text):not(.errored) div.text, .message.has-files:not(.has-text):not(.errored) div.text, -.message:not(.has-tool) .tool, -.message:not(.has-reasoning) .reasoning { +.message.has-images:not(.has-text):not(.errored) div.text { display: none; } diff --git a/static/js/chat.js b/static/js/chat.js index 27ad799..b574044 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -137,6 +137,7 @@ #role; #reasoning; #text; + #images = []; #files = []; #tool; @@ -158,10 +159,11 @@ #_reasoning; #_text; #_edit; + #_images; #_tool; #_statistics; - constructor(role, reasoning, text, files = [], collapsed = false) { + constructor(role, reasoning, text, tool = false, files = [], images = [], tags = [], collapsed = false) { this.#id = uid(); this.#role = role; this.#reasoning = reasoning || ""; @@ -172,15 +174,25 @@ this.#build(collapsed); this.#render(); + if (tool) { + this.setTool(tool); + } + for (const file of files) { this.addFile(file); } + for (const image of images) { + this.addImage(image); + } + + for (const tag of tags) { + this.addTag(tag); + } + messages.push(this); - if (this.#reasoning || this.#text) { - this.#save(); - } + this.#save(); } #build(collapsed) { @@ -273,6 +285,11 @@ this.updateEditHeight(); }); + // message images + this.#_images = make("div", "images"); + + _body.appendChild(this.#_images); + // message tool this.#_tool = make("div", "tool"); @@ -514,6 +531,38 @@ this.#_message.classList.toggle("has-tags", this.#tags.length > 0); } + if (!only || only === "images") { + for (let x = 0; x < this.#images.length; x++) { + if (this.#_images.querySelector(`.i-${x}`)) { + continue; + } + + const image = this.#images[x], + blob = dataBlob(image), + url = URL.createObjectURL(blob); + + const _link = make("a", "image", `i-${x}`); + + _link.download = `image-${x + 1}`; + _link.target = "_blank"; + _link.href = url; + + this.#_images.appendChild(_link); + + const _image = make("img"); + + _image.src = url; + + _link.appendChild(_image); + } + + this.#_message.classList.toggle("has-images", !!this.#images.length); + + noScroll || scroll(); + + updateScrollButton(); + } + if (!only || only === "tool") { if (this.#tool) { const { name, args, result, cost, invalid } = this.#tool; @@ -650,6 +699,10 @@ data.reasoning = this.#reasoning; } + if (this.#images.length && full) { + data.images = this.#images; + } + if (this.#error && full) { data.error = this.#error; } @@ -666,7 +719,7 @@ data.collapsed = true; } - if (!data.files?.length && !data.reasoning && !data.text && !data.tool) { + if (!data.images?.length && !data.files?.length && !data.reasoning && !data.text && !data.tool) { return false; } @@ -790,6 +843,13 @@ this.#save(); } + addImage(image) { + this.#images.push(image); + + this.#render("images"); + this.#save(); + } + addReasoning(chunk) { this.#reasoning += chunk; @@ -995,7 +1055,7 @@ $temperature.classList.remove("invalid"); } - let iterations = parseInt($iterations.value); + let iterations = parseInt($iterations.value, 10); if (Number.isNaN(iterations) || iterations < 1 || iterations > 50) { iterations = 3; @@ -1006,7 +1066,7 @@ const effort = $reasoningEffort.value; - let tokens = parseInt($reasoningTokens.value); + let tokens = parseInt($reasoningTokens.value, 10); if (!effort && (Number.isNaN(tokens) || tokens <= 0 || tokens > 1024 * 1024)) { tokens = 1024; @@ -1146,6 +1206,10 @@ finish(); } + break; + case "image": + message.addImage(chunk.text); + break; case "reason": message.setState("reasoning"); @@ -1354,22 +1418,12 @@ } loadValue("messages", []).forEach(message => { - const obj = new Message(message.role, message.reasoning, message.text, message.files || [], message.collapsed); + const obj = new Message(message.role, message.reasoning, message.text, message.tool, message.files || [], message.images || [], message.tags || [], message.collapsed); if (message.error) { obj.showError(message.error); } - if (message.tags) { - message.tags.forEach(tag => { - obj.addTag(tag); - }); - } - - if (message.tool) { - obj.setTool(message.tool); - } - if (message.statistics) { obj.setStatistics(message.statistics); } @@ -1689,7 +1743,7 @@ }, json: jsonMode, search: searchTool, - messages: messages.map(message => message.getData()).filter(Boolean), + messages: messages.map(message => message.getData(true)).filter(Boolean), }); download("chat.json", "application/json", data); diff --git a/static/js/lib.js b/static/js/lib.js index 983b730..aab1954 100644 --- a/static/js/lib.js +++ b/static/js/lib.js @@ -86,6 +86,36 @@ function formatMilliseconds(ms) { return `${Math.round(ms / 1000)}s`; } +function dataBlob(dataUrl) { + const [header, data] = dataUrl.split(","), + mime = header.match(/data:(.*?)(;|$)/)[1]; + + let blob; + + if (header.includes(";base64")) { + const bytes = atob(data), + numbers = new Array(bytes.length); + + for (let i = 0; i < bytes.length; i++) { + numbers[i] = bytes.charCodeAt(i); + } + + const byteArray = new Uint8Array(numbers); + + blob = new Blob([byteArray], { + type: mime, + }); + } else { + const text = decodeURIComponent(data); + + blob = new Blob([text], { + type: mime, + }); + } + + return blob; +} + function fixed(num, decimals = 0) { return num.toFixed(decimals).replace(/\.?0+$/m, ""); } diff --git a/stream.go b/stream.go index b669931..1ac62e5 100644 --- a/stream.go +++ b/stream.go @@ -79,6 +79,13 @@ func TextChunk(text string) Chunk { } } +func ImageChunk(image string) Chunk { + return Chunk{ + Type: "image", + Text: image, + } +} + func ToolChunk(tool *ToolCall) Chunk { return Chunk{ Type: "tool",