clarity-ai 4.1.0 → 4.3.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "clarity-ai",
3
- "version": "4.1.0",
4
- "description": "Premium terminal AI chat for Termux — Ink+React TUI with streaming, markdown, agent mode",
3
+ "version": "4.3.0",
4
+ "description": "Premium terminal AI agent for Termux — OpenCode-style UI, streaming, markdown, tools, agent mode",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "clarity": "bin/clarity.js"
@@ -15,17 +15,11 @@
15
15
  "dependencies": {
16
16
  "ink": "^5",
17
17
  "react": "^18",
18
- "ink-text-input": "^5",
19
- "ink-select-input": "^5",
18
+ "ink-text-input": "^6.0.0",
20
19
  "ink-spinner": "^5",
21
20
  "ink-big-text": "^2",
22
21
  "ink-gradient": "^3",
23
22
  "marked": "^12",
24
- "cli-highlight": "^2",
25
- "wrap-ansi": "^9",
26
- "string-width": "^7",
27
- "strip-ansi": "^7",
28
- "ansi-escapes": "^6",
29
- "groq-sdk": "^2"
23
+ "cli-highlight": "^2"
30
24
  }
31
25
  }
package/src/app.js CHANGED
@@ -1,25 +1,23 @@
1
1
  import React, { useState, useCallback } from 'react';
2
- import { Box, useApp } from 'ink';
2
+ import { Box } from 'ink';
3
3
  import { Banner } from './components/Banner.js';
4
4
  import { MessageList } from './components/MessageList.js';
5
- import { InputArea } from './components/InputArea.js';
6
- import { StatusBar } from './components/StatusBar.js';
5
+ import { PromptBox } from './components/PromptBox.js';
7
6
  import { CommandPicker } from './components/CommandPicker.js';
8
7
  import { ModelPicker } from './components/ModelPicker.js';
9
8
  import { useScroll } from './hooks/useScroll.js';
10
- import { useHistory } from './hooks/useHistory.js';
11
9
  import { createChatState, handleSend, handleCommand } from './chat.js';
12
10
  const { createElement: h } = React;
13
11
 
14
12
  export function App({ config }) {
15
13
  const [state, setState] = useState(createChatState);
16
- const [model, setModel] = useState(config.model || 'groq/llama-3.3-70b-versatile');
14
+ const defaultModel = (config.model || 'groq/llama-3.3-70b-versatile').replace(/^[^/]+\//, '');
15
+ const [model, setModel] = useState(defaultModel);
17
16
  const [provider, setProvider] = useState(config.provider || 'groq');
18
17
  const [showCommands, setShowCommands] = useState(false);
19
18
  const [showModels, setShowModels] = useState(false);
20
19
  const [showBanner, setShowBanner] = useState(true);
21
20
  const { scrollOffset, termHeight } = useScroll(state.messages.length);
22
- const { goBack, goForward } = useHistory();
23
21
 
24
22
  const onSubmit = useCallback(async (input) => {
25
23
  if (input.startsWith('/')) {
@@ -40,7 +38,7 @@ export function App({ config }) {
40
38
  function handleModelSelect(modelId) {
41
39
  const p = modelId.split('/')[0];
42
40
  setProvider(p);
43
- setModel(modelId);
41
+ setModel(modelId.replace(/^[^/]+\//, ''));
44
42
  setShowModels(false);
45
43
  setState(s => ({
46
44
  ...s,
@@ -48,12 +46,15 @@ export function App({ config }) {
48
46
  }));
49
47
  }
50
48
 
51
- const messages = state.messages;
52
-
53
49
  return h(Box, { flexDirection: 'column', height: '100%' },
54
- h(Box, { flexGrow: 1, flexDirection: 'column', overflowY: 'hidden' },
50
+ h(Box, { flexGrow: 1, flexDirection: 'column' },
55
51
  showBanner ? h(Banner) : null,
56
- h(MessageList, { messages, thinking: state.thinking, scrollOffset, termHeight })
52
+ h(MessageList, {
53
+ messages: state.messages,
54
+ thinking: state.thinking,
55
+ scrollOffset,
56
+ termHeight,
57
+ })
57
58
  ),
58
59
  showCommands ? h(CommandPicker, {
59
60
  query: '',
@@ -64,7 +65,13 @@ export function App({ config }) {
64
65
  onSelect: handleModelSelect,
65
66
  onClose: () => setShowModels(false),
66
67
  }) : null,
67
- h(InputArea, { onSubmit, onSlash: () => setShowCommands(true), thinking: state.thinking }),
68
- h(StatusBar, { provider, model, agentMode: state.agentMode })
68
+ h(PromptBox, {
69
+ provider,
70
+ model,
71
+ agentMode: state.agentMode,
72
+ thinking: state.thinking,
73
+ onSlash: () => setShowCommands(true),
74
+ onSubmit,
75
+ })
69
76
  );
70
77
  }
package/src/chat.js CHANGED
@@ -31,27 +31,7 @@ export async function handleSend(state, setState, input, model, provider) {
31
31
  content: m.content,
32
32
  }));
33
33
 
34
- const stream = callAI(provider, model, history, { tools: state.agentMode ? TOOLS : undefined });
35
- let buffer = '';
36
-
37
- for await (const event of stream) {
38
- if (event.type === 'token') {
39
- buffer += event.content;
40
- setState(s => ({
41
- ...s,
42
- streamBuffer: buffer,
43
- messages: updateLastAssistant(s.messages, buffer),
44
- }));
45
- }
46
- if (event.type === 'error') {
47
- handleError(setState, event);
48
- break;
49
- }
50
- }
51
-
52
- if (buffer) {
53
- setState(s => ({ ...s, thinking: false, streamBuffer: '' }));
54
- }
34
+ await processStream(provider, model, history, state.agentMode, setState);
55
35
  } catch (err) {
56
36
  if (err.type) {
57
37
  handleError(setState, err);
@@ -64,6 +44,70 @@ export async function handleSend(state, setState, input, model, provider) {
64
44
  }
65
45
  }
66
46
 
47
+ async function processStream(provider, model, history, agentMode, setState, depth = 0) {
48
+ if (depth > 10) {
49
+ setState(s => ({ ...s, thinking: false, streamBuffer: '' }));
50
+ return;
51
+ }
52
+
53
+ const stream = callAI(provider, model, history, { tools: agentMode ? TOOLS : undefined });
54
+ let buffer = '';
55
+ let toolCalls = null;
56
+
57
+ for await (const event of stream) {
58
+ if (event.type === 'token') {
59
+ buffer += event.content;
60
+ setState(s => ({
61
+ ...s,
62
+ streamBuffer: buffer,
63
+ messages: updateLastAssistant(s.messages, buffer),
64
+ }));
65
+ } else if (event.type === 'tool_calls') {
66
+ toolCalls = event.calls;
67
+ } else if (event.type === 'error') {
68
+ handleError(setState, event);
69
+ return;
70
+ }
71
+ }
72
+
73
+ if (toolCalls && toolCalls.length > 0 && agentMode) {
74
+ const assistantMsg = { id: nextId(), role: 'assistant', content: buffer || '', toolCalls };
75
+ const toolResults = [];
76
+
77
+ for (const tc of toolCalls) {
78
+ const { name, arguments: argsStr } = tc.function;
79
+ let args;
80
+ try {
81
+ args = JSON.parse(argsStr);
82
+ } catch {
83
+ args = {};
84
+ }
85
+
86
+ setState(s => ({
87
+ ...s,
88
+ messages: [...s.messages, { id: nextId(), role: 'tool', content: '⚙ ' + name + '(' + JSON.stringify(args).slice(0, 100) + ')...', toolName: name }],
89
+ }));
90
+
91
+ const result = await executeTool(name, args);
92
+ toolResults.push({ tool_call_id: tc.id, role: 'tool', content: result });
93
+ }
94
+
95
+ const newHistory = [
96
+ ...history,
97
+ { role: 'assistant', content: buffer || null, tool_calls: toolCalls.map(tc => ({
98
+ id: tc.id,
99
+ type: 'function',
100
+ function: { name: tc.function.name, arguments: tc.function.arguments },
101
+ }))},
102
+ ...toolResults,
103
+ ];
104
+
105
+ await processStream(provider, model, newHistory, agentMode, setState, depth + 1);
106
+ } else {
107
+ setState(s => ({ ...s, thinking: false, streamBuffer: '' }));
108
+ }
109
+ }
110
+
67
111
  function handleError(setState, err) {
68
112
  if (err.type === 'auth_error') {
69
113
  setState(s => ({
@@ -27,14 +27,15 @@ export function CommandPicker({ query, onSelect, onClose }) {
27
27
  if (key.escape) onClose();
28
28
  });
29
29
 
30
- return h(Box, { flexDirection: 'column', paddingX: 2, paddingY: 1 },
30
+ return h(Box, { flexDirection: 'column', paddingX: 1 },
31
+ h(Text, { color: '#00D4FF', bold: true }, 'Commands'),
32
+ h(Text, { color: '#333333' }, '\u2500'.repeat(30)),
31
33
  filtered.map((cmd, i) =>
32
- h(Box, { key: cmd.name },
34
+ h(Box, { key: cmd.name, flexDirection: 'row', gap: 2 },
33
35
  h(Text, {
34
- color: i === idx ? '#00FFD1' : '#F0F0F0',
36
+ color: i === idx ? '#FF6B6B' : '#F0F0F0',
35
37
  bold: i === idx,
36
- },
37
- ' ' + cmd.name.padEnd(18)),
38
+ }, '\u25B6 ' + cmd.name),
38
39
  h(Text, { color: '#555555' }, cmd.desc)
39
40
  )
40
41
  )
@@ -1,82 +1,66 @@
1
1
  import React from 'react';
2
2
  import { Box, Text } from 'ink';
3
- import { marked } from 'marked';
4
- import { renderMarkdown } from '../renderer/markdown.js';
5
- import { renderTable } from '../renderer/table.js';
6
- import { renderDiff } from '../renderer/diff.js';
7
- import { ErrorMessage } from './ErrorMessage.js';
8
3
  const { createElement: h } = React;
9
-
10
4
  const termWidth = () => process.stdout.columns || 80;
11
5
 
12
- function YouMessage({ text }) {
13
- return h(Box, { flexDirection: 'column', marginBottom: 1 },
14
- h(Box, null,
15
- h(Text, { color: '#00FFD1', bold: true }, 'YOU '),
16
- h(Text, { color: '#FF2EF7' }, '\u2500'.repeat(Math.max(0, termWidth() - 7)))
17
- ),
18
- h(Box, { paddingLeft: 2 },
19
- h(Text, { wrap: 'wrap' }, text)
20
- )
21
- );
22
- }
23
-
24
- function AssistantMessage({ text, streaming }) {
25
- let content;
26
- if (streaming) {
27
- content = h(Text, { wrap: 'wrap' }, text);
28
- } else {
29
- try {
30
- const tokens = marked.lexer(text);
31
- const rendered = renderMarkdown(tokens);
32
- content = h(Box, { flexDirection: 'column' }, ...rendered);
33
- } catch {
34
- content = h(Text, { wrap: 'wrap' }, text);
35
- }
6
+ export function MessageBubble({ msg }) {
7
+ if (msg.role === 'user') {
8
+ const lines = String(msg.content).split('\n');
9
+ return h(Box, { flexDirection: 'column', marginBottom: 1 },
10
+ h(Box, null,
11
+ h(Text, { color: '#9B59FF', bold: true }, '\u276f YOU '),
12
+ h(Text, { color: '#9B59FF' }, '\u2500'.repeat(Math.max(0, termWidth() - 9)))
13
+ ),
14
+ lines.map((line, i) =>
15
+ h(Text, { key: i, color: '#C39BD3', backgroundColor: '#2D1B4E' },
16
+ ' ' + line
17
+ )
18
+ )
19
+ );
36
20
  }
37
21
 
38
- return h(Box, { flexDirection: 'column', marginBottom: 1 },
39
- h(Box, null,
40
- h(Text, { color: '#F0F0F0', bold: true }, 'CLARITY '),
41
- h(Text, { color: '#555555' }, '\u2500'.repeat(Math.max(0, termWidth() - 11)))
42
- ),
43
- h(Box, { paddingLeft: 2 }, content)
44
- );
45
- }
46
-
47
- function ToolMessage({ text }) {
48
- const lines = text.split('\n');
49
- const header = lines[0];
50
- const body = lines.slice(1).join('\n');
51
-
52
- return h(Box, { flexDirection: 'column', marginBottom: 1 },
53
- h(Box, null,
54
- h(Text, { color: '#FFD700', bold: true }, 'TOOL '),
55
- h(Text, { color: '#555555' }, '\u2500'.repeat(Math.max(0, termWidth() - 8)))
56
- ),
57
- h(Box, { paddingLeft: 2 },
58
- h(Text, { color: '#FFD700', dimColor: true }, header)
59
- ),
60
- body ? h(Box, { paddingLeft: 2 },
61
- h(Text, { color: '#F0F0F0' }, body)
62
- ) : null
63
- );
64
- }
22
+ if (msg.role === 'assistant') {
23
+ const lines = String(msg.content).split('\n');
24
+ return h(Box, { flexDirection: 'column', marginBottom: 1 },
25
+ h(Box, null,
26
+ h(Text, { color: '#7B2FFF', bold: true }, '\u25c6 CLARITY '),
27
+ h(Text, { color: '#555555' }, '\u2500'.repeat(Math.max(0, termWidth() - 13)))
28
+ ),
29
+ lines.map((line, i) =>
30
+ h(Box, { key: i },
31
+ h(Text, { color: '#7B2FFF' }, '\u2502'),
32
+ h(Text, { color: '#F0F0F0' }, ' ' + line)
33
+ )
34
+ )
35
+ );
36
+ }
65
37
 
66
- function SystemMessage({ text }) {
67
- return h(Box, { paddingLeft: 2, marginBottom: 1 },
68
- h(Text, { color: '#00FF88' }, '\u2714 '),
69
- h(Text, { color: '#00FF88' }, text)
70
- );
71
- }
38
+ if (msg.role === 'tool') {
39
+ return h(Box, { flexDirection: 'column', marginBottom: 1 },
40
+ h(Box, null,
41
+ h(Text, { color: '#FFD700', bold: true }, '\u2699 TOOL '),
42
+ h(Text, { color: '#555555' }, '\u2500'.repeat(Math.max(0, termWidth() - 11)))
43
+ ),
44
+ h(Box, { paddingLeft: 2 },
45
+ h(Text, { color: '#AAAAAA' }, String(msg.content))
46
+ )
47
+ );
48
+ }
72
49
 
73
- export function MessageBubble({ msg }) {
74
- if (msg.role === 'user') return h(YouMessage, { text: msg.content });
75
- if (msg.role === 'assistant') return h(AssistantMessage, { text: msg.content, streaming: msg.streaming });
76
- if (msg.role === 'tool') return h(ToolMessage, { text: msg.content });
77
50
  if (msg.role === 'error') {
78
- return h(ErrorMessage, { raw: msg.content });
51
+ let display = String(msg.content).slice(0, 120);
52
+ return h(Box, { paddingLeft: 2, marginBottom: 1 },
53
+ h(Text, { color: '#FF4455' }, '\u2716 '),
54
+ h(Text, { color: '#FF4455' }, display)
55
+ );
79
56
  }
80
- if (msg.role === 'system') return h(SystemMessage, { text: msg.content });
57
+
58
+ if (msg.role === 'system') {
59
+ return h(Box, { paddingLeft: 2, marginBottom: 1 },
60
+ h(Text, { color: '#00FF88' }, '\u2714 '),
61
+ h(Text, { color: '#00FF88' }, String(msg.content))
62
+ );
63
+ }
64
+
81
65
  return null;
82
66
  }
@@ -5,31 +5,63 @@ const { createElement: h } = React;
5
5
 
6
6
  export function ModelPicker({ onSelect, onClose }) {
7
7
  const [search, setSearch] = useState('');
8
- const filtered = useMemo(() =>
9
- ALL_MODELS.filter(m => m.id.toLowerCase().includes(search.toLowerCase())),
10
- [search]
11
- );
12
8
  const [idx, setIdx] = useState(0);
13
9
 
10
+ const grouped = useMemo(() => {
11
+ const filtered = ALL_MODELS.filter(m =>
12
+ m.id.toLowerCase().includes(search.toLowerCase()) ||
13
+ m.label.toLowerCase().includes(search.toLowerCase())
14
+ );
15
+ const groups = {};
16
+ for (const m of filtered) {
17
+ if (!groups[m.provider]) groups[m.provider] = [];
18
+ groups[m.provider].push(m);
19
+ }
20
+ return groups;
21
+ }, [search]);
22
+
23
+ const flatList = useMemo(() => {
24
+ const list = [];
25
+ for (const [provider, models] of Object.entries(grouped)) {
26
+ for (const m of models) {
27
+ list.push({ ...m, providerHeader: list.length === 0 || list[list.length - 1]?.provider !== provider });
28
+ }
29
+ }
30
+ return list;
31
+ }, [grouped]);
32
+
14
33
  useInput((input, key) => {
15
34
  if (key.upArrow) setIdx(i => Math.max(0, i - 1));
16
- if (key.downArrow) setIdx(i => Math.min(filtered.length - 1, i + 1));
17
- if (key.return) { onSelect(filtered[idx]?.id); return; }
35
+ if (key.downArrow) setIdx(i => Math.min(flatList.length - 1, i + 1));
36
+ if (key.return) { onSelect(flatList[idx]?.id); return; }
18
37
  if (key.escape) onClose();
19
38
  if (key.backspace) setSearch(s => s.slice(0, -1));
20
39
  else if (input && !key.ctrl && !key.meta) setSearch(s => s + input);
21
40
  });
22
41
 
23
- return h(Box, { flexDirection: 'column', paddingX: 2, paddingY: 1 },
24
- h(Text, { color: search ? '#F0F0F0' : '#555555' }, 'Search: ' + (search || '')),
25
- filtered.map((m, i) =>
26
- h(Box, { key: m.id, justifyContent: 'space-between' },
27
- h(Text, {
28
- color: i === idx ? '#00FFD1' : '#F0F0F0',
29
- bold: i === idx,
30
- }, ' ' + m.id),
31
- m.badge ? h(Text, { color: '#555555' }, '[' + m.badge + ']') : null
32
- )
33
- )
42
+ return h(Box, { flexDirection: 'column', paddingX: 1 },
43
+ h(Text, { color: '#00D4FF', bold: true }, 'Select model'),
44
+ h(Text, { color: '#333333' }, '\u2500'.repeat(30)),
45
+ h(Box, { flexDirection: 'row', gap: 1 },
46
+ h(Text, { color: '#555555' }, 'Search:'),
47
+ h(Text, { color: search ? '#F0F0F0' : '#333333' }, search || '_')
48
+ ),
49
+ h(Text, { color: '#333333' }, ''),
50
+ flatList.map((m, i) => {
51
+ const items = [];
52
+ if (m.providerHeader) {
53
+ items.push(h(Text, { key: 'h-' + m.provider, color: '#00D4FF', bold: true }, '\u25BC ' + m.provider.toUpperCase()));
54
+ }
55
+ items.push(
56
+ h(Box, { key: m.id, flexDirection: 'row', gap: 2 },
57
+ h(Text, {
58
+ color: i === idx ? '#FF6B6B' : '#F0F0F0',
59
+ bold: i === idx,
60
+ }, i === idx ? '\u25B6 ' : ' ' + m.label),
61
+ m.badge ? h(Text, { color: '#555555' }, '[' + m.badge + ']') : null
62
+ )
63
+ );
64
+ return items;
65
+ })
34
66
  );
35
67
  }
@@ -0,0 +1,43 @@
1
+ import React, { useState } from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import TextInput from 'ink-text-input';
4
+ const { createElement: h } = React;
5
+
6
+ export function PromptBox({ provider, model, agentMode, thinking, onSlash, onSubmit }) {
7
+ const [value, setValue] = useState('');
8
+ const displayName = provider + '/' + model;
9
+
10
+ function handleChange(val) {
11
+ setValue(val);
12
+ if (val === '/') { setValue(''); onSlash?.(); }
13
+ }
14
+
15
+ function handleSubmit(val) {
16
+ setValue('');
17
+ onSubmit(val);
18
+ }
19
+
20
+ return h(Box, { flexDirection: 'column', flexShrink: 0 },
21
+ h(Box, { flexDirection: 'row', gap: 1 },
22
+ h(Text, { color: '#00D4FF' }, '\u2502'),
23
+ h(Box, { flexDirection: 'column', flexGrow: 1 },
24
+ h(Box, { flexDirection: 'row', gap: 1 },
25
+ h(Text, { color: '#555555' }, displayName),
26
+ h(Text, { color: '#333333' }, ' \u00B7 '),
27
+ h(Text, { color: agentMode ? '#00FF88' : '#555555' }, agentMode ? 'agent:ON' : 'agent:OFF'),
28
+ h(Text, { color: '#333333' }, ' \u00B7 '),
29
+ h(Text, { color: '#555555' }, 'ctrl+p commands')
30
+ ),
31
+ h(Box, { flexDirection: 'row', gap: 1 },
32
+ h(Text, { color: '#00D4FF' }, thinking ? ' \u25CF ' : ' \u276F '),
33
+ h(TextInput, {
34
+ value,
35
+ onChange: handleChange,
36
+ onSubmit: handleSubmit,
37
+ placeholder: thinking ? 'Thinking...' : 'Ask anything or type / for commands',
38
+ })
39
+ )
40
+ )
41
+ )
42
+ );
43
+ }
@@ -1,41 +1,93 @@
1
1
  export async function* streamResponse(endpoint, body, apiKey, extraHeaders = {}) {
2
- const res = await fetch(endpoint, {
3
- method: 'POST',
4
- headers: {
5
- 'Content-Type': 'application/json',
6
- 'Authorization': 'Bearer ' + apiKey,
7
- ...extraHeaders,
8
- },
9
- body: JSON.stringify({ ...body, stream: true }),
10
- });
11
-
12
- if (!res.ok) {
13
- const text = await res.text();
14
- const { parseErrorResponse } = await import('./errors.js');
15
- throw parseErrorResponse(res, text);
16
- }
2
+ const controller = new AbortController();
3
+ const timeoutId = setTimeout(() => controller.abort(), 30000);
4
+
5
+ try {
6
+ const res = await fetch(endpoint, {
7
+ method: 'POST',
8
+ headers: {
9
+ 'Content-Type': 'application/json',
10
+ 'Authorization': 'Bearer ' + apiKey,
11
+ ...extraHeaders,
12
+ },
13
+ body: JSON.stringify({ ...body, stream: true }),
14
+ signal: controller.signal,
15
+ });
16
+
17
+ clearTimeout(timeoutId);
18
+
19
+ if (!res.ok) {
20
+ const text = await res.text();
21
+ const { parseErrorResponse } = await import('./errors.js');
22
+ throw parseErrorResponse(res, text);
23
+ }
24
+
25
+ const reader = res.body.getReader();
26
+ const decoder = new TextDecoder();
27
+ let buffer = '';
28
+ const toolCallsMap = new Map();
17
29
 
18
- const reader = res.body.getReader();
19
- const decoder = new TextDecoder();
20
- let buffer = '';
21
-
22
- while (true) {
23
- const { done, value } = await reader.read();
24
- if (done) break;
25
- buffer += decoder.decode(value, { stream: true });
26
- const lines = buffer.split('\n');
27
- buffer = lines.pop() || '';
28
- for (const line of lines) {
29
- if (line.startsWith('data: ')) {
30
- const data = line.slice(6).trim();
31
- if (data === '[DONE]') return;
32
- try {
33
- const json = JSON.parse(data);
34
- const delta = json.choices?.[0]?.delta?.content;
35
- if (delta) yield { type: 'token', content: delta };
36
- if (json.choices?.[0]?.finish_reason) yield { type: 'done', reason: json.choices[0].finish_reason };
37
- } catch {}
30
+ while (true) {
31
+ const { done, value } = await reader.read();
32
+ if (done) break;
33
+ buffer += decoder.decode(value, { stream: true });
34
+ const lines = buffer.split('\n');
35
+ buffer = lines.pop() || '';
36
+ for (const line of lines) {
37
+ if (line.startsWith('data: ')) {
38
+ const data = line.slice(6).trim();
39
+ if (data === '[DONE]') {
40
+ const toolCalls = Array.from(toolCallsMap.values());
41
+ if (toolCalls.length > 0) {
42
+ yield { type: 'tool_calls', calls: toolCalls };
43
+ }
44
+ return;
45
+ }
46
+ try {
47
+ const json = JSON.parse(data);
48
+ const choice = json.choices?.[0];
49
+ if (!choice) continue;
50
+
51
+ const delta = choice.delta;
52
+
53
+ if (delta?.content) {
54
+ yield { type: 'token', content: delta.content };
55
+ }
56
+
57
+ if (delta?.tool_calls) {
58
+ for (const tc of delta.tool_calls) {
59
+ const idx = tc.index ?? 0;
60
+ if (!toolCallsMap.has(idx)) {
61
+ toolCallsMap.set(idx, {
62
+ id: tc.id || 'call_' + Date.now() + '_' + idx,
63
+ type: 'function',
64
+ function: { name: '', arguments: '' },
65
+ });
66
+ }
67
+ const existing = toolCallsMap.get(idx);
68
+ if (tc.id) existing.id = tc.id;
69
+ if (tc.function?.name) existing.function.name += tc.function.name;
70
+ if (tc.function?.arguments) existing.function.arguments += tc.function.arguments;
71
+ }
72
+ }
73
+
74
+ if (choice.finish_reason) {
75
+ const toolCalls = Array.from(toolCallsMap.values());
76
+ if (toolCalls.length > 0 && choice.finish_reason === 'tool_calls') {
77
+ yield { type: 'tool_calls', calls: toolCalls };
78
+ }
79
+ yield { type: 'done', reason: choice.finish_reason };
80
+ }
81
+ } catch {}
82
+ }
38
83
  }
39
84
  }
85
+
86
+ const toolCalls = Array.from(toolCallsMap.values());
87
+ if (toolCalls.length > 0) {
88
+ yield { type: 'tool_calls', calls: toolCalls };
89
+ }
90
+ } finally {
91
+ clearTimeout(timeoutId);
40
92
  }
41
93
  }