全文改写支持可选配图与思考过程展示,深度研究接入 DashScope 原生出图并剥离内联 think 标签;8BF 会商补齐第一步工作区与任务卡回退,同时限制热重载扫描范围。 Co-authored-by: Cursor <cursoragent@cursor.com>
91 lines
2.9 KiB
TypeScript
91 lines
2.9 KiB
TypeScript
/**
|
|
* Some OpenAI-compatible reasoning models write ``<think>...</think>`` into
|
|
* the ordinary text stream rather than a dedicated reasoning field. Markdown
|
|
* sandboxes must never render those fragments as document content. Callers
|
|
* can separately render the returned thinking part in a labelled UI trace.
|
|
*
|
|
* The filter is stateful because either tag can be split across arbitrary SSE
|
|
* deltas. Hold only a possible tag suffix, so confirmed Markdown continues
|
|
* to appear with the normal token-streaming cadence.
|
|
*/
|
|
export class InlineThinkTagFilter {
|
|
private static readonly OPEN_TAG = "<think>";
|
|
private static readonly CLOSE_TAG = "</think>";
|
|
|
|
private insideThink = false;
|
|
private pending = "";
|
|
|
|
private possibleTagPrefixSize(content: string, tag: string) {
|
|
const lowered = content.toLowerCase();
|
|
for (let size = Math.min(content.length, tag.length - 1); size > 0; size -= 1) {
|
|
if (lowered.slice(-size) === tag.slice(0, size)) return size;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
pushParts(chunk: string): { text: string; thinking: string } {
|
|
if (!chunk) return { text: "", thinking: "" };
|
|
|
|
let remaining = this.pending + chunk;
|
|
this.pending = "";
|
|
let visible = "";
|
|
let thinking = "";
|
|
|
|
while (remaining) {
|
|
const tag = this.insideThink
|
|
? InlineThinkTagFilter.CLOSE_TAG
|
|
: InlineThinkTagFilter.OPEN_TAG;
|
|
const tagIndex = remaining.toLowerCase().indexOf(tag);
|
|
if (tagIndex >= 0) {
|
|
if (this.insideThink) thinking += remaining.slice(0, tagIndex);
|
|
else visible += remaining.slice(0, tagIndex);
|
|
remaining = remaining.slice(tagIndex + tag.length);
|
|
this.insideThink = !this.insideThink;
|
|
continue;
|
|
}
|
|
|
|
const suffixSize = this.possibleTagPrefixSize(remaining, tag);
|
|
const confirmed = suffixSize ? remaining.slice(0, -suffixSize) : remaining;
|
|
if (this.insideThink) thinking += confirmed;
|
|
else visible += confirmed;
|
|
this.pending = suffixSize ? remaining.slice(-suffixSize) : "";
|
|
break;
|
|
}
|
|
|
|
return { text: visible, thinking };
|
|
}
|
|
|
|
push(chunk: string) {
|
|
return this.pushParts(chunk).text;
|
|
}
|
|
|
|
/** Flush a completed stream into its visible/thinking channels. */
|
|
finishParts(): { text: string; thinking: string } {
|
|
if (this.insideThink) {
|
|
const thinking = this.pending;
|
|
this.insideThink = false;
|
|
this.pending = "";
|
|
return { text: "", thinking };
|
|
}
|
|
const tail = this.pending;
|
|
this.pending = "";
|
|
return { text: tail, thinking: "" };
|
|
}
|
|
|
|
finish() {
|
|
return this.finishParts().text;
|
|
}
|
|
|
|
/** Replace a rendered checkpoint while retaining the correct tag state. */
|
|
replace(content: string) {
|
|
this.insideThink = false;
|
|
this.pending = "";
|
|
return this.push(content);
|
|
}
|
|
}
|
|
|
|
export function stripInlineThinkTags(content: string) {
|
|
const filter = new InlineThinkTagFilter();
|
|
return filter.push(content) + filter.finish();
|
|
}
|