thatgfsj-code 0.3.0 → 0.3.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 (262) hide show
  1. package/CHANGELOG.md +229 -0
  2. package/DEVELOPMENT.md +286 -0
  3. package/README.md +144 -79
  4. package/dist/agent/core.d.ts +56 -0
  5. package/dist/agent/core.d.ts.map +1 -0
  6. package/dist/agent/core.js +142 -0
  7. package/dist/agent/core.js.map +1 -0
  8. package/dist/agent/index.d.ts +8 -0
  9. package/dist/agent/index.d.ts.map +1 -0
  10. package/dist/agent/index.js +8 -0
  11. package/dist/agent/index.js.map +1 -0
  12. package/dist/agent/intent.d.ts +36 -0
  13. package/dist/agent/intent.d.ts.map +1 -0
  14. package/dist/agent/intent.js +146 -0
  15. package/dist/agent/intent.js.map +1 -0
  16. package/dist/agent/streaming.d.ts +50 -0
  17. package/dist/agent/streaming.d.ts.map +1 -0
  18. package/dist/agent/streaming.js +110 -0
  19. package/dist/agent/streaming.js.map +1 -0
  20. package/dist/core/ai-engine.d.ts +76 -0
  21. package/dist/core/ai-engine.d.ts.map +1 -0
  22. package/dist/core/ai-engine.js +394 -0
  23. package/dist/core/ai-engine.js.map +1 -0
  24. package/dist/core/cli.d.ts +21 -0
  25. package/dist/core/cli.d.ts.map +1 -0
  26. package/dist/core/cli.js +143 -0
  27. package/dist/core/cli.js.map +1 -0
  28. package/dist/core/config.d.ts +55 -0
  29. package/dist/core/config.d.ts.map +1 -0
  30. package/dist/core/config.js +162 -0
  31. package/dist/core/config.js.map +1 -0
  32. package/dist/core/context-compactor.d.ts +54 -0
  33. package/dist/core/context-compactor.d.ts.map +1 -0
  34. package/dist/core/context-compactor.js +197 -0
  35. package/dist/core/context-compactor.js.map +1 -0
  36. package/dist/core/hooks.d.ts +75 -0
  37. package/dist/core/hooks.d.ts.map +1 -0
  38. package/dist/core/hooks.js +146 -0
  39. package/dist/core/hooks.js.map +1 -0
  40. package/dist/core/permissions.d.ts +52 -0
  41. package/dist/core/permissions.d.ts.map +1 -0
  42. package/dist/core/permissions.js +237 -0
  43. package/dist/core/permissions.js.map +1 -0
  44. package/dist/core/session.d.ts +54 -0
  45. package/dist/core/session.d.ts.map +1 -0
  46. package/dist/core/session.js +147 -0
  47. package/dist/core/session.js.map +1 -0
  48. package/dist/core/skills.d.ts +60 -0
  49. package/dist/core/skills.d.ts.map +1 -0
  50. package/dist/core/skills.js +175 -0
  51. package/dist/core/skills.js.map +1 -0
  52. package/dist/core/state.d.ts +60 -0
  53. package/dist/core/state.d.ts.map +1 -0
  54. package/dist/core/state.js +106 -0
  55. package/dist/core/state.js.map +1 -0
  56. package/dist/core/subagent.d.ts +68 -0
  57. package/dist/core/subagent.d.ts.map +1 -0
  58. package/dist/core/subagent.js +142 -0
  59. package/dist/core/subagent.js.map +1 -0
  60. package/dist/core/system-prompt.d.ts +40 -0
  61. package/dist/core/system-prompt.d.ts.map +1 -0
  62. package/dist/core/system-prompt.js +138 -0
  63. package/dist/core/system-prompt.js.map +1 -0
  64. package/dist/core/tool-registry.d.ts +53 -0
  65. package/dist/core/tool-registry.d.ts.map +1 -0
  66. package/dist/core/tool-registry.js +138 -0
  67. package/dist/core/tool-registry.js.map +1 -0
  68. package/dist/core/types.d.ts +206 -0
  69. package/dist/core/types.d.ts.map +1 -0
  70. package/dist/core/types.js +127 -0
  71. package/dist/core/types.js.map +1 -0
  72. package/dist/index.d.ts +7 -0
  73. package/dist/{cmd/index.d.ts.map → index.d.ts.map} +1 -1
  74. package/dist/index.js +472 -0
  75. package/dist/index.js.map +1 -0
  76. package/dist/mcp/client.d.ts +1 -1
  77. package/dist/mcp/client.d.ts.map +1 -1
  78. package/dist/repl/index.d.ts +8 -0
  79. package/dist/repl/index.d.ts.map +1 -0
  80. package/dist/repl/index.js +8 -0
  81. package/dist/repl/index.js.map +1 -0
  82. package/dist/repl/input.d.ts +69 -0
  83. package/dist/repl/input.d.ts.map +1 -0
  84. package/dist/repl/input.js +139 -0
  85. package/dist/repl/input.js.map +1 -0
  86. package/dist/repl/loop.d.ts +184 -0
  87. package/dist/repl/loop.d.ts.map +1 -0
  88. package/dist/repl/loop.js +932 -0
  89. package/dist/repl/loop.js.map +1 -0
  90. package/dist/repl/output.d.ts +105 -0
  91. package/dist/repl/output.d.ts.map +1 -0
  92. package/dist/repl/output.js +199 -0
  93. package/dist/repl/output.js.map +1 -0
  94. package/dist/repl/welcome.d.ts +68 -0
  95. package/dist/repl/welcome.d.ts.map +1 -0
  96. package/dist/repl/welcome.js +254 -0
  97. package/dist/repl/welcome.js.map +1 -0
  98. package/dist/tools/file.d.ts +1 -1
  99. package/dist/tools/file.d.ts.map +1 -1
  100. package/dist/tools/git.d.ts +1 -1
  101. package/dist/tools/git.d.ts.map +1 -1
  102. package/dist/tools/index.d.ts +15 -35
  103. package/dist/tools/index.d.ts.map +1 -1
  104. package/dist/tools/index.js +31 -80
  105. package/dist/tools/index.js.map +1 -1
  106. package/dist/tools/search.d.ts +1 -1
  107. package/dist/tools/search.d.ts.map +1 -1
  108. package/dist/tools/shell.d.ts +11 -4
  109. package/dist/tools/shell.d.ts.map +1 -1
  110. package/dist/tools/shell.js +78 -42
  111. package/dist/tools/shell.js.map +1 -1
  112. package/dist/utils/diff-preview.d.ts +42 -0
  113. package/dist/utils/diff-preview.d.ts.map +1 -0
  114. package/dist/utils/diff-preview.js +174 -0
  115. package/dist/utils/diff-preview.js.map +1 -0
  116. package/dist/utils/index.d.ts +8 -0
  117. package/dist/utils/index.d.ts.map +1 -0
  118. package/dist/utils/index.js +8 -0
  119. package/dist/utils/index.js.map +1 -0
  120. package/dist/utils/memory.d.ts +81 -0
  121. package/dist/utils/memory.d.ts.map +1 -0
  122. package/dist/utils/memory.js +186 -0
  123. package/dist/utils/memory.js.map +1 -0
  124. package/dist/utils/project-context.d.ts +62 -0
  125. package/dist/utils/project-context.d.ts.map +1 -0
  126. package/dist/utils/project-context.js +197 -0
  127. package/dist/utils/project-context.js.map +1 -0
  128. package/docs/API_KEY_GUIDE.md +6 -0
  129. package/docs/FAQ.md +25 -3
  130. package/package.json +41 -9
  131. package/ROADMAP.md +0 -61
  132. package/dist/app/agent.d.ts +0 -31
  133. package/dist/app/agent.d.ts.map +0 -1
  134. package/dist/app/agent.js +0 -106
  135. package/dist/app/agent.js.map +0 -1
  136. package/dist/app/index.d.ts +0 -39
  137. package/dist/app/index.d.ts.map +0 -1
  138. package/dist/app/index.js +0 -80
  139. package/dist/app/index.js.map +0 -1
  140. package/dist/cmd/index.d.ts +0 -12
  141. package/dist/cmd/index.js +0 -85
  142. package/dist/cmd/index.js.map +0 -1
  143. package/dist/config/index.d.ts +0 -39
  144. package/dist/config/index.d.ts.map +0 -1
  145. package/dist/config/index.js +0 -110
  146. package/dist/config/index.js.map +0 -1
  147. package/dist/config/providers.d.ts +0 -23
  148. package/dist/config/providers.d.ts.map +0 -1
  149. package/dist/config/providers.js +0 -152
  150. package/dist/config/providers.js.map +0 -1
  151. package/dist/config/types.d.ts +0 -33
  152. package/dist/config/types.d.ts.map +0 -1
  153. package/dist/config/types.js +0 -5
  154. package/dist/config/types.js.map +0 -1
  155. package/dist/hooks/index.d.ts +0 -31
  156. package/dist/hooks/index.d.ts.map +0 -1
  157. package/dist/hooks/index.js +0 -71
  158. package/dist/hooks/index.js.map +0 -1
  159. package/dist/llm/anthropic.d.ts +0 -17
  160. package/dist/llm/anthropic.d.ts.map +0 -1
  161. package/dist/llm/anthropic.js +0 -126
  162. package/dist/llm/anthropic.js.map +0 -1
  163. package/dist/llm/gemini.d.ts +0 -16
  164. package/dist/llm/gemini.d.ts.map +0 -1
  165. package/dist/llm/gemini.js +0 -107
  166. package/dist/llm/gemini.js.map +0 -1
  167. package/dist/llm/index.d.ts +0 -48
  168. package/dist/llm/index.d.ts.map +0 -1
  169. package/dist/llm/index.js +0 -174
  170. package/dist/llm/index.js.map +0 -1
  171. package/dist/llm/openai.d.ts +0 -17
  172. package/dist/llm/openai.d.ts.map +0 -1
  173. package/dist/llm/openai.js +0 -113
  174. package/dist/llm/openai.js.map +0 -1
  175. package/dist/llm/provider.d.ts +0 -33
  176. package/dist/llm/provider.d.ts.map +0 -1
  177. package/dist/llm/provider.js +0 -6
  178. package/dist/llm/provider.js.map +0 -1
  179. package/dist/prompts/index.d.ts +0 -27
  180. package/dist/prompts/index.d.ts.map +0 -1
  181. package/dist/prompts/index.js +0 -102
  182. package/dist/prompts/index.js.map +0 -1
  183. package/dist/session/compactor.d.ts +0 -48
  184. package/dist/session/compactor.d.ts.map +0 -1
  185. package/dist/session/compactor.js +0 -77
  186. package/dist/session/compactor.js.map +0 -1
  187. package/dist/session/index.d.ts +0 -48
  188. package/dist/session/index.d.ts.map +0 -1
  189. package/dist/session/index.js +0 -69
  190. package/dist/session/index.js.map +0 -1
  191. package/dist/session/message.d.ts +0 -26
  192. package/dist/session/message.d.ts.map +0 -1
  193. package/dist/session/message.js +0 -34
  194. package/dist/session/message.js.map +0 -1
  195. package/dist/tools/types.d.ts +0 -68
  196. package/dist/tools/types.d.ts.map +0 -1
  197. package/dist/tools/types.js +0 -57
  198. package/dist/tools/types.js.map +0 -1
  199. package/dist/tui/index.d.ts +0 -8
  200. package/dist/tui/index.d.ts.map +0 -1
  201. package/dist/tui/index.js +0 -8
  202. package/dist/tui/index.js.map +0 -1
  203. package/dist/tui/input.d.ts +0 -14
  204. package/dist/tui/input.d.ts.map +0 -1
  205. package/dist/tui/input.js +0 -50
  206. package/dist/tui/input.js.map +0 -1
  207. package/dist/tui/output.d.ts +0 -20
  208. package/dist/tui/output.d.ts.map +0 -1
  209. package/dist/tui/output.js +0 -70
  210. package/dist/tui/output.js.map +0 -1
  211. package/dist/tui/repl.d.ts +0 -25
  212. package/dist/tui/repl.d.ts.map +0 -1
  213. package/dist/tui/repl.js +0 -114
  214. package/dist/tui/repl.js.map +0 -1
  215. package/dist/tui/welcome.d.ts +0 -15
  216. package/dist/tui/welcome.d.ts.map +0 -1
  217. package/dist/tui/welcome.js +0 -86
  218. package/dist/tui/welcome.js.map +0 -1
  219. package/dist/types.d.ts +0 -38
  220. package/dist/types.d.ts.map +0 -1
  221. package/dist/types.js +0 -6
  222. package/dist/types.js.map +0 -1
  223. package/dist/utils/diff.d.ts +0 -22
  224. package/dist/utils/diff.d.ts.map +0 -1
  225. package/dist/utils/diff.js +0 -60
  226. package/dist/utils/diff.js.map +0 -1
  227. package/dist/utils/project.d.ts +0 -32
  228. package/dist/utils/project.d.ts.map +0 -1
  229. package/dist/utils/project.js +0 -89
  230. package/dist/utils/project.js.map +0 -1
  231. package/src/app/agent.ts +0 -140
  232. package/src/app/index.ts +0 -101
  233. package/src/cmd/index.ts +0 -96
  234. package/src/config/index.ts +0 -130
  235. package/src/config/providers.ts +0 -160
  236. package/src/config/types.ts +0 -46
  237. package/src/hooks/index.ts +0 -111
  238. package/src/llm/anthropic.ts +0 -146
  239. package/src/llm/gemini.ts +0 -127
  240. package/src/llm/index.ts +0 -209
  241. package/src/llm/openai.ts +0 -132
  242. package/src/llm/provider.ts +0 -38
  243. package/src/mcp/client.ts +0 -330
  244. package/src/prompts/index.ts +0 -123
  245. package/src/session/compactor.ts +0 -103
  246. package/src/session/index.ts +0 -81
  247. package/src/session/message.ts +0 -42
  248. package/src/tools/file.ts +0 -117
  249. package/src/tools/git.ts +0 -132
  250. package/src/tools/index.ts +0 -108
  251. package/src/tools/search.ts +0 -263
  252. package/src/tools/shell.ts +0 -136
  253. package/src/tools/types.ts +0 -122
  254. package/src/tui/index.ts +0 -8
  255. package/src/tui/input.ts +0 -56
  256. package/src/tui/output.ts +0 -83
  257. package/src/tui/repl.ts +0 -131
  258. package/src/tui/welcome.ts +0 -101
  259. package/src/types.ts +0 -42
  260. package/src/utils/diff.ts +0 -71
  261. package/src/utils/project.ts +0 -99
  262. package/tsconfig.json +0 -19
@@ -0,0 +1,932 @@
1
+ /**
2
+ * REPL Loop
3
+ * Main interactive loop with streaming support
4
+ *
5
+ * S07-fix:
6
+ * - 输入端换用 REPLInput (基于 @inquirer/input),支持方向键/小键盘
7
+ * - 流式输出保留完整内容(用户可滚动终端查看历史),不跳顶不刷屏
8
+ * - Ctrl+C 行为:有未提交内容=>清空当前输入;空输入两次=>退出
9
+ */
10
+ import chalk from 'chalk';
11
+ import readline from 'readline';
12
+ import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
13
+ import { join } from 'path';
14
+ import { homedir } from 'os';
15
+ import { REPLInput } from './input.js';
16
+ import { REPLOutput } from './output.js';
17
+ import { WelcomeScreen } from './welcome.js';
18
+ import { AIEngine } from '../core/ai-engine.js';
19
+ import { SessionManager } from '../core/session.js';
20
+ import { ConfigManager } from '../core/config.js';
21
+ import { SystemPromptBuilder } from '../core/system-prompt.js';
22
+ import { getBuiltInTools } from '../tools/index.js';
23
+ export class REPLLoop {
24
+ input;
25
+ output;
26
+ ai = null;
27
+ session = null;
28
+ running = false;
29
+ // Used to abort an in-flight AI stream when the user presses Ctrl+C twice
30
+ streamAbort = null;
31
+ constructor() {
32
+ this.input = new REPLInput();
33
+ this.output = new REPLOutput();
34
+ }
35
+ /**
36
+ * Initialize the REPL
37
+ * S04: Use SystemPromptBuilder for dynamic prompt construction
38
+ */
39
+ async init() {
40
+ const config = await ConfigManager.load();
41
+ this.ai = new AIEngine(config);
42
+ const builtInTools = getBuiltInTools();
43
+ for (const tool of builtInTools) {
44
+ this.ai.registerTool(tool);
45
+ }
46
+ this.session = new SessionManager();
47
+ const promptBuilder = new SystemPromptBuilder({
48
+ cwd: process.cwd(),
49
+ tools: builtInTools,
50
+ permissionMode: 'ask'
51
+ });
52
+ const systemPrompt = promptBuilder.build();
53
+ this.session.addMessage('system', systemPrompt);
54
+ this.running = true;
55
+ // 全局 SIGINT 处理:第一次按下 abort 当前 AI stream,第二次退出 REPL
56
+ process.on('SIGINT', () => {
57
+ if (this.streamAbort) {
58
+ this.streamAbort.abort();
59
+ this.streamAbort = null;
60
+ this.output.stopSpinner();
61
+ this.output.printWarning('\n⏹ Generation cancelled (Ctrl+C again to exit)');
62
+ return;
63
+ }
64
+ // 没有在生成:模拟一次 cancel 计数,以便空输入连续两次 Ctrl+C 可以退出 REPL
65
+ // (原本这里只打 warning,导致 Ctrl+C 完全退出不了,本修复把决策权交回给 REPLInput)
66
+ const shouldExit = this.input.requestCancel();
67
+ if (shouldExit) {
68
+ this.output.printInfo('\n👋 Goodbye!');
69
+ this.running = false;
70
+ }
71
+ else {
72
+ this.output.printWarning('\n(press Ctrl+C again on an empty prompt to exit)');
73
+ }
74
+ });
75
+ }
76
+ /**
77
+ * Start the REPL loop
78
+ */
79
+ async start() {
80
+ await this.init();
81
+ this.output.clear();
82
+ this.output.printBanner();
83
+ this.output.printInfo('\nType "help" for available commands\n');
84
+ this.output.printDivider();
85
+ while (this.running) {
86
+ let result;
87
+ try {
88
+ result = await this.input.prompt();
89
+ }
90
+ catch (err) {
91
+ this.output.printError(err?.message ?? String(err));
92
+ continue;
93
+ }
94
+ if (result.kind === 'cancelled') {
95
+ if (this.input.shouldExitOnCancel()) {
96
+ this.output.printInfo('\n👋 Goodbye!');
97
+ this.running = false;
98
+ break;
99
+ }
100
+ // 继续让用户输入
101
+ continue;
102
+ }
103
+ // input layer 自动重置 cancel 计数,无需手动调
104
+ // Trim and unwrap accidentally-pasted quoted prompts like `""` or `''`
105
+ // — common when the user copy-pastes from a markdown example.
106
+ let userInput = result.value;
107
+ userInput = userInput.replace(/^["'](.*)["']$/s, '$1').trim();
108
+ if (!userInput)
109
+ continue;
110
+ const handled = await this.handleCommand(userInput);
111
+ if (handled)
112
+ continue;
113
+ await this.processInput(userInput);
114
+ }
115
+ // 清理全局监听
116
+ process.removeAllListeners('SIGINT');
117
+ }
118
+ /**
119
+ * Handle built-in commands
120
+ *
121
+ * Accepts bare words, slash-prefixed forms (`/model`), AND Chinese aliases
122
+ * (`/模型`, `/提供商`, `/帮助`, `/清除`, `/退出`, `/历史`, `/工具`).
123
+ * The Chinese forms mirror thatgfsj-code@1.0.4 behaviour.
124
+ */
125
+ async handleCommand(input) {
126
+ // Strip leading slash and full-width slash; trim whitespace; lowercase.
127
+ const cmd = input
128
+ .replace(/^\//, '')
129
+ .replace(/^//, '')
130
+ .toLowerCase()
131
+ .trim();
132
+ switch (cmd) {
133
+ // ── Session control ────────────────────────────────────────────
134
+ case 'exit':
135
+ case 'quit':
136
+ case '退出':
137
+ case '\\x03':
138
+ this.output.printInfo('\n👋 Goodbye!');
139
+ this.running = false;
140
+ return true;
141
+ case 'clear':
142
+ case '清除':
143
+ case '清屏':
144
+ this.output.clear();
145
+ this.output.printBanner();
146
+ return true;
147
+ // ── Inspection ─────────────────────────────────────────────────
148
+ case 'context':
149
+ case '上下文':
150
+ this.showContext();
151
+ return true;
152
+ case 'history':
153
+ case '历史':
154
+ this.showHistory();
155
+ return true;
156
+ case 'tools':
157
+ case '工具':
158
+ this.showTools();
159
+ return true;
160
+ case 'help':
161
+ case '帮助':
162
+ this.output.printHelp();
163
+ return true;
164
+ case 'models':
165
+ case 'providers':
166
+ case '模型列表':
167
+ case '提供商':
168
+ // read-only listing
169
+ this.showProviders();
170
+ return true;
171
+ // ── Switching (interactive picker, actually mutates config) ────
172
+ case 'model':
173
+ case '模型':
174
+ await this.handleModelSwitch();
175
+ return true;
176
+ case 'provider':
177
+ case '提供商切换':
178
+ case '切换':
179
+ await this.handleProviderSwitch();
180
+ return true;
181
+ case 'edit':
182
+ case '修改':
183
+ // /edit [n] — directly jump to the edit wizard. With no arg,
184
+ // shows the saved-model list first (same as /model).
185
+ await this.runEditShortcut();
186
+ return true;
187
+ default:
188
+ return false;
189
+ }
190
+ }
191
+ /**
192
+ * Process user input with AI (S01: streaming, no prompt reset)
193
+ *
194
+ * 关键修复:
195
+ * - 不再调用 rl.question,所以不会 reset 终端
196
+ * - chunk 直接 process.stdout.write,完整保留所有输出,用户可滚动查看
197
+ * - spinner 在第一个 chunk 到达时停止,然后正常流式
198
+ */
199
+ async processInput(input) {
200
+ if (!this.ai || !this.session) {
201
+ this.output.printError('AI not initialized');
202
+ return;
203
+ }
204
+ this.session.addMessage('user', input);
205
+ // 视觉提示用户:AI 开始工作,但不阻塞,可滚动
206
+ this.output.printInfo(chalk.gray('🤖 Thinking...\n'));
207
+ let fullResponse = '';
208
+ let firstChunk = true;
209
+ this.streamAbort = new AbortController();
210
+ try {
211
+ const stream = this.ai.chatStream(this.session.getMessages());
212
+ for await (const chunk of stream) {
213
+ if (firstChunk) {
214
+ // 第一个 chunk 到来,把 "Thinking..." 那行通过 ANSI 清除
215
+ // 但不能清光——只把光标移到下一行的开头即可,内容自然出现在前面
216
+ firstChunk = false;
217
+ }
218
+ // 关键:直接 stdout.write,允许任意长度、可滚动
219
+ process.stdout.write(chunk);
220
+ fullResponse += chunk;
221
+ if (this.streamAbort.signal.aborted)
222
+ break;
223
+ }
224
+ console.log(); // 流结束换行
225
+ }
226
+ catch (error) {
227
+ if (error?.name === 'AbortError' || this.streamAbort.signal.aborted) {
228
+ console.log();
229
+ this.output.printWarning('[cancelled]');
230
+ }
231
+ else {
232
+ this.output.printError(error.message);
233
+ }
234
+ }
235
+ finally {
236
+ this.streamAbort = null;
237
+ }
238
+ const accepted = this.session.addMessage('assistant', fullResponse);
239
+ if (!accepted) {
240
+ this.output.printWarning(chalk.yellow(`⚠️ 上轮回复包含 "[已中断]" 等污染标记,已自动丢弃以避免循环。本次回复不会基于它继续;请重说你的问题。\n` +
241
+ `(本次会话已累计过滤 ${this.session.getDroppedCount()} 条污染消息)`));
242
+ }
243
+ this.session.truncate(20);
244
+ }
245
+ showContext() {
246
+ const cwd = process.cwd();
247
+ this.output.printHeader('📁 Project Context');
248
+ this.output.printInfo(`Working directory: ${cwd}`);
249
+ }
250
+ showHistory() {
251
+ const items = this.input.getHistory();
252
+ this.output.printHeader('📜 Command History');
253
+ if (items.length === 0) {
254
+ this.output.printInfo('(no history yet)');
255
+ return;
256
+ }
257
+ items.forEach((h, i) => this.output.printInfo(` ${i + 1}. ${h}`));
258
+ }
259
+ showTools() {
260
+ this.output.printHeader('🔧 Available Tools');
261
+ this.output.printInfo(' file - File operations (read, write, list, delete)');
262
+ this.output.printInfo(' shell - Execute shell commands');
263
+ this.output.printInfo(' git - Git operations (coming soon)');
264
+ }
265
+ showProviders() {
266
+ this.output.printHeader('🌐 Available Providers (read-only — use /provider to switch)');
267
+ this.output.printInfo(' siliconflow - 硅基流动 (default)');
268
+ this.output.printInfo(' minimax - MiniMax M2.5');
269
+ this.output.printInfo(' openai - OpenAI GPT');
270
+ this.output.printInfo(' anthropic - Anthropic Claude');
271
+ this.output.printInfo(' gemini - Google Gemini');
272
+ this.output.printInfo(' kimi - Moonshot Kimi');
273
+ this.output.printInfo(' deepseek - DeepSeek');
274
+ this.output.printInfo(' ernie - 文心一言 ERNIE');
275
+ this.output.printInfo(' ollama - 本地模型');
276
+ }
277
+ /**
278
+ * /model — main entry point for model management.
279
+ *
280
+ * 0.3.0 UX (per user feedback):
281
+ * - 先看到的是**已经保存的模型** (从 ~/.thatgfsj/models.json 读),
282
+ * 而不是内置 provider 列表。
283
+ * - 列表末尾是 `+ 添加新模型` 选项 → 触发完整向导
284
+ * (provider → api_key → baseUrl(可选,用于中转站) → model_name →
285
+ * context length (M) → thinking effort)。
286
+ * - 顶部有 `edit` 模式,可以重新修改任意已保存模型。
287
+ * - 选某条 saved model 后立即切换并退出;按 Enter 不变。
288
+ * - 兼容 0.2.x / 1.0.4 老 history:
289
+ * - 字符串数组会透明迁移成 {id,...} 形式。
290
+ * - 内置模型 (`Qwen3-32B` 之类) 也可选择,作为「saved + builtin」展示。
291
+ */
292
+ async handleModelSwitch() {
293
+ if (!this.ai || !this.session)
294
+ return;
295
+ const currentConfig = this.ai.getConfig();
296
+ const provider = currentConfig.provider || 'siliconflow';
297
+ const builtin = WelcomeScreen.getModelsForProvider(provider);
298
+ const saved = this.loadSavedModels();
299
+ const currentModel = currentConfig.model || builtin[0]?.id;
300
+ const RECENT_LIMIT = 8;
301
+ // Most-recent first (exclude the current one in the listing — it's
302
+ // surfaced separately as the active row).
303
+ const recents = [...saved]
304
+ .sort((a, b) => (b.addedAt ?? 0) - (a.addedAt ?? 0))
305
+ .filter(m => m.id !== currentModel)
306
+ .slice(0, RECENT_LIMIT);
307
+ this.output.printHeader(`🤖 /model — 切换 / 管理模型 (provider: ${provider})`);
308
+ // 当前
309
+ if (currentModel) {
310
+ const curSaved = saved.find(m => m.id.toLowerCase() === currentModel.toLowerCase());
311
+ const meta = curSaved && (curSaved.ctx || curSaved.thinking)
312
+ ? chalk.gray(` ctx=${curSaved.ctx ?? '-'}M thinking=${curSaved.thinking ?? '-'}`)
313
+ : '';
314
+ this.output.printInfo(chalk.green(` ⮕ 当前: ${currentModel}`) + meta);
315
+ }
316
+ // 已保存
317
+ if (recents.length > 0) {
318
+ this.output.printInfo('');
319
+ this.output.printInfo(chalk.yellow(' 已保存模型:'));
320
+ recents.forEach((m, idx) => {
321
+ const meta = [];
322
+ if (m.ctx)
323
+ meta.push(`ctx=${m.ctx}M`);
324
+ if (m.thinking)
325
+ meta.push(`think=${m.thinking}`);
326
+ const extra = meta.length ? chalk.gray(` [${meta.join(' · ')}]`) : '';
327
+ this.output.printInfo(` ${(idx + 1).toString().padStart(2)}. ${chalk.cyan(m.id)}${extra}`);
328
+ });
329
+ }
330
+ else {
331
+ this.output.printInfo('');
332
+ this.output.printInfo(chalk.gray(' (尚无保存的模型)'));
333
+ }
334
+ // 添加 / 编辑 提示
335
+ this.output.printInfo('');
336
+ this.output.printInfo(chalk.yellow(' 操作:'));
337
+ this.output.printInfo(` a. ${chalk.cyan('+ 添加新模型')} (完整向导: provider → key → url → 名称 → ctx → thinking)`);
338
+ if (recents.length > 0) {
339
+ this.output.printInfo(` e. ${chalk.cyan('edit <编号>')} (修改已保存模型的 ctx / thinking / 备注)`);
340
+ }
341
+ if (builtin.length > 0) {
342
+ this.output.printInfo(chalk.gray(` 或输入编号 1-${recents.length} 切换到对应已保存模型;直接回车保持当前。`));
343
+ }
344
+ const choice = await this.askOnce(chalk.cyan('\nmodel > ') + (recents[0]?.id ? `${recents[0].id} (1) / a / e / id` : 'a / id'));
345
+ if (choice === null) {
346
+ this.output.printInfo(chalk.gray('(已取消,保持当前模型)'));
347
+ return;
348
+ }
349
+ if (choice === '') {
350
+ this.output.printInfo(chalk.gray('(保持当前模型)'));
351
+ return;
352
+ }
353
+ const trimmed = choice.trim().toLowerCase();
354
+ // === 添加新模型 ===
355
+ if (trimmed === 'a' || trimmed === '+' || trimmed === 'add') {
356
+ await this.runAddModelWizard();
357
+ return;
358
+ }
359
+ // === 编辑已保存 ===
360
+ const editMatch = /^e(?:dit)?\s*(\d+)$/.exec(trimmed);
361
+ if (editMatch) {
362
+ const k = Number.parseInt(editMatch[1], 10) - 1;
363
+ const target = recents[k];
364
+ if (!target) {
365
+ this.output.printError('编号超出范围。');
366
+ return;
367
+ }
368
+ await this.runEditModelWizard(target);
369
+ return;
370
+ }
371
+ // === 数字 — 已保存模型中的第 k 个 ===
372
+ if (/^\d+$/.test(trimmed)) {
373
+ const k = Number.parseInt(trimmed, 10) - 1;
374
+ const target = recents[k];
375
+ if (!target) {
376
+ this.output.printError(`编号超出范围 (1-${recents.length} 或 a / edit).`);
377
+ return;
378
+ }
379
+ await this.applyModelSwitch(target.id);
380
+ this.appendSavedModel(target);
381
+ const meta = target.ctx || target.thinking
382
+ ? chalk.gray(` (ctx=${target.ctx ?? '-'}M, think=${target.thinking ?? '-'})`)
383
+ : '';
384
+ this.output.printSuccess(`模型已切换为: ${target.id}${meta}`);
385
+ this.session.addMessage('system', `[system: model switched to ${target.id}. Continue with the task.]`);
386
+ return;
387
+ }
388
+ // === 自由输入 id ===
389
+ if (trimmed === '') {
390
+ this.output.printInfo(chalk.gray('(保持当前模型)'));
391
+ return;
392
+ }
393
+ // 把裸输入当成 model id(兼容老的 /model Qwen3-32B 行为)
394
+ await this.applyModelSwitch(trimmed);
395
+ this.appendSavedModel({ id: trimmed });
396
+ this.output.printSuccess(`模型已切换为: ${trimmed} (新保存)`);
397
+ this.session.addMessage('system', `[system: model switched to ${trimmed}. Continue with the task.]`);
398
+ }
399
+ /**
400
+ * Add-model wizard (0.3.0+):
401
+ * Step 1: Provider (内置 / custom_openai / custom_anthropic)
402
+ * Step 2: API Key (内置:给注册链接;custom:无)
403
+ * Step 3: Base URL (仅 custom_*;内置跳过)
404
+ * Step 4: 模型名称 (自由输入)
405
+ * Step 5: 上下文长度 (MiB,数字,默认 8)
406
+ * Step 6: 思考强度 (none / low / medium / high / max,默认 none)
407
+ *
408
+ * 完成后:写入 ~/.thatgfsj/models.json,顺便把新模型切到当前,
409
+ * 更新 AIEngine + 写入 ~/.thatgfsj/config.json model 字段。
410
+ */
411
+ async runAddModelWizard() {
412
+ if (!this.ai)
413
+ return;
414
+ this.output.printHeader('➕ 添加新模型');
415
+ // Step 1: provider
416
+ const providers = [
417
+ { id: 'siliconflow', name: '硅基流动 (SiliconFlow, OpenAI 兼容)' },
418
+ { id: 'openai', name: 'OpenAI' },
419
+ { id: 'anthropic', name: 'Anthropic (Claude, Anthropic Messages)' },
420
+ { id: 'minimax', name: 'MiniMax (M3)' },
421
+ { id: 'gemini', name: 'Google Gemini' },
422
+ { id: 'kimi', name: 'Kimi (Moonshot AI)' },
423
+ { id: 'deepseek', name: 'DeepSeek' },
424
+ { id: 'ernie', name: '文心一言 (ERNIE)' },
425
+ { id: 'custom_openai', name: '自定义 → OpenAI 兼容 (中转站)' },
426
+ { id: 'custom_anthropic', name: '自定义 → Anthropic 兼容 (中转站)' },
427
+ ];
428
+ this.output.printInfo(' 步骤 1/6: 选择 provider');
429
+ providers.forEach((p, i) => {
430
+ this.output.printInfo(` ${(i + 1).toString().padStart(2)}. ${p.name}`);
431
+ });
432
+ const provIdx = await this.askChoice('选择编号 (1-10): ', 1, 1, providers.length);
433
+ if (provIdx === null)
434
+ return;
435
+ const provider = providers[provIdx - 1];
436
+ // Step 2: api key
437
+ let apiKey = '';
438
+ this.output.printInfo(`\n 步骤 2/6: API Key (provider: ${provider.name})`);
439
+ if (provider.id.startsWith('custom_')) {
440
+ this.output.printInfo(' (中转站模式: 这一步可选,直接回车表示使用 baseUrl 自身的鉴权头)');
441
+ }
442
+ const keyRaw = await this.askOnce(' API Key (输入或回车跳过): ');
443
+ if (keyRaw === null)
444
+ return;
445
+ apiKey = keyRaw.trim();
446
+ // Step 3: baseUrl (only custom_*)
447
+ let baseUrl;
448
+ if (provider.id === 'custom_openai' || provider.id === 'custom_anthropic') {
449
+ this.output.printInfo('\n 步骤 3/6: baseUrl (中转站 URL)');
450
+ this.output.printInfo(chalk.gray(' 例如: https://api.example.com/v1'));
451
+ const url = await this.askOnce(' baseUrl: ');
452
+ if (url === null || !url.trim()) {
453
+ this.output.printError('需要 baseUrl 才能保存自定义 provider。');
454
+ return;
455
+ }
456
+ baseUrl = url.trim();
457
+ }
458
+ // Step 4: model name
459
+ this.output.printInfo('\n 步骤 4/6: 模型名称');
460
+ const modelId = await this.askOnce(' 模型 id (例如 gpt-4.1-mini, claude-sonnet-4-5): ');
461
+ if (modelId === null || !modelId.trim()) {
462
+ this.output.printError('模型名称不能为空。已取消。');
463
+ return;
464
+ }
465
+ const model = modelId.trim();
466
+ // Step 5: context length
467
+ this.output.printInfo('\n 步骤 5/6: 上下文长度 (MiB)');
468
+ this.output.printInfo(chalk.gray(' 常见的 8 / 32 / 128 / 200;默认 8'));
469
+ const ctxRaw = await this.askOnce(' ctx (M): ');
470
+ let ctx;
471
+ if (ctxRaw !== null && ctxRaw.trim()) {
472
+ const n = Number.parseInt(ctxRaw.trim(), 10);
473
+ if (Number.isFinite(n) && n > 0)
474
+ ctx = n;
475
+ }
476
+ if (!ctx)
477
+ ctx = 8;
478
+ // Step 6: thinking effort
479
+ this.output.printInfo('\n 步骤 6/6: 思考强度');
480
+ this.output.printInfo(chalk.gray(' none / low / medium / high / max (默认 none)'));
481
+ const thinkRaw = await this.askOnce(' thinking: ');
482
+ let thinking;
483
+ const t = (thinkRaw ?? '').trim().toLowerCase();
484
+ if (t === 'low' || t === 'medium' || t === 'high' || t === 'max')
485
+ thinking = t;
486
+ else if (t === 'none' || t === '')
487
+ thinking = 'none';
488
+ else {
489
+ this.output.printWarning(`未识别的 thinking 值 "${t}", 按 none 处理。`);
490
+ thinking = 'none';
491
+ }
492
+ // 持久化 + 切换
493
+ const saved = {
494
+ id: model,
495
+ addedAt: Date.now(),
496
+ ctx,
497
+ thinking,
498
+ note: provider.id,
499
+ };
500
+ this.appendSavedModel(saved);
501
+ // 同步切到当前模型,顺带把 provider/baseUrl 写入 config.json
502
+ await this.applyProviderSwitchInternal(provider.id, baseUrl, apiKey);
503
+ await this.applyModelSwitch(model);
504
+ this.output.printSuccess(`已保存并切换到: ${model} (ctx=${ctx}M, thinking=${thinking}, provider=${provider.id}${baseUrl ? ', baseUrl=' + baseUrl : ''})`);
505
+ this.session?.addMessage('system', `[system: model ${model} added (ctx=${ctx}M, thinking=${thinking}, provider=${provider.id}). Continue with the task.]`);
506
+ }
507
+ /**
508
+ * Edit-model wizard (0.3.0+): pick a saved model and update ctx /
509
+ * thinking / note without leaving the REPL.
510
+ */
511
+ async runEditModelWizard(target) {
512
+ this.output.printHeader(`✏️ 修改已保存模型: ${target.id}`);
513
+ this.output.printInfo(chalk.gray(` provider=${target.note ?? '?'}, ctx=${target.ctx ?? '?'}M, thinking=${target.thinking ?? '?'}`));
514
+ this.output.printInfo(' 输入新值;直接回车保留旧值;输入 `-` 清空字段');
515
+ this.output.printInfo('');
516
+ const ctxRaw = await this.askOnce(` ctx (M) [${target.ctx ?? '?'}]: `);
517
+ if (ctxRaw === null)
518
+ return;
519
+ const ctxTrim = ctxRaw.trim();
520
+ let newCtx = target.ctx;
521
+ if (ctxTrim === '-')
522
+ newCtx = undefined;
523
+ else if (ctxTrim !== '') {
524
+ const n = Number.parseInt(ctxTrim, 10);
525
+ if (Number.isFinite(n) && n > 0)
526
+ newCtx = n;
527
+ else
528
+ this.output.printWarning(`ctx 未识别 ("${ctxTrim}"), 保留 ${target.ctx}。`);
529
+ }
530
+ const thinkRaw = await this.askOnce(` thinking [${target.thinking ?? 'none'}]: `);
531
+ if (thinkRaw === null)
532
+ return;
533
+ const thinkTrim = thinkRaw.trim().toLowerCase();
534
+ let newThinking = target.thinking;
535
+ if (thinkTrim === '-')
536
+ newThinking = undefined;
537
+ else if (['none', 'low', 'medium', 'high', 'max'].includes(thinkTrim))
538
+ newThinking = thinkTrim;
539
+ else if (thinkTrim !== '')
540
+ this.output.printWarning(`thinking 未识别 ("${thinkRaw}"), 保留 ${target.thinking ?? 'none'}。`);
541
+ const noteRaw = await this.askOnce(` note [${target.note ?? ''}]: `);
542
+ if (noteRaw === null)
543
+ return;
544
+ const noteTrim = noteRaw.trim();
545
+ let newNote = target.note;
546
+ if (noteTrim === '-')
547
+ newNote = undefined;
548
+ else if (noteTrim !== '')
549
+ newNote = noteTrim;
550
+ const updated = {
551
+ ...target,
552
+ ctx: newCtx,
553
+ thinking: newThinking,
554
+ note: newNote,
555
+ };
556
+ this.replaceSavedModel(target.id, updated);
557
+ this.output.printSuccess(`已更新: ${target.id}`);
558
+ this.output.printInfo(chalk.gray(` ctx=${newCtx ?? '-'}M, thinking=${newThinking ?? '-'}, note=${newNote ?? '-'}`));
559
+ }
560
+ /**
561
+ * Persist a provider choice + custom baseUrl + apiKey into
562
+ * ~/.thatgfsj/config.json and reload AIEngine config. Differs from
563
+ * applyProviderSwitch in that this also writes a custom baseUrl.
564
+ */
565
+ async applyProviderSwitchInternal(providerId, baseUrl, apiKey) {
566
+ if (!this.ai)
567
+ return;
568
+ const cfg = await this.readPersistedConfig();
569
+ cfg.provider = providerId;
570
+ if (baseUrl)
571
+ cfg.baseUrl = baseUrl;
572
+ if (apiKey)
573
+ cfg.apiKey = apiKey;
574
+ await this.persistConfig(cfg);
575
+ const next = await ConfigManager.load();
576
+ this.ai.updateConfig(next);
577
+ }
578
+ /**
579
+ * Read ~/.thatgfsj/models.json — a list of saved models the user has
580
+ * previously switched to or added. Starting from 0.3.0 each entry is
581
+ * `{ id, addedAt, ctx?, thinking?, note? }`. Older releases stored a
582
+ * flat array of strings; we transparently migrate that on read.
583
+ */
584
+ loadModelHistory() {
585
+ // Kept for backward compat with existing call sites. Returns just the
586
+ // model ids, newest last.
587
+ return this.loadSavedModels().map(m => m.id);
588
+ }
589
+ /**
590
+ * Read ~/.thatgfsj/models.json as a list of SavedModel entries.
591
+ * Migrates v0.2.x's `["id1","id2"]` shape into 0.3.0's `{id,...}` shape
592
+ * on the fly, leaving the on-disk file untouched until a write happens.
593
+ */
594
+ loadSavedModels() {
595
+ try {
596
+ const p = join(homedir(), '.thatgfsj', 'models.json');
597
+ if (!existsSync(p))
598
+ return [];
599
+ const raw = JSON.parse(readFileSync(p, 'utf-8'));
600
+ if (!Array.isArray(raw))
601
+ return [];
602
+ // v0.3.0+: [{id, ctx?, thinking?}, ...]
603
+ const looksLikeObjects = raw.length > 0 && raw[0] && typeof raw[0] === 'object';
604
+ if (looksLikeObjects) {
605
+ return raw
606
+ .filter(x => x && typeof x === 'object' && typeof x.id === 'string')
607
+ .map((x) => ({
608
+ id: x.id,
609
+ addedAt: typeof x.addedAt === 'number' ? x.addedAt : 0,
610
+ ctx: typeof x.ctx === 'number' ? x.ctx : undefined,
611
+ thinking: typeof x.thinking === 'string' ? x.thinking : undefined,
612
+ note: typeof x.note === 'string' ? x.note : undefined,
613
+ }));
614
+ }
615
+ // v0.2.x: ["id1", "id2", ...] — migrate in memory only.
616
+ return raw
617
+ .filter(x => typeof x === 'string')
618
+ .map(id => ({ id: id, addedAt: 0 }));
619
+ }
620
+ catch {
621
+ return [];
622
+ }
623
+ }
624
+ /**
625
+ * Legacy helper: append a model id to ~/.thatgfsj/models.json.
626
+ * New callers should prefer `appendSavedModel` so we don't lose
627
+ * ctx / thinking metadata.
628
+ */
629
+ appendModelHistory(modelId) {
630
+ this.appendSavedModel({ id: modelId });
631
+ }
632
+ /**
633
+ * Persist a SavedModel into ~/.thatgfsj/models.json, replacing any
634
+ * existing entry with the same id (case-insensitive). The original
635
+ * casing of the entry is preserved — i.e. if the user already has
636
+ * `Qwen3-32B` saved and types `qwen3-32b`, the canonical `Qwen3-32B`
637
+ * casing is kept. Newest entries go at the tail of the file.
638
+ */
639
+ appendSavedModel(model) {
640
+ try {
641
+ const dir = join(homedir(), '.thatgfsj');
642
+ if (!existsSync(dir))
643
+ mkdirSync(dir, { recursive: true });
644
+ const p = join(dir, 'models.json');
645
+ const list = this.loadSavedModels();
646
+ const existing = list.find(m => m.id.toLowerCase() === model.id.toLowerCase());
647
+ const filtered = list.filter(m => m.id.toLowerCase() !== model.id.toLowerCase());
648
+ const canonical = {
649
+ ...(existing ?? {}),
650
+ ...model,
651
+ id: existing?.id ?? model.id, // canonical casing wins
652
+ addedAt: Date.now(),
653
+ };
654
+ filtered.push(canonical);
655
+ writeFileSync(p, JSON.stringify(filtered, null, 2));
656
+ }
657
+ catch {
658
+ // best-effort
659
+ }
660
+ }
661
+ /** Replace an existing saved model entry (matched by id). */
662
+ replaceSavedModel(id, patch) {
663
+ try {
664
+ const dir = join(homedir(), '.thatgfsj');
665
+ if (!existsSync(dir))
666
+ mkdirSync(dir, { recursive: true });
667
+ const p = join(dir, 'models.json');
668
+ const list = this.loadSavedModels();
669
+ const idx = list.findIndex(m => m.id.toLowerCase() === id.toLowerCase());
670
+ if (idx < 0)
671
+ return;
672
+ list[idx] = { ...list[idx], ...patch, id: list[idx].id, addedAt: list[idx].addedAt };
673
+ writeFileSync(p, JSON.stringify(list, null, 2));
674
+ }
675
+ catch {
676
+ // best-effort
677
+ }
678
+ }
679
+ /**
680
+ * /provider — interactive provider picker. After provider selection,
681
+ * delegates to /model so the user can also pick a new model for the new provider.
682
+ */
683
+ async handleProviderSwitch() {
684
+ if (!this.ai || !this.session)
685
+ return;
686
+ const currentConfig = this.ai.getConfig();
687
+ const providers = [
688
+ { id: 'siliconflow', name: 'SiliconFlow (推荐)', envKey: 'SILICONFLOW_API_KEY' },
689
+ { id: 'minimax', name: 'MiniMax', envKey: 'MINIMAX_API_KEY' },
690
+ { id: 'openai', name: 'OpenAI', envKey: 'OPENAI_API_KEY' },
691
+ { id: 'anthropic', name: 'Anthropic', envKey: 'ANTHROPIC_API_KEY' },
692
+ { id: 'gemini', name: 'Google Gemini', envKey: 'GEMINI_API_KEY' },
693
+ { id: 'kimi', name: 'Kimi (Moonshot AI)', envKey: 'KIMI_API_KEY' },
694
+ { id: 'deepseek', name: 'DeepSeek', envKey: 'DEEPSEEK_API_KEY' },
695
+ { id: 'ernie', name: '文心一言 (ERNIE)', envKey: 'ERNIE_API_KEY' },
696
+ { id: 'custom_openai', name: '自定义 → OpenAI 兼容 (中转站)', envKey: 'CUSTOM_API_KEY' },
697
+ { id: 'custom_anthropic', name: '自定义 → Anthropic 兼容 (中转站)', envKey: 'CUSTOM_API_KEY' },
698
+ ];
699
+ this.output.printHeader('🌐 /provider — 切换提供商');
700
+ providers.forEach((p, idx) => {
701
+ const mark = p.id === currentConfig.provider ? ' ✓' : '';
702
+ this.output.printInfo(` ${(idx + 1).toString().padStart(2)}. ${p.name.padEnd(28)} ${chalk.gray(p.id)}${mark}`);
703
+ });
704
+ this.output.printInfo('');
705
+ this.output.printInfo('输入编号或 provider id,直接回车保持当前。');
706
+ const choice = await this.askOnce(chalk.cyan('provider > ') + (currentConfig.provider ?? ''));
707
+ if (choice === null) {
708
+ this.output.printInfo(chalk.gray('(已取消,保持当前 provider)'));
709
+ return;
710
+ }
711
+ const idx = Number.parseInt(choice, 10);
712
+ const pickedProvider = (Number.isInteger(idx) && providers[idx - 1]) ||
713
+ providers.find(p => p.id === choice);
714
+ if (!pickedProvider) {
715
+ this.output.printError(`未识别: "${choice}". 保持当前 provider。`);
716
+ return;
717
+ }
718
+ if (pickedProvider.id === currentConfig.provider) {
719
+ this.output.printInfo(chalk.gray('(同一个 provider,无需切换)'));
720
+ return;
721
+ }
722
+ // 自定义 provider 走完整向导,内置 provider 只切 + 提示 key
723
+ if (pickedProvider.id.startsWith('custom_')) {
724
+ this.output.printInfo(chalk.cyan(`\n 切换到 ${pickedProvider.id},需要 baseUrl:`));
725
+ const url = await this.askOnce(' baseUrl (例如 https://api.example.com/v1): ');
726
+ if (!url || !url.trim()) {
727
+ this.output.printError('需要 baseUrl,已取消。');
728
+ return;
729
+ }
730
+ const keyRaw = await this.askOnce(` API Key (可回车跳过,env ${pickedProvider.envKey} 也行): `);
731
+ await this.applyProviderSwitchInternal(pickedProvider.id, url.trim(), (keyRaw ?? '').trim());
732
+ this.output.printSuccess(`provider 已切换为: ${pickedProvider.name} (${pickedProvider.id})`);
733
+ this.session.addMessage('system', `[system: provider switched to ${pickedProvider.id} (baseUrl=${url.trim()}).]`);
734
+ await this.handleModelSwitch();
735
+ return;
736
+ }
737
+ // 内置 provider
738
+ const envHasKey = !!process.env[pickedProvider.envKey];
739
+ let cfgHasKey = false;
740
+ try {
741
+ const cfgPath = join(homedir(), '.thatgfsj', 'config.json');
742
+ if (existsSync(cfgPath)) {
743
+ const cfg = JSON.parse(readFileSync(cfgPath, 'utf-8'));
744
+ cfgHasKey = !!(cfg.apiKey) && (cfg.provider === pickedProvider.id || !cfg.provider);
745
+ }
746
+ }
747
+ catch { }
748
+ if (!envHasKey && !cfgHasKey) {
749
+ this.output.printWarning(`未检测到 ${pickedProvider.envKey}。运行 \`gfcode init\` 设置,或 export ${pickedProvider.envKey}=... 后重试。`);
750
+ }
751
+ await this.applyProviderSwitch(pickedProvider.id);
752
+ this.output.printSuccess(`provider 已切换为: ${pickedProvider.name} (${pickedProvider.id})`);
753
+ this.session.addMessage('system', `[system: provider switched to ${pickedProvider.id}. Picking a new model…]`);
754
+ await this.handleModelSwitch();
755
+ }
756
+ /**
757
+ * /edit [n] — short-hand for the edit wizard.
758
+ * `/edit` → list saved models, ask which to edit.
759
+ * `/edit 1` → jump to wizard for saved model #1.
760
+ * `/edit Qwen3-32B` → jump to wizard for the model whose id matches.
761
+ */
762
+ async runEditShortcut() {
763
+ if (!this.ai)
764
+ return;
765
+ const saved = this.loadSavedModels().sort((a, b) => (b.addedAt ?? 0) - (a.addedAt ?? 0));
766
+ if (saved.length === 0) {
767
+ this.output.printWarning('没有已保存的模型。先用 /model 添加几条。');
768
+ return;
769
+ }
770
+ this.output.printHeader('✏️ /edit — 修改已保存模型');
771
+ saved.forEach((m, i) => {
772
+ const meta = [];
773
+ if (m.ctx)
774
+ meta.push(`ctx=${m.ctx}M`);
775
+ if (m.thinking)
776
+ meta.push(`think=${m.thinking}`);
777
+ const extra = meta.length ? chalk.gray(` [${meta.join(' · ')}]`) : '';
778
+ this.output.printInfo(` ${(i + 1).toString().padStart(2)}. ${chalk.cyan(m.id)}${extra}`);
779
+ });
780
+ this.output.printInfo('');
781
+ const arg = await this.askOnce(' 编号或完整 id: ');
782
+ if (arg === null)
783
+ return;
784
+ const trimmed = arg.trim();
785
+ let target = null;
786
+ if (/^\d+$/.test(trimmed)) {
787
+ target = saved[Number.parseInt(trimmed, 10) - 1] ?? null;
788
+ }
789
+ else {
790
+ target = saved.find(m => m.id.toLowerCase() === trimmed.toLowerCase()) ?? null;
791
+ }
792
+ if (!target) {
793
+ this.output.printError(`未识别: "${trimmed}".`);
794
+ return;
795
+ }
796
+ await this.runEditModelWizard(target);
797
+ }
798
+ /**
799
+ * Persist a model choice and update the running AIEngine.
800
+ */
801
+ async applyModelSwitch(modelId) {
802
+ if (!this.ai)
803
+ return;
804
+ const cfg = await this.readPersistedConfig();
805
+ cfg.model = modelId;
806
+ await this.persistConfig(cfg);
807
+ const next = await ConfigManager.load();
808
+ this.ai.updateConfig(next);
809
+ }
810
+ /**
811
+ * Persist a provider choice (with the corresponding baseUrl from PROVIDERS
812
+ * map) and update the running AIEngine.
813
+ */
814
+ async applyProviderSwitch(providerId) {
815
+ if (!this.ai)
816
+ return;
817
+ const cfg = await this.readPersistedConfig();
818
+ cfg.provider = providerId;
819
+ await this.persistConfig(cfg);
820
+ const next = await ConfigManager.load();
821
+ this.ai.updateConfig(next);
822
+ }
823
+ /**
824
+ * Read the on-disk config; if no file exists, derive a starting config
825
+ * from the current AIEngine state.
826
+ */
827
+ async readPersistedConfig() {
828
+ try {
829
+ const cfgPath = join(homedir(), '.thatgfsj', 'config.json');
830
+ if (existsSync(cfgPath)) {
831
+ return JSON.parse(readFileSync(cfgPath, 'utf-8'));
832
+ }
833
+ }
834
+ catch { }
835
+ if (this.ai) {
836
+ const snap = this.ai.getConfig();
837
+ return {
838
+ provider: snap.provider,
839
+ model: snap.model,
840
+ apiKey: snap.apiKey ?? '',
841
+ baseUrl: snap.baseUrl,
842
+ temperature: snap.temperature ?? 0.7,
843
+ maxTokens: snap.maxTokens ?? 4096,
844
+ };
845
+ }
846
+ return {
847
+ provider: 'siliconflow',
848
+ model: 'Qwen/Qwen2.5-7B-Instruct',
849
+ temperature: 0.7,
850
+ maxTokens: 4096,
851
+ apiKey: '',
852
+ };
853
+ }
854
+ async persistConfig(cfg) {
855
+ const dir = join(homedir(), '.thatgfsj');
856
+ if (!existsSync(dir))
857
+ mkdirSync(dir, { recursive: true });
858
+ writeFileSync(join(dir, 'config.json'), JSON.stringify(cfg, null, 2));
859
+ }
860
+ /**
861
+ * Resolve either a numeric index or a model id to a model entry.
862
+ */
863
+ resolveModelChoice(choice, models) {
864
+ const trimmed = choice.trim();
865
+ const idx = Number.parseInt(trimmed, 10);
866
+ if (Number.isInteger(idx) && models[idx - 1])
867
+ return models[idx - 1];
868
+ return models.find(m => m.id === trimmed) ?? null;
869
+ }
870
+ /**
871
+ * Wrap readline in a one-shot prompt. Returns the trimmed answer, or
872
+ * `null` if the user pressed Ctrl+C (does not throw).
873
+ */
874
+ askOnce(prefix) {
875
+ return new Promise((resolve) => {
876
+ const rl = readline.createInterface({
877
+ input: process.stdin,
878
+ output: process.stdout,
879
+ terminal: true,
880
+ });
881
+ const onSigint = () => {
882
+ rl.close();
883
+ resolve(null);
884
+ };
885
+ rl.once('SIGINT', onSigint);
886
+ rl.question(prefix, (answer) => {
887
+ rl.removeListener('SIGINT', onSigint);
888
+ rl.close();
889
+ resolve(answer.trim());
890
+ });
891
+ });
892
+ }
893
+ /**
894
+ * Validate-and-collect a numeric choice within `[min..max]`. Accepts
895
+ * empty input as `defaultValue`. Returns `null` on Ctrl+C.
896
+ */
897
+ async askChoice(prefix, defaultValue, min, max) {
898
+ while (true) {
899
+ const raw = await this.askOnce(prefix);
900
+ if (raw === null)
901
+ return null;
902
+ if (raw === '')
903
+ return defaultValue;
904
+ const n = Number.parseInt(raw, 10);
905
+ if (Number.isInteger(n) && n >= min && n <= max)
906
+ return n;
907
+ this.output.printWarning(`请输入 ${min}-${max} 之间的整数。`);
908
+ }
909
+ }
910
+ /**
911
+ * Stop the REPL
912
+ */
913
+ stop() {
914
+ this.running = false;
915
+ if (this.streamAbort) {
916
+ this.streamAbort.abort();
917
+ this.streamAbort = null;
918
+ }
919
+ this.output.stopStreaming();
920
+ }
921
+ /**
922
+ * Handle interrupt (Ctrl+C) while streaming
923
+ */
924
+ interrupt() {
925
+ if (this.streamAbort) {
926
+ this.streamAbort.abort();
927
+ this.streamAbort = null;
928
+ }
929
+ this.output.stopStreaming();
930
+ }
931
+ }
932
+ //# sourceMappingURL=loop.js.map