-
Notifications
You must be signed in to change notification settings - Fork 92
Expand file tree
/
Copy paththink-parser.js
More file actions
159 lines (138 loc) · 4.29 KB
/
Copy paththink-parser.js
File metadata and controls
159 lines (138 loc) · 4.29 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
/**
* Parse <think>...</think> tags from LLM output.
*
* Handles WeCom thinking tags in streamed LLM output:
* - normalize tag variants to <think></think> for outbound WS content
* - split visible/thinking text for analysis and tests
* - ignore tags inside code blocks
*/
const QUICK_TAG_RE = /<\s*\/?\s*(?:think(?:ing)?|thought)\b/i;
const THINK_TAG_RE = /<\s*(\/?)\s*(?:think(?:ing)?|thought)\b[^<>]*>/gi;
/**
* Find code regions (``` blocks and `inline`) to avoid processing think tags
* that appear inside code.
* @param {string} text
* @returns {Array<[number, number]>}
*/
function findCodeRegions(text) {
const regions = [];
// Fenced code blocks (triple backtick).
const blockRe = /```[\s\S]*?```/g;
for (const m of text.matchAll(blockRe)) {
regions.push([m.index, m.index + m[0].length]);
}
// Inline code (single backtick, same line).
const inlineRe = /`[^`\n]+`/g;
for (const m of text.matchAll(inlineRe)) {
if (!isInsideRegion(m.index, regions)) {
regions.push([m.index, m.index + m[0].length]);
}
}
return regions;
}
/**
* @param {number} pos
* @param {Array<[number, number]>} regions
*/
function isInsideRegion(pos, regions) {
for (const [start, end] of regions) {
if (pos >= start && pos < end) return true;
}
return false;
}
/**
* Normalize think tag variants to the canonical <think></think> form that
* WeCom clients recognize, while leaving code blocks untouched.
*
* @param {string} text
* @returns {string}
*/
export function normalizeThinkingTags(text) {
if (!text) {
return "";
}
if (!QUICK_TAG_RE.test(text)) {
return String(text);
}
const source = String(text);
const codeRegions = findCodeRegions(source);
const normalized = [];
let lastIndex = 0;
THINK_TAG_RE.lastIndex = 0;
for (const match of source.matchAll(THINK_TAG_RE)) {
const idx = match.index;
if (isInsideRegion(idx, codeRegions)) {
continue;
}
normalized.push(source.slice(lastIndex, idx));
normalized.push(match[1] === "/" ? "</think>" : "<think>");
lastIndex = idx + match[0].length;
}
normalized.push(source.slice(lastIndex));
return normalized.join("");
}
/**
* Parse thinking content from text that may contain <think>...</think> tags.
*
* @param {string} text - Raw accumulated stream text
* @returns {{ visibleContent: string, thinkingContent: string, isThinking: boolean }}
* - visibleContent: text with think blocks removed
* - thinkingContent: concatenated thinking text
* - isThinking: true when an unclosed <think> tag is present (streaming)
*/
export function parseThinkingContent(text) {
if (!text) {
return { visibleContent: "", thinkingContent: "", isThinking: false };
}
const source = String(text);
// Fast path: no think tags at all.
if (!QUICK_TAG_RE.test(source)) {
return { visibleContent: source, thinkingContent: "", isThinking: false };
}
const codeRegions = findCodeRegions(source);
const visibleParts = [];
const thinkingParts = [];
let lastIndex = 0;
let inThinking = false;
THINK_TAG_RE.lastIndex = 0;
for (const match of source.matchAll(THINK_TAG_RE)) {
const idx = match.index;
const isClose = match[1] === "/";
// Skip tags inside code blocks.
if (isInsideRegion(idx, codeRegions)) {
continue;
}
const segment = source.slice(lastIndex, idx);
if (!inThinking) {
if (!isClose) {
// Opening <think>: preceding text is visible.
visibleParts.push(segment);
inThinking = true;
} else {
// Stray </think> without opening: treat as visible text.
visibleParts.push(segment);
}
} else {
if (isClose) {
// Closing </think>: text since opening is thinking content.
thinkingParts.push(segment);
inThinking = false;
}
// Nested or duplicate opening tag inside thinking: ignore.
}
lastIndex = idx + match[0].length;
}
// Remaining text after the last tag.
const remaining = source.slice(lastIndex);
if (inThinking) {
// Unclosed <think>: remaining text is part of thinking (streaming state).
thinkingParts.push(remaining);
} else {
visibleParts.push(remaining);
}
return {
visibleContent: visibleParts.join("").trim(),
thinkingContent: thinkingParts.join("\n").trim(),
isThinking: inThinking,
};
}