committed by
GitHub
parent
71457fa100
commit
390b730b37
@@ -12,6 +12,7 @@ Docs: https://docs.clawd.bot
|
||||
### Fixes
|
||||
- Web UI: hide internal `message_id` hints in chat bubbles.
|
||||
- Heartbeat: normalize target identifiers for consistent routing.
|
||||
- TUI: unify reasoning tag stripping so `<final>` wrappers stay hidden. (#1613) Thanks @kyleok.
|
||||
|
||||
## 2026.1.23-1
|
||||
|
||||
|
||||
@@ -460,6 +460,22 @@ File contents here`,
|
||||
expect(result).toBe("The actual answer.");
|
||||
});
|
||||
|
||||
it("strips final tags while keeping content", () => {
|
||||
const msg: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "<final>\nAnswer\n</final>",
|
||||
},
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const result = extractAssistantText(msg);
|
||||
expect(result).toBe("Answer");
|
||||
});
|
||||
|
||||
it("strips thought tags", () => {
|
||||
const msg: AssistantMessage = {
|
||||
role: "assistant",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
||||
import { stripReasoningTagsFromText } from "../shared/text/reasoning-tags.js";
|
||||
import { sanitizeUserFacingText } from "./pi-embedded-helpers.js";
|
||||
import { formatToolDetail, resolveToolDisplay } from "./tool-display.js";
|
||||
|
||||
@@ -166,36 +167,7 @@ export function stripDowngradedToolCallText(text: string): string {
|
||||
* that slip through other filtering mechanisms.
|
||||
*/
|
||||
export function stripThinkingTagsFromText(text: string): string {
|
||||
if (!text) return text;
|
||||
// Quick check to avoid regex overhead when no tags present.
|
||||
if (!/(?:think(?:ing)?|thought|antthinking)/i.test(text)) return text;
|
||||
|
||||
const tagRe = /<\s*(\/?)\s*(?:think(?:ing)?|thought|antthinking)\b[^>]*>/gi;
|
||||
let result = "";
|
||||
let lastIndex = 0;
|
||||
let inThinking = false;
|
||||
|
||||
for (const match of text.matchAll(tagRe)) {
|
||||
const idx = match.index ?? 0;
|
||||
const isClose = match[1] === "/";
|
||||
|
||||
if (!inThinking && !isClose) {
|
||||
// Opening tag - save text before it.
|
||||
result += text.slice(lastIndex, idx);
|
||||
inThinking = true;
|
||||
} else if (inThinking && isClose) {
|
||||
// Closing tag - skip content inside.
|
||||
inThinking = false;
|
||||
}
|
||||
lastIndex = idx + match[0].length;
|
||||
}
|
||||
|
||||
// Append remaining text if we're not inside thinking.
|
||||
if (!inThinking) {
|
||||
result += text.slice(lastIndex);
|
||||
}
|
||||
|
||||
return result.trim();
|
||||
return stripReasoningTagsFromText(text, { mode: "strict", trim: "both" });
|
||||
}
|
||||
|
||||
export function extractAssistantText(msg: AssistantMessage): string {
|
||||
|
||||
@@ -67,9 +67,10 @@ export function normalizeAgentId(value: string | undefined | null): string {
|
||||
export function sanitizeAgentId(value: string | undefined | null): string {
|
||||
const trimmed = (value ?? "").trim();
|
||||
if (!trimmed) return DEFAULT_AGENT_ID;
|
||||
if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed;
|
||||
if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed.toLowerCase();
|
||||
return (
|
||||
trimmed
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]+/gi, "-")
|
||||
.replace(/^-+/, "")
|
||||
.replace(/-+$/, "")
|
||||
|
||||
61
src/shared/text/reasoning-tags.ts
Normal file
61
src/shared/text/reasoning-tags.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
export type ReasoningTagMode = "strict" | "preserve";
|
||||
export type ReasoningTagTrim = "none" | "start" | "both";
|
||||
|
||||
const QUICK_TAG_RE = /<\s*\/?\s*(?:think(?:ing)?|thought|antthinking|final)\b/i;
|
||||
const FINAL_TAG_RE = /<\s*\/?\s*final\b[^>]*>/gi;
|
||||
const THINKING_TAG_RE = /<\s*(\/?)\s*(?:think(?:ing)?|thought|antthinking)\b[^>]*>/gi;
|
||||
|
||||
function applyTrim(value: string, mode: ReasoningTagTrim): string {
|
||||
if (mode === "none") return value;
|
||||
if (mode === "start") return value.trimStart();
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
export function stripReasoningTagsFromText(
|
||||
text: string,
|
||||
options?: {
|
||||
mode?: ReasoningTagMode;
|
||||
trim?: ReasoningTagTrim;
|
||||
},
|
||||
): string {
|
||||
if (!text) return text;
|
||||
if (!QUICK_TAG_RE.test(text)) return text;
|
||||
|
||||
const mode = options?.mode ?? "strict";
|
||||
const trimMode = options?.trim ?? "both";
|
||||
|
||||
let cleaned = text;
|
||||
if (FINAL_TAG_RE.test(cleaned)) {
|
||||
FINAL_TAG_RE.lastIndex = 0;
|
||||
cleaned = cleaned.replace(FINAL_TAG_RE, "");
|
||||
} else {
|
||||
FINAL_TAG_RE.lastIndex = 0;
|
||||
}
|
||||
|
||||
THINKING_TAG_RE.lastIndex = 0;
|
||||
let result = "";
|
||||
let lastIndex = 0;
|
||||
let inThinking = false;
|
||||
|
||||
for (const match of cleaned.matchAll(THINKING_TAG_RE)) {
|
||||
const idx = match.index ?? 0;
|
||||
const isClose = match[1] === "/";
|
||||
|
||||
if (!inThinking) {
|
||||
result += cleaned.slice(lastIndex, idx);
|
||||
if (!isClose) {
|
||||
inThinking = true;
|
||||
}
|
||||
} else if (isClose) {
|
||||
inThinking = false;
|
||||
}
|
||||
|
||||
lastIndex = idx + match[0].length;
|
||||
}
|
||||
|
||||
if (!inThinking || mode === "preserve") {
|
||||
result += cleaned.slice(lastIndex);
|
||||
}
|
||||
|
||||
return applyTrim(result, trimMode);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { stripReasoningTagsFromText } from "../../../src/shared/text/reasoning-tags.js";
|
||||
|
||||
export function formatMs(ms?: number | null): string {
|
||||
if (!ms && ms !== 0) return "n/a";
|
||||
return new Date(ms).toLocaleString();
|
||||
@@ -67,38 +69,6 @@ export function parseList(input: string): string[] {
|
||||
.filter((v) => v.length > 0);
|
||||
}
|
||||
|
||||
const THINKING_TAG_RE = /<\s*\/?\s*(?:think(?:ing)?|final)\s*>/gi;
|
||||
const THINKING_OPEN_RE = /<\s*(?:think(?:ing)?|final)\s*>/i;
|
||||
const THINKING_CLOSE_RE = /<\s*\/\s*(?:think(?:ing)?|final)\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();
|
||||
return stripReasoningTagsFromText(value, { mode: "preserve", trim: "start" });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user