thatgfsj-code 3.0.1 → 3.0.3

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 (55) hide show
  1. package/dist/app/index.d.ts +6 -0
  2. package/dist/app/index.d.ts.map +1 -1
  3. package/dist/app/index.js +8 -0
  4. package/dist/app/index.js.map +1 -1
  5. package/dist/cache/index.d.ts +1 -1
  6. package/dist/cache/index.d.ts.map +1 -1
  7. package/dist/cache/index.js +1 -1
  8. package/dist/cache/index.js.map +1 -1
  9. package/dist/cache/smartModel.d.ts +68 -20
  10. package/dist/cache/smartModel.d.ts.map +1 -1
  11. package/dist/cache/smartModel.js +98 -20
  12. package/dist/cache/smartModel.js.map +1 -1
  13. package/dist/config/index.d.ts.map +1 -1
  14. package/dist/config/index.js +6 -4
  15. package/dist/config/index.js.map +1 -1
  16. package/dist/config/types.d.ts +5 -1
  17. package/dist/config/types.d.ts.map +1 -1
  18. package/dist/llm/anthropic.d.ts +17 -0
  19. package/dist/llm/anthropic.d.ts.map +1 -1
  20. package/dist/llm/anthropic.js +25 -1
  21. package/dist/llm/anthropic.js.map +1 -1
  22. package/dist/llm/index.d.ts +9 -0
  23. package/dist/llm/index.d.ts.map +1 -1
  24. package/dist/llm/index.js +36 -1
  25. package/dist/llm/index.js.map +1 -1
  26. package/dist/llm/provider.d.ts +6 -1
  27. package/dist/llm/provider.d.ts.map +1 -1
  28. package/dist/tui/app.d.ts.map +1 -1
  29. package/dist/tui/app.js +17 -44
  30. package/dist/tui/app.js.map +1 -1
  31. package/dist/tui/components/Header.d.ts +7 -0
  32. package/dist/tui/components/Header.d.ts.map +1 -1
  33. package/dist/tui/components/Header.js +2 -2
  34. package/dist/tui/components/Header.js.map +1 -1
  35. package/dist/tui/components/InitWizard.d.ts +18 -9
  36. package/dist/tui/components/InitWizard.d.ts.map +1 -1
  37. package/dist/tui/components/InitWizard.js +76 -52
  38. package/dist/tui/components/InitWizard.js.map +1 -1
  39. package/dist/tui/hooks/useCommands.d.ts.map +1 -1
  40. package/dist/tui/hooks/useCommands.js +38 -0
  41. package/dist/tui/hooks/useCommands.js.map +1 -1
  42. package/package.json +1 -1
  43. package/src/app/index.ts +8 -0
  44. package/src/cache/index.ts +7 -1
  45. package/src/cache/smartModel.ts +113 -20
  46. package/src/config/index.ts +6 -4
  47. package/src/config/types.ts +5 -1
  48. package/src/llm/anthropic.ts +26 -1
  49. package/src/llm/index.ts +38 -1
  50. package/src/llm/provider.ts +6 -1
  51. package/src/tui/app.tsx +15 -56
  52. package/src/tui/components/Header.tsx +12 -2
  53. package/src/tui/components/InitWizard.tsx +103 -69
  54. package/src/tui/hooks/useCommands.ts +39 -0
  55. package/tests/cache/smartModel.test.ts +112 -0
@@ -1,8 +1,9 @@
1
1
  /** @jsxImportSource react */
2
- import React, { useState, useEffect } from 'react';
3
- import { Box, Text } from 'ink';
2
+ import React, { useState } from 'react';
3
+ import { Box, Text, useInput } from 'ink';
4
4
  import SelectInput from 'ink-select-input';
5
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
5
+ import TextInput from 'ink-text-input';
6
+ import { writeFileSync, existsSync, mkdirSync } from 'fs';
6
7
  import { join } from 'path';
7
8
  import { homedir } from 'os';
8
9
  import { listProviders, getModelsForProvider, isCustomProvider } from '../../config/providers.js';
@@ -13,14 +14,7 @@ interface Props {
13
14
  onCancel: () => void;
14
15
  }
15
16
 
16
- type InitStep =
17
- | 'provider'
18
- | 'api_key'
19
- | 'model'
20
- | 'custom_model'
21
- | 'custom_url'
22
- | 'cache_strategy'
23
- | 'cache_ttl';
17
+ type InitStep = 'provider' | 'custom_url' | 'api_key' | 'model' | 'custom_model' | 'cache_strategy';
24
18
 
25
19
  interface CacheChoice {
26
20
  enabled: boolean;
@@ -28,15 +22,24 @@ interface CacheChoice {
28
22
  }
29
23
 
30
24
  /**
31
- * v3.0.0: InitWizard gained two new steps after model selection:
32
- * - cache_strategy: enable / disable prompt caching
33
- * - cache_ttl: 5m vs 1h (only if enabled)
25
+ * v3.0.1: InitWizard is now fully self-contained it manages its own
26
+ * step state AND its own keyboard input for text-entry steps (api_key,
27
+ * custom_url, custom_model). The previous version had a fatal bug:
34
28
  *
35
- * The defaults mirror ConfigManager.DEFAULT_CONFIG (enabled, 5m). Users on
36
- * OpenAI / DeepSeek / Gemini can ignore these those providers do
37
- * automatic caching and the flag is purely advisory there. The flag
38
- * matters for Anthropic users who want to opt out of cache_control
39
- * markers or pick a longer TTL.
29
+ * The text-entry steps (`api_key`, `custom_url`, `custom_model`) only
30
+ * rendered a `<Text>` prompt with no actual input box. The host
31
+ * component (app.tsx) had its own UserInput BUT only rendered it when
32
+ * viewMode was NOT 'init_wizard' (see app.tsx ternary). So when the
33
+ * wizard's internal step advanced to 'api_key', the user could SEE the
34
+ * "输入 API Key: 服务商: deepseek" prompt but had NO way to type into
35
+ * it. Ink silently swallowed keystrokes (no useInput handler was
36
+ * active), and the user was stuck on a frozen screen — exactly the
37
+ * bug that prompted the user-reported "选择 deepseek 然后退出":
38
+ * pressing Ctrl+C felt like the only escape.
39
+ *
40
+ * Fix: this file now uses `useInput` and `ink-text-input` for the text
41
+ * steps. The SetViewMode-based input delegation in app.tsx is no longer
42
+ * required for the InitWizard flow.
40
43
  */
41
44
  export function InitWizard({ onComplete, onCancel }: Props) {
42
45
  const [step, setStep] = useState<InitStep>('provider');
@@ -61,7 +64,6 @@ export function InitWizard({ onComplete, onCancel }: Props) {
61
64
  temperature: 0.7,
62
65
  maxTokens: 4096,
63
66
  contextLength: 50,
64
- // v3.0.0: cache policy from wizard
65
67
  cache: {
66
68
  enabled: cacheChoice.enabled,
67
69
  ttl: cacheChoice.ttl,
@@ -70,18 +72,17 @@ export function InitWizard({ onComplete, onCancel }: Props) {
70
72
  };
71
73
  if (url) config.baseUrl = url;
72
74
  writeFileSync(configPath, JSON.stringify(config, null, 2));
75
+ };
73
76
 
74
- // Save to models history
75
- const historyPath = join(dir, 'models.json');
76
- let history: string[] = [];
77
- if (existsSync(historyPath)) {
78
- try { history = JSON.parse(readFileSync(historyPath, 'utf-8')); } catch {}
79
- }
80
- if (!history.includes(model)) {
81
- history.push(model);
82
- writeFileSync(historyPath, JSON.stringify(history, null, 2));
77
+ // v3.0.1: ESC cancels the wizard from any text-entry step. This is the
78
+ // hook that the previous version was missing — when the user pressed
79
+ // ESC on the frozen "输入 API Key" screen, nothing happened because no
80
+ // useInput was active. Now it bails out cleanly.
81
+ useInput((_input, key) => {
82
+ if (key.escape && (step === 'api_key' || step === 'custom_url' || step === 'custom_model')) {
83
+ onCancel();
83
84
  }
84
- };
85
+ });
85
86
 
86
87
  // Step 1: Provider selection
87
88
  if (step === 'provider') {
@@ -92,8 +93,9 @@ export function InitWizard({ onComplete, onCancel }: Props) {
92
93
  <SelectInput
93
94
  items={items}
94
95
  onSelect={(item) => {
95
- setSelectedProvider(item.value as ProviderName);
96
- if (isCustomProvider(item.value as ProviderName)) {
96
+ const provider = item.value as ProviderName;
97
+ setSelectedProvider(provider);
98
+ if (isCustomProvider(provider)) {
97
99
  setStep('custom_url');
98
100
  } else {
99
101
  setStep('api_key');
@@ -104,24 +106,57 @@ export function InitWizard({ onComplete, onCancel }: Props) {
104
106
  );
105
107
  }
106
108
 
107
- // Custom URL input
109
+ // Custom URL input — v3.0.1 NOW has a real TextInput
108
110
  if (step === 'custom_url') {
109
111
  return (
110
112
  <Box flexDirection="column" paddingLeft={1}>
111
113
  <Text color="#06B6D4" bold>输入中转站 URL:</Text>
112
114
  <Text dimColor>例如: https://api.example.com/v1</Text>
113
- <Text color="#F59E0B">(请直接输入 URL 并回车)</Text>
115
+ <Text color="#F59E0B">(请直接输入 URL 并回车,ESC 取消)</Text>
116
+ <Box marginTop={1}>
117
+ <Text color="#06B6D4">❯ </Text>
118
+ <TextInput
119
+ value={customUrl}
120
+ onChange={setCustomUrl}
121
+ onSubmit={(value) => {
122
+ if (value.trim()) {
123
+ setCustomUrl(value.trim());
124
+ setStep('api_key');
125
+ }
126
+ }}
127
+ />
128
+ </Box>
114
129
  </Box>
115
130
  );
116
131
  }
117
132
 
118
- // Step 2: API Key
133
+ // Step 2: API Key — v3.0.1 NOW has a real TextInput
119
134
  if (step === 'api_key') {
135
+ const providerLabel = providers.find(p => p.key === selectedProvider)?.name || selectedProvider;
120
136
  return (
121
137
  <Box flexDirection="column" paddingLeft={1}>
122
138
  <Text color="#06B6D4" bold>输入 API Key:</Text>
123
- <Text dimColor>服务商: {providers.find(p => p.key === selectedProvider)?.name}</Text>
124
- <Text color="#F59E0B">(请直接输入 Key 并回车)</Text>
139
+ <Text dimColor>服务商: {providerLabel}</Text>
140
+ <Text color="#F59E0B">(请直接输入 Key 并回车,ESC 取消)</Text>
141
+ <Box marginTop={1}>
142
+ <Text color="#06B6D4">❯ </Text>
143
+ <TextInput
144
+ value={apiKey}
145
+ onChange={setApiKey}
146
+ onSubmit={(value) => {
147
+ if (value.trim()) {
148
+ setApiKey(value.trim());
149
+ if (isCustomProvider(selectedProvider)) {
150
+ // Custom OpenAI/Anthropic: after key, go to model
151
+ setStep('model');
152
+ } else {
153
+ // Standard providers: skip to model selection
154
+ setStep('model');
155
+ }
156
+ }
157
+ }}
158
+ />
159
+ </Box>
125
160
  </Box>
126
161
  );
127
162
  }
@@ -142,7 +177,6 @@ export function InitWizard({ onComplete, onCancel }: Props) {
142
177
  setStep('custom_model');
143
178
  } else {
144
179
  setSelectedModel(item.value);
145
- // v3.0.0: prompt the user for cache strategy after model.
146
180
  setStep('cache_strategy');
147
181
  }
148
182
  }}
@@ -151,56 +185,56 @@ export function InitWizard({ onComplete, onCancel }: Props) {
151
185
  );
152
186
  }
153
187
 
154
- // Custom model name
188
+ // Custom model name input — v3.0.1 NOW has a real TextInput
155
189
  if (step === 'custom_model') {
156
190
  return (
157
191
  <Box flexDirection="column" paddingLeft={1}>
158
192
  <Text color="#06B6D4" bold>输入模型名称:</Text>
159
- <Text color="#F59E0B">(请直接输入模型名并回车)</Text>
193
+ <Text color="#F59E0B">(请直接输入模型名并回车,ESC 取消)</Text>
194
+ <Box marginTop={1}>
195
+ <Text color="#06B6D4">❯ </Text>
196
+ <TextInput
197
+ value={selectedModel}
198
+ onChange={setSelectedModel}
199
+ onSubmit={(value) => {
200
+ if (value.trim()) {
201
+ setSelectedModel(value.trim());
202
+ setStep('cache_strategy');
203
+ }
204
+ }}
205
+ />
206
+ </Box>
160
207
  </Box>
161
208
  );
162
209
  }
163
210
 
164
- // v3.0.0: prompt-cache strategy step
211
+ // v3.0.3: prompt-cache strategy step — auto / 5m / 1h / off.
212
+ // The wizard used to require two questions (on/off + 5m/1h). Now it
213
+ // gives the user a single, clear choice up front, with "自动" as the
214
+ // default. The auto path lets the runtime pick the TTL based on the
215
+ // conversation length (see cache/smartModel.ts decideTTL).
165
216
  if (step === 'cache_strategy') {
166
217
  const items = [
167
- { label: ' 开启(推荐,节省 token 成本)', value: 'on' },
218
+ { label: '🤖 自动(推荐:根据会话长度智能选 5m / 1h)', value: 'auto' },
219
+ { label: '⏱ 强制 5 分钟(短会话,写入便宜)', value: '5m' },
220
+ { label: '🕐 强制 1 小时(长会话,命中率优先)', value: '1h' },
168
221
  { label: '✗ 关闭(每次请求都重新计算)', value: 'off' },
169
222
  ];
170
223
  return (
171
224
  <Box flexDirection="column" paddingLeft={1}>
172
- <Text color="#06B6D4" bold>是否开启 Prompt Caching?</Text>
173
- <Text dimColor>对 Anthropic / DeepSeek / Gemini 有效,OpenAI 兼容接口默认自动缓存。</Text>
225
+ <Text color="#06B6D4" bold>Prompt Caching 策略:</Text>
226
+ <Text dimColor>对 Anthropic / DeepSeek / Gemini 有效。</Text>
227
+ <Text dimColor>自动模式:短会话用 5m(便宜),长会话(&gt;15 轮)自动切 1h。</Text>
174
228
  <SelectInput
175
229
  items={items}
176
230
  onSelect={(item) => {
177
- if (item.value === 'on') {
178
- setCacheChoice(c => ({ ...c, enabled: true }));
179
- setStep('cache_ttl');
231
+ if (item.value === 'off') {
232
+ setCacheChoice({ enabled: false, ttl: '5m' });
233
+ } else if (item.value === 'auto') {
234
+ setCacheChoice({ enabled: true, ttl: '5m' }); // start at 5m, smartModel will upgrade
180
235
  } else {
181
- setCacheChoice(c => ({ ...c, enabled: false }));
182
- saveConfig(selectedProvider, selectedModel, apiKey, customUrl || undefined);
183
- onComplete(selectedProvider, selectedModel, apiKey, customUrl || undefined);
236
+ setCacheChoice({ enabled: true, ttl: item.value as '5m' | '1h' });
184
237
  }
185
- }}
186
- />
187
- </Box>
188
- );
189
- }
190
-
191
- // v3.0.0: TTL choice
192
- if (step === 'cache_ttl') {
193
- const items = [
194
- { label: '5 分钟(写入成本低,适合短会话)', value: '5m' },
195
- { label: '1 小时(写入成本高,适合长会话)', value: '1h' },
196
- ];
197
- return (
198
- <Box flexDirection="column" paddingLeft={1}>
199
- <Text color="#06B6D4" bold>Cache TTL:</Text>
200
- <SelectInput
201
- items={items}
202
- onSelect={(item) => {
203
- setCacheChoice(c => ({ ...c, ttl: item.value as '5m' | '1h' }));
204
238
  saveConfig(selectedProvider, selectedModel, apiKey, customUrl || undefined);
205
239
  onComplete(selectedProvider, selectedModel, apiKey, customUrl || undefined);
206
240
  }}
@@ -20,6 +20,8 @@ const CMD_ALIASES: Record<string, string> = {
20
20
  '/服务商': '/provider',
21
21
  '/思考': '/thinking',
22
22
  '/缓存': '/cache',
23
+ '/ttl': '/ttl',
24
+ '/TTL': '/ttl',
23
25
  };
24
26
 
25
27
  export const COMMAND_LIST = [
@@ -28,6 +30,7 @@ export const COMMAND_LIST = [
28
30
  { name: '/新建', desc: '新建会话' },
29
31
  { name: '/压缩', desc: '压缩上下文' },
30
32
  { name: '/缓存', desc: '缓存命中率' },
33
+ { name: '/ttl', desc: '查看/设置 Cache TTL' },
31
34
  { name: '/技能', desc: '管理技能' },
32
35
  { name: '/mcp', desc: 'MCP 设置' },
33
36
  { name: '/帮助', desc: '查看帮助' },
@@ -142,6 +145,42 @@ export function useCommands(app: App) {
142
145
  return { handled: true, output: lines.join('\n') };
143
146
  }
144
147
 
148
+ // v3.0.3: /ttl — view or pin the cache TTL.
149
+ //
150
+ // /ttl show current effective TTL + decision reason
151
+ // /ttl 5m|1h pin TTL for this session (sticky, no cache reset)
152
+ // /ttl auto resume auto-decision (clears the pin)
153
+ if (name === '/ttl') {
154
+ const configTtl = (app.config.get() as any).cache?.ttl as 'auto' | '5m' | '1h' | undefined;
155
+ const resolved = app.resolvedTtl;
156
+ if (arg === 'auto') {
157
+ app.config.save({ cache: { ...(app.config.get() as any).cache, ttl: 'auto' } });
158
+ app.resolvedTtl = null;
159
+ return { handled: true, output: '✓ Cache TTL 已重置为自动(下一轮重新评估)' };
160
+ }
161
+ if (arg === '5m' || arg === '1h') {
162
+ app.config.save({ cache: { ...(app.config.get() as any).cache, ttl: arg } });
163
+ app.resolvedTtl = arg;
164
+ return { handled: true, output: `✓ Cache TTL 已固定为 ${arg}(首次请求会重建缓存)` };
165
+ }
166
+ // No arg: show current state
167
+ const lines = [
168
+ '⏱ Cache TTL',
169
+ '─────────────────────────',
170
+ ` 配置: ${configTtl ?? 'auto'}`,
171
+ ` 当前会话: ${resolved ?? '尚未评估'}`,
172
+ '',
173
+ '说明: '+
174
+ (configTtl === 'auto'
175
+ ? '自动模式 – 短会话(≤14 轮)用 5m节省写入成本,' +
176
+ '长会话(≥15 轮 或 ≥50k 字符)自动升级到 1h 复用缓存。'
177
+ : `${configTtl} 模式 – TTL 每次请求都固定。`),
178
+ '',
179
+ '/ttl 5m|1h|auto 调整 TTL',
180
+ ];
181
+ return { handled: true, output: lines.join('\n') };
182
+ }
183
+
145
184
  // ── /skills [id] ────────────────────────────────────
146
185
  if (name === '/skills') {
147
186
  if (arg) {
@@ -0,0 +1,112 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { decideTTL, totalConversationChars, shouldDowngrade } from '../../src/cache/smartModel.js';
3
+ import type { ChatMessage } from '../../src/types.js';
4
+
5
+ function userMsg(s: string): ChatMessage {
6
+ return { role: 'user', content: s };
7
+ }
8
+ function assistantMsg(s: string): ChatMessage {
9
+ return { role: 'assistant', content: s };
10
+ }
11
+
12
+ describe('decideTTL', () => {
13
+ it('round 0 → 5m (default)', () => {
14
+ const d = decideTTL([], null);
15
+ expect(d.ttl).toBe('5m');
16
+ expect(d.isFirstDecision).toBe(true);
17
+ });
18
+
19
+ it('short session (≤14 turns, <50k chars) → 5m', () => {
20
+ const messages = [
21
+ userMsg('hello'),
22
+ assistantMsg('hi'),
23
+ userMsg('how are you?'),
24
+ assistantMsg('good'),
25
+ ];
26
+ const d = decideTTL(messages, null);
27
+ expect(d.ttl).toBe('5m');
28
+ expect(d.reason).toMatch(/short-session/);
29
+ });
30
+
31
+ it('long session (≥15 turns) → 1h', () => {
32
+ // Build 16 user + 16 assistant = 32 messages (16 turns)
33
+ const messages: ChatMessage[] = [];
34
+ for (let i = 0; i < 16; i++) {
35
+ messages.push(userMsg(`question ${i}`));
36
+ messages.push(assistantMsg(`answer ${i} long enough to be content`));
37
+ }
38
+ const d = decideTTL(messages, null);
39
+ expect(d.ttl).toBe('1h');
40
+ expect(d.reason).toMatch(/long-session/);
41
+ });
42
+
43
+ it('large context (>50k chars) → 1h even with few turns', () => {
44
+ const bigContent = 'x'.repeat(60_000);
45
+ const messages: ChatMessage[] = [
46
+ userMsg('hi'),
47
+ { role: 'assistant', content: bigContent },
48
+ ];
49
+ const d = decideTTL(messages, null);
50
+ expect(d.ttl).toBe('1h');
51
+ expect(d.reason).toMatch(/long-context/);
52
+ });
53
+
54
+ it('reuses previous TTL (stability rule)', () => {
55
+ const messages = [userMsg('hello'), assistantMsg('hi')];
56
+ const d1 = decideTTL(messages, null);
57
+ expect(d1.ttl).toBe('5m');
58
+ expect(d1.isFirstDecision).toBe(true);
59
+
60
+ // Now grow to 30 turns without changing TTL
61
+ for (let i = 0; i < 30; i++) {
62
+ messages.push(userMsg(`q${i}`));
63
+ messages.push(assistantMsg(`a${i}`));
64
+ }
65
+ const d2 = decideTTL(messages, '5m');
66
+ expect(d2.ttl).toBe('5m');
67
+ expect(d2.isFirstDecision).toBe(false);
68
+ expect(d2.reason).toBe('reused-from-previous-round');
69
+ });
70
+ });
71
+
72
+ describe('totalConversationChars', () => {
73
+ it('sums string content', () => {
74
+ const total = totalConversationChars([
75
+ userMsg('abc'),
76
+ assistantMsg('defgh'),
77
+ ]);
78
+ expect(total).toBe(8);
79
+ });
80
+
81
+ it('handles array content blocks', () => {
82
+ const total = totalConversationChars([
83
+ { role: 'user', content: [
84
+ { type: 'text', text: 'hello' },
85
+ { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'XXXX' } },
86
+ ] as any },
87
+ ]);
88
+ expect(total).toBe(5);
89
+ });
90
+ });
91
+
92
+ describe('shouldDowngrade', () => {
93
+ it('downgrades short prompts without tool calls', () => {
94
+ const d = shouldDowngrade([], 'hi');
95
+ expect(d.downgrade).toBe(true);
96
+ });
97
+
98
+ it('does not downgrade long prompts', () => {
99
+ const d = shouldDowngrade([], 'x'.repeat(300));
100
+ expect(d.downgrade).toBe(false);
101
+ expect(d.reason).toBe('prompt-too-long');
102
+ });
103
+
104
+ it('does not downgrade when recent tool calls exist', () => {
105
+ const messages: ChatMessage[] = [
106
+ { role: 'assistant', content: '...', tool_calls: [{ id: '1', type: 'function', function: { name: 'f', arguments: '{}' } }] },
107
+ ];
108
+ const d = shouldDowngrade(messages, 'hi');
109
+ expect(d.downgrade).toBe(false);
110
+ expect(d.reason).toBe('recent-tool-call');
111
+ });
112
+ });