thatgfsj-code 3.0.2 → 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 +7 -1
  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 +1 -1
  36. package/dist/tui/components/InitWizard.d.ts.map +1 -1
  37. package/dist/tui/components/InitWizard.js +15 -19
  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 +7 -0
  52. package/src/tui/components/Header.tsx +12 -2
  53. package/src/tui/components/InitWizard.tsx +17 -30
  54. package/src/tui/hooks/useCommands.ts +39 -0
  55. package/tests/cache/smartModel.test.ts +112 -0
@@ -1,24 +1,37 @@
1
1
  /**
2
- * Smart-model routing (P3 of the Reasonix plan).
3
- *
4
- * Idea: classify the user's input as "simple" or "complex" and route
5
- * simple queries to a cheaper / faster model while keeping the main model
6
- * for complex work. The classification is intentionally trivial — prompt
7
- * length + presence of recent tool calls — because the cost of a
8
- * misclassification (a slightly worse answer on a simple query) is much
9
- * lower than the cost of running a frontier model on every greeting.
10
- *
11
- * Trade-off: each routing decision adds one model swap (different
12
- * `model` field on the wire). If the cache prefix is keyed on the model
13
- * string, the cache miss rate can spike on every simple query. To keep
14
- * the prefix stable, callers should pass a *family* (e.g. "anthropic" or
15
- * "deepseek") rather than a specific model name when forwarding the
16
- * prompt to the downstream provider — that's outside the scope of this
17
- * hook; this module only answers the question "should I downgrade?".
18
- *
19
- * v3.0.0: shipped as a stub. Real decision logic is conservative
20
- * (downgrade only when prompt is short AND no recent tool activity).
21
- * Future iterations can add heuristic or classifier-based routing.
2
+ * Smart-model routing + auto TTL selection (P3 of the Reasonix plan,
3
+ * v3.0.3 onwards).
4
+ *
5
+ * Idea: classify the conversation as "short" / "medium" / "long" and
6
+ * route accordingly. Two levers:
7
+ *
8
+ * 1. Model downgrade
9
+ * Short follow-ups ("yes", "thanks") mini model
10
+ * Tool-heavy turns → main model
11
+ *
12
+ * 2. Cache TTL
13
+ * Short conversations → 5m (cheap write, expires fast)
14
+ * Long conversations (>15 turns) 1h (cache hits pay back the
15
+ * expensive write each round)
16
+ *
17
+ * Why TTL is decided per-ROUND but CHANGES per-CONVERSATION:
18
+ * The Anthropic cache_control marker is part of the request body.
19
+ * Changing the TTL between rounds would push the wire bytes to a
20
+ * different Anthropic cache bucket, blowing the cache on every round
21
+ * and *costing* more in cache_creation_input_tokens than what we save.
22
+ *
23
+ * So we compute the TTL once per conversation (lazily, on the first
24
+ * round) and never change it. Subsequent rounds get the same TTL,
25
+ * which keeps the cache prefix stable and lets Anthropic match it.
26
+ *
27
+ * The "smart" part is that we look at the FULL conversation so far,
28
+ * not just the last input. A 16-turn conversation that started with a
29
+ * short prompt but grew into a long refactor still gets 1h.
30
+ *
31
+ * Trade-off: the first round is evaluated with 0 history, so it
32
+ * defaults to 5m. If the conversation then grows long, future rounds
33
+ * still use 5m for the system-token cache (because we already wrote
34
+ * it with 5m). The tool definition cache gets the same TTL.
22
35
  */
23
36
 
24
37
  import type { ChatMessage } from '../types.js';
@@ -30,6 +43,13 @@ export interface SmartModelDecision {
30
43
  reason: string;
31
44
  }
32
45
 
46
+ export interface SmartTTLDecision {
47
+ ttl: '5m' | '1h';
48
+ reason: string;
49
+ /** Computed per-conversation. Once we pick a TTL, we keep it. */
50
+ isFirstDecision: boolean;
51
+ }
52
+
33
53
  /**
34
54
  * Heuristic: downgrade when
35
55
  * 1. The latest user message is short (< 200 chars), AND
@@ -50,3 +70,76 @@ export function shouldDowngrade(messages: ChatMessage[], lastUserInput: string):
50
70
  }
51
71
  return { downgrade: true, reason: 'short-no-tools' };
52
72
  }
73
+
74
+ /**
75
+ * Total character count of all message content. Used as a cheap
76
+ * proxy for "is this conversation long enough to justify 1h TTL?"
77
+ * (Anthropic charges cache_creation at 1.25x normal input, so we
78
+ * need to be sure the conversation will hit cache ~10+ times to
79
+ * break even on 1h vs 5m.)
80
+ */
81
+ export function totalConversationChars(messages: ChatMessage[]): number {
82
+ let total = 0;
83
+ for (const m of messages) {
84
+ if (typeof m.content === 'string') {
85
+ total += m.content.length;
86
+ } else if (Array.isArray(m.content)) {
87
+ for (const blk of m.content) {
88
+ if (blk.type === 'text') total += (blk as any).text.length;
89
+ }
90
+ }
91
+ }
92
+ return total;
93
+ }
94
+
95
+ /**
96
+ * Decide the cache TTL for the current round.
97
+ *
98
+ * Inputs:
99
+ * - messages: full conversation so far (system + user + assistant + tool)
100
+ * - previousTTL: TTL chosen in a prior round (null on round 0)
101
+ *
102
+ * Decision matrix:
103
+ * - round 0 + any input length → 5m (default; user can override via init)
104
+ * - round 1-14 + total chars < 50k → 5m (short sessions, 5m is enough)
105
+ * - round 15+ OR total chars > 50k → 1h (long sessions, 1h amortizes the write)
106
+ *
107
+ * Stability rule: once a TTL is chosen, it is reused for every
108
+ * subsequent round in the same session. Changing TTL between rounds
109
+ * would invalidate the Anthropic cache prefix and cost more than it
110
+ * saves.
111
+ *
112
+ * The `isFirstDecision` flag tells the caller whether to apply the
113
+ * decision (first round) or reuse the previous one (later rounds).
114
+ */
115
+ export function decideTTL(
116
+ messages: ChatMessage[],
117
+ previousTTL: '5m' | '1h' | null,
118
+ ): SmartTTLDecision {
119
+ if (previousTTL) {
120
+ return { ttl: previousTTL, reason: 'reused-from-previous-round', isFirstDecision: false };
121
+ }
122
+
123
+ const turnCount = messages.filter(m => m.role === 'user' || m.role === 'assistant').length;
124
+ const totalChars = totalConversationChars(messages);
125
+
126
+ // Round 0: empty / first input. Default to 5m.
127
+ if (turnCount === 0) {
128
+ return { ttl: '5m', reason: 'first-round-default', isFirstDecision: true };
129
+ }
130
+
131
+ // Multi-turn but small conversation → 5m
132
+ if (turnCount < 15 && totalChars < 50_000) {
133
+ return { ttl: '5m', reason: `short-session-${turnCount}-turns`, isFirstDecision: true };
134
+ }
135
+
136
+ // Long conversations → 1h
137
+ if (turnCount >= 15) {
138
+ return { ttl: '1h', reason: `long-session-${turnCount}-turns`, isFirstDecision: true };
139
+ }
140
+ if (totalChars >= 50_000) {
141
+ return { ttl: '1h', reason: `long-context-${totalChars}-chars`, isFirstDecision: true };
142
+ }
143
+
144
+ return { ttl: '5m', reason: 'fallback', isFirstDecision: true };
145
+ }
@@ -16,12 +16,14 @@ const DEFAULT_CONFIG: Config = {
16
16
  maxTokens: 4096,
17
17
  contextLength: 50,
18
18
  provider: 'siliconflow',
19
- // v3.0.0: prompt cache policy. Default = enabled, 5m TTL, auto strategy
20
- // (Anthropic gets explicit cache_control markers; everything else falls
21
- // through to the provider's built-in caching).
19
+ // v3.0.0: prompt cache policy. Default = enabled, 'auto' TTL.
20
+ // v3.0.3: 'auto' TTL lets the runtime's decideTTL() pick 5m for short
21
+ // sessions and 1h for long ones (>= 15 turns or >= 50k chars). The
22
+ // chosen TTL is sticky across rounds within a session so the cache
23
+ // prefix stays stable.
22
24
  cache: {
23
25
  enabled: true,
24
- ttl: '5m',
26
+ ttl: 'auto',
25
27
  strategy: 'auto',
26
28
  },
27
29
  };
@@ -43,10 +43,14 @@ export interface Config {
43
43
  * Note: enabling/disabling only affects Anthropic; other providers do
44
44
  * not expose a programmatic cache toggle, so the flag is purely
45
45
  * advisory there.
46
+ *
47
+ * v3.0.3: `ttl` accepts '5m' | '1h' | 'auto'. 'auto' lets the runtime
48
+ * pick the TTL based on conversation length (decideTTL in smartModel.ts).
49
+ * The wizard's default is 'auto' so users don't have to choose.
46
50
  */
47
51
  cache?: {
48
52
  enabled: boolean;
49
- ttl?: '5m' | '1h';
53
+ ttl?: '5m' | '1h' | 'auto';
50
54
  strategy?: 'auto' | 'manual' | 'off';
51
55
  };
52
56
  }
@@ -46,11 +46,31 @@ const DEFAULT_CACHE_TTL: '5m' | '1h' = '5m';
46
46
  export class AnthropicProvider implements LLMProvider {
47
47
  readonly name = 'anthropic';
48
48
  protected config: ProviderConfig;
49
+ /**
50
+ * v3.0.3: TTL resolved from the user's config + smart routing. This is
51
+ * the *actual* TTL attached to every cache_control marker we send this
52
+ * session. Stays stable across rounds to keep the Anthropic cache
53
+ * prefix intact.
54
+ *
55
+ * Computed once by LLMService.chatStream when ttl='auto', then
56
+ * persisted via setResolvedTTL. Subsequent rounds call chatStream
57
+ * without re-deciding (ChatOptions.resolvedTtl is sticky per session).
58
+ */
59
+ protected resolvedTtl: '5m' | '1h' = DEFAULT_CACHE_TTL;
49
60
 
50
61
  constructor(config: ProviderConfig) {
51
62
  this.config = config;
52
63
  }
53
64
 
65
+ /**
66
+ * Called by LLMService after decideTTL(). Persists the TTL on the
67
+ * provider instance so buildRequest() writes it consistently across
68
+ * every round in this session.
69
+ */
70
+ setResolvedTTL(ttl: '5m' | '1h'): void {
71
+ this.resolvedTtl = ttl;
72
+ }
73
+
54
74
  buildTools(tools: Tool[]): any[] {
55
75
  return tools.map(tool => ({
56
76
  name: tool.name,
@@ -247,7 +267,12 @@ export class AnthropicProvider implements LLMProvider {
247
267
  // final block (and only the final block, per Anthropic convention)
248
268
  // carries the cache_control marker.
249
269
  const systemMessages = messages.filter(m => m.role === 'system');
250
- const cacheTtl = (this.config.cache?.ttl) || DEFAULT_CACHE_TTL;
270
+ // v3.0.3: TTL is sticky per session. The provider's resolvedTtl is
271
+ // set once by LLMService after decideTTL() and never changed within
272
+ // a session (changing it would invalidate the Anthropic cache
273
+ // prefix and cost more in cache_creation_input_tokens than it
274
+ // saves).
275
+ const cacheTtl = this.resolvedTtl;
251
276
  const cacheEnabled = this.config.cache?.enabled !== false; // default on for Anthropic
252
277
 
253
278
  const systemBlocks = systemMessages.length === 0 ? undefined : systemMessages.map((m, i, arr) => {
package/src/llm/index.ts CHANGED
@@ -20,6 +20,7 @@ import { PROVIDERS } from '../config/providers.js';
20
20
  import { OpenAIProvider } from './openai.js';
21
21
  import { AnthropicProvider } from './anthropic.js';
22
22
  import { GeminiProvider } from './gemini.js';
23
+ import { decideTTL } from '../cache/smartModel.js';
23
24
 
24
25
  export class LLMService {
25
26
  private provider: LLMProvider;
@@ -31,6 +32,19 @@ export class LLMService {
31
32
  this.apiKey = apiKey;
32
33
  }
33
34
 
35
+ /**
36
+ * v3.0.3: TTL resolved from config + smart routing. Stays null until
37
+ * the first round, then never changes for the session. Anthropic
38
+ * provider's setResolvedTTL is called at the same time, so the wire
39
+ * prefix is consistent across rounds.
40
+ */
41
+ private resolvedTtl: '5m' | '1h' | null = null;
42
+
43
+ /** Public accessor used by App.streamResponse to surface TTL in the UI. */
44
+ getResolvedTTL(): '5m' | '1h' | null {
45
+ return this.resolvedTtl;
46
+ }
47
+
34
48
  static fromConfig(config: AIConfig & { cache?: Config['cache'] }): LLMService {
35
49
  const providerName = config.provider || 'siliconflow';
36
50
  const providerConfig = PROVIDERS[providerName];
@@ -43,7 +57,9 @@ export class LLMService {
43
57
  maxTokens: config.maxTokens ?? 4096,
44
58
  // v3.0.0: forward cache policy. Anthropic reads this to decide
45
59
  // whether to attach cache_control markers; other providers ignore it.
46
- cache: config.cache ?? { enabled: true, ttl: '5m', strategy: 'auto' as const },
60
+ // v3.0.3: ttl accepts '5m' | '1h' | 'auto'. 'auto' is resolved
61
+ // per-session by decideTTL() inside chatStream.
62
+ cache: config.cache ?? { enabled: true, ttl: 'auto' as const, strategy: 'auto' as const },
47
63
  };
48
64
 
49
65
  const format = providerConfig.format;
@@ -98,6 +114,27 @@ export class LLMService {
98
114
  ): AsyncGenerator<StreamChunk, ChatResponse> {
99
115
  if (!this.hasApiKey()) throw new Error(this.getNoKeyMessage());
100
116
 
117
+ // v3.0.3: Resolve TTL once per session. If config says 'auto', call
118
+ // decideTTL to pick 5m (short sessions) or 1h (long ones). For
119
+ // non-Anthropic providers we don't need to do anything — cache
120
+ // markers are wired into the system regardless.
121
+ const configTtl = (this.provider as any).config?.cache?.ttl;
122
+ if (configTtl === 'auto' && this.resolvedTtl === null) {
123
+ const decision = decideTTL(messages, null);
124
+ this.resolvedTtl = decision.ttl;
125
+ if (typeof (this.provider as any).setResolvedTTL === 'function') {
126
+ (this.provider as any).setResolvedTTL(decision.ttl);
127
+ }
128
+ } else if (configTtl === '5m' || configTtl === '1h') {
129
+ // User pinned a specific TTL — apply it once and keep it.
130
+ if (this.resolvedTtl === null) {
131
+ this.resolvedTtl = configTtl;
132
+ if (typeof (this.provider as any).setResolvedTTL === 'function') {
133
+ (this.provider as any).setResolvedTTL(configTtl);
134
+ }
135
+ }
136
+ }
137
+
101
138
  const maxIterations = options?.maxIterations ?? 10;
102
139
  let currentMessages = [...messages];
103
140
  let iterations = 0;
@@ -52,10 +52,15 @@ export interface ProviderConfig {
52
52
  * Optional cache control policy. When omitted, providers fall back to their
53
53
  * default behavior (Anthropic: explicit cache_control markers; OpenAI / Gemini
54
54
  * / DeepSeek: automatic prefix cache, no markers needed).
55
+ *
56
+ * v3.0.3: `ttl` accepts '5m' | '1h' | 'auto'. 'auto' lets the runtime
57
+ * decide per-session via decideTTL() in cache/smartModel.ts. The actual
58
+ * value used at request time is what gets stored on the provider's
59
+ * resolvedTtl field (set by LLMService.chatStream).
55
60
  */
56
61
  cache?: {
57
62
  enabled: boolean;
58
- ttl?: '5m' | '1h';
63
+ ttl?: '5m' | '1h' | 'auto';
59
64
  /**
60
65
  * 'auto' — provider default (Anthropic: explicit, OpenAI/DeepSeek: auto)
61
66
  * 'manual' — always emit cache_control markers regardless of provider
package/src/tui/app.tsx CHANGED
@@ -48,8 +48,14 @@ export function TuiApp({ app }: Props) {
48
48
  // change (i.e. a round just completed and recorded new usage). Polling on
49
49
  // an interval would be wasteful; React re-render is the trigger.
50
50
  const [cacheSnapshot, setCacheSnapshot] = useState(() => app.cacheStats.snapshot());
51
+ // v3.0.3: resolved TTL (sticky per session). null until first round.
52
+ const [resolvedTtl, setResolvedTtl] = useState<'5m' | '1h' | null>(app.resolvedTtl);
53
+ // The user-pinned config TTL ('auto' | '5m' | '1h') — used to render the
54
+ // Header chip BEFORE the first round, when resolvedTtl is still null.
55
+ const configTtl = (app.config.get() as any).cache?.ttl as 'auto' | '5m' | '1h' | undefined;
51
56
  useEffect(() => {
52
57
  setCacheSnapshot(app.cacheStats.snapshot());
58
+ setResolvedTtl(app.resolvedTtl);
53
59
  }, [messages.length]);
54
60
 
55
61
  const addMsg = useCallback((content: string) => {
@@ -99,6 +105,7 @@ export function TuiApp({ app }: Props) {
99
105
  model={app.config.get().model}
100
106
  cacheHitRate={cacheSnapshot.hitRate > 0 ? cacheSnapshot.hitRate : null}
101
107
  cacheSavingsCNY={cacheSnapshot.estimatedSavingsCNY}
108
+ cacheTtl={resolvedTtl ?? configTtl ?? null}
102
109
  />
103
110
  <ChatList
104
111
  messages={allMessages}
@@ -13,6 +13,13 @@ interface Props {
13
13
  */
14
14
  cacheHitRate?: number | null;
15
15
  cacheSavingsCNY?: number;
16
+ /**
17
+ * v3.0.3: cache TTL marker. '5m' (default for short sessions) or '1h'
18
+ * (auto-decided for long sessions, or pinned by the user). 'auto' is
19
+ * the user-pinned setting and shows up as ⏱ auto. null = not yet
20
+ * decided (waiting for first round).
21
+ */
22
+ cacheTtl?: '5m' | '1h' | 'auto' | null;
16
23
  }
17
24
 
18
25
  /**
@@ -27,7 +34,7 @@ function hitRateColor(rate: number): string {
27
34
  return '#6B7280';
28
35
  }
29
36
 
30
- export const Header = React.memo(function Header({ provider, model, cacheHitRate, cacheSavingsCNY }: Props) {
37
+ export const Header = React.memo(function Header({ provider, model, cacheHitRate, cacheSavingsCNY, cacheTtl }: Props) {
31
38
  const showCache = typeof cacheHitRate === 'number' && cacheHitRate > 0;
32
39
  return (
33
40
  <Box flexDirection="column" marginBottom={0}>
@@ -35,7 +42,7 @@ export const Header = React.memo(function Header({ provider, model, cacheHitRate
35
42
  <Box>
36
43
  <Text color="#06B6D4" bold> ⚡ </Text>
37
44
  <Text color="#22D3EE" bold>THATGFSJ CODE</Text>
38
- <Text dimColor> v3.0.0</Text>
45
+ <Text dimColor> v3.0.3</Text>
39
46
  </Box>
40
47
  <Box>
41
48
  {showCache && (
@@ -45,6 +52,9 @@ export const Header = React.memo(function Header({ provider, model, cacheHitRate
45
52
  <Text dimColor> · </Text>
46
53
  </>
47
54
  )}
55
+ {cacheTtl && (
56
+ <Text color="#A78BFA" bold> ⏱ {cacheTtl} </Text>
57
+ )}
48
58
  <Text color="#06B6D4" bold> {provider} </Text>
49
59
  <Text dimColor>/</Text>
50
60
  <Text color="#22D3EE"> {model} </Text>
@@ -14,7 +14,7 @@ interface Props {
14
14
  onCancel: () => void;
15
15
  }
16
16
 
17
- type InitStep = 'provider' | 'custom_url' | 'api_key' | 'model' | 'custom_model' | 'cache_strategy' | 'cache_ttl';
17
+ type InitStep = 'provider' | 'custom_url' | 'api_key' | 'model' | 'custom_model' | 'cache_strategy';
18
18
 
19
19
  interface CacheChoice {
20
20
  enabled: boolean;
@@ -208,46 +208,33 @@ export function InitWizard({ onComplete, onCancel }: Props) {
208
208
  );
209
209
  }
210
210
 
211
- // 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).
212
216
  if (step === 'cache_strategy') {
213
217
  const items = [
214
- { label: ' 开启(推荐,节省 token 成本)', value: 'on' },
218
+ { label: '🤖 自动(推荐:根据会话长度智能选 5m / 1h)', value: 'auto' },
219
+ { label: '⏱ 强制 5 分钟(短会话,写入便宜)', value: '5m' },
220
+ { label: '🕐 强制 1 小时(长会话,命中率优先)', value: '1h' },
215
221
  { label: '✗ 关闭(每次请求都重新计算)', value: 'off' },
216
222
  ];
217
223
  return (
218
224
  <Box flexDirection="column" paddingLeft={1}>
219
- <Text color="#06B6D4" bold>是否开启 Prompt Caching?</Text>
220
- <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>
221
228
  <SelectInput
222
229
  items={items}
223
230
  onSelect={(item) => {
224
- if (item.value === 'on') {
225
- setCacheChoice(c => ({ ...c, enabled: true }));
226
- 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
227
235
  } else {
228
- setCacheChoice(c => ({ ...c, enabled: false }));
229
- saveConfig(selectedProvider, selectedModel, apiKey, customUrl || undefined);
230
- onComplete(selectedProvider, selectedModel, apiKey, customUrl || undefined);
236
+ setCacheChoice({ enabled: true, ttl: item.value as '5m' | '1h' });
231
237
  }
232
- }}
233
- />
234
- </Box>
235
- );
236
- }
237
-
238
- // v3.0.0: TTL choice
239
- if (step === 'cache_ttl') {
240
- const items = [
241
- { label: '5 分钟(写入成本低,适合短会话)', value: '5m' },
242
- { label: '1 小时(写入成本高,适合长会话)', value: '1h' },
243
- ];
244
- return (
245
- <Box flexDirection="column" paddingLeft={1}>
246
- <Text color="#06B6D4" bold>Cache TTL:</Text>
247
- <SelectInput
248
- items={items}
249
- onSelect={(item) => {
250
- setCacheChoice(c => ({ ...c, ttl: item.value as '5m' | '1h' }));
251
238
  saveConfig(selectedProvider, selectedModel, apiKey, customUrl || undefined);
252
239
  onComplete(selectedProvider, selectedModel, apiKey, customUrl || undefined);
253
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
+ });