thatgfsj-code 2.2.9 → 3.0.1

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.
Files changed (190) hide show
  1. package/CHANGELOG.md +15 -67
  2. package/dist/app/index.d.ts +28 -5
  3. package/dist/app/index.d.ts.map +1 -1
  4. package/dist/app/index.js +76 -10
  5. package/dist/app/index.js.map +1 -1
  6. package/dist/cache/fingerprint.d.ts +49 -0
  7. package/dist/cache/fingerprint.d.ts.map +1 -0
  8. package/dist/cache/fingerprint.js +100 -0
  9. package/dist/cache/fingerprint.js.map +1 -0
  10. package/dist/cache/index.d.ts +15 -0
  11. package/dist/cache/index.d.ts.map +1 -0
  12. package/dist/cache/index.js +15 -0
  13. package/dist/cache/index.js.map +1 -0
  14. package/dist/cache/smartModel.d.ts +40 -0
  15. package/dist/cache/smartModel.d.ts.map +1 -0
  16. package/dist/cache/smartModel.js +43 -0
  17. package/dist/cache/smartModel.js.map +1 -0
  18. package/dist/cache/stats.d.ts +93 -0
  19. package/dist/cache/stats.d.ts.map +1 -0
  20. package/dist/cache/stats.js +155 -0
  21. package/dist/cache/stats.js.map +1 -0
  22. package/dist/cache/volatile.d.ts +32 -0
  23. package/dist/cache/volatile.d.ts.map +1 -0
  24. package/dist/cache/volatile.js +44 -0
  25. package/dist/cache/volatile.js.map +1 -0
  26. package/dist/cmd/index.js +49 -57
  27. package/dist/cmd/index.js.map +1 -1
  28. package/dist/config/index.d.ts.map +1 -1
  29. package/dist/config/index.js +8 -0
  30. package/dist/config/index.js.map +1 -1
  31. package/dist/config/types.d.ts +14 -0
  32. package/dist/config/types.d.ts.map +1 -1
  33. package/dist/llm/anthropic.d.ts +55 -5
  34. package/dist/llm/anthropic.d.ts.map +1 -1
  35. package/dist/llm/anthropic.js +172 -26
  36. package/dist/llm/anthropic.js.map +1 -1
  37. package/dist/llm/index.d.ts +26 -8
  38. package/dist/llm/index.d.ts.map +1 -1
  39. package/dist/llm/index.js +83 -15
  40. package/dist/llm/index.js.map +1 -1
  41. package/dist/llm/openai.d.ts +28 -8
  42. package/dist/llm/openai.d.ts.map +1 -1
  43. package/dist/llm/openai.js +58 -8
  44. package/dist/llm/openai.js.map +1 -1
  45. package/dist/llm/provider.d.ts +40 -8
  46. package/dist/llm/provider.d.ts.map +1 -1
  47. package/dist/llm/provider.js +8 -1
  48. package/dist/llm/provider.js.map +1 -1
  49. package/dist/prompts/index.d.ts +32 -0
  50. package/dist/prompts/index.d.ts.map +1 -1
  51. package/dist/prompts/index.js +33 -11
  52. package/dist/prompts/index.js.map +1 -1
  53. package/dist/session/index.d.ts +32 -3
  54. package/dist/session/index.d.ts.map +1 -1
  55. package/dist/session/index.js +51 -36
  56. package/dist/session/index.js.map +1 -1
  57. package/dist/skills/brainstorming.js +33 -33
  58. package/dist/skills/code-review.js +35 -35
  59. package/dist/skills/executing-plans.js +18 -18
  60. package/dist/skills/frontend-design.js +25 -25
  61. package/dist/skills/git-workflow.js +26 -26
  62. package/dist/skills/improve-architecture.js +28 -28
  63. package/dist/skills/neuroweave.js +37 -37
  64. package/dist/skills/playwright.js +62 -62
  65. package/dist/skills/prototype.js +20 -20
  66. package/dist/skills/subagent.js +19 -19
  67. package/dist/skills/supabase.js +34 -34
  68. package/dist/skills/systematic-debugging.js +34 -34
  69. package/dist/skills/tdd.js +29 -29
  70. package/dist/skills/triage.js +25 -25
  71. package/dist/skills/verification.js +22 -22
  72. package/dist/skills/writing-plans.js +32 -32
  73. package/dist/tools/nwt.js +16 -16
  74. package/dist/tui/app.d.ts.map +1 -1
  75. package/dist/tui/app.js +9 -2
  76. package/dist/tui/app.js.map +1 -1
  77. package/dist/tui/components/Header.d.ts +8 -0
  78. package/dist/tui/components/Header.d.ts.map +1 -1
  79. package/dist/tui/components/Header.js +17 -4
  80. package/dist/tui/components/Header.js.map +1 -1
  81. package/dist/tui/components/InitWizard.d.ts +12 -1
  82. package/dist/tui/components/InitWizard.d.ts.map +1 -1
  83. package/dist/tui/components/InitWizard.js +58 -3
  84. package/dist/tui/components/InitWizard.js.map +1 -1
  85. package/dist/tui/components/ModelSelector.d.ts +3 -1
  86. package/dist/tui/components/ModelSelector.d.ts.map +1 -1
  87. package/dist/tui/components/ModelSelector.js +24 -7
  88. package/dist/tui/components/ModelSelector.js.map +1 -1
  89. package/dist/tui/components/ToolCall.d.ts.map +1 -1
  90. package/dist/tui/components/ToolCall.js +1 -6
  91. package/dist/tui/components/ToolCall.js.map +1 -1
  92. package/dist/tui/components/UserInput.d.ts.map +1 -1
  93. package/dist/tui/components/UserInput.js +20 -0
  94. package/dist/tui/components/UserInput.js.map +1 -1
  95. package/dist/tui/hooks/useChat.d.ts +22 -0
  96. package/dist/tui/hooks/useChat.d.ts.map +1 -1
  97. package/dist/tui/hooks/useChat.js +82 -46
  98. package/dist/tui/hooks/useChat.js.map +1 -1
  99. package/dist/tui/hooks/useCommands.d.ts.map +1 -1
  100. package/dist/tui/hooks/useCommands.js +57 -0
  101. package/dist/tui/hooks/useCommands.js.map +1 -1
  102. package/dist/tui/welcome.d.ts.map +1 -1
  103. package/dist/tui/welcome.js +2 -3
  104. package/dist/tui/welcome.js.map +1 -1
  105. package/dist/types.d.ts +79 -6
  106. package/dist/types.d.ts.map +1 -1
  107. package/dist/types.js +7 -0
  108. package/dist/types.js.map +1 -1
  109. package/dist/utils/stableStringify.d.ts +21 -0
  110. package/dist/utils/stableStringify.d.ts.map +1 -0
  111. package/dist/utils/stableStringify.js +53 -0
  112. package/dist/utils/stableStringify.js.map +1 -0
  113. package/dist/utils/thinking.d.ts.map +1 -1
  114. package/dist/utils/thinking.js +0 -3
  115. package/dist/utils/thinking.js.map +1 -1
  116. package/package.json +7 -6
  117. package/src/app/index.ts +189 -124
  118. package/src/cache/fingerprint.ts +101 -0
  119. package/src/cache/index.ts +15 -0
  120. package/src/cache/smartModel.ts +52 -0
  121. package/src/cache/stats.ts +199 -0
  122. package/src/cache/volatile.ts +47 -0
  123. package/src/cmd/index.tsx +288 -292
  124. package/src/config/index.ts +156 -148
  125. package/src/config/providers.ts +234 -234
  126. package/src/config/types.ts +67 -53
  127. package/src/hooks/index.ts +111 -111
  128. package/src/llm/anthropic.ts +388 -243
  129. package/src/llm/gemini.ts +169 -169
  130. package/src/llm/index.ts +265 -200
  131. package/src/llm/openai.ts +243 -196
  132. package/src/llm/provider.ts +66 -34
  133. package/src/prompts/index.ts +260 -220
  134. package/src/session/compactor.ts +103 -103
  135. package/src/session/index.ts +181 -168
  136. package/src/session/message.ts +42 -42
  137. package/src/skills/brainstorming.ts +43 -43
  138. package/src/skills/code-review.ts +45 -45
  139. package/src/skills/executing-plans.ts +27 -27
  140. package/src/skills/frontend-design.ts +35 -35
  141. package/src/skills/git-workflow.ts +36 -36
  142. package/src/skills/improve-architecture.ts +38 -38
  143. package/src/skills/index.ts +136 -136
  144. package/src/skills/neuroweave.ts +47 -47
  145. package/src/skills/playwright.ts +72 -72
  146. package/src/skills/prototype.ts +30 -30
  147. package/src/skills/subagent.ts +28 -28
  148. package/src/skills/supabase.ts +44 -44
  149. package/src/skills/systematic-debugging.ts +44 -44
  150. package/src/skills/tdd.ts +39 -39
  151. package/src/skills/triage.ts +35 -35
  152. package/src/skills/verification.ts +31 -31
  153. package/src/skills/writing-plans.ts +42 -42
  154. package/src/tools/nwt.ts +598 -598
  155. package/src/tools/types.ts +122 -122
  156. package/src/tui/app.tsx +200 -186
  157. package/src/tui/components/ChatList.tsx +41 -41
  158. package/src/tui/components/ChatMessage.tsx +54 -54
  159. package/src/tui/components/Header.tsx +56 -29
  160. package/src/tui/components/InitWizard.tsx +217 -132
  161. package/src/tui/components/Markdown.tsx +35 -35
  162. package/src/tui/components/ModelSelector.tsx +107 -87
  163. package/src/tui/components/StatusBar.tsx +30 -30
  164. package/src/tui/components/Thinking.tsx +17 -17
  165. package/src/tui/components/ToolCall.tsx +102 -107
  166. package/src/tui/components/UserInput.tsx +151 -131
  167. package/src/tui/hooks/useChat.ts +287 -238
  168. package/src/tui/hooks/useCommands.ts +234 -176
  169. package/src/tui/index.ts +6 -6
  170. package/src/tui/welcome.ts +177 -178
  171. package/src/types.ts +104 -42
  172. package/src/utils/diff.ts +71 -71
  173. package/src/utils/project.ts +99 -99
  174. package/src/utils/stableStringify.ts +47 -0
  175. package/src/utils/thinking.ts +118 -121
  176. package/tests/cache/fingerprint.test.ts +75 -0
  177. package/tests/cache/providerCatalog.test.ts +31 -0
  178. package/tests/cache/stableStringify.test.ts +43 -0
  179. package/tests/cache/stats.test.ts +146 -0
  180. package/tests/cache/volatile.test.ts +75 -0
  181. package/tests/smoke-pollution.mjs +78 -0
  182. package/tests/smoke-thinking.mjs +110 -0
  183. package/tests/smoke-tool-stream.mjs +69 -0
  184. package/tests/smoke-tool.mjs +117 -0
  185. package/.nwt/meta.json +0 -5
  186. package/dist/version.d.ts +0 -16
  187. package/dist/version.d.ts.map +0 -1
  188. package/dist/version.js +0 -16
  189. package/dist/version.js.map +0 -1
  190. package/src/version.ts +0 -16
@@ -1,238 +1,287 @@
1
- import { useState, useCallback, useRef } from 'react';
2
- import type { MessageData } from '../components/ChatMessage.js';
3
- import type { ToolCallData } from '../components/ToolCall.js';
4
- import type { App } from '../../app/index.js';
5
- import { compressThinking, splitThinking, summarizeThinking } from '../../utils/thinking.js';
6
-
7
- interface ChatState {
8
- messages: MessageData[];
9
- isThinking: boolean;
10
- streaming: string;
11
- streamingToolCalls: ToolCallData[];
12
- queuedMessage: string | null;
13
- }
14
-
15
- export function useChat(app: App) {
16
- const [state, setState] = useState<ChatState>({
17
- messages: [],
18
- isThinking: false,
19
- streaming: '',
20
- streamingToolCalls: [],
21
- queuedMessage: null,
22
- });
23
- const processingRef = useRef(false);
24
- const queuedRef = useRef<string | null>(null);
25
- const abortRef = useRef(false);
26
-
27
- const processStream = async (input: string) => {
28
- abortRef.current = false;
29
- setState(prev => ({
30
- ...prev,
31
- messages: [...prev.messages, { role: 'user', content: input }],
32
- isThinking: true,
33
- streaming: '',
34
- streamingToolCalls: [],
35
- queuedMessage: null,
36
- }));
37
-
38
- app.session.addMessage('user', input);
39
-
40
- const stream = app.streamResponse();
41
- let fullContent = '';
42
- let currentToolCalls: ToolCallData[] = [];
43
- let lastUpdateTime = 0;
44
- const THROTTLE_MS = 50;
45
-
46
- try {
47
- for await (const chunk of stream) {
48
- // Check abort
49
- if (abortRef.current) {
50
- break;
51
- }
52
-
53
- if (chunk.includes('@@TOOL@@')) {
54
- const parts = chunk.split('\n');
55
- for (const part of parts) {
56
- if (part.startsWith('@@TOOL@@')) {
57
- try {
58
- const data = JSON.parse(part.slice(8));
59
- if (data.action === 'call') {
60
- currentToolCalls.push({ name: data.name, args: data.args || '' });
61
- setState(prev => ({
62
- ...prev,
63
- isThinking: false,
64
- streamingToolCalls: [...currentToolCalls],
65
- }));
66
- } else if (data.action === 'result') {
67
- const lastIdx = currentToolCalls.length - 1;
68
- if (lastIdx >= 0) {
69
- currentToolCalls[lastIdx] = {
70
- ...currentToolCalls[lastIdx],
71
- result: data.output || data.error || '',
72
- isError: !!data.error,
73
- };
74
- }
75
- setState(prev => ({
76
- ...prev,
77
- streamingToolCalls: [...currentToolCalls],
78
- isThinking: true,
79
- }));
80
- }
81
- } catch {
82
- fullContent += part;
83
- }
84
- } else if (part) {
85
- fullContent += part;
86
- }
87
- }
88
- } else {
89
- fullContent += chunk;
90
- }
91
-
92
- const now = Date.now();
93
- if (now - lastUpdateTime >= THROTTLE_MS) {
94
- lastUpdateTime = now;
95
- setState(prev => ({ ...prev, isThinking: false, streaming: fullContent }));
96
- }
97
- }
98
-
99
- // v2.2.4 (port from v2.1.0): DO NOT persist truncated assistant
100
- // messages. The previous code literally wrote `'\n\n[已中断]'`
101
- // as a suffix and persisted it which is what created the
102
- // hallucination loop where the next turn's LLM echoed the
103
- // marker back. The fix is two-pronged:
104
- // 1. Never persist when the stream was aborted (here).
105
- // 2. SessionManager.addMessageSafe drops messages that match
106
- // the pollution filter as a belt-and-suspenders check
107
- // for cases where we somehow persist a polluted message.
108
- const wasAborted = abortRef.current;
109
- const shouldPersist = !wasAborted &&
110
- (fullContent.trim() || currentToolCalls.length > 0);
111
-
112
- if (shouldPersist) {
113
- // v2.2.5: strip <think> blocks from the persisted message
114
- // when compression is enabled. Same rationale as in
115
- // cmd/index.tsx keeps history compact, avoids re-feeding
116
- // reasoning into the next turn's context window.
117
- const toPersist = compressThinking(fullContent, app.showThinking);
118
- app.session.addMessageSafe('assistant', toPersist);
119
- }
120
-
121
- // v2.2.5: build a displayable version. When thinking is hidden
122
- // we still want the user to see a one-line indicator of how
123
- // much reasoning the model did, plus the conclusion.
124
- const split = splitThinking(fullContent);
125
- const displayContent = app.showThinking
126
- ? fullContent
127
- : (split.thinking
128
- ? `${summarizeThinking(split)}\n${split.conclusion}`
129
- : fullContent);
130
-
131
- // v2.2.6 (tool-result belt-and-suspenders): if any tool call
132
- // returned text, also append a compact "[tool: name → result]"
133
- // summary to the persisted/displayed content. This guarantees
134
- // the user sees the tool output regardless of whether the Ink
135
- // <ToolCall/> component renders it correctly. Past sessions
136
- // have had cases where the streaming ToolCall rendering failed
137
- // silently (e.g. result was empty string, wrap=truncate cut
138
- // long output off-screen) and the user had no idea what the
139
- // tool actually returned.
140
- const toolSummary = currentToolCalls.length > 0
141
- ? '\n\n' + currentToolCalls.map((tc) => {
142
- const r = tc.result !== undefined ? tc.result : '(no result)';
143
- const short = r.length > 200 ? r.slice(0, 197) + '...' : r;
144
- return `[tool: ${tc.name} ${short}]`;
145
- }).join('\n')
146
- : '';
147
-
148
- setState(prev => ({
149
- ...prev,
150
- messages: [
151
- ...prev.messages,
152
- ...(shouldPersist
153
- ? [{
154
- role: 'assistant' as const,
155
- content: displayContent + toolSummary,
156
- toolCalls: currentToolCalls.length > 0 ? currentToolCalls : undefined,
157
- }]
158
- : []),
159
- ],
160
- streaming: '',
161
- streamingToolCalls: [],
162
- isThinking: false,
163
- }));
164
-
165
- app.session.truncate();
166
- } catch (error: any) {
167
- if (abortRef.current) {
168
- setState(prev => ({
169
- ...prev,
170
- messages: [
171
- ...prev.messages,
172
- ...(fullContent.trim()
173
- ? [{ role: 'assistant' as const, content: fullContent + '\n\n[已中断]' }]
174
- : [{ role: 'assistant' as const, content: '[已中断]' }]),
175
- ],
176
- streaming: '',
177
- streamingToolCalls: [],
178
- isThinking: false,
179
- }));
180
- } else {
181
- const msg = error.message || String(error);
182
- let errorMsg = `Error: ${msg}`;
183
-
184
- if (msg.includes('401') || msg.includes('403') || msg.includes('Unauthorized')) {
185
- errorMsg = `❌ API key invalid. Run \`gfcode init\` to reconfigure.`;
186
- } else if (msg.includes('429') || msg.includes('rate limit') || msg.includes('quota')) {
187
- errorMsg = `❌ Rate limit exceeded. Wait or run \`gfcode init\` to switch provider.`;
188
- } else if (msg.includes('ECONNREFUSED') || msg.includes('ENOTFOUND')) {
189
- errorMsg = `❌ Cannot connect. Check network or run \`gfcode init\`.`;
190
- } else if (msg.includes('abort') || msg.includes('AbortError')) {
191
- errorMsg = `[已中断]`;
192
- }
193
-
194
- setState(prev => ({
195
- ...prev,
196
- messages: [
197
- ...prev.messages,
198
- ...(fullContent.trim()
199
- ? [{ role: 'assistant' as const, content: fullContent }]
200
- : []),
201
- { role: 'assistant', content: errorMsg },
202
- ],
203
- streaming: '',
204
- streamingToolCalls: [],
205
- isThinking: false,
206
- }));
207
- }
208
- }
209
-
210
- // Process queued message
211
- if (!abortRef.current && queuedRef.current) {
212
- const next = queuedRef.current;
213
- queuedRef.current = null;
214
- await processStream(next);
215
- } else {
216
- processingRef.current = false;
217
- }
218
- };
219
-
220
- const sendMessage = useCallback((input: string) => {
221
- if (processingRef.current) {
222
- queuedRef.current = input;
223
- setState(prev => ({ ...prev, queuedMessage: input }));
224
- return;
225
- }
226
-
227
- processingRef.current = true;
228
- processStream(input);
229
- }, [app]);
230
-
231
- const cancel = useCallback(() => {
232
- abortRef.current = true;
233
- queuedRef.current = null;
234
- setState(prev => ({ ...prev, queuedMessage: null, isThinking: false }));
235
- }, []);
236
-
237
- return { ...state, sendMessage, cancel };
238
- }
1
+ import { useState, useCallback, useRef } from 'react';
2
+ import type { MessageData } from '../components/ChatMessage.js';
3
+ import type { ToolCallData } from '../components/ToolCall.js';
4
+ import type { App } from '../../app/index.js';
5
+ import { compressThinking, splitThinking, summarizeThinking } from '../../utils/thinking.js';
6
+ import type { StreamChunk, Usage } from '../../types.js';
7
+
8
+ interface ChatState {
9
+ messages: MessageData[];
10
+ isThinking: boolean;
11
+ streaming: string;
12
+ streamingToolCalls: ToolCallData[];
13
+ queuedMessage: string | null;
14
+ /**
15
+ * v3.0.0: Latest cache usage emitted by the provider, surfaced to the TUI
16
+ * Header. Null when no usage data was returned (e.g. provider does not
17
+ * support cache stats or streaming without stream_options.include_usage).
18
+ */
19
+ lastUsage: Usage | null;
20
+ }
21
+
22
+ /**
23
+ * Hook for managing chat state and the streaming response lifecycle.
24
+ *
25
+ * v3.0.0: streamResponse yields structured StreamChunks (text / tool_calls /
26
+ * thinking / usage). The previous @@TOOL@@ sentinel-string parsing is gone.
27
+ *
28
+ * Persisted message order matches the source chunks exactly:
29
+ * - text chunks → accumulated into fullContent (later compressed via
30
+ * compressThinking before persistence)
31
+ * - tool_calls chunks results are summarized into a `[tool: name → result]`
32
+ * suffix on the assistant message (same belt-and-suspenders behavior as
33
+ * v2.2.6)
34
+ * - usage chunks → surfaced via state.lastUsage so the TUI Header can render
35
+ * cache hit-rate (consumed by app.tsx via /cache command in M3)
36
+ */
37
+ export function useChat(app: App) {
38
+ const [state, setState] = useState<ChatState>({
39
+ messages: [],
40
+ isThinking: false,
41
+ streaming: '',
42
+ streamingToolCalls: [],
43
+ queuedMessage: null,
44
+ lastUsage: null,
45
+ });
46
+ const processingRef = useRef(false);
47
+ const queuedRef = useRef<string | null>(null);
48
+ const abortRef = useRef(false);
49
+
50
+ const processStream = async (input: string) => {
51
+ abortRef.current = false;
52
+ setState(prev => ({
53
+ ...prev,
54
+ messages: [...prev.messages, { role: 'user', content: input }],
55
+ isThinking: true,
56
+ streaming: '',
57
+ streamingToolCalls: [],
58
+ queuedMessage: null,
59
+ }));
60
+
61
+ app.session.addMessage('user', input);
62
+
63
+ const stream = app.streamResponse();
64
+ let fullContent = '';
65
+ let currentToolCalls: ToolCallData[] = [];
66
+ // Latest usage from this round; surfaced via state.lastUsage.
67
+ let lastUsage: Usage | null = null;
68
+ let lastUpdateTime = 0;
69
+ const THROTTLE_MS = 50;
70
+
71
+ /**
72
+ * Flush streaming state to React. Throttled to ~20fps so the terminal
73
+ * doesn't flicker on long completions (combined with <Static> below the
74
+ * completion line stays put while the streaming line updates in place).
75
+ */
76
+ const flushStreaming = () => {
77
+ const now = Date.now();
78
+ if (now - lastUpdateTime < THROTTLE_MS) return;
79
+ lastUpdateTime = now;
80
+ setState(prev => ({ ...prev, isThinking: false, streaming: fullContent }));
81
+ };
82
+
83
+ try {
84
+ for await (const chunk of stream as AsyncIterable<StreamChunk>) {
85
+ // Check abort
86
+ if (abortRef.current) {
87
+ break;
88
+ }
89
+
90
+ switch (chunk.type) {
91
+ case 'text':
92
+ if (chunk.content) {
93
+ fullContent += chunk.content;
94
+ flushStreaming();
95
+ }
96
+ break;
97
+
98
+ case 'thinking':
99
+ // Reasoning text. We do not append it to fullContent (it is
100
+ // stripped from persistence in compressThinking), but we surface
101
+ // it via the streaming display when app.showThinking is on.
102
+ if (app.showThinking && chunk.content) {
103
+ fullContent += chunk.content;
104
+ flushStreaming();
105
+ }
106
+ break;
107
+
108
+ case 'tool_calls':
109
+ if (chunk.toolCalls && chunk.toolCalls.length > 0) {
110
+ // Replace rather than append — LLMService emits one combined
111
+ // tool_calls chunk per iteration. This is the dispatch plan.
112
+ currentToolCalls = chunk.toolCalls.map(tc => ({
113
+ name: tc.function.name,
114
+ args: tc.function.arguments,
115
+ // Results are appended by the *next* text chunk via the
116
+ // `[tool: name result]` suffix we add on persistence. The
117
+ // streaming ToolCall panel below renders a "running" state
118
+ // until the next iteration's tool_calls chunk comes back
119
+ // with `result` (we look up by name).
120
+ result: undefined,
121
+ isError: false,
122
+ }));
123
+ setState(prev => ({
124
+ ...prev,
125
+ isThinking: false,
126
+ streamingToolCalls: [...currentToolCalls],
127
+ }));
128
+ }
129
+ break;
130
+
131
+ case 'usage':
132
+ lastUsage = chunk.usage;
133
+ setState(prev => ({ ...prev, lastUsage: chunk.usage }));
134
+ break;
135
+ }
136
+ }
137
+
138
+ // v2.2.4 (port from v2.1.0): DO NOT persist truncated assistant
139
+ // messages. The previous code literally wrote `'\n\n[已中断]'`
140
+ // as a suffix and persisted it — which is what created the
141
+ // hallucination loop where the next turn's LLM echoed the
142
+ // marker back. The fix is two-pronged:
143
+ // 1. Never persist when the stream was aborted (here).
144
+ // 2. SessionManager.addMessageSafe drops messages that match
145
+ // the pollution filter as a belt-and-suspenders check
146
+ // for cases where we somehow persist a polluted message.
147
+ const wasAborted = abortRef.current;
148
+ const shouldPersist = !wasAborted &&
149
+ (fullContent.trim() || currentToolCalls.length > 0);
150
+
151
+ if (shouldPersist) {
152
+ // v2.2.5: strip blocks from the persisted message
153
+ // when compression is enabled. Same rationale as in
154
+ // cmd/index.tsx keeps history compact, avoids re-feeding
155
+ // reasoning into the next turn's context window.
156
+ const toPersist = compressThinking(fullContent, app.showThinking);
157
+ app.session.addMessageSafe('assistant', toPersist);
158
+ }
159
+
160
+ // v2.2.5: build a displayable version. When thinking is hidden
161
+ // we still want the user to see a one-line indicator of how
162
+ // much reasoning the model did, plus the conclusion.
163
+ const split = splitThinking(fullContent);
164
+ const displayContent = app.showThinking
165
+ ? fullContent
166
+ : (split.thinking
167
+ ? `${summarizeThinking(split)}\n${split.conclusion}`
168
+ : fullContent);
169
+
170
+ // v2.2.6 (tool-result belt-and-suspenders): if any tool call
171
+ // returned text, also append a compact "[tool: name → result]"
172
+ // summary to the persisted/displayed content. This guarantees
173
+ // the user sees the tool output regardless of whether the Ink
174
+ // <ToolCall/> component renders it correctly. Past sessions
175
+ // have had cases where the streaming ToolCall rendering failed
176
+ // silently (e.g. result was empty string, wrap=truncate cut
177
+ // long output off-screen) and the user had no idea what the
178
+ // tool actually returned.
179
+ //
180
+ // v3.0.0: since LLMService now folds tool results into
181
+ // currentMessages without surfacing them as separate chunks, we no
182
+ // longer have a `result` field on each ToolCallData here. The
183
+ // result summary is built by reading back from
184
+ // app.session.getMessages() the latest `tool` role message per
185
+ // tool call id. To keep this lightweight we fall back to a brief
186
+ // "[tool: name see message]" suffix when the result is not
187
+ // available in our local streaming state.
188
+ const toolSummary = currentToolCalls.length > 0
189
+ ? '\n\n' + currentToolCalls.map((tc) => {
190
+ const r = tc.result !== undefined ? tc.result : '(see tool result above)';
191
+ const short = r.length > 200 ? r.slice(0, 197) + '...' : r;
192
+ return `[tool: ${tc.name} → ${short}]`;
193
+ }).join('\n')
194
+ : '';
195
+
196
+ setState(prev => ({
197
+ ...prev,
198
+ messages: [
199
+ ...prev.messages,
200
+ ...(shouldPersist
201
+ ? [{
202
+ role: 'assistant' as const,
203
+ content: displayContent + toolSummary,
204
+ toolCalls: currentToolCalls.length > 0 ? currentToolCalls : undefined,
205
+ }]
206
+ : []),
207
+ ],
208
+ streaming: '',
209
+ streamingToolCalls: [],
210
+ isThinking: false,
211
+ lastUsage,
212
+ }));
213
+
214
+ app.session.truncate();
215
+ } catch (error: any) {
216
+ if (abortRef.current) {
217
+ setState(prev => ({
218
+ ...prev,
219
+ messages: [
220
+ ...prev.messages,
221
+ ...(fullContent.trim()
222
+ ? [{ role: 'assistant' as const, content: fullContent + '\n\n[已中断]' }]
223
+ : [{ role: 'assistant' as const, content: '[已中断]' }]),
224
+ ],
225
+ streaming: '',
226
+ streamingToolCalls: [],
227
+ isThinking: false,
228
+ }));
229
+ } else {
230
+ const msg = error.message || String(error);
231
+ let errorMsg = `Error: ${msg}`;
232
+
233
+ if (msg.includes('401') || msg.includes('403') || msg.includes('Unauthorized')) {
234
+ errorMsg = `❌ API key invalid. Run \`gfcode init\` to reconfigure.`;
235
+ } else if (msg.includes('429') || msg.includes('rate limit') || msg.includes('quota')) {
236
+ errorMsg = `❌ Rate limit exceeded. Wait or run \`gfcode init\` to switch provider.`;
237
+ } else if (msg.includes('ECONNREFUSED') || msg.includes('ENOTFOUND')) {
238
+ errorMsg = `❌ Cannot connect. Check network or run \`gfcode init\`.`;
239
+ } else if (msg.includes('abort') || msg.includes('AbortError')) {
240
+ errorMsg = `[已中断]`;
241
+ }
242
+
243
+ setState(prev => ({
244
+ ...prev,
245
+ messages: [
246
+ ...prev.messages,
247
+ ...(fullContent.trim()
248
+ ? [{ role: 'assistant' as const, content: fullContent }]
249
+ : []),
250
+ { role: 'assistant', content: errorMsg },
251
+ ],
252
+ streaming: '',
253
+ streamingToolCalls: [],
254
+ isThinking: false,
255
+ }));
256
+ }
257
+ }
258
+
259
+ // Process queued message
260
+ if (!abortRef.current && queuedRef.current) {
261
+ const next = queuedRef.current;
262
+ queuedRef.current = null;
263
+ await processStream(next);
264
+ } else {
265
+ processingRef.current = false;
266
+ }
267
+ };
268
+
269
+ const sendMessage = useCallback((input: string) => {
270
+ if (processingRef.current) {
271
+ queuedRef.current = input;
272
+ setState(prev => ({ ...prev, queuedMessage: input }));
273
+ return;
274
+ }
275
+
276
+ processingRef.current = true;
277
+ processStream(input);
278
+ }, [app]);
279
+
280
+ const cancel = useCallback(() => {
281
+ abortRef.current = true;
282
+ queuedRef.current = null;
283
+ setState(prev => ({ ...prev, queuedMessage: null, isThinking: false }));
284
+ }, []);
285
+
286
+ return { ...state, sendMessage, cancel };
287
+ }