1
0
mirror of https://github.com/coalaura/whiskr.git synced 2025-12-02 20:22:52 +00:00
This commit is contained in:
Laura
2025-09-12 14:34:08 +02:00
parent 5998f61823
commit ea65f7fd60
7 changed files with 143 additions and 29 deletions

21
chat.go
View File

@@ -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))
}
}
}

2
go.mod
View File

@@ -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
)

2
go.sum
View File

@@ -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=

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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, "");
}

View File

@@ -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",