banana-code 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +246 -0
- package/banana.js +5464 -0
- package/lib/agenticRunner.js +1884 -0
- package/lib/borderRenderer.js +41 -0
- package/lib/commandRunner.js +205 -0
- package/lib/completer.js +286 -0
- package/lib/config.js +301 -0
- package/lib/contextBuilder.js +324 -0
- package/lib/diffViewer.js +295 -0
- package/lib/fileManager.js +224 -0
- package/lib/historyManager.js +124 -0
- package/lib/hookManager.js +1143 -0
- package/lib/imageHandler.js +268 -0
- package/lib/inlineComplete.js +192 -0
- package/lib/interactivePicker.js +254 -0
- package/lib/lmStudio.js +226 -0
- package/lib/markdownRenderer.js +423 -0
- package/lib/mcpClient.js +288 -0
- package/lib/modelRegistry.js +350 -0
- package/lib/monkeyModels.js +97 -0
- package/lib/oauthOpenAI.js +167 -0
- package/lib/parser.js +134 -0
- package/lib/promptManager.js +96 -0
- package/lib/providerClients.js +1014 -0
- package/lib/providerManager.js +130 -0
- package/lib/providerStore.js +413 -0
- package/lib/statusBar.js +283 -0
- package/lib/streamHandler.js +306 -0
- package/lib/subAgentManager.js +406 -0
- package/lib/tokenCounter.js +132 -0
- package/lib/visionAnalyzer.js +163 -0
- package/lib/watcher.js +138 -0
- package/models.json +57 -0
- package/package.json +42 -0
- package/prompts/base.md +23 -0
- package/prompts/code-agent-glm.md +16 -0
- package/prompts/code-agent-gptoss.md +25 -0
- package/prompts/code-agent-nemotron.md +17 -0
- package/prompts/code-agent-qwen.md +20 -0
- package/prompts/code-agent.md +70 -0
- package/prompts/plan.md +44 -0
package/lib/statusBar.js
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* statusBar.js - Pinned status bar at the bottom of the terminal
|
|
3
|
+
*
|
|
4
|
+
* Uses a scroll region to constrain content above the bar, plus cursor
|
|
5
|
+
* save/restore to paint a 3-row bar (rule + content + pad) in the
|
|
6
|
+
* reserved bottom rows, with a blank spacer row above it so the input
|
|
7
|
+
* prompt is not visually cramped. The bar is rendered on demand (updates, prompt,
|
|
8
|
+
* resize) to avoid cursor races while typing.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const RESET = '\x1b[0m';
|
|
12
|
+
const DIM = '\x1b[2m';
|
|
13
|
+
const GREEN = '\x1b[32m';
|
|
14
|
+
const YELLOW = '\x1b[33m';
|
|
15
|
+
const RED = '\x1b[31m';
|
|
16
|
+
const BG_DARK = '\x1b[48;5;236m';
|
|
17
|
+
const FG_LIGHT = '\x1b[38;5;252m';
|
|
18
|
+
const BORDER_COLOR = '\x1b[38;5;240m';
|
|
19
|
+
const SEP = `${DIM} \u2502 ${RESET}`;
|
|
20
|
+
|
|
21
|
+
const BAR_HEIGHT = 4;
|
|
22
|
+
const GAP_ROWS = 1;
|
|
23
|
+
class StatusBar {
|
|
24
|
+
constructor() {
|
|
25
|
+
this._installed = false;
|
|
26
|
+
this._lastRendered = '';
|
|
27
|
+
this._timerStart = null;
|
|
28
|
+
this._responseTime = null;
|
|
29
|
+
this._inputHint = ''; // Dim text shown in gap row during active work
|
|
30
|
+
this._fields = {
|
|
31
|
+
modelName: '?',
|
|
32
|
+
modelId: '',
|
|
33
|
+
mode: 'work',
|
|
34
|
+
contextPct: 0,
|
|
35
|
+
contextTokens: 0,
|
|
36
|
+
contextLimit: 0,
|
|
37
|
+
mcpConnected: false,
|
|
38
|
+
sessionIn: 0,
|
|
39
|
+
sessionOut: 0,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Install: set scroll region, start repaint interval, listen for resize */
|
|
44
|
+
install() {
|
|
45
|
+
if (!process.stdout.isTTY) return;
|
|
46
|
+
if (this._installed) {
|
|
47
|
+
this._lastRendered = '';
|
|
48
|
+
this._applyScrollRegion();
|
|
49
|
+
this._renderBar();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
this._installed = true;
|
|
53
|
+
this._applyScrollRegion();
|
|
54
|
+
this._renderBar();
|
|
55
|
+
this._resizeTimer = null;
|
|
56
|
+
this._resizeHandler = () => {
|
|
57
|
+
// Debounce resize to avoid racing with readline's own resize handling.
|
|
58
|
+
// Without this, rapid resize events cause the scroll region, cursor
|
|
59
|
+
// position, and prompt to fight each other, producing garbled output.
|
|
60
|
+
if (this._resizeTimer) clearTimeout(this._resizeTimer);
|
|
61
|
+
this._resizeTimer = setTimeout(() => {
|
|
62
|
+
this._resizeTimer = null;
|
|
63
|
+
this._lastRendered = '';
|
|
64
|
+
this._applyScrollRegion();
|
|
65
|
+
this._renderBar();
|
|
66
|
+
this._paintGapRow();
|
|
67
|
+
}, 80);
|
|
68
|
+
};
|
|
69
|
+
process.stdout.on('resize', this._resizeHandler);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Uninstall: stop repaint, reset scroll region, clear bar */
|
|
73
|
+
uninstall() {
|
|
74
|
+
if (!process.stdout.isTTY) return;
|
|
75
|
+
this._installed = false;
|
|
76
|
+
if (this._resizeTimer) {
|
|
77
|
+
clearTimeout(this._resizeTimer);
|
|
78
|
+
this._resizeTimer = null;
|
|
79
|
+
}
|
|
80
|
+
if (this._resizeHandler) {
|
|
81
|
+
process.stdout.removeListener('resize', this._resizeHandler);
|
|
82
|
+
this._resizeHandler = null;
|
|
83
|
+
}
|
|
84
|
+
// Reset scroll region to full terminal
|
|
85
|
+
process.stdout.write('\x1b[r');
|
|
86
|
+
// Clear the reserved rows (gap + bar)
|
|
87
|
+
const rows = process.stdout.rows || 24;
|
|
88
|
+
const reserved = BAR_HEIGHT + GAP_ROWS;
|
|
89
|
+
const clearStart = Math.max(1, rows - reserved + 1);
|
|
90
|
+
for (let r = clearStart; r <= rows; r++) {
|
|
91
|
+
process.stdout.write(`\x1b[${r};1H\x1b[2K`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Reinstall after console.clear() */
|
|
96
|
+
reinstall() {
|
|
97
|
+
if (!process.stdout.isTTY) return;
|
|
98
|
+
this._installed = true;
|
|
99
|
+
this._lastRendered = '';
|
|
100
|
+
this._applyScrollRegion();
|
|
101
|
+
this._renderBar();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Partial field update + re-render */
|
|
105
|
+
update(fields) {
|
|
106
|
+
Object.assign(this._fields, fields);
|
|
107
|
+
if (this._installed) this._renderBar();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Start wall-clock response timer */
|
|
111
|
+
startTiming() {
|
|
112
|
+
this._timerStart = Date.now();
|
|
113
|
+
this._responseTime = null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Stop timer and re-render */
|
|
117
|
+
stopTiming() {
|
|
118
|
+
if (this._timerStart) {
|
|
119
|
+
this._responseTime = ((Date.now() - this._timerStart) / 1000).toFixed(1);
|
|
120
|
+
this._timerStart = null;
|
|
121
|
+
if (this._installed) this._renderBar();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Show or clear the input hint in the gap row above the bar */
|
|
126
|
+
setInputHint(text) {
|
|
127
|
+
this._inputHint = text || '';
|
|
128
|
+
if (this._installed) this._paintGapRow();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Force a render (called from showPrompt) */
|
|
132
|
+
render() {
|
|
133
|
+
if (this._installed) {
|
|
134
|
+
this._lastRendered = '';
|
|
135
|
+
this._renderBar();
|
|
136
|
+
this._paintGapRow();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Constrain scrollable area to rows above the bar.
|
|
141
|
+
* Uses cursor save/restore so the cursor stays wherever it was before. */
|
|
142
|
+
_applyScrollRegion() {
|
|
143
|
+
const rows = process.stdout.rows || 24;
|
|
144
|
+
const reserved = BAR_HEIGHT + GAP_ROWS;
|
|
145
|
+
const scrollEnd = Math.max(1, rows - reserved);
|
|
146
|
+
// Save cursor, set scroll region, restore cursor.
|
|
147
|
+
// This prevents the scroll region reset from moving the cursor.
|
|
148
|
+
process.stdout.write(`\x1b[s\x1b[1;${scrollEnd}r\x1b[u`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Force-repaint the bar. Safe to call frequently (e.g. after every
|
|
152
|
+
* clearLine/cursorTo in spinner code). Does NOT reapply the scroll
|
|
153
|
+
* region since clearLine/cursorTo don't reset it. */
|
|
154
|
+
refresh() {
|
|
155
|
+
if (!process.stdout.isTTY || !this._installed) return;
|
|
156
|
+
this._lastRendered = '';
|
|
157
|
+
this._renderBar();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Paint the status bar rows at the bottom of the terminal */
|
|
161
|
+
_renderBar() {
|
|
162
|
+
if (!process.stdout.isTTY || !this._installed) return;
|
|
163
|
+
|
|
164
|
+
const cols = process.stdout.columns || 80;
|
|
165
|
+
const rows = process.stdout.rows || 24;
|
|
166
|
+
const f = this._fields;
|
|
167
|
+
|
|
168
|
+
// Build content segments
|
|
169
|
+
const segments = [];
|
|
170
|
+
|
|
171
|
+
const modelDisplay = f.modelId
|
|
172
|
+
? `${f.modelName} (${f.modelId})`
|
|
173
|
+
: f.modelName;
|
|
174
|
+
segments.push(modelDisplay);
|
|
175
|
+
|
|
176
|
+
// Mode indicator
|
|
177
|
+
const modeColors = { work: GREEN, plan: '\x1b[36m', ask: '\x1b[35m' };
|
|
178
|
+
const modeColor = modeColors[f.mode] || GREEN;
|
|
179
|
+
segments.push(`${modeColor}${(f.mode || 'work').toUpperCase()}${RESET}${BG_DARK}${FG_LIGHT}`);
|
|
180
|
+
|
|
181
|
+
const ctxColor = f.contextPct >= 80 ? RED : f.contextPct >= 50 ? YELLOW : GREEN;
|
|
182
|
+
const ctxTokensStr = this._formatTokens(f.contextTokens);
|
|
183
|
+
const ctxLimitStr = this._formatTokens(f.contextLimit);
|
|
184
|
+
const ctxDisplay = f.contextLimit
|
|
185
|
+
? `ctx: ${ctxColor}${f.contextPct}%${RESET}${BG_DARK}${FG_LIGHT} (${ctxTokensStr}/${ctxLimitStr})`
|
|
186
|
+
: `ctx: ${ctxColor}${f.contextPct}%${RESET}${BG_DARK}${FG_LIGHT}`;
|
|
187
|
+
segments.push(ctxDisplay);
|
|
188
|
+
|
|
189
|
+
const mcpDot = f.mcpConnected
|
|
190
|
+
? `${GREEN}\u25CF${RESET}${BG_DARK}${FG_LIGHT}`
|
|
191
|
+
: `${RED}\u25CF${RESET}${BG_DARK}${FG_LIGHT}`;
|
|
192
|
+
segments.push(`MCP: ${mcpDot}`);
|
|
193
|
+
|
|
194
|
+
const inStr = this._formatTokens(f.sessionIn);
|
|
195
|
+
const outStr = this._formatTokens(f.sessionOut);
|
|
196
|
+
segments.push(`in:${inStr} out:${outStr}`);
|
|
197
|
+
|
|
198
|
+
if (this._responseTime) {
|
|
199
|
+
segments.push(`${this._responseTime}s`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Progressive collapse for narrow terminals
|
|
203
|
+
let bar = this._joinSegments(segments);
|
|
204
|
+
let barVisualLen = this._visualLen(bar);
|
|
205
|
+
|
|
206
|
+
if (barVisualLen > cols - 4 && this._responseTime) {
|
|
207
|
+
segments.pop();
|
|
208
|
+
bar = this._joinSegments(segments);
|
|
209
|
+
barVisualLen = this._visualLen(bar);
|
|
210
|
+
}
|
|
211
|
+
if (barVisualLen > cols - 4 && f.modelId) {
|
|
212
|
+
segments[0] = f.modelName;
|
|
213
|
+
bar = this._joinSegments(segments);
|
|
214
|
+
barVisualLen = this._visualLen(bar);
|
|
215
|
+
}
|
|
216
|
+
if (barVisualLen > cols - 4) {
|
|
217
|
+
// Remove MCP segment (index 3: model, mode, ctx, MCP, in/out)
|
|
218
|
+
const mcpIdx = segments.findIndex(s => s.startsWith('MCP:'));
|
|
219
|
+
if (mcpIdx !== -1) segments.splice(mcpIdx, 1);
|
|
220
|
+
bar = this._joinSegments(segments);
|
|
221
|
+
barVisualLen = this._visualLen(bar);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Build the 4 rows: rule, top pad, content, bottom pad
|
|
225
|
+
const rule = `${BORDER_COLOR}${'\u2500'.repeat(cols)}${RESET}`;
|
|
226
|
+
const contentPad = Math.max(0, cols - barVisualLen - 1);
|
|
227
|
+
const content = `${BG_DARK}${FG_LIGHT} ${bar}${' '.repeat(contentPad)}${RESET}`;
|
|
228
|
+
const padRow = `${BG_DARK}${' '.repeat(cols)}${RESET}`;
|
|
229
|
+
|
|
230
|
+
const fullBar = rule + padRow + content + padRow;
|
|
231
|
+
if (fullBar === this._lastRendered) return;
|
|
232
|
+
this._lastRendered = fullBar;
|
|
233
|
+
|
|
234
|
+
const barStartRow = rows - BAR_HEIGHT + 1;
|
|
235
|
+
|
|
236
|
+
// Save cursor, draw bar rows, restore cursor.
|
|
237
|
+
let paint = '\x1b[s';
|
|
238
|
+
paint +=
|
|
239
|
+
`\x1b[${barStartRow};1H\x1b[2K` + rule + // row 1: rule
|
|
240
|
+
`\x1b[${barStartRow + 1};1H\x1b[2K` + padRow + // row 2: top pad
|
|
241
|
+
`\x1b[${barStartRow + 2};1H\x1b[2K` + content + // row 3: content
|
|
242
|
+
`\x1b[${barStartRow + 3};1H\x1b[2K` + padRow + // row 4: bottom pad
|
|
243
|
+
'\x1b[u';
|
|
244
|
+
process.stdout.write(paint);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Paint the gap row above the bar with the input hint (or blank) */
|
|
248
|
+
_paintGapRow() {
|
|
249
|
+
if (!process.stdout.isTTY || !this._installed) return;
|
|
250
|
+
const rows = process.stdout.rows || 24;
|
|
251
|
+
const cols = process.stdout.columns || 80;
|
|
252
|
+
const gapRow = rows - BAR_HEIGHT - GAP_ROWS + 1;
|
|
253
|
+
if (gapRow < 1) return;
|
|
254
|
+
|
|
255
|
+
let rowContent;
|
|
256
|
+
if (this._inputHint) {
|
|
257
|
+
const hint = this._inputHint.length > cols - 2
|
|
258
|
+
? this._inputHint.slice(0, cols - 2)
|
|
259
|
+
: this._inputHint;
|
|
260
|
+
rowContent = `${DIM} ${hint}${RESET}`;
|
|
261
|
+
} else {
|
|
262
|
+
rowContent = '';
|
|
263
|
+
}
|
|
264
|
+
process.stdout.write(`\x1b[s\x1b[${gapRow};1H\x1b[2K${rowContent}\x1b[u`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
_joinSegments(segments) {
|
|
268
|
+
return segments.join(`${RESET}${BG_DARK}${FG_LIGHT}${SEP}${BG_DARK}${FG_LIGHT}`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
_formatTokens(n) {
|
|
272
|
+
if (!n || n === 0) return '0';
|
|
273
|
+
if (n < 1000) return String(n);
|
|
274
|
+
return (n / 1000).toFixed(1) + 'K';
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
_visualLen(str) {
|
|
278
|
+
// eslint-disable-next-line no-control-regex
|
|
279
|
+
return str.replace(/\x1b\[[0-9;]*m/g, '').length;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
module.exports = StatusBar;
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming response handler for Banana Code
|
|
3
|
+
*
|
|
4
|
+
* Filters out <file_operation>, <run_command>, and <think> blocks during streaming
|
|
5
|
+
* so users only see the explanation text. The full response (with XML)
|
|
6
|
+
* is still captured for parsing after streaming completes.
|
|
7
|
+
*
|
|
8
|
+
* Also handles "orphan" </think> tags where the model outputs reasoning
|
|
9
|
+
* WITHOUT an opening <think> tag (common with Qwen models).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// Strip model control tokens like <|start|>, <|channel|>, <|constrain|>, <|end|>, etc.
|
|
13
|
+
// These leak from models like GPT-OSS, Llama, and others.
|
|
14
|
+
const CONTROL_TOKEN_RE = /<\|[^|]*\|>/g;
|
|
15
|
+
function stripControlTokens(text) {
|
|
16
|
+
const cleaned = text.replace(CONTROL_TOKEN_RE, '');
|
|
17
|
+
// If the line was entirely control tokens + whitespace, return empty
|
|
18
|
+
return cleaned.replace(/^\s+$/, '');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
class StreamHandler {
|
|
22
|
+
constructor(options = {}) {
|
|
23
|
+
this.onToken = options.onToken || (() => {});
|
|
24
|
+
this.onComplete = options.onComplete || (() => {});
|
|
25
|
+
this.onError = options.onError || (() => {});
|
|
26
|
+
this.onBlockProgress = options.onBlockProgress || null; // (type, charsSoFar) - called while inside suppressed XML blocks
|
|
27
|
+
this.buffer = '';
|
|
28
|
+
this.fullResponse = '';
|
|
29
|
+
|
|
30
|
+
// For filtering XML blocks during display
|
|
31
|
+
this.displayBuffer = '';
|
|
32
|
+
this.insideXmlBlock = false;
|
|
33
|
+
this.xmlBlockType = null; // 'file_operation', 'run_command', or 'think'
|
|
34
|
+
|
|
35
|
+
// For detecting orphan </think> tags (model reasoning without opening tag)
|
|
36
|
+
// Buffer output until we confirm there's no orphan </think> coming
|
|
37
|
+
this.orphanBuffer = '';
|
|
38
|
+
this.orphanCheckComplete = false; // Once true, stop buffering for orphan check
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Process content for display, filtering out XML blocks
|
|
43
|
+
* Returns only the text that should be shown to the user
|
|
44
|
+
*
|
|
45
|
+
* Handles two cases:
|
|
46
|
+
* 1. Normal XML blocks: <think>...</think>, <file_operation>...</file_operation>, etc.
|
|
47
|
+
* 2. Orphan closing tags: Model outputs reasoning without <think> tag, only </think> at end
|
|
48
|
+
*/
|
|
49
|
+
filterForDisplay(content) {
|
|
50
|
+
this.displayBuffer += content;
|
|
51
|
+
|
|
52
|
+
// Regex patterns
|
|
53
|
+
const openTagRegex = /<\s*(file_operation|run_command|think|thinking)\s*>[\r\n]*/i;
|
|
54
|
+
const closeFileOp = /<\s*\/\s*file_operation\s*>/i;
|
|
55
|
+
const closeRunCmd = /<\s*\/\s*run_command\s*>/i;
|
|
56
|
+
const closeThink = /<\s*\/\s*think(?:ing)?\s*>/i;
|
|
57
|
+
|
|
58
|
+
let output = '';
|
|
59
|
+
|
|
60
|
+
while (this.displayBuffer.length > 0) {
|
|
61
|
+
if (this.insideXmlBlock) {
|
|
62
|
+
// Look for closing tag based on block type
|
|
63
|
+
let closeRegex;
|
|
64
|
+
if (this.xmlBlockType === 'file_operation') {
|
|
65
|
+
closeRegex = closeFileOp;
|
|
66
|
+
} else if (this.xmlBlockType === 'run_command') {
|
|
67
|
+
closeRegex = closeRunCmd;
|
|
68
|
+
} else {
|
|
69
|
+
closeRegex = closeThink;
|
|
70
|
+
}
|
|
71
|
+
const closeMatch = this.displayBuffer.match(closeRegex);
|
|
72
|
+
|
|
73
|
+
if (closeMatch) {
|
|
74
|
+
// Found closing tag, skip everything up to and including it
|
|
75
|
+
// Use displayBuffer length as the final count (displayBuffer has accumulated content since block open)
|
|
76
|
+
if (this.onBlockProgress) this.onBlockProgress(this.xmlBlockType, this.displayBuffer.length, true);
|
|
77
|
+
const closeEnd = closeMatch.index + closeMatch[0].length;
|
|
78
|
+
this.displayBuffer = this.displayBuffer.slice(closeEnd);
|
|
79
|
+
this.insideXmlBlock = false;
|
|
80
|
+
this.xmlBlockType = null;
|
|
81
|
+
} else {
|
|
82
|
+
// Still inside block, keep buffering
|
|
83
|
+
// displayBuffer contains all content since block opened, so use its length directly
|
|
84
|
+
if (this.onBlockProgress) this.onBlockProgress(this.xmlBlockType, this.displayBuffer.length, false);
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
// Not inside a block - check for orphan </think> first (before checking opening tags)
|
|
89
|
+
if (!this.orphanCheckComplete) {
|
|
90
|
+
// Check if we have an orphan </think> (closing tag without opening)
|
|
91
|
+
const orphanCloseMatch = this.displayBuffer.match(closeThink);
|
|
92
|
+
const openThinkMatch = this.displayBuffer.match(/<\s*(think|thinking)\s*>/i);
|
|
93
|
+
|
|
94
|
+
if (orphanCloseMatch) {
|
|
95
|
+
// Found </think> - check if there's an opening tag before it
|
|
96
|
+
if (!openThinkMatch || openThinkMatch.index > orphanCloseMatch.index) {
|
|
97
|
+
// Orphan! Discard everything up to and including </think>
|
|
98
|
+
const closeEnd = orphanCloseMatch.index + orphanCloseMatch[0].length;
|
|
99
|
+
let remaining = this.displayBuffer.slice(closeEnd);
|
|
100
|
+
// Also strip any leading whitespace/newlines after </think>
|
|
101
|
+
remaining = remaining.replace(/^[\s\r\n]+/, '');
|
|
102
|
+
this.displayBuffer = remaining;
|
|
103
|
+
this.orphanCheckComplete = true;
|
|
104
|
+
// Also discard anything we had buffered
|
|
105
|
+
this.orphanBuffer = '';
|
|
106
|
+
continue; // Re-process remaining buffer
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Check if buffer might still receive </think>
|
|
111
|
+
// If we see content that looks like actual response (not reasoning), stop checking
|
|
112
|
+
// Heuristic: If buffer is > 500 chars with no </think>, assume no orphan
|
|
113
|
+
if (this.displayBuffer.length > 500) {
|
|
114
|
+
this.orphanCheckComplete = true;
|
|
115
|
+
// Flush orphan buffer
|
|
116
|
+
output += this.orphanBuffer;
|
|
117
|
+
this.orphanBuffer = '';
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Look for opening tag
|
|
122
|
+
const openMatch = this.displayBuffer.match(openTagRegex);
|
|
123
|
+
|
|
124
|
+
if (openMatch) {
|
|
125
|
+
// Found opening tag - mark orphan check complete if it's a think tag
|
|
126
|
+
const blockType = openMatch[1].toLowerCase();
|
|
127
|
+
if (blockType === 'think' || blockType === 'thinking') {
|
|
128
|
+
this.orphanCheckComplete = true;
|
|
129
|
+
output += this.orphanBuffer;
|
|
130
|
+
this.orphanBuffer = '';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Output everything before the tag
|
|
134
|
+
const beforeTag = this.displayBuffer.slice(0, openMatch.index);
|
|
135
|
+
if (this.orphanCheckComplete) {
|
|
136
|
+
output += beforeTag;
|
|
137
|
+
} else {
|
|
138
|
+
this.orphanBuffer += beforeTag;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Skip past the opening tag
|
|
142
|
+
this.displayBuffer = this.displayBuffer.slice(openMatch.index + openMatch[0].length);
|
|
143
|
+
this.insideXmlBlock = true;
|
|
144
|
+
this.xmlBlockType = blockType === 'thinking' ? 'think' : blockType;
|
|
145
|
+
} else {
|
|
146
|
+
// No complete opening tag found
|
|
147
|
+
// Check if buffer ends with a partial tag
|
|
148
|
+
const partialMatch = this.displayBuffer.match(/<[^>]{0,20}$/);
|
|
149
|
+
|
|
150
|
+
if (partialMatch) {
|
|
151
|
+
// Output everything before the potential partial tag
|
|
152
|
+
const beforePartial = this.displayBuffer.slice(0, partialMatch.index);
|
|
153
|
+
if (this.orphanCheckComplete) {
|
|
154
|
+
output += beforePartial;
|
|
155
|
+
} else {
|
|
156
|
+
this.orphanBuffer += beforePartial;
|
|
157
|
+
}
|
|
158
|
+
this.displayBuffer = this.displayBuffer.slice(partialMatch.index);
|
|
159
|
+
break;
|
|
160
|
+
} else {
|
|
161
|
+
// Safe to output everything
|
|
162
|
+
if (this.orphanCheckComplete) {
|
|
163
|
+
output += this.displayBuffer;
|
|
164
|
+
} else {
|
|
165
|
+
this.orphanBuffer += this.displayBuffer;
|
|
166
|
+
}
|
|
167
|
+
this.displayBuffer = '';
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return stripControlTokens(output);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async handleStream(response) {
|
|
177
|
+
const reader = response.body.getReader();
|
|
178
|
+
const decoder = new TextDecoder();
|
|
179
|
+
const IDLE_TIMEOUT = 30000; // 30 seconds with no data = assume done (LM Studio needs time after prompt processing)
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
while (true) {
|
|
183
|
+
// Race between read and timeout
|
|
184
|
+
const readPromise = reader.read();
|
|
185
|
+
const timeoutPromise = new Promise((_, reject) =>
|
|
186
|
+
setTimeout(() => reject(new Error('IDLE_TIMEOUT')), IDLE_TIMEOUT)
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
let done, value;
|
|
190
|
+
try {
|
|
191
|
+
const result = await Promise.race([readPromise, timeoutPromise]);
|
|
192
|
+
done = result.done;
|
|
193
|
+
value = result.value;
|
|
194
|
+
} catch (e) {
|
|
195
|
+
if (e.message === 'IDLE_TIMEOUT') {
|
|
196
|
+
// No data for 5 seconds, assume stream is complete
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
throw e;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (done) {
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
207
|
+
this.buffer += chunk;
|
|
208
|
+
|
|
209
|
+
// Process SSE data
|
|
210
|
+
const lines = this.buffer.split('\n');
|
|
211
|
+
this.buffer = lines.pop() || ''; // Keep incomplete line in buffer
|
|
212
|
+
|
|
213
|
+
for (const line of lines) {
|
|
214
|
+
let content = '';
|
|
215
|
+
|
|
216
|
+
if (line.startsWith('data: ')) {
|
|
217
|
+
const data = line.slice(6);
|
|
218
|
+
|
|
219
|
+
if (data === '[DONE]') {
|
|
220
|
+
// Stream complete signal - exit the loop
|
|
221
|
+
this.onComplete(this.fullResponse);
|
|
222
|
+
return this.fullResponse;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
const parsed = JSON.parse(data);
|
|
227
|
+
// Try multiple possible response formats
|
|
228
|
+
content = parsed.choices?.[0]?.delta?.content
|
|
229
|
+
|| parsed.choices?.[0]?.text
|
|
230
|
+
|| parsed.content
|
|
231
|
+
|| parsed.delta?.content
|
|
232
|
+
|| parsed.text
|
|
233
|
+
|| '';
|
|
234
|
+
} catch {
|
|
235
|
+
// Not valid JSON, treat as raw text
|
|
236
|
+
content = data;
|
|
237
|
+
}
|
|
238
|
+
} else if (line.trim() && !line.startsWith(':')) {
|
|
239
|
+
// Raw text line (not SSE comment)
|
|
240
|
+
content = line;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (content) {
|
|
244
|
+
this.fullResponse += content;
|
|
245
|
+
// Filter out XML blocks for display
|
|
246
|
+
const displayContent = this.filterForDisplay(content);
|
|
247
|
+
if (displayContent) {
|
|
248
|
+
this.onToken(displayContent);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Process any remaining buffer
|
|
255
|
+
if (this.buffer) {
|
|
256
|
+
const lines = this.buffer.split('\n');
|
|
257
|
+
for (const line of lines) {
|
|
258
|
+
if (line.startsWith('data: ') && line.slice(6) !== '[DONE]') {
|
|
259
|
+
try {
|
|
260
|
+
const parsed = JSON.parse(line.slice(6));
|
|
261
|
+
// Try multiple possible response formats
|
|
262
|
+
const content = parsed.choices?.[0]?.delta?.content
|
|
263
|
+
|| parsed.choices?.[0]?.text
|
|
264
|
+
|| parsed.content
|
|
265
|
+
|| parsed.delta?.content
|
|
266
|
+
|| parsed.text
|
|
267
|
+
|| '';
|
|
268
|
+
if (content) {
|
|
269
|
+
this.fullResponse += content;
|
|
270
|
+
// Filter out XML blocks for display
|
|
271
|
+
const displayContent = this.filterForDisplay(content);
|
|
272
|
+
if (displayContent) {
|
|
273
|
+
this.onToken(displayContent);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
} catch {
|
|
277
|
+
// Ignore
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
this.onComplete(this.fullResponse);
|
|
284
|
+
return this.fullResponse;
|
|
285
|
+
|
|
286
|
+
} catch (error) {
|
|
287
|
+
this.onError(error);
|
|
288
|
+
throw error;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
getFullResponse() {
|
|
293
|
+
return this.fullResponse;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Non-streaming fallback with simulated streaming effect
|
|
298
|
+
async function simulateStreaming(text, onToken, delay = 5) {
|
|
299
|
+
const words = text.split(/(\s+)/);
|
|
300
|
+
for (const word of words) {
|
|
301
|
+
onToken(word);
|
|
302
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
module.exports = { StreamHandler, simulateStreaming };
|