fix: strip thinking tags in chat UI
This commit is contained in:
@@ -30,6 +30,7 @@ describe("chat markdown rendering", () => {
|
|||||||
const app = mountApp("/chat");
|
const app = mountApp("/chat");
|
||||||
await app.updateComplete;
|
await app.updateComplete;
|
||||||
|
|
||||||
|
const timestamp = Date.now();
|
||||||
app.chatMessages = [
|
app.chatMessages = [
|
||||||
{
|
{
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
@@ -37,9 +38,11 @@ describe("chat markdown rendering", () => {
|
|||||||
{ type: "toolcall", name: "noop", arguments: {} },
|
{ type: "toolcall", name: "noop", arguments: {} },
|
||||||
{ type: "toolresult", name: "noop", text: "Hello **world**" },
|
{ type: "toolresult", name: "noop", text: "Hello **world**" },
|
||||||
],
|
],
|
||||||
timestamp: Date.now(),
|
timestamp,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
// Expand the tool output card so its markdown is rendered into the DOM.
|
||||||
|
app.toolOutputExpanded = new Set([`${timestamp}:1`]);
|
||||||
|
|
||||||
await app.updateComplete;
|
await app.updateComplete;
|
||||||
|
|
||||||
@@ -47,4 +50,3 @@ describe("chat markdown rendering", () => {
|
|||||||
expect(strong?.textContent).toBe("world");
|
expect(strong?.textContent).toBe("world");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { GatewayBrowserClient } from "../gateway";
|
import type { GatewayBrowserClient } from "../gateway";
|
||||||
|
import { stripThinkingTags } from "../format";
|
||||||
import { generateUUID } from "../uuid";
|
import { generateUUID } from "../uuid";
|
||||||
|
|
||||||
export type ChatState = {
|
export type ChatState = {
|
||||||
@@ -127,8 +128,11 @@ export function handleChatEvent(
|
|||||||
|
|
||||||
function extractText(message: unknown): string | null {
|
function extractText(message: unknown): string | null {
|
||||||
const m = message as Record<string, unknown>;
|
const m = message as Record<string, unknown>;
|
||||||
|
const role = typeof m.role === "string" ? m.role : "";
|
||||||
const content = m.content;
|
const content = m.content;
|
||||||
if (typeof content === "string") return content;
|
if (typeof content === "string") {
|
||||||
|
return role === "assistant" ? stripThinkingTags(content) : content;
|
||||||
|
}
|
||||||
if (Array.isArray(content)) {
|
if (Array.isArray(content)) {
|
||||||
const parts = content
|
const parts = content
|
||||||
.map((p) => {
|
.map((p) => {
|
||||||
@@ -137,8 +141,13 @@ function extractText(message: unknown): string | null {
|
|||||||
return null;
|
return null;
|
||||||
})
|
})
|
||||||
.filter((v): v is string => typeof v === "string");
|
.filter((v): v is string => typeof v === "string");
|
||||||
if (parts.length > 0) return parts.join("\n");
|
if (parts.length > 0) {
|
||||||
|
const joined = parts.join("\n");
|
||||||
|
return role === "assistant" ? stripThinkingTags(joined) : joined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof m.text === "string") {
|
||||||
|
return role === "assistant" ? stripThinkingTags(m.text) : m.text;
|
||||||
}
|
}
|
||||||
if (typeof m.text === "string") return m.text;
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
25
ui/src/ui/format.test.ts
Normal file
25
ui/src/ui/format.test.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { stripThinkingTags } from "./format";
|
||||||
|
|
||||||
|
describe("stripThinkingTags", () => {
|
||||||
|
it("strips <think>…</think> segments", () => {
|
||||||
|
const input = ["<think>", "secret", "</think>", "", "Hello"].join("\n");
|
||||||
|
expect(stripThinkingTags(input)).toBe("Hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips <thinking>…</thinking> segments", () => {
|
||||||
|
const input = ["<thinking>", "secret", "</thinking>", "", "Hello"].join("\n");
|
||||||
|
expect(stripThinkingTags(input)).toBe("Hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps text when tags are unpaired", () => {
|
||||||
|
expect(stripThinkingTags("<think>\nsecret\nHello")).toBe("secret\nHello");
|
||||||
|
expect(stripThinkingTags("Hello\n</think>")).toBe("Hello\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns original text when no tags exist", () => {
|
||||||
|
expect(stripThinkingTags("Hello")).toBe("Hello");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -66,3 +66,39 @@ export function parseList(input: string): string[] {
|
|||||||
.map((v) => v.trim())
|
.map((v) => v.trim())
|
||||||
.filter((v) => v.length > 0);
|
.filter((v) => v.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const THINKING_TAG_RE = /<\s*\/?\s*think(?:ing)?\s*>/gi;
|
||||||
|
const THINKING_OPEN_RE = /<\s*think(?:ing)?\s*>/i;
|
||||||
|
const THINKING_CLOSE_RE = /<\s*\/\s*think(?:ing)?\s*>/i;
|
||||||
|
|
||||||
|
export function stripThinkingTags(value: string): string {
|
||||||
|
if (!value) return value;
|
||||||
|
const hasOpen = THINKING_OPEN_RE.test(value);
|
||||||
|
const hasClose = THINKING_CLOSE_RE.test(value);
|
||||||
|
if (!hasOpen && !hasClose) return value;
|
||||||
|
// If we don't have a balanced pair, avoid dropping trailing content.
|
||||||
|
if (hasOpen !== hasClose) {
|
||||||
|
if (!hasOpen) return value.replace(THINKING_CLOSE_RE, "").trimStart();
|
||||||
|
return value.replace(THINKING_OPEN_RE, "").trimStart();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!THINKING_TAG_RE.test(value)) return value;
|
||||||
|
THINKING_TAG_RE.lastIndex = 0;
|
||||||
|
|
||||||
|
let result = "";
|
||||||
|
let lastIndex = 0;
|
||||||
|
let inThinking = false;
|
||||||
|
for (const match of value.matchAll(THINKING_TAG_RE)) {
|
||||||
|
const idx = match.index ?? 0;
|
||||||
|
if (!inThinking) {
|
||||||
|
result += value.slice(lastIndex, idx);
|
||||||
|
}
|
||||||
|
const tag = match[0].toLowerCase();
|
||||||
|
inThinking = !tag.includes("/");
|
||||||
|
lastIndex = idx + match[0].length;
|
||||||
|
}
|
||||||
|
if (!inThinking) {
|
||||||
|
result += value.slice(lastIndex);
|
||||||
|
}
|
||||||
|
return result.trimStart();
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { html, nothing } from "lit";
|
|||||||
import { repeat } from "lit/directives/repeat.js";
|
import { repeat } from "lit/directives/repeat.js";
|
||||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||||
|
|
||||||
|
import { stripThinkingTags } from "../format";
|
||||||
import { toSanitizedMarkdownHtml } from "../markdown";
|
import { toSanitizedMarkdownHtml } from "../markdown";
|
||||||
import { formatToolDetail, resolveToolDisplay } from "../tool-display";
|
import { formatToolDetail, resolveToolDisplay } from "../tool-display";
|
||||||
import type { SessionsListResult } from "../types";
|
import type { SessionsListResult } from "../types";
|
||||||
@@ -388,8 +389,11 @@ function renderMessage(
|
|||||||
|
|
||||||
function extractText(message: unknown): string | null {
|
function extractText(message: unknown): string | null {
|
||||||
const m = message as Record<string, unknown>;
|
const m = message as Record<string, unknown>;
|
||||||
|
const role = typeof m.role === "string" ? m.role : "";
|
||||||
const content = m.content;
|
const content = m.content;
|
||||||
if (typeof content === "string") return content;
|
if (typeof content === "string") {
|
||||||
|
return role === "assistant" ? stripThinkingTags(content) : content;
|
||||||
|
}
|
||||||
if (Array.isArray(content)) {
|
if (Array.isArray(content)) {
|
||||||
const parts = content
|
const parts = content
|
||||||
.map((p) => {
|
.map((p) => {
|
||||||
@@ -398,9 +402,14 @@ function extractText(message: unknown): string | null {
|
|||||||
return null;
|
return null;
|
||||||
})
|
})
|
||||||
.filter((v): v is string => typeof v === "string");
|
.filter((v): v is string => typeof v === "string");
|
||||||
if (parts.length > 0) return parts.join("\n");
|
if (parts.length > 0) {
|
||||||
|
const joined = parts.join("\n");
|
||||||
|
return role === "assistant" ? stripThinkingTags(joined) : joined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof m.text === "string") {
|
||||||
|
return role === "assistant" ? stripThinkingTags(m.text) : m.text;
|
||||||
}
|
}
|
||||||
if (typeof m.text === "string") return m.text;
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user