@geminilight/mindos 0.5.11 → 0.5.13
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/README.md +9 -9
- package/README_zh.md +9 -9
- package/app/README.md +2 -2
- package/app/app/api/ask/route.ts +191 -19
- package/app/app/api/mcp/install/route.ts +1 -1
- package/app/app/api/mcp/status/route.ts +11 -16
- package/app/app/api/settings/route.ts +3 -1
- package/app/app/api/setup/route.ts +7 -7
- package/app/app/api/sync/route.ts +18 -15
- package/app/components/AskModal.tsx +28 -32
- package/app/components/SettingsModal.tsx +7 -3
- package/app/components/ask/MessageList.tsx +65 -3
- package/app/components/ask/ThinkingBlock.tsx +55 -0
- package/app/components/ask/ToolCallBlock.tsx +97 -0
- package/app/components/settings/AiTab.tsx +76 -2
- package/app/components/settings/types.ts +8 -0
- package/app/components/setup/StepReview.tsx +31 -25
- package/app/components/setup/index.tsx +6 -3
- package/app/lib/agent/context.ts +317 -0
- package/app/lib/agent/index.ts +4 -0
- package/app/lib/agent/prompt.ts +46 -31
- package/app/lib/agent/stream-consumer.ts +212 -0
- package/app/lib/agent/tools.ts +159 -4
- package/app/lib/i18n.ts +28 -0
- package/app/lib/settings.ts +22 -0
- package/app/lib/types.ts +23 -0
- package/app/package.json +2 -3
- package/bin/cli.js +41 -21
- package/bin/lib/build.js +6 -2
- package/bin/lib/gateway.js +24 -3
- package/bin/lib/mcp-install.js +2 -2
- package/bin/lib/mcp-spawn.js +3 -3
- package/bin/lib/stop.js +1 -1
- package/bin/lib/sync.js +81 -40
- package/mcp/README.md +5 -5
- package/mcp/src/index.ts +2 -2
- package/package.json +3 -2
- package/scripts/setup.js +17 -12
- package/scripts/upgrade-prompt.md +6 -6
- package/skills/mindos/SKILL.md +47 -183
- package/skills/mindos-zh/SKILL.md +47 -183
- package/app/package-lock.json +0 -15615
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 3: Context management — token estimation, compaction, tool output truncation.
|
|
3
|
+
*
|
|
4
|
+
* All operations are request-scoped (no persistence to frontend session).
|
|
5
|
+
*/
|
|
6
|
+
import { generateText, type ModelMessage, type ToolResultPart, type ToolModelMessage } from 'ai';
|
|
7
|
+
import type { LanguageModel } from 'ai';
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Token estimation (1 token ≈ 4 chars)
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
/** Rough token count for a single ModelMessage */
|
|
14
|
+
function messageTokens(msg: ModelMessage): number {
|
|
15
|
+
if (typeof msg.content === 'string') return Math.ceil(msg.content.length / 4);
|
|
16
|
+
if (Array.isArray(msg.content)) {
|
|
17
|
+
let chars = 0;
|
|
18
|
+
for (const part of msg.content) {
|
|
19
|
+
if ('text' in part && typeof part.text === 'string') chars += part.text.length;
|
|
20
|
+
if ('value' in part && typeof part.value === 'string') chars += part.value.length;
|
|
21
|
+
if ('input' in part) chars += JSON.stringify(part.input).length;
|
|
22
|
+
}
|
|
23
|
+
return Math.ceil(chars / 4);
|
|
24
|
+
}
|
|
25
|
+
return 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Estimate total tokens for a message array */
|
|
29
|
+
export function estimateTokens(messages: ModelMessage[]): number {
|
|
30
|
+
let total = 0;
|
|
31
|
+
for (const m of messages) total += messageTokens(m);
|
|
32
|
+
return total;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Estimate tokens for a plain string (e.g. system prompt) */
|
|
36
|
+
export function estimateStringTokens(text: string): number {
|
|
37
|
+
return Math.ceil(text.length / 4);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Context limits by model family
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
const MODEL_LIMITS: Record<string, number> = {
|
|
45
|
+
'claude': 200_000,
|
|
46
|
+
'gpt-4o': 128_000,
|
|
47
|
+
'gpt-4': 128_000,
|
|
48
|
+
'gpt-3.5': 16_000,
|
|
49
|
+
'gpt-5': 200_000,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Sort by prefix length descending so "gpt-4o" matches before "gpt-4"
|
|
53
|
+
const MODEL_LIMIT_ENTRIES = Object.entries(MODEL_LIMITS)
|
|
54
|
+
.sort((a, b) => b[0].length - a[0].length);
|
|
55
|
+
|
|
56
|
+
/** Get context token limit for a model string */
|
|
57
|
+
export function getContextLimit(model: string): number {
|
|
58
|
+
const lower = model.toLowerCase();
|
|
59
|
+
for (const [prefix, limit] of MODEL_LIMIT_ENTRIES) {
|
|
60
|
+
if (lower.includes(prefix)) return limit;
|
|
61
|
+
}
|
|
62
|
+
return 100_000; // conservative default
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Check if messages + system prompt exceed threshold of context limit */
|
|
66
|
+
export function needsCompact(
|
|
67
|
+
messages: ModelMessage[],
|
|
68
|
+
systemPrompt: string,
|
|
69
|
+
model: string,
|
|
70
|
+
threshold = 0.7,
|
|
71
|
+
): boolean {
|
|
72
|
+
const total = estimateTokens(messages) + estimateStringTokens(systemPrompt);
|
|
73
|
+
const limit = getContextLimit(model);
|
|
74
|
+
return total > limit * threshold;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Tool output truncation (per-tool-type thresholds)
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
const TOOL_OUTPUT_LIMITS: Record<string, number> = {
|
|
82
|
+
// List/search tools — only need to know "what was found"
|
|
83
|
+
search: 500,
|
|
84
|
+
list_files: 500,
|
|
85
|
+
get_recent: 500,
|
|
86
|
+
get_backlinks: 500,
|
|
87
|
+
get_history: 500,
|
|
88
|
+
// Read tools — some context value, but not full file
|
|
89
|
+
read_file: 2000,
|
|
90
|
+
get_file_at_version: 2000,
|
|
91
|
+
// Write tools — only need success/failure
|
|
92
|
+
write_file: 200,
|
|
93
|
+
create_file: 200,
|
|
94
|
+
delete_file: 200,
|
|
95
|
+
rename_file: 200,
|
|
96
|
+
move_file: 200,
|
|
97
|
+
append_to_file: 200,
|
|
98
|
+
insert_after_heading: 200,
|
|
99
|
+
update_section: 200,
|
|
100
|
+
append_csv: 200,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Truncate tool outputs in historical messages to save tokens.
|
|
105
|
+
* Only truncates non-last tool messages (the last tool message is kept intact
|
|
106
|
+
* because the model may need its full output for the current step).
|
|
107
|
+
*/
|
|
108
|
+
export function truncateToolOutputs(messages: ModelMessage[]): ModelMessage[] {
|
|
109
|
+
// Find the index of the last 'tool' role message
|
|
110
|
+
let lastToolIdx = -1;
|
|
111
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
112
|
+
if (messages[i].role === 'tool') { lastToolIdx = i; break; }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return messages.map((msg, idx) => {
|
|
116
|
+
if (msg.role !== 'tool' || idx === lastToolIdx) return msg;
|
|
117
|
+
|
|
118
|
+
const toolMsg = msg as ToolModelMessage;
|
|
119
|
+
const truncatedContent = toolMsg.content.map(part => {
|
|
120
|
+
if (part.type !== 'tool-result') return part;
|
|
121
|
+
const trp = part as ToolResultPart;
|
|
122
|
+
const toolName = trp.toolName ?? '';
|
|
123
|
+
const limit = TOOL_OUTPUT_LIMITS[toolName] ?? 500;
|
|
124
|
+
if (!trp.output || typeof trp.output !== 'object' || trp.output.type !== 'text') return part;
|
|
125
|
+
if (trp.output.value.length <= limit) return part;
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
...trp,
|
|
129
|
+
output: {
|
|
130
|
+
...trp.output,
|
|
131
|
+
value: trp.output.value.slice(0, limit) + `\n[...truncated from ${trp.output.value.length} chars]`,
|
|
132
|
+
},
|
|
133
|
+
} satisfies ToolResultPart;
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
return { ...toolMsg, content: truncatedContent } satisfies ToolModelMessage;
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// Compact: summarize early messages via LLM
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
const COMPACT_PROMPT = `Summarize the key points, decisions, and file operations from this conversation in under 500 words. Focus on:
|
|
145
|
+
- What the user asked for
|
|
146
|
+
- What files were read, created, or modified
|
|
147
|
+
- Key decisions and outcomes
|
|
148
|
+
- Any unresolved issues
|
|
149
|
+
|
|
150
|
+
Be concise and factual. Output only the summary, no preamble.`;
|
|
151
|
+
|
|
152
|
+
/** Extract a short text representation from a ModelMessage for summarization */
|
|
153
|
+
function messageToText(m: ModelMessage): string {
|
|
154
|
+
const role = m.role;
|
|
155
|
+
let content = '';
|
|
156
|
+
if (typeof m.content === 'string') {
|
|
157
|
+
content = m.content;
|
|
158
|
+
} else if (Array.isArray(m.content)) {
|
|
159
|
+
const pieces: string[] = [];
|
|
160
|
+
for (const part of m.content) {
|
|
161
|
+
if ('text' in part && typeof (part as { text?: string }).text === 'string') {
|
|
162
|
+
pieces.push((part as { text: string }).text);
|
|
163
|
+
} else if (part.type === 'tool-call' && 'toolName' in part) {
|
|
164
|
+
pieces.push(`[Tool: ${(part as { toolName: string }).toolName}]`);
|
|
165
|
+
} else if (part.type === 'tool-result' && 'output' in part) {
|
|
166
|
+
const trp = part as ToolResultPart;
|
|
167
|
+
const val = trp.output && typeof trp.output === 'object' && trp.output.type === 'text' ? trp.output.value : '';
|
|
168
|
+
pieces.push(`[Result: ${val.slice(0, 200)}]`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
content = pieces.filter(Boolean).join(' ');
|
|
172
|
+
}
|
|
173
|
+
return `${role}: ${content}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Compact messages by summarizing early ones with LLM.
|
|
178
|
+
* Returns a new message array with early messages replaced by a summary.
|
|
179
|
+
* Only called when needsCompact() returns true.
|
|
180
|
+
*
|
|
181
|
+
* NOTE: Currently uses the same model as the main generation. A cheaper model
|
|
182
|
+
* (e.g. haiku) would suffice for summarization and avoid competing for rate
|
|
183
|
+
* limits. Deferred until users report rate-limit issues — compact triggers
|
|
184
|
+
* infrequently (>70% context fill).
|
|
185
|
+
*/
|
|
186
|
+
export async function compactMessages(
|
|
187
|
+
messages: ModelMessage[],
|
|
188
|
+
model: LanguageModel,
|
|
189
|
+
): Promise<{ messages: ModelMessage[]; compacted: boolean }> {
|
|
190
|
+
if (messages.length < 6) {
|
|
191
|
+
return { messages, compacted: false };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Keep the last 6 messages intact, summarize the rest.
|
|
195
|
+
// Adjust split point to avoid cutting between an assistant (with tool calls)
|
|
196
|
+
// and its tool result. Only need to check for orphaned 'tool' messages —
|
|
197
|
+
// an assistant at the split point is safe because its tool results follow it.
|
|
198
|
+
// (Orphaned assistants without results can't exist in history: only completed
|
|
199
|
+
// tool calls are persisted by the frontend.)
|
|
200
|
+
let splitIdx = messages.length - 6;
|
|
201
|
+
while (splitIdx > 0 && messages[splitIdx]?.role === 'tool') {
|
|
202
|
+
splitIdx--;
|
|
203
|
+
}
|
|
204
|
+
if (splitIdx < 2) {
|
|
205
|
+
return { messages, compacted: false };
|
|
206
|
+
}
|
|
207
|
+
const earlyMessages = messages.slice(0, splitIdx);
|
|
208
|
+
const recentMessages = messages.slice(splitIdx);
|
|
209
|
+
|
|
210
|
+
// Build a text representation of early messages for summarization
|
|
211
|
+
let earlyText = earlyMessages.map(messageToText).join('\n\n');
|
|
212
|
+
|
|
213
|
+
// Truncate if enormous (avoid sending too much to summarizer)
|
|
214
|
+
if (earlyText.length > 30_000) {
|
|
215
|
+
earlyText = earlyText.slice(0, 30_000) + '\n[...truncated]';
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
const { text: summary } = await generateText({
|
|
220
|
+
model,
|
|
221
|
+
prompt: `${COMPACT_PROMPT}\n\n---\n\nConversation to summarize:\n\n${earlyText}`,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
console.log(`[ask] Compacted ${earlyMessages.length} early messages into summary (${summary.length} chars)`);
|
|
225
|
+
|
|
226
|
+
const summaryText = `[Summary of earlier conversation]\n\n${summary}`;
|
|
227
|
+
|
|
228
|
+
// If first recent message is also 'user', merge summary into it to avoid
|
|
229
|
+
// consecutive user messages (Anthropic rejects user→user sequences).
|
|
230
|
+
if (recentMessages[0]?.role === 'user') {
|
|
231
|
+
const merged = { ...recentMessages[0] };
|
|
232
|
+
if (typeof merged.content === 'string') {
|
|
233
|
+
merged.content = `${summaryText}\n\n---\n\n${merged.content}`;
|
|
234
|
+
} else if (Array.isArray(merged.content)) {
|
|
235
|
+
// Multimodal content (e.g. images) — prepend summary as text part
|
|
236
|
+
merged.content = [{ type: 'text' as const, text: `${summaryText}\n\n---\n\n` }, ...merged.content];
|
|
237
|
+
} else {
|
|
238
|
+
merged.content = summaryText;
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
messages: [merged, ...recentMessages.slice(1)],
|
|
242
|
+
compacted: true,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Otherwise prepend as separate user message
|
|
247
|
+
const summaryMessage: ModelMessage = {
|
|
248
|
+
role: 'user',
|
|
249
|
+
content: summaryText,
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
messages: [summaryMessage, ...recentMessages],
|
|
254
|
+
compacted: true,
|
|
255
|
+
};
|
|
256
|
+
} catch (err) {
|
|
257
|
+
console.error('[ask] Compact failed, using uncompacted messages:', err);
|
|
258
|
+
return { messages, compacted: false };
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
// Hard prune: drop earliest messages as last resort (>90% context)
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Hard prune: if still over 90% context after compact, drop earliest messages.
|
|
268
|
+
* Respects assistant-tool pairs: never cuts between an assistant message
|
|
269
|
+
* (containing tool calls) and its following tool result message.
|
|
270
|
+
*/
|
|
271
|
+
export function hardPrune(
|
|
272
|
+
messages: ModelMessage[],
|
|
273
|
+
systemPrompt: string,
|
|
274
|
+
model: string,
|
|
275
|
+
): ModelMessage[] {
|
|
276
|
+
const limit = getContextLimit(model);
|
|
277
|
+
const threshold = limit * 0.9;
|
|
278
|
+
const systemTokens = estimateStringTokens(systemPrompt);
|
|
279
|
+
|
|
280
|
+
let total = systemTokens + estimateTokens(messages);
|
|
281
|
+
if (total <= threshold) return messages;
|
|
282
|
+
|
|
283
|
+
// Find the cut index: keep messages from cutIdx onward
|
|
284
|
+
let cutIdx = 0;
|
|
285
|
+
while (cutIdx < messages.length - 2 && total > threshold) {
|
|
286
|
+
total -= messageTokens(messages[cutIdx]);
|
|
287
|
+
cutIdx++;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Ensure we don't cut between an assistant (with tool calls) and its tool result.
|
|
291
|
+
// If cutIdx lands on a 'tool' message, advance past it so the pair stays together
|
|
292
|
+
// or is fully removed.
|
|
293
|
+
while (cutIdx < messages.length - 1 && messages[cutIdx].role === 'tool') {
|
|
294
|
+
total -= messageTokens(messages[cutIdx]);
|
|
295
|
+
cutIdx++;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Ensure first message is 'user' (Anthropic requirement)
|
|
299
|
+
while (cutIdx < messages.length - 1 && messages[cutIdx].role !== 'user') {
|
|
300
|
+
total -= messageTokens(messages[cutIdx]);
|
|
301
|
+
cutIdx++;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Fallback: if no user message found in remaining messages, inject a synthetic one
|
|
305
|
+
const pruned = cutIdx > 0 ? messages.slice(cutIdx) : messages;
|
|
306
|
+
if (pruned.length > 0 && pruned[0].role !== 'user') {
|
|
307
|
+
console.log(`[ask] Hard pruned ${cutIdx} messages, injecting synthetic user message (${messages.length} → ${pruned.length + 1})`);
|
|
308
|
+
return [{ role: 'user', content: '[Conversation context was pruned due to length. Continuing from here.]' } as ModelMessage, ...pruned];
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (cutIdx > 0) {
|
|
312
|
+
console.log(`[ask] Hard pruned ${cutIdx} messages (${messages.length} → ${messages.length - cutIdx})`);
|
|
313
|
+
return pruned;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return messages;
|
|
317
|
+
}
|
package/app/lib/agent/index.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
1
|
export { getModel } from './model';
|
|
2
2
|
export { knowledgeBaseTools, truncate, assertWritable } from './tools';
|
|
3
3
|
export { AGENT_SYSTEM_PROMPT } from './prompt';
|
|
4
|
+
export {
|
|
5
|
+
estimateTokens, estimateStringTokens, getContextLimit, needsCompact,
|
|
6
|
+
truncateToolOutputs, compactMessages, hardPrune,
|
|
7
|
+
} from './context';
|
package/app/lib/agent/prompt.ts
CHANGED
|
@@ -1,32 +1,47 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
-
|
|
6
|
-
|
|
7
|
-
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
- INSTRUCTION.md
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Agent system prompt — v3: de-duplicated, persona-driven, with missing instructions added.
|
|
3
|
+
*
|
|
4
|
+
* Design principles:
|
|
5
|
+
* - prompt.ts owns: identity, persona, global behavioral constraints, output format
|
|
6
|
+
* - SKILL.md owns: knowledge-base-specific execution patterns, tool selection, safety rules
|
|
7
|
+
* - Tool descriptions own: per-tool usage instructions (no duplication here)
|
|
8
|
+
*
|
|
9
|
+
* Token budget: ~600 tokens (down from ~900 in v2). Freed space = more room for
|
|
10
|
+
* SKILL.md + bootstrap context within the same context window.
|
|
11
|
+
*/
|
|
12
|
+
export const AGENT_SYSTEM_PROMPT = `You are MindOS Agent — a personal knowledge-base operator that reads, writes, and organizes a user's second brain.
|
|
13
|
+
|
|
14
|
+
Persona: methodical, concise, execution-oriented. You surface what you found (or didn't find) and act on it — no filler, no caveats that add no information.
|
|
15
|
+
|
|
16
|
+
## What is already loaded
|
|
17
|
+
|
|
18
|
+
The server auto-loads before each request:
|
|
19
|
+
- Bootstrap context: INSTRUCTION.md, README.md, CONFIG files, and directory-local guidance.
|
|
20
|
+
- Skill guidance (SKILL.md): detailed knowledge-base rules, tool selection, execution patterns.
|
|
21
|
+
- Tool definitions with per-tool usage instructions.
|
|
22
|
+
|
|
23
|
+
Treat these as your initialization baseline. If the task needs fresher or broader evidence, call tools proactively before concluding.
|
|
24
|
+
|
|
25
|
+
## Behavioral rules
|
|
26
|
+
|
|
27
|
+
1. **Read before write.** Never modify a file you haven't read in this request.
|
|
28
|
+
2. **Minimal edits.** Prefer section/heading/line-level tools over full file overwrites.
|
|
29
|
+
3. **Verify after edit.** Re-read the changed file to confirm correctness.
|
|
30
|
+
4. **Cite sources.** When answering from stored knowledge, state the file path so the user can verify.
|
|
31
|
+
5. **Fail fast.** If a tool call returns an error or unexpected result, try a different approach or ask the user — do not retry identical arguments.
|
|
32
|
+
6. **Be token-aware.** You have a limited step budget (typically 10-30). Batch parallel reads/searches when possible. Do not waste steps on redundant tool calls.
|
|
33
|
+
7. **Multilingual content, user-language replies.** Write file content in whatever language the file already uses. Reply to the user in the language they used.
|
|
34
|
+
|
|
35
|
+
## Uploaded files
|
|
36
|
+
|
|
37
|
+
Users may upload local files (PDF, txt, csv, etc.) via the chat interface.
|
|
38
|
+
- Their content appears in a "⚠️ USER-UPLOADED FILES" section near the end of this prompt.
|
|
39
|
+
- Use that content directly — do NOT call read_file or search tools for uploaded files; they are not in the knowledge base.
|
|
40
|
+
- If the section is empty or missing, tell the user the upload may have failed.
|
|
41
|
+
|
|
42
|
+
## Output format
|
|
43
|
+
|
|
29
44
|
- Answer in the user's language.
|
|
30
|
-
-
|
|
31
|
-
-
|
|
32
|
-
-
|
|
45
|
+
- Use Markdown when it improves clarity (headings, lists, tables, code blocks).
|
|
46
|
+
- For multi-step tasks: output a brief numbered plan, execute, then summarize outcomes.
|
|
47
|
+
- End with concrete next actions when applicable.`;
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import type { Message, MessagePart, ToolCallPart, TextPart, ReasoningPart } from '@/lib/types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parse a UIMessageStream SSE response into structured Message parts.
|
|
5
|
+
* The stream format is Server-Sent Events where each data line is a JSON-encoded UIMessageChunk.
|
|
6
|
+
*/
|
|
7
|
+
export async function consumeUIMessageStream(
|
|
8
|
+
body: ReadableStream<Uint8Array>,
|
|
9
|
+
onUpdate: (message: Message) => void,
|
|
10
|
+
signal?: AbortSignal,
|
|
11
|
+
): Promise<Message> {
|
|
12
|
+
const reader = body.getReader();
|
|
13
|
+
const decoder = new TextDecoder();
|
|
14
|
+
let buffer = '';
|
|
15
|
+
|
|
16
|
+
// Mutable working copies — we deep-clone when emitting to React
|
|
17
|
+
const parts: MessagePart[] = [];
|
|
18
|
+
const toolCalls = new Map<string, ToolCallPart>();
|
|
19
|
+
let currentTextId: string | null = null;
|
|
20
|
+
let currentReasoningPart: ReasoningPart | null = null;
|
|
21
|
+
|
|
22
|
+
/** Deep-clone parts into an immutable Message snapshot for React state */
|
|
23
|
+
function buildMessage(): Message {
|
|
24
|
+
const clonedParts: MessagePart[] = parts.map(p => {
|
|
25
|
+
if (p.type === 'text') return { type: 'text' as const, text: p.text };
|
|
26
|
+
if (p.type === 'reasoning') return { type: 'reasoning' as const, text: p.text };
|
|
27
|
+
return { ...p }; // ToolCallPart — shallow copy is safe (all primitive fields + `input` is replaced, not mutated)
|
|
28
|
+
});
|
|
29
|
+
const textContent = clonedParts
|
|
30
|
+
.filter((p): p is TextPart => p.type === 'text')
|
|
31
|
+
.map(p => p.text)
|
|
32
|
+
.join('');
|
|
33
|
+
return {
|
|
34
|
+
role: 'assistant',
|
|
35
|
+
content: textContent,
|
|
36
|
+
parts: clonedParts,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function findOrCreateTextPart(id: string): TextPart {
|
|
41
|
+
if (currentTextId === id) {
|
|
42
|
+
const last = parts[parts.length - 1];
|
|
43
|
+
if (last && last.type === 'text') return last;
|
|
44
|
+
}
|
|
45
|
+
const part: TextPart = { type: 'text', text: '' };
|
|
46
|
+
parts.push(part);
|
|
47
|
+
currentTextId = id;
|
|
48
|
+
return part;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function findOrCreateToolCall(toolCallId: string, toolName?: string): ToolCallPart {
|
|
52
|
+
let tc = toolCalls.get(toolCallId);
|
|
53
|
+
if (!tc) {
|
|
54
|
+
tc = {
|
|
55
|
+
type: 'tool-call',
|
|
56
|
+
toolCallId,
|
|
57
|
+
toolName: toolName ?? 'unknown',
|
|
58
|
+
input: undefined,
|
|
59
|
+
state: 'pending',
|
|
60
|
+
};
|
|
61
|
+
toolCalls.set(toolCallId, tc);
|
|
62
|
+
parts.push(tc);
|
|
63
|
+
currentTextId = null; // break text continuity
|
|
64
|
+
}
|
|
65
|
+
return tc;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
while (true) {
|
|
70
|
+
if (signal?.aborted) break;
|
|
71
|
+
const { done, value } = await reader.read();
|
|
72
|
+
if (done) break;
|
|
73
|
+
|
|
74
|
+
buffer += decoder.decode(value, { stream: true });
|
|
75
|
+
|
|
76
|
+
// Process complete SSE lines
|
|
77
|
+
const lines = buffer.split('\n');
|
|
78
|
+
buffer = lines.pop() ?? ''; // keep incomplete last line
|
|
79
|
+
|
|
80
|
+
let changed = false;
|
|
81
|
+
|
|
82
|
+
for (const line of lines) {
|
|
83
|
+
const trimmed = line.trim();
|
|
84
|
+
|
|
85
|
+
// SSE format: the ai SDK v6 UIMessageStream uses "d:{json}\n"
|
|
86
|
+
// Also handle standard "data:{json}" for robustness
|
|
87
|
+
let jsonStr: string | null = null;
|
|
88
|
+
if (trimmed.startsWith('d:')) {
|
|
89
|
+
jsonStr = trimmed.slice(2);
|
|
90
|
+
} else if (trimmed.startsWith('data:')) {
|
|
91
|
+
jsonStr = trimmed.slice(5).trim();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!jsonStr) continue;
|
|
95
|
+
|
|
96
|
+
let chunk: Record<string, unknown>;
|
|
97
|
+
try {
|
|
98
|
+
chunk = JSON.parse(jsonStr);
|
|
99
|
+
} catch {
|
|
100
|
+
continue; // skip malformed lines
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const type = chunk.type as string;
|
|
104
|
+
|
|
105
|
+
switch (type) {
|
|
106
|
+
case 'text-start': {
|
|
107
|
+
findOrCreateTextPart(chunk.id as string);
|
|
108
|
+
changed = true;
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
case 'text-delta': {
|
|
112
|
+
const part = findOrCreateTextPart(chunk.id as string);
|
|
113
|
+
part.text += chunk.delta as string;
|
|
114
|
+
changed = true;
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
case 'text-end': {
|
|
118
|
+
// Text part is complete — no state change needed
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
case 'tool-input-start': {
|
|
122
|
+
const tc = findOrCreateToolCall(chunk.toolCallId as string, chunk.toolName as string);
|
|
123
|
+
tc.state = 'running';
|
|
124
|
+
changed = true;
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
case 'tool-input-delta': {
|
|
128
|
+
// Streaming input — we wait for input-available for the complete input
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
case 'tool-input-available': {
|
|
132
|
+
const tc = findOrCreateToolCall(chunk.toolCallId as string, chunk.toolName as string);
|
|
133
|
+
tc.input = chunk.input;
|
|
134
|
+
tc.state = 'running';
|
|
135
|
+
changed = true;
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
case 'tool-output-available': {
|
|
139
|
+
const tc = toolCalls.get(chunk.toolCallId as string);
|
|
140
|
+
if (tc) {
|
|
141
|
+
tc.output = typeof chunk.output === 'string' ? chunk.output : JSON.stringify(chunk.output);
|
|
142
|
+
tc.state = 'done';
|
|
143
|
+
changed = true;
|
|
144
|
+
}
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
case 'tool-output-error':
|
|
148
|
+
case 'tool-input-error': {
|
|
149
|
+
const tc = toolCalls.get(chunk.toolCallId as string);
|
|
150
|
+
if (tc) {
|
|
151
|
+
tc.output = (chunk.errorText as string) ?? 'Error';
|
|
152
|
+
tc.state = 'error';
|
|
153
|
+
changed = true;
|
|
154
|
+
}
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
case 'error': {
|
|
158
|
+
const errorText = (chunk.errorText as string) ?? 'Unknown error';
|
|
159
|
+
parts.push({ type: 'text', text: `\n\n**Error:** ${errorText}` });
|
|
160
|
+
currentTextId = null;
|
|
161
|
+
changed = true;
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
// step-start, metadata, finish — ignored for now
|
|
165
|
+
case 'reasoning-start': {
|
|
166
|
+
currentReasoningPart = { type: 'reasoning', text: '' };
|
|
167
|
+
parts.push(currentReasoningPart);
|
|
168
|
+
currentTextId = null;
|
|
169
|
+
changed = true;
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
case 'reasoning-delta': {
|
|
173
|
+
if (currentReasoningPart) {
|
|
174
|
+
currentReasoningPart.text += chunk.delta as string;
|
|
175
|
+
changed = true;
|
|
176
|
+
}
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
case 'reasoning-end': {
|
|
180
|
+
currentReasoningPart = null;
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
default:
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Emit once per reader.read() batch, not per SSE line
|
|
189
|
+
if (changed) {
|
|
190
|
+
onUpdate(buildMessage());
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
} finally {
|
|
194
|
+
reader.releaseLock();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Finalize any tool calls still stuck in running/pending state
|
|
198
|
+
// (stream ended before their output arrived — e.g. abort, network error, step limit)
|
|
199
|
+
let finalized = false;
|
|
200
|
+
for (const tc of toolCalls.values()) {
|
|
201
|
+
if (tc.state === 'running' || tc.state === 'pending') {
|
|
202
|
+
tc.state = 'error';
|
|
203
|
+
tc.output = tc.output ?? 'Stream ended before tool completed';
|
|
204
|
+
finalized = true;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (finalized) {
|
|
208
|
+
onUpdate(buildMessage());
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return buildMessage();
|
|
212
|
+
}
|