fix: strip thinking tags in chat UI
This commit is contained in:
@@ -30,6 +30,7 @@ describe("chat markdown rendering", () => {
|
||||
const app = mountApp("/chat");
|
||||
await app.updateComplete;
|
||||
|
||||
const timestamp = Date.now();
|
||||
app.chatMessages = [
|
||||
{
|
||||
role: "assistant",
|
||||
@@ -37,9 +38,11 @@ describe("chat markdown rendering", () => {
|
||||
{ type: "toolcall", name: "noop", arguments: {} },
|
||||
{ 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;
|
||||
|
||||
@@ -47,4 +50,3 @@ describe("chat markdown rendering", () => {
|
||||
expect(strong?.textContent).toBe("world");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { GatewayBrowserClient } from "../gateway";
|
||||
import { stripThinkingTags } from "../format";
|
||||
import { generateUUID } from "../uuid";
|
||||
|
||||
export type ChatState = {
|
||||
@@ -127,8 +128,11 @@ export function handleChatEvent(
|
||||
|
||||
function extractText(message: unknown): string | null {
|
||||
const m = message as Record<string, unknown>;
|
||||
const role = typeof m.role === "string" ? m.role : "";
|
||||
const content = m.content;
|
||||
if (typeof content === "string") return content;
|
||||
if (typeof content === "string") {
|
||||
return role === "assistant" ? stripThinkingTags(content) : content;
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
const parts = content
|
||||
.map((p) => {
|
||||
@@ -137,8 +141,13 @@ function extractText(message: unknown): string | null {
|
||||
return null;
|
||||
})
|
||||
.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;
|
||||
}
|
||||
|
||||
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())
|
||||
.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 { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||
|
||||
import { stripThinkingTags } from "../format";
|
||||
import { toSanitizedMarkdownHtml } from "../markdown";
|
||||
import { formatToolDetail, resolveToolDisplay } from "../tool-display";
|
||||
import type { SessionsListResult } from "../types";
|
||||
@@ -388,8 +389,11 @@ function renderMessage(
|
||||
|
||||
function extractText(message: unknown): string | null {
|
||||
const m = message as Record<string, unknown>;
|
||||
const role = typeof m.role === "string" ? m.role : "";
|
||||
const content = m.content;
|
||||
if (typeof content === "string") return content;
|
||||
if (typeof content === "string") {
|
||||
return role === "assistant" ? stripThinkingTags(content) : content;
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
const parts = content
|
||||
.map((p) => {
|
||||
@@ -398,9 +402,14 @@ function extractText(message: unknown): string | null {
|
||||
return null;
|
||||
})
|
||||
.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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user