diff --git a/.changeset/cli-multi-root-mention.md b/.changeset/cli-multi-root-mention.md new file mode 100644 index 000000000..cc7b13ca3 --- /dev/null +++ b/.changeset/cli-multi-root-mention.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix `@` file completion missing deeply nested files in large projects after adding extra workspace directories. diff --git a/.changeset/pi-tui-multi-root-fd.md b/.changeset/pi-tui-multi-root-fd.md new file mode 100644 index 000000000..df26b534f --- /dev/null +++ b/.changeset/pi-tui-multi-root-fd.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/pi-tui": patch +--- + +Support searching multiple workspace roots for `@` file completion through fd, deduplicated by absolute path. diff --git a/.gitignore b/.gitignore index 46dca949e..2f3246b13 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ docker-compose.yml docs/superpowers/ reports/ .superpowers/ +/plan/ diff --git a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts index ff489a09f..722682db6 100644 --- a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts +++ b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts @@ -1,4 +1,4 @@ -import { readdirSync, statSync } from 'node:fs'; +import { accessSync, constants as fsConstants, readdirSync, statSync } from 'node:fs'; import { basename, join, resolve } from 'node:path'; import { @@ -27,13 +27,13 @@ interface FsMentionCandidate { /** * Kimi wrapper around pi-tui's combined autocomplete provider. * - * File / folder mention behavior uses pi-tui's fd-backed provider when fd is - * available and only the current working directory is involved. While managed fd - * is downloading, when it is unavailable, or when the session has additional - * roots, a small filesystem fallback keeps `@` file and folder completion usable - * across every root. Ordinary path completion is still handled by pi-tui's - * readdir-backed path completer. This wrapper also keeps Kimi-specific - * slash-command guards. + * File / folder mention behavior uses pi-tui's fd-backed provider whenever fd + * is available, fanning out across the working directory and any additional + * roots so `@` completion pushes the query down to fd instead of enumerating + * every file. A small filesystem fallback is used only while managed fd is + * downloading, when it is unavailable, or if fd fails to spawn. Ordinary path + * completion is still handled by pi-tui's readdir-backed path completer. This + * wrapper also keeps Kimi-specific slash-command guards. */ export class FileMentionProvider implements AutocompleteProvider { private readonly inner: CombinedAutocompleteProvider; @@ -56,7 +56,7 @@ export class FileMentionProvider implements AutocompleteProvider { expanded.push({ ...cmd, name: alias }); } } - this.inner = new CombinedAutocompleteProvider(expanded, workDir, fdPath); + this.inner = new CombinedAutocompleteProvider(expanded, workDir, fdPath, this.additionalDirs); } async getSuggestions( @@ -75,7 +75,11 @@ export class FileMentionProvider implements AutocompleteProvider { // runs, so the file list never opens. const atPrefix = extractAtPrefix(textBeforeCursor); if (atPrefix !== null) { - if (this.fdPath === null || this.additionalDirs.length > 0) { + // fd backs `@` completion across every root (cwd + additional dirs). Fall + // back to the filesystem scanner when fd is unavailable, not executable + // (e.g. the managed binary was removed or lost execute permission), or if + // spawning it fails below. A genuine fd no-match still returns null. + if (this.fdPath === null || !isExecutableFd(this.fdPath)) { return getFsMentionSuggestions( this.workDir, this.additionalDirs, @@ -227,6 +231,21 @@ export function extractAtPrefix(text: string): string | null { return text.slice(tokenStart); } +function isExecutableFd(fdPath: string): boolean { + // Bare command names (for example "fd" discovered on the system PATH) are + // trusted: spawn resolves them through PATH. Only absolute/relative paths are + // probed, which is how the managed fd is referenced and which can go stale. + if (!fdPath.includes('/') && !fdPath.includes('\\')) { + return true; + } + try { + accessSync(fdPath, fsConstants.X_OK); + return true; + } catch { + return false; + } +} + /** * Match the `/add-dir` directory completer, which skips every entry whose name * starts with `.` (see registry.ts). pi-tui's path completer sets `label` to diff --git a/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts index 5e2fa6ef3..b53b49a83 100644 --- a/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts +++ b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts @@ -1,3 +1,4 @@ +import { spawnSync } from 'node:child_process'; import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -11,6 +12,17 @@ function ctrl(): AbortSignal { } const NO_FD = null; + +function resolveFdPath(): string | null { + const command = process.platform === 'win32' ? 'where' : 'which'; + const result = spawnSync(command, ['fd'], { encoding: 'utf-8' }); + if (result.status !== 0 || !result.stdout) return null; + const firstLine = result.stdout.split(/\r?\n/).find(Boolean); + return firstLine ? firstLine.trim() : null; +} + +const FD_PATH = resolveFdPath(); +const IS_FD_INSTALLED = Boolean(FD_PATH); const GOAL_COMMAND = { name: 'goal', description: 'Start or manage a goal', @@ -298,6 +310,56 @@ describe('FileMentionProvider', () => { ); }); + it.runIf(IS_FD_INSTALLED)( + 'uses fd for additionalDirs even when cwd is large enough to exhaust the fallback scanner', + async () => { + // Fill cwd with enough entries to push the filesystem fallback past its + // 2000-entry scan cap, so it would never reach the additional root. fd + // searches each root independently and still finds the deep target. + for (let i = 0; i < 2000; i++) { + writeFileSync(join(workDir, `filler-${i}.ts`), 'export {};'); + } + const extraDir = createExtraDir(); + mkdirSync(join(extraDir, 'deep'), { recursive: true }); + writeFileSync(join(extraDir, 'deep', 'target-needle.ts'), 'export {};'); + const provider = new FileMentionProvider([], workDir, FD_PATH!, [extraDir]); + + const result = await provider.getSuggestions(['@target-needle'], 0, '@target-needle'.length, { + signal: ctrl(), + }); + + expect(result).not.toBeNull(); + expect(result!.items.map((item) => item.value)).toContain( + `@${join(extraDir, 'deep', 'target-needle.ts').replaceAll('\\', '/')}`, + ); + }, + ); + + it.runIf(IS_FD_INSTALLED)( + 'treats a bare fd command name as executable and resolves it via PATH', + async () => { + // A bare "fd" (system PATH lookup) must not be mistaken for unavailable; + // otherwise the large cwd would push the fallback scanner past its cap + // and hide the deep target in the additional root. + for (let i = 0; i < 2000; i++) { + writeFileSync(join(workDir, `filler-${i}.ts`), 'export {};'); + } + const extraDir = createExtraDir(); + mkdirSync(join(extraDir, 'deep'), { recursive: true }); + writeFileSync(join(extraDir, 'deep', 'target-needle.ts'), 'export {};'); + const provider = new FileMentionProvider([], workDir, 'fd', [extraDir]); + + const result = await provider.getSuggestions(['@target-needle'], 0, '@target-needle'.length, { + signal: ctrl(), + }); + + expect(result).not.toBeNull(); + expect(result!.items.map((item) => item.value)).toContain( + `@${join(extraDir, 'deep', 'target-needle.ts').replaceAll('\\', '/')}`, + ); + }, + ); + it('keeps cwd @ mention values relative and additionalDir values absolute', async () => { mkdirSync(join(workDir, 'src'), { recursive: true }); writeFileSync(join(workDir, 'src', 'Cwd.ts'), 'export {};'); @@ -332,14 +394,19 @@ describe('FileMentionProvider', () => { expect(overlapItems).toHaveLength(1); }); - it('does not bypass fd filtering with filesystem suggestions when fd returns no matches', async () => { - writeFileSync(join(workDir, 'README.md'), 'readme'); - const provider = new FileMentionProvider([], workDir, join(workDir, 'missing-fd')); + it.runIf(IS_FD_INSTALLED)( + 'does not bypass fd filtering with filesystem suggestions when fd returns no matches', + async () => { + writeFileSync(join(workDir, 'README.md'), 'readme'); + const provider = new FileMentionProvider([], workDir, FD_PATH!); - const result = await provider.getSuggestions(['@read'], 0, 5, { signal: ctrl() }); + const result = await provider.getSuggestions(['@zzz-no-match-xyz'], 0, '@zzz-no-match-xyz'.length, { + signal: ctrl(), + }); - expect(result).toBeNull(); - }); + expect(result).toBeNull(); + }, + ); it('filesystem fallback returns folders and excludes .git', async () => { mkdirSync(join(workDir, 'src')); diff --git a/packages/pi-tui/src/autocomplete.ts b/packages/pi-tui/src/autocomplete.ts index 205748d88..7ecf6a0a2 100644 --- a/packages/pi-tui/src/autocomplete.ts +++ b/packages/pi-tui/src/autocomplete.ts @@ -273,11 +273,18 @@ export interface AutocompleteProvider { export class CombinedAutocompleteProvider implements AutocompleteProvider { private commands: (SlashCommand | AutocompleteItem)[]; private basePath: string; + private additionalBasePaths: string[]; private fdPath: string | null; - constructor(commands: (SlashCommand | AutocompleteItem)[] = [], basePath: string, fdPath: string | null = null) { + constructor( + commands: (SlashCommand | AutocompleteItem)[] = [], + basePath: string, + fdPath: string | null = null, + additionalBasePaths: readonly string[] = [], + ) { this.commands = commands; this.basePath = basePath; + this.additionalBasePaths = additionalBasePaths.map((p) => toDisplayPath(p)); this.fdPath = fdPath; } @@ -518,7 +525,24 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { return path; } - private resolveScopedFuzzyQuery(rawQuery: string): { baseDir: string; query: string; displayBase: string } | null { + private searchRoots(): string[] { + const seen = new Set(); + const roots: string[] = []; + for (const root of [this.basePath, ...this.additionalBasePaths]) { + const key = toDisplayPath(root); + if (seen.has(key)) continue; + seen.add(key); + roots.push(root); + } + return roots; + } + + private resolveScopedFuzzyQuery( + rawQuery: string, + ): + | { kind: "relative"; displayBase: string; query: string } + | { kind: "absolute"; baseDir: string; displayBase: string; query: string } + | null { const normalizedQuery = toDisplayPath(rawQuery); const slashIndex = normalizedQuery.lastIndexOf("/"); if (slashIndex === -1) { @@ -528,24 +552,22 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { const displayBase = normalizedQuery.slice(0, slashIndex + 1); const query = normalizedQuery.slice(slashIndex + 1); - let baseDir: string; - if (displayBase.startsWith("~/")) { - baseDir = this.expandHomePath(displayBase); - } else if (displayBase.startsWith("/")) { - baseDir = displayBase; - } else { - baseDir = join(this.basePath, displayBase); - } - - try { - if (!statSync(baseDir).isDirectory()) { + // Absolute (~/, /) scopes resolve to a single existing directory and do + // not fan out across additional roots. Relative scopes are expanded per + // search root by the caller. + if (displayBase.startsWith("~/") || displayBase.startsWith("/")) { + const baseDir = displayBase.startsWith("~/") ? this.expandHomePath(displayBase) : displayBase; + try { + if (!statSync(baseDir).isDirectory()) { + return null; + } + } catch { return null; } - } catch { - return null; + return { kind: "absolute", baseDir, displayBase, query }; } - return { baseDir, query, displayBase }; + return { kind: "relative", displayBase, query }; } private scopedPathForDisplay(displayBase: string, relativePath: string): string { @@ -716,51 +738,155 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { return score; } - // Fuzzy file search using fd (fast, respects .gitignore) + // Fuzzy file search using fd (fast, respects .gitignore). Fans out across + // every search root (cwd + additional dirs) so `@` completion covers all + // roots while still pushing the query down to fd. private async getFuzzyFileSuggestions( query: string, options: { isQuotedPrefix: boolean; signal: AbortSignal }, ): Promise { - if (!this.fdPath || options.signal.aborted) { + const fdPath = this.fdPath; + if (!fdPath || options.signal.aborted) { return []; } try { - const scopedQuery = this.resolveScopedFuzzyQuery(query); - const fdBaseDir = scopedQuery?.baseDir ?? this.basePath; - const fdQuery = scopedQuery?.query ?? query; - const entries = await walkDirectoryWithFd(fdBaseDir, this.fdPath, fdQuery, 100, options.signal); + const scoped = this.resolveScopedFuzzyQuery(query); + + type RootTarget = { + baseDir: string; + pathRoot: string; + displayBase: string; + fdQuery: string; + isAdditional: boolean; + absolute: boolean; + }; + const targets: RootTarget[] = []; + + const pushFullPathTarget = (root: string) => { + // Whole-tree search for this root using the original query (which + // contains "/", so fd runs in --full-path mode). + targets.push({ + baseDir: root, + pathRoot: root, + displayBase: "", + fdQuery: query, + isAdditional: root !== this.basePath, + absolute: false, + }); + }; + + if (scoped?.kind === "absolute") { + targets.push({ + baseDir: scoped.baseDir, + pathRoot: scoped.baseDir, + displayBase: scoped.displayBase, + fdQuery: scoped.query, + isAdditional: false, + absolute: true, + }); + } else if (scoped?.kind === "relative") { + for (const root of this.searchRoots()) { + const baseDir = join(root, scoped.displayBase); + let isDir = false; + try { + isDir = statSync(baseDir).isDirectory(); + } catch { + isDir = false; + } + if (isDir) { + targets.push({ + baseDir, + pathRoot: root, + displayBase: scoped.displayBase, + fdQuery: scoped.query, + isAdditional: root !== this.basePath, + absolute: false, + }); + } else { + // This root does not have the scoped directory. Fall back to a + // per-root full-path search so another root having the prefix + // does not hide matches that only exist under this root. + pushFullPathTarget(root); + } + } + } else { + for (const root of this.searchRoots()) { + pushFullPathTarget(root); + } + } + + if (targets.length === 0) { + return []; + } + + const perRoot = await Promise.all( + targets.map(async (target) => { + const entries = await walkDirectoryWithFd(target.baseDir, fdPath, target.fdQuery, 100, options.signal); + return entries.map((entry) => ({ entry, target })); + }), + ); if (options.signal.aborted) { return []; } - const scoredEntries = entries - .map((entry) => ({ - ...entry, - score: fdQuery ? this.scoreEntry(entry.path, fdQuery, entry.isDirectory) : 1, - })) - .filter((entry) => entry.score > 0); + type Scored = { + path: string; + isDirectory: boolean; + score: number; + target: RootTarget; + absPath: string; + }; + const bestByAbs = new Map(); + for (const group of perRoot) { + for (const { entry, target } of group) { + const score = target.fdQuery ? this.scoreEntry(entry.path, target.fdQuery, entry.isDirectory) : 1; + if (score <= 0) continue; + const pathWithoutSlash = entry.isDirectory ? entry.path.slice(0, -1) : entry.path; + const absPath = target.absolute + ? toDisplayPath(join(target.displayBase, pathWithoutSlash)) + : toDisplayPath(join(target.pathRoot, target.displayBase, pathWithoutSlash)); + const existing = bestByAbs.get(absPath); + if (!existing || score > existing.score) { + bestByAbs.set(absPath, { + path: entry.path, + isDirectory: entry.isDirectory, + score, + target, + absPath, + }); + } + } + } - scoredEntries.sort((a, b) => b.score - a.score); - const topEntries = scoredEntries.slice(0, 20); + const scored = [...bestByAbs.values()]; + scored.sort((a, b) => b.score - a.score); + const topEntries = scored.slice(0, 20); const suggestions: AutocompleteItem[] = []; - for (const { path: entryPath, isDirectory } of topEntries) { - const pathWithoutSlash = isDirectory ? entryPath.slice(0, -1) : entryPath; - const displayPath = scopedQuery - ? this.scopedPathForDisplay(scopedQuery.displayBase, pathWithoutSlash) - : pathWithoutSlash; + for (const item of topEntries) { + const pathWithoutSlash = item.isDirectory ? item.path.slice(0, -1) : item.path; const entryName = basename(pathWithoutSlash); - const completionPath = isDirectory ? `${displayPath}/` : displayPath; + let displayPath: string; + if (item.target.absolute) { + displayPath = this.scopedPathForDisplay(item.target.displayBase, pathWithoutSlash); + } else if (item.target.isAdditional) { + displayPath = item.absPath; + } else { + displayPath = item.target.displayBase + ? this.scopedPathForDisplay(item.target.displayBase, pathWithoutSlash) + : pathWithoutSlash; + } + const completionPath = item.isDirectory ? `${displayPath}/` : displayPath; const value = buildCompletionValue(completionPath, { - isDirectory, + isDirectory: item.isDirectory, isAtPrefix: true, isQuotedPrefix: options.isQuotedPrefix, }); suggestions.push({ value, - label: entryName + (isDirectory ? "/" : ""), + label: entryName + (item.isDirectory ? "/" : ""), description: displayPath, }); } diff --git a/packages/pi-tui/test/autocomplete.test.ts b/packages/pi-tui/test/autocomplete.test.ts index be27d152b..4eabbb4a5 100644 --- a/packages/pi-tui/test/autocomplete.test.ts +++ b/packages/pi-tui/test/autocomplete.test.ts @@ -228,6 +228,87 @@ describe("CombinedAutocompleteProvider", () => { assert.ok(!values?.includes("@packages/ai/src/autocomplete.ts")); }); + test("searches additional base paths with fd for @ mentions", async () => { + setupFolder(baseDir, { + files: { "shared-cwd.ts": "export {};" }, + }); + setupFolder(outsideDir, { + files: { "shared-extra.ts": "export {};" }, + }); + + const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath(), [outsideDir]); + const result = await getSuggestions(provider, ["@shared"], 0, 7); + + const values = result?.items.map((item) => item.value) ?? []; + assert.ok(values.includes("@shared-cwd.ts")); + assert.ok(values.includes(`@${join(outsideDir, "shared-extra.ts").replace(/\\/g, "/")}`)); + }); + + test("scopes @dir/ queries across every root", async () => { + setupFolder(baseDir, { + files: { "sub/cwd-file.ts": "export {};" }, + }); + setupFolder(outsideDir, { + files: { "sub/extra-file.ts": "export {};" }, + }); + + const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath(), [outsideDir]); + const result = await getSuggestions(provider, ["@sub/file"], 0, 9); + + const values = result?.items.map((item) => item.value) ?? []; + assert.ok(values.includes("@sub/cwd-file.ts")); + assert.ok(values.includes(`@${join(outsideDir, "sub", "extra-file.ts").replace(/\\/g, "/")}`)); + }); + + test("deduplicates entries when an additional root is inside cwd", async () => { + const nestedDir = join(baseDir, "extra"); + setupFolder(nestedDir, { + files: { "Overlap.ts": "export {};" }, + }); + + const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath(), [nestedDir]); + const result = await getSuggestions(provider, ["@overlap"], 0, 8); + + const values = result?.items.map((item) => item.value) ?? []; + const absValue = `@${join(nestedDir, "Overlap.ts").replace(/\\/g, "/")}`; + const relValue = "@extra/Overlap.ts"; + const overlapCount = values.filter((v) => v === absValue || v === relValue).length; + assert.strictEqual(overlapCount, 1); + }); + + test("merges empty @ query across roots within the result cap", async () => { + const cwdFiles: Record = {}; + for (let i = 0; i < 15; i++) cwdFiles[`f${i}.ts`] = "export {};"; + setupFolder(baseDir, { files: cwdFiles }); + const extraFiles: Record = {}; + for (let i = 0; i < 15; i++) extraFiles[`g${i}.ts`] = "export {};"; + setupFolder(outsideDir, { files: extraFiles }); + + const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath(), [outsideDir]); + const result = await getSuggestions(provider, ["@"], 0, 1); + + const count = result?.items.length ?? 0; + assert.ok(count <= 20, `expected <= 20 items, got ${count}`); + assert.ok(count > 0); + }); + + test("falls back to full-path per root when another root has the scoped directory", async () => { + setupFolder(baseDir, { + files: { "packages/tui/src/autocomplete.ts": "export {};" }, + }); + // outsideDir has a top-level src/ but no matching file; a global fallback + // would skip cwd's full-path search entirely and hide the cwd match. + setupFolder(outsideDir, { + files: { "src/other.ts": "export {};" }, + }); + + const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath(), [outsideDir]); + const result = await getSuggestions(provider, ["@src/auto"], 0, 9); + + const values = result?.items.map((item) => item.value) ?? []; + assert.ok(values.includes("@packages/tui/src/autocomplete.ts")); + }); + test("matches directory in middle of path with --full-path", async () => { setupFolder(baseDir, { files: { diff --git a/packages/pi-tui/test/editor.test.ts b/packages/pi-tui/test/editor.test.ts index ff928f592..a47f45dd7 100644 --- a/packages/pi-tui/test/editor.test.ts +++ b/packages/pi-tui/test/editor.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert"; -import { describe, it } from "node:test"; +import { describe, it, mock } from "node:test"; import { stripVTControlCharacters } from "node:util"; import { type AutocompleteProvider, CombinedAutocompleteProvider } from "../src/autocomplete.ts"; import { Editor, wordWrapLine } from "../src/components/editor.ts"; @@ -525,24 +525,34 @@ describe("Editor component", () => { describe("Paste burst fallback", () => { it("inserts a newline instead of submitting during a rapid burst", () => { - const editor = new Editor(createTestTUI(), defaultEditorTheme); - let submitted = false; - editor.onSubmit = () => { - submitted = true; - }; + // Freeze the clock so the 8 synchronous keystrokes are all seen as one + // burst regardless of host speed or scheduler jitter (the production + // heuristic uses an 8ms inter-char interval, which a slow CI runner can + // exceed between synchronous calls). + mock.timers.enable({ apis: ["Date"] }); + mock.timers.setTime(1000); + try { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + let submitted = false; + editor.onSubmit = () => { + submitted = true; + }; - editor.handleInput("a"); - editor.handleInput("b"); - editor.handleInput("c"); - editor.handleInput("d"); - editor.handleInput("e"); - editor.handleInput("f"); - editor.handleInput("g"); - editor.handleInput("h"); - editor.handleInput("\r"); + editor.handleInput("a"); + editor.handleInput("b"); + editor.handleInput("c"); + editor.handleInput("d"); + editor.handleInput("e"); + editor.handleInput("f"); + editor.handleInput("g"); + editor.handleInput("h"); + editor.handleInput("\r"); - assert.strictEqual(submitted, false); - assert.strictEqual(editor.getText(), "abcdefgh\n"); + assert.strictEqual(submitted, false); + assert.strictEqual(editor.getText(), "abcdefgh\n"); + } finally { + mock.timers.reset(); + } }); it("can be disabled", () => { diff --git a/plan/bracketed-paste-paste-burst-solution.md b/plan/bracketed-paste-paste-burst-solution.md deleted file mode 100644 index a98c89c93..000000000 --- a/plan/bracketed-paste-paste-burst-solution.md +++ /dev/null @@ -1,530 +0,0 @@ -# 多行粘贴误提交解决方案:Bracketed Paste + Paste-Burst Fallback - -## 1. 背景 - -在 iTerm2 + tmux 环境下,向 Kimi Code CLI 输入框粘贴包含换行的文本时,预期行为是整段文本进入输入框;但在某些环境下,回车前的语句会被直接提交执行。 - -实测结论: - -- 当 bracketed paste 标记 `\x1b[200~` / `\x1b[201~` 正常到达时,pi-tui 能正确识别成一次粘贴,不会逐行提交。 -- 当 bracketed paste 标记没有到达时,粘贴内容里的 `\r` 会被当成裸 Enter,触发提交。 -- 单独的 `\n` 在 pi-tui 的 `Editor` 里会被当成换行插入;单独的 `\r` 才是主要提交来源。 - -关键代码位置: - -- `packages/pi-tui/src/terminal.ts:147`:启动时写入 `\x1b[?2004h`,启用 bracketed paste。 -- `packages/pi-tui/src/terminal.ts:412`:退出时写入 `\x1b[?2004l`,关闭 bracketed paste。 -- `packages/pi-tui/src/stdin-buffer.ts:315-369`:识别 `\x1b[200~` / `\x1b[201~`,组装 bracketed paste。 -- `packages/pi-tui/src/keys.ts:921`:裸 `\r` 被映射成 `enter`。 -- `packages/pi-tui/src/components/editor.ts:780`:单独的 `\n` 被当成换行。 -- `packages/pi-tui/src/components/editor.ts:792`:`enter` 触发 `submitValue()`。 - -## 2. 根因 - -bracketed paste 是由程序主动启用的,流程是: - -```text -app 输出 \x1b[?2004h -→ terminal 把粘贴内容包成 \x1b[200~ ... \x1b[201~ -→ app 识别标记,整段作为 paste 处理 -``` - -如果中间链路正常: - -```text -iTerm2 → tmux → pi-tui -``` - -粘贴内容会被作为一次 paste,不会逐行提交。 - -如果 `\x1b[200~` / `\x1b[201~` 没有到达程序,程序只能看到裸字节流: - -```text -line1\rline2\rline3\r -``` - -此时: - -- `\r` → `enter` → `submitValue()` -- 于是第一行被直接提交。 - -需要注意: - -- iTerm2 的 “Applications in terminal may access clipboard” 是 OSC 52 剪贴板访问,不是 bracketed paste 开关。 -- tmux 的 `set-clipboard` / `allow-passthrough` 主要服务于 OSC 52 / escape passthrough,也不是 bracketed paste 开关。 -- 真正决定 bracketed paste 是否生效的是:终端是否在 app 启用 `\x1b[?2004h` 后,把 `\x1b[200~` / `\x1b[201~` 透传给 app。 - -## 3. 目标 - -解决方案需要满足: - -1. bracketed paste 正常时,多行粘贴作为整体插入,不逐行提交。 -2. bracketed paste 失效时,多行粘贴尽量不逐行提交。 -3. 不影响正常手敲输入和正常 Enter 提交。 -4. 可测试、可关闭、可灰度。 -5. 不依赖用户手动修改 tmux 配置作为唯一修复手段。 - -## 4. 现有实现对比 - -### 4.1 kimi-code pi-tui - -已有: - -- 启用 `\x1b[?2004h` -- `StdinBuffer` 识别 `\x1b[200~` / `\x1b[201~` -- `Editor.handlePaste()` 处理粘贴内容 - -缺少: - -- bracketed paste 标记完全没到时的启发式兜底。 - -### 4.2 oh-my-pi - -oh-my-pi 是 pi-tui 的演进 fork,核心机制相同,但健壮性更强: - -- 同样启用 `\x1b[?2004h` -- `StdinBuffer` 组装 bracketed paste,性能更好 -- 有 paste watchdog,防止结束标记丢失后卡死 -- 有 paste byte limit,限制内存 -- `bracketed-paste.ts` 解码 tmux extended-keys 重编码的控制字节 - -但它仍然依赖 bracketed paste 标记到达,**没有解决“标记完全没到”的情况**。 - -### 4.3 Codex - -Codex 的处理最值得参考。它是两层方案: - -1. 正常路径:使用 crossterm 的 bracketed paste。 -2. 兜底路径:在没有 bracketed paste 的终端上,用 `PasteBurst` 启发式识别“快速连续输入 + Enter”的粘贴突发。 - -参考: - -- [`codex-rs/tui/Cargo.toml`](https://github.com/openai/codex/blob/main/codex-rs/tui/Cargo.toml) -- [`codex-rs/tui/src/tui.rs`](https://github.com/openai/codex/blob/main/codex-rs/tui/src/tui.rs) -- [`codex-rs/tui/src/tui/event_stream.rs`](https://github.com/openai/codex/blob/main/codex-rs/tui/src/tui/event_stream.rs) -- [`codex-rs/tui/src/bottom_pane/paste_burst.rs`](https://github.com/openai/codex/blob/main/codex-rs/tui/src/bottom_pane/paste_burst.rs) -- [`codex-rs/tui/src/bottom_pane/chat_composer.rs`](https://github.com/openai/codex/blob/main/codex-rs/tui/src/bottom_pane/chat_composer.rs) - -Codex 的 `handle_paste()` 会把 `\r\n` / `\r` 归一化成 `\n`,避免粘贴里的 CR 触发提交。 - -## 5. 推荐方案 - -采用两层防御: - -```text -第一层:bracketed paste 正常路径 -第二层:paste-burst 启发式兜底 -``` - -### 5.1 第一层:保持并增强 bracketed paste - -保留现有 bracketed paste 流程,并可从 oh-my-pi backport 以下健壮性改进: - -1. tmux extended-keys 重编码解码 - 处理 tmux 在 bracketed paste 内把控制字节重编码成: - - `\x1b[106;5u` - - `\x1b[27;5;106~` - -2. paste watchdog - 开始标记到了但结束标记丢失时,不能永远卡在 paste mode。 - -3. paste byte limit - 防止异常输入无限增长内存。 - -4. O(n) paste 组装 - 避免大粘贴时字符串重复拼接导致卡顿。 - -这些改进能增强“标记到了之后”的稳定性,但不能解决“标记没到”的问题。 - -### 5.2 第二层:新增 paste-burst 启发式兜底 - -新增一个 `PasteBurst` 状态机,只在**没有 bracketed paste 标记**时生效,用于识别这种输入: - -```text -短时间内连续收到多个普通字符 + Enter -``` - -典型触发场景: - -```text -line1\rline2\rline3\r -``` - -但没有 `\x1b[200~` / `\x1b[201~` 包裹。 - -进入 paste-burst 后: - -- 普通字符仍然按现有逻辑正常插入,避免改变当前输入体验。 -- `PasteBurst` 只记录最近普通字符的时间与数量。 -- 当短时间内累计到足够多的普通字符后,接下来的 Enter / `\r` 会被当成换行,而不是提交。 -- burst 结束后的短窗口内,Enter 仍按换行处理,避免粘贴末尾的 Enter 误提交。 - -## 6. PasteBurst 详细设计 - -### 6.1 放置位置 - -建议新增: - -```text -packages/pi-tui/src/paste-burst.ts -``` - -由 `Editor` 使用: - -```text -packages/pi-tui/src/components/editor.ts -``` - -理由: - -- 裸 `\r` / `\n` 是在 `Editor.handleInput()` 里被解释成 submit / newline 的。 -- `StdinBuffer` 只负责序列切分,不应引入输入框语义。 -- `PasteBurst` 是纯状态机,便于单独测试。 - -### 6.2 输入分类 - -`PasteBurst` 只处理这些输入: - -1. 单个可打印字符 - 包括 ASCII、中文、Emoji 等。 - -2. Enter - 主要是裸 `\r`。 - -3. 非字符输入 - 例如方向键、Ctrl/Alt 组合键、popup 导航键等。遇到这些输入时,应调用 `reset()` 清空 burst 状态,再按正常逻辑处理。 - -不要把带修饰键的输入喂给 `PasteBurst`,避免把快捷键误判成粘贴。 - -### 6.3 推荐参数 - -初始值建议参考 Codex,并根据实测调整: - -```ts -const PASTE_BURST_MIN_CHARS = 8; -const PASTE_BURST_CHAR_INTERVAL_MS = 8; -const PASTE_BURST_ACTIVE_IDLE_TIMEOUT_MS = 30; -const PASTE_ENTER_SUPPRESS_WINDOW_MS = 120; -``` - -含义: - -- `PASTE_BURST_MIN_CHARS`:连续多少个普通字符后才可能是粘贴。 -- `PASTE_BURST_CHAR_INTERVAL_MS`:两个字符间隔小于该值才认为属于同一突发。 -- `PASTE_BURST_ACTIVE_IDLE_TIMEOUT_MS`:进入 burst 后,Enter 在这个窗口内仍按换行处理。 -- `PASTE_ENTER_SUPPRESS_WINDOW_MS`:burst 结束后的一小段时间内,Enter 仍按换行处理,避免粘贴末尾的 Enter 误提交。 - -### 6.4 状态 - -```text -Idle -PendingFirstChar -Buffering -EnterSuppressWindow -``` - -状态说明: - -- `Idle`:没有最近输入状态。 -- `BurstActive`:已经连续收到足够多的快速普通字符,Enter 应按换行处理。 -- `EnterSuppressWindow`:burst 结束后的短窗口,Enter 仍按换行处理。 - -### 6.5 核心方法 - -建议接口: - -```ts -class PasteBurst { - onPlainChar(now: number): void; - shouldInsertNewlineInsteadOfSubmit(now: number): boolean; - extendWindow(now: number): void; - reset(): void; -} -``` - -### 6.6 ASCII / 非 ASCII 差异 - -为了尽量降低对正常手敲的影响,当前实现不 hold 任何字符: - -- ASCII 和非 ASCII 字符都立即插入。 -- `PasteBurst` 只根据输入时序判断接下来的 Enter 是否应该被当成换行。 -- 这样不会引入“先显示一个字符,再突然变成 paste”的闪烁,也不会影响 IME 输入。 - -### 6.7 与 `Editor.handleInput()` 集成 - -处理顺序建议: - -1. 如果当前输入是 bracketed paste 标记: - - 走现有 `handlePaste()`。 - - 调用 `pasteBurst.reset()`,避免显式 paste 和 burst 状态互相污染。 -2. 如果当前输入不是普通字符、也不是 Enter: - - 调用 `pasteBurst.reset()`,避免状态泄漏到后续输入。 -3. 如果当前输入是裸 Enter: - - 先保留现有 backslash workaround。 - - 如果 `shouldInsertNewlineInsteadOfSubmit(now)` 为 true,则 `addNewLine()`,并 `extendWindow(now)`。 - - 否则走原来的 submit 逻辑。 -4. 如果当前输入是单个可打印字符: - - 调用 `onPlainChar(now)` 记录时序。 - - 然后按现有逻辑插入字符。 - -## 7. 配置与灰度 - -建议增加一个开关,避免影响所有用户: - -```ts -disablePasteBurst?: boolean; -``` - -或者使用实验 flag: - -```text -KIMI_CODE_EXPERIMENTAL_PASTE_BURST -``` - -推荐发布顺序: - -1. 默认关闭,先在测试和内部使用。 -2. 手动验证 iTerm2 + tmux、Windows Terminal、普通 macOS Terminal。 -3. 观察是否有正常手敲被误判成 paste。 -4. 默认开启。 -5. 保留关闭开关作为 escape hatch。 - -## 8. 测试计划 - -建议把测试加到现有 `Editor` 测试文件里,避免新增过多测试文件。 - -### 8.1 bracketed paste 路径不变 - -- 输入 `\x1b[200~a\r\nb\x1b[201~` -- 应作为一次 paste 插入 -- 不触发 submit - -### 8.2 非 bracketed paste burst 被识别 - -输入: - -```text -a -b -c - -``` - -字符间隔小于 `PASTE_BURST_CHAR_INTERVAL_MS` 时: - -- 字符仍按现有逻辑插入 -- 不触发 submit -- 随后的 Enter 在 burst 窗口内按换行处理 - -### 8.3 burst 内 Enter 不提交 - -输入: - -```text -abc\rdef\r - -``` - -期望: - -- `\r` 被当成换行处理 -- 不触发 submit -- 输入框保留多行文本 - -### 8.4 正常手敲不受影响 - -输入: - -```text -a -<等待超过 interval> -b -<等待超过 interval> -Enter -``` - -期望: - -- `a`、`b` 作为正常输入 -- Enter 正常 submit - -### 8.5 Enter suppression window - -输入: - -```text -abcdefgh -Enter -``` - -如果 Enter 距离上次 burst 活动小于 `PASTE_ENTER_SUPPRESS_WINDOW_MS`: - -- 先按换行处理 - -超过窗口后: - -- 恢复 submit 行为 - -### 8.6 非 ASCII / IME - -输入中文: - -```text -测 -试 -文 -本 -``` - -快速连续到达时: - -- 中文字符仍按现有逻辑立即插入 -- 快速连续输入后的 Enter 应按换行处理 - -### 8.7 disable 开关 - -当 `disablePasteBurst = true`: - -- 所有输入按正常 typing 处理 -- 不做 paste-burst Enter 抑制 - -## 9. 手动验证 - -### 9.1 bracketed paste 正常 - -在 tmux 里运行: - -```sh -printf '\033[?2004h'; cat -A; printf '\033[?2004l' -``` - -粘贴多行文本,应看到: - -```text -^[[200~ ... -... -^[[201~ -``` - -### 9.2 CRLF 文件粘贴 - -使用: - -```text -paste-test-crlf.txt -``` - -粘贴到 Kimi Code 输入框: - -- 修复前:如果 bracketed paste 生效,也不会复现。 -- 修复后:无论 bracketed paste 是否生效,都应尽量作为整段粘贴处理。 - -### 9.3 send-keys 强制模拟裸 Enter - -```sh -tmux send-keys -t -l '复现第一行' -tmux send-keys -t Enter -tmux send-keys -t -l '复现第二行' -tmux send-keys -t Enter -``` - -修复前:裸 Enter 会提交。 - -修复后:如果输入足够快,应被 paste-burst 合并为一次粘贴,至少不应逐行误提交。 - -注意:`send-keys` 的时序可能受 tmux 和系统调度影响,不能完全等同于真实粘贴,只能作为近似验证。 - -## 10. 风险与缓解 - -### 10.1 快速手敲被误判成粘贴 - -风险: - -```text -用户快速敲一个短命令 + Enter -``` - -可能被误判成 paste。 - -缓解: - -- 只处理无修饰键的普通字符。 -- 要求至少 `PASTE_BURST_MIN_CHARS` 个连续快速字符。 -- 提供 disable 开关。 -- 初始默认关闭,先灰度。 - -### 10.2 短 Enter suppression 影响正常提交 - -`PASTE_ENTER_SUPPRESS_WINDOW_MS` 可能让用户在快速输入后按 Enter 时被当成换行。 - -缓解: - -- 窗口保持较短,例如 80-120ms。 -- 只在前一个输入被判定为 burst 后开启。 -- 正常慢速输入不开启。 - -### 10.3 慢速粘贴无法识别 - -如果终端把粘贴拆成很慢的 chunk,间隔超过 `PASTE_BURST_CHAR_INTERVAL_MS`,启发式可能失效。 - -缓解: - -- 可适当提高 interval。 -- 但不能无限提高,否则会误伤正常输入。 -- bracketed paste 仍是主路径,启发式只是兜底。 - -## 11. 环境侧建议 - -应用层启发式是兜底,环境侧仍建议保持健康配置。 - -`~/.tmux.conf`: - -```conf -set -g extended-keys on -``` - -然后重启 tmux: - -```sh -tmux kill-server -tmux new -s test -``` - -可选,不是 bracketed paste 必需: - -```conf -set -g set-clipboard on -set -g allow-passthrough on -``` - -这两行主要用于 OSC 52 / escape passthrough,不应被当作 bracketed paste 修复手段。 - -## 12. 推荐落地步骤 - -1. 新增 `PasteBurst` 状态机,先写单元测试。 -2. 在 `Editor.handleInput()` 接入,默认关闭。 -3. 确保 bracketed paste 显式路径不变。 -4. 增加 disable 开关或实验 flag。 -5. 跑现有测试,确认无回归。 -6. 手动验证 iTerm2 + tmux、`send-keys`、CRLF 文件粘贴。 -7. 内部试用一段时间。 -8. 默认开启,保留关闭开关。 - -## 13. 结论 - -根本机制是 bracketed paste: - -```text -标记到了 → 一次粘贴 → 不逐行提交 -``` - -但不能保证所有终端、tmux、SSH、Windows 环境都可靠传递标记。 - -因此完整方案是: - -```text -bracketed paste 作为主路径; -paste-burst 启发式作为失效兜底。 -``` - -这和 Codex 的方向一致。它不能从协议层面“根本消除”标记不到的情况,但能把实际误提交概率降到足够低,并且可测试、可关闭、可灰度。 \ No newline at end of file diff --git a/plan/thinking-effort-switching.md b/plan/thinking-effort-switching.md deleted file mode 100644 index 184e736c7..000000000 --- a/plan/thinking-effort-switching.md +++ /dev/null @@ -1,303 +0,0 @@ -# Thinking Effort 多档位切换方案 - -## 1. 背景与目标 - -新模型支持切换 reasoning effort,约 `low` / `high` / `max` 三档。模型目录会为每个模型返回: - -- `support_efforts = ["low", "high", "max"]`:该模型支持的档位列表(按模型不同)。 -- `default_effort = "high"`:该模型的出厂默认档位。 - -目标: - -1. 把 `support_efforts` / `default_effort` 像现有能力值一样存进 `config.toml`。 -2. 在 `/model` 选择器里,把模型下方的 thinking 控件从「On/Off 两段」扩展为「多档位」,并默认高亮默认值。 -3. 用户切换档位时立即生效,并更新**全局** `thinking.effort`(复用现有字段,跨重启自动保留)。 - -非目标(本期不做): - -- 按模型分别记住不同 effort(先全局生效,后续有需要再加)。 -- 改动 agent-core 的 effort 解析逻辑(客户端始终下发具体档位,解析逻辑不变)。 - -## 2. 现状关键事实 - -- **数据流(上游→下游)**:managed 模型目录 → `packages/oauth` 解析并写入 `config.toml` → `/models` 从 `config.toml` 透出;TUI 直接读 `config.toml`。 -- **模型条目刷新**:managed(`kimi-code`)模型每次刷新会**整条删除再重建**(`packages/oauth/src/managed-kimi-code.ts:464-478`),所以用户偏好**不能**存在模型条目里,会被冲掉。 -- **能力存储**:`config.toml` 里 `[models.]` 下的 `capabilities = ["thinking", "always_thinking", "tool_use", ...]`(字符串标签数组)。 -- **全局 effort 已存在**:`ThinkingConfigSchema = { mode?, effort? }`,`resolveThinkingEffort` 已用 `thinking.effort` 作为 `'on'` 的默认值(缺省 `'high'`)。 -- **底层已支持多档**:`ThinkingEffort = 'off' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'`,`Session.setThinking(level: string)` 接受任意字符串。 -- **kimi provider 当前发 `reasoning_effort`**:`packages/kosong/src/providers/kimi.ts` 的 `withThinking` 目前把 effort 映射成 `reasoning_effort`(与 openai 一致),同时额外发一个 `thinking: { type }`。新接口要求改成在 `thinking` 对象里带 `effort`;过渡期 `reasoning_effort` **仍保留一起传**(加 TODO,后续再删)。 -- **TUI 当前是布尔 On/Off**:`apps/kimi-code/src/tui/components/dialogs/model-selector.ts`,`←/→` 翻转布尔,`On` 左、`Off` 右。 - -## 3. 设计决策(已确认) - -| 决策点 | 结论 | -|---|---| -| `support_efforts` / `default_effort` 存哪 | 模型条目 `[models.]` 下的独立字段,与 `capabilities` 平级 | -| Off 行为 | 复用现有 `always_thinking` / `thinking` 标签:`always_thinking` 无 Off,`thinking` 有 Off | -| 用户选择存哪 | **全局** `thinking.effort`(已有字段);不新增按模型的表 | -| 跨重启 | 切换写入 `thinking.effort`,现有解析逻辑会自动用它,跨重启自然保留 | -| `default_effort` 角色 | 模型出厂默认值(随刷新更新);用于选择器里非当前模型的初始高亮 | -| kimi 请求里的 effort | 新增 `thinking: { type, effort? }`(`keep` 恒为 `all`,本期不传;仅当模型有 `supportEfforts` 时带 effort);`reasoning_effort` 过渡期**继续一起传**(加 TODO,后续删除) | - -## 4. config.toml 形态 - -```toml -default_model = "kimi-code/kimi-k2" -default_thinking = true - -[thinking] -mode = "auto" -effort = "max" # 用户切换后更新这里(全局) - -[models."kimi-code/kimi-k2"] -provider = "kimi-code" -model = "kimi-k2" -max_context_size = 262144 -capabilities = ["thinking", "always_thinking", "tool_use"] -support_efforts = ["low", "high", "max"] # 新增(接口给,随刷新) -default_effort = "high" # 新增(接口给,随刷新) -``` - -## 5. 配置 schema 改动 - -### 5.1 `packages/agent-core/src/config/schema.ts` - -`ModelAliasSchema` 新增两个可选字段(**通用**,任何 provider 的模型都可以有): - -```ts -supportEfforts: z.array(z.string()).optional(), -defaultEffort: z.string().optional(), -``` - -> managed 模型由接口自动写入;其它模型(openai / anthropic / 自定义 provider 等)也可以**手动在 `config.toml` 的 `[models.]` 里写 `support_efforts` / `default_effort`**,UI 读到就会展示多档位切换。 - -`ThinkingConfigSchema` **不变**(复用现有 `effort`)。 - -### 5.2 `packages/protocol/src/modelCatalog.ts` - -`modelCatalogItemSchema` 新增: - -```ts -support_efforts: z.array(z.string()).optional(), -default_effort: z.string().optional(), -``` - -### 5.3 `packages/agent-core/src/services/modelCatalog/modelCatalog.ts` - -`toProtocolModel()` 把 `alias.supportEfforts` / `alias.defaultEffort` 透到 `support_efforts` / `default_effort`,让 `/models` 返回。 - -## 6. OAuth 解析与写入 - -### 6.1 `packages/oauth/src/managed-kimi-code.ts` - -- `ManagedKimiCodeModelInfo` 新增 `supportEfforts?: readonly string[]`、`defaultEffort?: string`。 -- `toModelInfo()`(约 `:364`)解析 `support_efforts`(字符串数组,过滤非字符串/空串)与 `default_effort`(非空字符串)。新增一个小工具 `parseStringArray()`。 -- 写 config 的循环(`:469-478`)把 `supportEfforts` / `defaultEffort` 写进模型条目。 - -### 6.2 `packages/oauth/src/open-platform.ts` - -为保持一致,同步在 `toModelInfo()`(`:44`)解析、模型写入(`:173-182`)带上这两个字段(open-platform 路径共用 `ManagedKimiCodeModelInfo`)。 - -## 7. kosong kimi provider 改造(新 wire 格式) - -文件:`packages/kosong/src/providers/kimi.ts`,并把 `supportEfforts` 从 `ModelAlias` 透到 provider(见下方第 5 点)。 - -新接口格式: - -```json -{ "thinking": { "type": "enabled", "keep": "all", "effort": "high" } } -``` - -- `keep` 恒为 `all`,本期**不传**。 -- `effort` 放在 `thinking` 对象内(仅当模型有 `supportEfforts` 时)。 -- `reasoning_effort` 过渡期**继续一起传**(加 `TODO`,后续再删)。 - -改动点: - -1. **`ThinkingConfig` 接口**(`:74-78`):新增 `effort?: string`(已有 `[key: string]: unknown` 兜底,显式声明更清晰)。 - -2. **`withThinking(effort)`**(`:500-524`)重写: - - `kimiEffort(effort)` 映射(唯一映射,**删除旧的 `high/xhigh/max→high` clamp**):`low→low`、`medium→medium`、`high→high`、`xhigh→high`(kimi 无 xhigh)、`max→max`。 - - 构造 `thinking`:`effort === 'off'` → `{ type: 'disabled' }`;否则模型有 `supportEfforts`(非空)→ `{ type: 'enabled', effort: kimiEffort(effort) }`;否则(旧模型)→ `{ type: 'enabled' }`(无 effort)。 - - `reasoningEffort`:**仅当 `effort !== 'off'` 且模型有 `supportEfforts`** 时取 `kimiEffort(effort)`(与 `thinking.effort` 同值),否则 `undefined`;旁加 `TODO` 注释标明后续删除。 - - 返回 `_withGenerationKwargs({ reasoning_effort: reasoningEffort })` + `withExtraBody({ thinking })`。 - -3. **`thinkingEffort` getter**(`:415-417`):从 `extra_body.thinking` 反推(不再读 `reasoning_effort`,旧的反推映射一并删除): - - `thinking` 未设置 → `null`。 - - `type === 'disabled'` → `'off'`。 - - `thinking.effort` 存在 → 反查为 `ThinkingEffort`(`'low'/'medium'/'high'/'xhigh'/'max'`,未知值兜底 `'high'`)。 - - `type === 'enabled'` 但无 `effort`(旧模型)→ `'high'`(逻辑默认档)。 - - 由此 `reasoningEffortToThinkingEffort` 在 kimi.ts 不再使用,移除该 import。 - -4. **`reasoning_effort` 字段**:本期保留,但与 `thinking.effort` **同值**(统一走 `kimiEffort`,旧的 clamp 映射删除),且**同样按 `supportEfforts` 门控**(旧模型不再发 `reasoning_effort`);代码里加 `TODO`,待新 wire 格式全量上线后删除(见第 11 节)。 - -5. **把 `supportEfforts` 透到 provider**(`packages/agent-core/src/session/provider-manager.ts`): - - `KimiOptions` 新增 `supportEfforts?: readonly string[]`;`KimiChatProvider` 构造时存为私有字段,`_clone()` 通过 `Object.assign` 自动保留。 - - `toKosongProviderConfig` 新增 `supportEfforts` 参数,`case 'kimi'` 里写入 provider config。 - - `resolveProviderConfig` 调用 `toKosongProviderConfig` 时传入 `alias.supportEfforts`。 - - `withThinking` 用该字段决定是否带 `effort`。 - -> 注意:`thinking: { type }` 之前就已经在发,本次只是在该对象里(按能力)加 `effort`;`reasoning_effort` 暂时仍保留一起发,改动面很小。 - -> 其它 provider(openai / anthropic / google-genai 等)的 `withThinking` 已经各自把 `ThinkingEffort` 映射到自己的原生参数,**本期不改**。这些模型只要(手动)配了 `support_efforts`,UI 就能切换档位,选中的档位照旧走各 provider 现有的 `withThinking` 传参逻辑。只有 kimi provider 需要按 `supportEfforts` 门控 `thinking.effort` / `reasoning_effort`。 - -## 8. TUI 改造 - -### 8.1 `model-selector.ts`(核心) - -**类型变化** - -- `ModelSelection.thinking`:`boolean` → `string`(档位:`'off'`、`'on'`、或具体 effort 如 `'high'`)。 -- `ModelSelectorOptions.currentThinking: boolean` → `currentThinkingLevel: string`(当前模型的运行时档位)。 -- 内部 `thinkingOverrides: Map` → `Map`。 - -**新增工具** - -```ts -function effortsOf(model: ModelAlias): readonly string[] { - return model.supportEfforts ?? []; -} -``` - -**分段(segments)规则** - -| 模型类型 | segments | 说明 | -|---|---|---| -| 不支持 thinking | `['off']`(渲染 On 不可选 + `[Off]`,保持现状) | 不变 | -| 普通 toggle(无 effort) | `['on', 'off']` | 不变 | -| always-on(无 effort) | `['on']` | 不变 | -| toggle + effort | `['off', ...supportEfforts]` | Off 在最左 | -| always-on + effort | `[...supportEfforts]` | 无 Off | - -> effort 模型 Off 放最左;非 effort 模型保持 `On` 左 / `Off` 右,避免改动现有视觉。 - -**高亮(draftFor)** - -- 有 `←/→` override → override。 -- 当前模型 → `currentThinkingLevel`(运行时实际档位)。 -- 其它 effort 模型 → `defaultEffort`(若在 `supportEfforts` 内),否则 `supportEfforts[0]`。 -- 其它非 effort 模型 → 保持现有逻辑(capable 默认 `'on'`,否则 `'off'`)。 - -**键盘** - -- `←`:active 段左移一位(到最左停)。 -- `→`:active 段右移一位(到最右停)。 -- 不循环;不支持的模型忽略。 - -**渲染 `renderThinkingControl`** - -按 `segmentsFor(model)` 渲染所有段,active 段用 `boldFg('primary', '[ label ]')`,其余 `fg('text', ' label ')`;不支持的侧保持 `textMuted` + `(Unsupported)`(仅非 effort 的 always-on/unsupported 路径保留)。段标签首字母大写(`off`→`Off`,`low`→`Low`,`max`→`Max`)。 - -**标题行** - -- toggle / effort 模型可切换时显示 `Thinking (←→ to switch)`,否则 `Thinking`。 - -### 8.2 `tabbed-model-selector.ts` - -透传:`TabbedModelSelectorOptions.currentThinking` → `currentThinkingLevel`,`makeSelector` 里传 `currentThinkingLevel`。`onSelect` 的 `thinking` 现在是 string,直接转发。 - -### 8.3 `commands/config.ts` - -- `showModelPicker`:`currentThinkingLevel: host.state.appState.thinkingLevel ?? (host.state.appState.thinking ? 'on' : 'off')`。 -- `performModelSwitch(host, alias, level: string, persist)`: - - `const prevLevel = host.state.appState.thinkingLevel ?? (host.state.appState.thinking ? 'on' : 'off')` - - session 路径:`alias !== prevModel` → `setModel(alias)`;`level !== prevLevel` → `setThinking(level)`(直接传档位字符串)。 - - 无 session:`authFlow.activateModelAfterLogin(alias, level)`。 - - `setAppState({ model: alias, thinking: level !== 'off', thinkingLevel: level })`。 - - 状态文案:`thinking on/off` → 直接显示档位(如 `thinking high`)。 -- `persistModelSelection(host, alias, level: string)`: - - `defaultModel = alias` - - `defaultThinking = level !== 'off'` - - 若 `level` 是具体 effort(`level !== 'on' && level !== 'off'`)→ 写 `thinking: { effort: level }`(`setConfig` 深合并,保留 `thinking.mode`)。 - - 仅在 `defaultModel` / `defaultThinking` / `thinking.effort` 任一变化时调用 `setConfig`。 - -### 8.4 `commands/provider.ts` - -`setDefaultModel(host, alias, thinking)` 的 `thinking` 由 `boolean` 改为 `level: string`,写 `defaultThinking: level !== 'off'`,并在具体 effort 时写 `thinking.effort`。两处 `onSelect` 透传 string。两处 `currentThinking` → `currentThinkingLevel`。 - -### 8.5 `commands/prompts.ts`(`runModelSelector`) - -- 选项 `currentThinking` → `currentThinkingLevel: initialThinking ? 'on' : 'off'`。 -- `onSelect` 拿到 string level,返回时转回 boolean:`{ alias, thinking: level !== 'off' }`(open-platform / catalog 模型无 effort 字段,level 只会是 `'on'`/`'off'`)。 - -### 8.6 `types.ts` / `kimi-tui.ts` / `auth-flow.ts` / `footer.ts` - -- `AppState` 新增 `thinkingLevel?: string`(运行时档位)。 -- `kimi-tui.ts syncRuntimeState`(`:1201`):加 `thinkingLevel: status.thinkingLevel`。 -- `kimi-tui.ts` 初始 state(`:193`)与 logout 重置:`thinkingLevel: 'off'`。 -- `kimi-tui.ts:1176-1177` reload 建 session:`thinking` 用 `this.state.appState.thinkingLevel ?? (this.state.appState.thinking ? 'on' : 'off')`,保留具体档位。 -- `auth-flow.ts activateModelAfterLogin(model, level?: string)`:参数由 `thinking?: boolean` 改为 `level?: string`,直接传给 `setThinking` / `CreateSessionOptions.thinking`。`refreshConfigAfterLogin` 里 `activateModelAfterLogin(defaultModel, config.defaultThinking === false ? 'off' : undefined)`。 -- `footer.ts:265`:有 effort 时显示档位,例如 `state.thinkingLevel` 为具体 effort 时渲染 ` thinking:max`,否则保持 ` thinking`。 - -## 9. 逐文件改动清单 - -| 包 | 文件 | 改动 | -|---|---|---| -| kosong | `src/providers/kimi.ts` | `withThinking` 新增 `thinking: { type, effort? }`(仅当模型有 supportEfforts 时带 effort);`reasoning_effort` 暂保留但与 effort 同值、同样按 supportEfforts 门控,旧 clamp 映射删除并加 TODO;`thinkingEffort` getter 从 `thinking` 反推;`KimiOptions` 加 `supportEfforts` | -| agent-core | `src/session/provider-manager.ts` | `toKosongProviderConfig` 透传 `supportEfforts`;`resolveProviderConfig` 传入 `alias.supportEfforts` | -| agent-core | `src/config/schema.ts` | `ModelAliasSchema` 加 `supportEfforts` / `defaultEffort` | -| agent-core | `src/services/modelCatalog/modelCatalog.ts` | `toProtocolModel` 透出两字段 | -| protocol | `src/modelCatalog.ts` | `modelCatalogItemSchema` 加 `support_efforts` / `default_effort` | -| oauth | `src/managed-kimi-code.ts` | `ManagedKimiCodeModelInfo` + `toModelInfo` + 写 config | -| oauth | `src/open-platform.ts` | `toModelInfo` + 写 config(同步) | -| kimi-code | `src/tui/components/dialogs/model-selector.ts` | 多档位 UI + 键盘 + 类型 | -| kimi-code | `src/tui/components/dialogs/tabbed-model-selector.ts` | 透传 `currentThinkingLevel` | -| kimi-code | `src/tui/commands/config.ts` | 档位化切换 + 持久化 `thinking.effort` | -| kimi-code | `src/tui/commands/provider.ts` | `setDefaultModel` 档位化 | -| kimi-code | `src/tui/commands/prompts.ts` | `runModelSelector` 适配 string level | -| kimi-code | `src/tui/types.ts` | `AppState.thinkingLevel` | -| kimi-code | `src/tui/kimi-tui.ts` | syncRuntimeState / 初始 / reload | -| kimi-code | `src/tui/controllers/auth-flow.ts` | `activateModelAfterLogin` 档位化 | -| kimi-code | `src/tui/components/chrome/footer.ts` | 显示 effort 档位 | - -## 10. 测试计划 - -就近扩展,不新增泛化测试文件: - -- `packages/kosong`(kimi provider 测试) - - `withThinking('high')` 在模型有 `supportEfforts` 时发出 `thinking: { type: 'enabled', effort: 'high' }`,且 `reasoning_effort === 'high'`。 - - `withThinking('max')` 在模型有 `supportEfforts` 时:`thinking.effort === 'max'` 且 `reasoning_effort === 'max'`(同值,无 clamp)。 - - `withThinking('high')` 在模型无 `supportEfforts` 时发出 `thinking: { type: 'enabled' }`(无 effort),且 `reasoning_effort === undefined`(按能力门控,旧模型不再发)。 - - `withThinking('off')` 发出 `thinking: { type: 'disabled' }`,`reasoning_effort === undefined`。 - - `thinkingEffort` getter:从 `thinking` 反推(`{enabled, effort:'max'}`→`'max'`、`{enabled}`→`'high'`、`{disabled}`→`'off'`、未设置→`null`)。 -- `packages/agent-core`(provider-manager 测试) - - `resolveProviderConfig` 把 `alias.supportEfforts` 透到 kimi provider config。 - -- `apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts` - - 现有 On/Off 用例:`currentThinking` → `currentThinkingLevel`(`'on'`/`'off'`),断言 `thinking` 为字符串。 - - 新增 effort 用例: - - effort 模型渲染 `[Off] [Low] [High] [Max]`(toggle)/ `[Low] [High] [Max]`(always-on)。 - - 默认高亮 `defaultEffort`。 - - `←/→` 在多档间移动并到端点停止;Enter 下发具体档位。 - - 当前模型高亮运行时档位(`currentThinkingLevel`)。 - - 切到 Off 时下发的 `thinking === 'off'`。 -- `apps/kimi-code/test/tui/components/dialogs/tabbed-model-selector.test.ts` - - 透传 `currentThinkingLevel`;断言 string 档位透传。 -- `packages/oauth` 现有测试 - - `toModelInfo` 解析 `support_efforts` / `default_effort`。 - - 写 config 时模型条目包含 `supportEfforts` / `defaultEffort`。 -- `packages/agent-core` 配置相关测试 - - `ModelAliasSchema` 接受新字段;`toProtocolModel` 透出。 - -跑测试: - -```bash -pnpm --filter @moonshot-ai/kimi-code test -- model-selector tabbed-model-selector -pnpm --filter @moonshot-ai/oauth test -pnpm --filter @moonshot-ai/kosong test -- kimi -pnpm --filter @moonshot-ai/agent-core test -- modelCatalog provider-manager -``` - -类型检查 / lint: - -```bash -pnpm --filter @moonshot-ai/kimi-code typecheck -pnpm lint -``` - -## 11. 范围外 / 后续 - -- 删除 kimi provider 的 `reasoning_effort`(待新 `thinking.effort` wire 格式全量上线、所有 kimi 模型都接受后;代码里已留 `TODO`)。 -- 按模型分别记住 effort(先全局;若需要,再加 `[thinking.model_efforts]` 之类的覆盖表)。 -- agent-core 解析感知 `default_effort`(本期客户端始终下发具体档位,无需改动)。 -- kimi-web 端的多档 UI(`/models` 透出字段后,web 已具备 `ThinkingLevel` 模型,可另行接入)。 diff --git a/plan/thinking-model-overhaul.md b/plan/thinking-model-overhaul.md deleted file mode 100644 index 4007a7f61..000000000 --- a/plan/thinking-model-overhaul.md +++ /dev/null @@ -1,385 +0,0 @@ -# Thinking 模型重构方案 - -> 前置工作:`plan/thinking-effort-switching.md`(effort 多档切换)已完成。本方案是在其基础上对 thinking 模型的整体重构,目标是消除冗余开关、硬编码默认值和静态档位枚举,让 `support_efforts` 成为档位的唯一真相源。 - -## 1. 背景与目标 - -当前 thinking 的开关和级别逻辑横跨 config、agent-core、SDK、TUI、provider 五层,经过多轮演进后积累了以下问题: - -- 配置层同时存在 `default_thinking`(boolean)、`thinking.mode`(auto/on/off)、`thinking.effort` 三个字段,语义重叠;`mode=auto` 与 `mode=on` 代码里完全等价,名存实亡。 -- kosong 的 `ThinkingEffort = 'off' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'` 是全局硬编码枚举,但档位本应来自模型声明的 `support_efforts`;`kimiEffort()` 还得硬编码 `'xhigh' → 'high'` 之类的映射。 -- `'on'` 是一个不在 `ThinkingEffort` 枚举里的幽灵档位,贯穿 TUI → SDK → agent-core,语义在层间断裂。 -- TUI 用 `AppState.thinking: boolean` + `AppState.thinkingLevel?: string` 两个字段存一个状态,五处重复 fallback。 -- `DEFAULT_THINKING_EFFORT = 'high'` 硬编码兜底,与模型声明的 `default_effort` 重复。 -- `always_thinking` 约束在 UI、agent-core getter、acp adapter 三处各 clamp 一次。 - -目标: - -1. 配置收敛到 `[thinking]` 一处,删除 `default_thinking` 和 `thinking.mode`。 -2. `support_efforts` 成为 effort 档位的唯一真相源,删除全局 `ThinkingEffort` 枚举和 `kimiEffort` 映射。 -3. 删除 `DEFAULT_THINKING_EFFORT` 硬编码,默认值完全由模型声明推导。 -4. `'on'` 圈禁为 boolean 模型(无 `support_efforts`)专属的开启信号,不再贯穿全栈。 -5. TUI 单一字段 `thinkingLevel`,删除 `thinking: boolean`。 -6. `always_thinking` 约束集中到 agent-core resolve 一处。 - -非目标(本期不做): - -- 按模型分别记住 effort 偏好(`[thinking.model_efforts]`),仍用全局 `thinking.effort`,切模型时由 provider 归一。 -- 真正实现 `mode=auto` 的"按任务/模型自适应",直接删除该值。 -- kimi-web 端改造。 - -## 2. 设计原则 - -1. **单一真值**:thinking 运行时状态在 agent-core 里就是一个 `ThinkingLevel`,不存在第二个 boolean 字段。 -2. **`support_efforts` 是 effort 档位的唯一真相源**:档位是模型的属性,不是全局枚举。 -3. **归一优先于校验**:不做 `isValidThinkingLevel` 严格校验抛错;非法 effort 在 provider 端宽容归一为 `undefined`。 -4. **`'off'` 和 `'on'` 是唯二保留字**:`'off'` 关闭,`'on'` 专属于无 `support_efforts` 的 boolean 模型;其余档位都是模型声明。 -5. **`always_thinking` 是约束,不是档位**:在 agent-core resolve 一处 clamp。 - -## 3. 设计决策(已确认) - -| 决策点 | 结论 | -|---|---| -| `default_thinking` | **直接删除**,不做兼容读取。breaking change,changeset 走 major + changelog 写迁移说明 | -| `thinking.mode` | **删除**。`off` 由 `enabled=false` 表达,`on`/`auto` 由 `enabled=true` 表达 | -| `DEFAULT_THINKING_EFFORT = 'high'` | **删除**。默认值由 `defaultLevelFor(model)` 从模型声明推导 | -| `ThinkingEffort` 静态枚举 | **删除**。改为 `ThinkingLevel = 'off' | 'on' | (string & {})`,档位动态化 | -| `isValidThinkingLevel` 校验 | **不做**。非法 effort 在 provider 宽容归一为 `undefined`,不抛错 | -| effort 非法值归一 | provider 端:`supportEfforts.includes(level) ? level : undefined` | -| `always_thinking` clamp | agent-core `resolveThinkingLevel`:`level === 'off'` 时归一为 `defaultLevelFor(model)` | -| `kimiEffort()` 映射 | **删除**。effort 直接来自 `support_efforts`,是 kimi 原生值 | -| `wireEffortToThinkingEffort()` | **删除**。getter 直接读 wire 值 | -| TUI `AppState.thinking: boolean` | **删除**。只留 `thinkingLevel: ThinkingLevel` | -| `setThinking` 类型 | `ThinkingLevel`,透传无运行时校验 | -| 全局 effort 偏好 | 保留 `thinking.effort`,切模型时由 provider 归一(不合法则不带 effort) | - -## 4. 目标形态 - -### 4.1 config.toml - -```toml -default_model = "kimi-code/kimi-k2" - -[thinking] -enabled = true # 默认是否开启(替代 default_thinking + thinking.mode) -effort = "high" # 开启时的默认档位(low/medium/high/xhigh/max,或模型声明的其他档) - -[models."kimi-code/kimi-k2"] -provider = "kimi-code" -model = "kimi-k2" -max_context_size = 262144 -capabilities = ["thinking", "always_thinking", "tool_use"] -support_efforts = ["low", "high", "max"] # effort 档的唯一真相源 -default_effort = "high" -``` - -迁移说明(changelog):旧配置 `default_thinking = true` 改为 `[thinking] enabled = true`;`default_thinking = false` 改为 `enabled = false`;`[thinking] mode = "off"` 改为 `enabled = false`;`mode = "on"` / `mode = "auto"` 删除该行(等价于 `enabled = true`)。 - -### 4.2 类型 - -```ts -// packages/kosong/src/provider.ts -export type ThinkingLevel = 'off' | 'on' | (string & {}); -// ^^^^^^^^^^^^ -// 模型声明的 effort 档,运行时 string -``` - -`ThinkingEffort` 类型**直接删除**,所有引用在同一个 PR 内全量替换为 `ThinkingLevel`,**不留别名、不留兼容层**。 - -`ThinkingLevel` 在 TS 里塌缩成 `string`,主要作为**语义标注**:告诉调用者这里应该是 `'off'` / `'on'` / 模型 effort 档。运行时就是 `string`,不做强约束。 - -### 4.3 agent-core 运行时 - -```ts -// packages/agent-core/src/agent/config/index.ts -private _thinkingLevel: ThinkingLevel = 'off'; - -get thinkingLevel(): ThinkingLevel { - return this._thinkingLevel; // 不在这里 clamp always_thinking -} -``` - -`_thinkingLevel` 是 agent-core 内唯一的 thinking 状态字段。 - -### 4.4 配置 schema - -```ts -// packages/agent-core/src/config/schema.ts -export const ThinkingConfigSchema = z.object({ - enabled: z.boolean().optional(), - effort: z.string().optional(), -}); -``` - -删除 `mode` 字段。 - -### 4.5 provider 层 - -```ts -// packages/kosong/src/providers/kimi.ts -withThinking(level: ThinkingLevel): KimiChatProvider { - let thinking: ThinkingConfig; - let reasoningEffort: string | undefined; - - if (level === 'off') { - thinking = { type: 'disabled' }; - } else { - // support_efforts 是 effort 档的唯一真相源:只下发声明里的值, - // 其余('on' / 'xhigh' / 非法值)一律归一为 undefined(不带 effort)。 - const effort = this._supportEfforts.includes(level) ? level : undefined; - thinking = effort !== undefined - ? { type: 'enabled', effort } - : { type: 'enabled' }; - // TODO: drop reasoning_effort once the new thinking.effort wire format is - // fully rolled out across all kimi models. - reasoningEffort = effort; - } - - const oldExtra = this._generationKwargs.extra_body ?? {}; - const keep = oldExtra.thinking?.keep; - if (keep !== undefined) { - thinking = { ...thinking, keep }; - } - return this._withGenerationKwargs({ - reasoning_effort: reasoningEffort, - extra_body: { ...oldExtra, thinking }, - }); -} -``` - -其它 provider(openai / anthropic / google-genai 等)的 `withThinking` 已经各自把 effort 映射到原生参数,本期不改。 - -### 4.6 TUI 层 - -```ts -// apps/kimi-code/src/tui/types.ts -AppState.thinkingLevel: ThinkingLevel; // 唯一字段,删除 thinking: boolean -``` - -boolean 模型的 UI 归一: - -```ts -// model-selector.ts -function commitLevel(choice: ModelChoice, draft: string): ThinkingLevel { - if (draft === 'off') return 'off'; - if (draft === 'on') { - return defaultLevelFor(choice.model); // boolean 模型:On → 模型默认 - } - return draft; -} -``` - -`ModelSelection.thinking` 类型收紧为 `ThinkingLevel`,不再有 `'on'` 漏出 UI 边界。 - -## 5. 关键归一规则 - -### 5.1 默认值推导 - -```ts -// packages/agent-core/src/agent/config/thinking.ts -function defaultLevelFor(model: ModelAlias): ThinkingLevel { - if (!supportsThinking(model)) return 'off'; - const efforts = model.supportEfforts; - if (efforts?.length) return model.defaultEffort ?? middleOf(efforts); - return 'on'; // boolean 模型 -} -``` - -| 模型 | 默认 | -|---|---| -| 不支持 thinking | `'off'` | -| 有 `support_efforts` | `default_effort` → `support_efforts` 中位 | -| 无 effort 的 boolean 模型 | `'on'` | - -### 5.2 resolve - -```ts -// packages/agent-core/src/agent/config/thinking.ts -export function resolveThinkingLevel( - requested: ThinkingLevel | undefined, - config: ThinkingConfig | undefined, - model: ModelAlias, -): ThinkingLevel { - let level: ThinkingLevel; - - if (requested !== undefined) { - level = requested; - } else if (config?.enabled === false) { - level = 'off'; - } else { - level = config?.effort ?? defaultLevelFor(model); - } - - // always_thinking 模型强制开启:'off' 归一为默认档 - if (level === 'off' && model.capabilities?.includes('always_thinking')) { - level = defaultLevelFor(model); - } - - return level; -} -``` - -删除 `resolveThinkingEffort`、`'on'` 分支、`mode` 分支、`DEFAULT_THINKING_EFFORT`。 - -### 5.3 provider effort 归一 - -```ts -const effort = this._supportEfforts.includes(level) ? level : undefined; -``` - -行为表: - -| 模型 | 传入 level | wire `thinking` | -|---|---|---| -| effort 模型 `['low','high','max']` | `'high'` | `{ type: 'enabled', effort: 'high' }` | -| effort 模型 | `'max'` | `{ type: 'enabled', effort: 'max' }` | -| effort 模型 | `'xhigh'` | `{ type: 'enabled' }`(不带 effort) | -| effort 模型 | `'on'` | `{ type: 'enabled' }` | -| effort 模型 | `'foo'` | `{ type: 'enabled' }` | -| boolean 模型(无 support_efforts) | `'on'` | `{ type: 'enabled' }` | -| 任意 | `'off'` | `{ type: 'disabled' }` | - -### 5.4 配置写入 - -```ts -// apps/kimi-code/src/tui/utils/thinking-config.ts -export function thinkingLevelToConfig(level: ThinkingLevel): ThinkingConfigPatch { - return level === 'off' - ? { enabled: false } - : { enabled: true, effort: level }; -} -``` - -删除 `config.ts` / `provider.ts` 里两处重复的 `level !== 'on' && level !== 'off' ? level : undefined`。 - -## 6. 实施步骤(单个 PR) - -所有改动合并成**一个 PR** 一次性完成,不留兼容层、不留别名、不留 TODO 债。PR 内按以下顺序执行(仅用于把控改动节奏,不是独立 PR): - -### 步骤 1:类型与 `'on'` 圈禁 - -目标:消除 `AppState.thinking` 双字段,把 `'on'` 圈禁在 UI 层。 - -- kosong 导出 `ThinkingLevel = 'off' | 'on' | (string & {})`,**直接删除 `ThinkingEffort`**,本 PR 内全量替换所有引用,不留别名。 -- node-sdk `setThinking(level: ThinkingLevel)`:保留透传,不做运行时校验(按决策)。 -- TUI 删 `AppState.thinking: boolean`,统一用 `thinkingLevel: ThinkingLevel`: - - `types.ts` 改字段 - - `kimi-tui.ts` 删 `thinking` 初始化、`syncRuntimeState` 删 `thinking` 派生写入 - - `footer.ts` / `status-panel.ts` / `info.ts` 等从 `thinkingLevel` 派生显示 -- `model-selector.ts` 提交前归一:`'on'` → `defaultLevelFor(model)`;`ModelSelection.thinking: ThinkingLevel`。 -- 删 5 处 `thinkingLevel ?? (thinking ? 'on' : 'off')` fallback,抽 `isThinkingOn(level)` / `thinkingLabel(level)` helper。 -- `prompts.ts` open-platform/catalog 路径:现在 `ModelSelection.thinking` 已是 `ThinkingLevel`,删除 `thinking !== 'off'` 转 boolean 的逻辑(按需调整返回类型)。 - -验证:现有测试通过;新增 `'on'` 归一、`isThinkingOn` helper 测试。 - -### 步骤 2:配置收敛 - -目标:删 `default_thinking` 和 `thinking.mode`,收敛到 `[thinking] { enabled, effort }`。 - -- `schema.ts` `ThinkingConfigSchema`:删 `mode`,`effort` 保持 `z.string().optional()`。 -- `schema.ts` 顶层:删 `defaultThinking`(`KimiConfigSchema`)。 -- `thinking.ts` `resolveThinkingLevel`:按 5.2 重写;删 `resolveThinkingEffort`、`'on'` 分支、`mode` 分支、`DEFAULT_THINKING_EFFORT`;新增 `defaultLevelFor`。 -- `core-impl.ts` `createSession`:调用 `resolveThinkingLevel(options.thinking, config.thinking, model)`。 -- TUI `persistModelSelection` / `setDefaultModel`:写入走 `thinkingLevelToConfig`,不再写 `defaultThinking`。 -- `auth-flow.ts` `refreshConfigAfterLogin`:读 `config.thinking?.enabled` 替代 `config.defaultThinking`。 -- `env-model.ts`:删 `KIMI_MODEL_THINKING_MODE` 处理(或改为设置 `enabled`)。 -- 文档:`config-files.md` / `env-vars.md` 更新字段说明;changelog 写迁移说明。 - -验证:老 config.toml(含 `default_thinking`、`mode`)按 major 迁移说明手动改写后行为一致;新写入只产 `[thinking] enabled/effort`。 - -### 步骤 3:always_thinking 集中 + provider 归一 - -目标:删三处 clamp,删 `kimiEffort` / `wireEffortToThinkingEffort` 映射。 - -- `thinking.ts` `resolveThinkingLevel`:加 `always_thinking` clamp(在步骤 2 一并写入,本步骤验证 + 删其它 clamp)。 -- `config/index.ts` `thinkingLevel` getter:删 `alwaysThinkingModel` clamp,直接返回 `_thinkingLevel`。 -- `acp-adapter/session.ts` `setThinking`:删 `currentModelAlwaysThinking()` clamp;`THINKING_ON_LEVEL = 'high'` 改为 `defaultLevelFor(currentModel)` 或从 agent-core status 取。 -- `kimi.ts` `withThinking`:按 4.5 重写;删 `kimiEffort()` 函数;`reasoning_effort` 双发本步骤保留,待服务端全量后在步骤 4 删除(唯一依赖服务端的收尾项)。 -- `kimi.ts` `thinkingEffort` getter:删 `wireEffortToThinkingEffort()`,直接读 `thinking.effort`,无 effort 返回 `'on'`。 -- UI `model-selector.ts` `renderThinkingControl`:保留 `Off (Unsupported)` 纯展示(不承担 clamp)。 - -验证:always_thinking 模型设 `'off'` 仍开启(agent-core clamp);effort 模型传 `'xhigh'` / `'foo'` wire 不带 effort;`kimiEffort` / `wireEffortToThinkingEffort` 无引用。 - -### 步骤 4:清理 - -目标:删除过渡期代码。 - -- 删 kimi `reasoning_effort` 双发(确认服务端所有 kimi 模型已接受新 wire 格式后执行;这是唯一依赖服务端的收尾项)。 -- 删 `effectiveDefaultEffort`(被 `defaultLevelFor` 覆盖,确认无引用后删除)。 -- 删 acp `THINKING_ON_LEVEL` 常量(如步骤 3 未删)。 -- 文档最终校对。 - -## 7. 逐文件改动清单 - -| 包 | 文件 | 改动 | -|---|---|---| -| kosong | `src/provider.ts` | 删 `ThinkingEffort`;新增 `ThinkingLevel` | -| kosong | `src/providers/kimi.ts` | `withThinking` 重写;删 `kimiEffort` / `wireEffortToThinkingEffort`;getter 简化 | -| node-sdk | `src/session.ts` | `setThinking(level: ThinkingLevel)` | -| node-sdk | `src/types.ts` | 导出 `ThinkingLevel` | -| agent-core | `src/config/schema.ts` | `ThinkingConfigSchema` 删 `mode`;顶层删 `defaultThinking` | -| agent-core | `src/agent/config/thinking.ts` | `resolveThinkingLevel` 重写;`defaultLevelFor`;删 `resolveThinkingEffort` / `DEFAULT_THINKING_EFFORT` / `effectiveDefaultEffort` | -| agent-core | `src/agent/config/index.ts` | `_thinkingLevel: ThinkingLevel`;getter 删 always_thinking clamp | -| agent-core | `src/rpc/core-impl.ts` | `createSession` 调用新 resolve | -| agent-core | `src/config/env-model.ts` | 删 `KIMI_MODEL_THINKING_MODE` 或改设 `enabled` | -| acp-adapter | `src/session.ts` | 删 always_thinking clamp;`THINKING_ON_LEVEL` 改动态 | -| kimi-code | `src/tui/types.ts` | 删 `thinking: boolean`,留 `thinkingLevel: ThinkingLevel` | -| kimi-code | `src/tui/kimi-tui.ts` | 删 `thinking` 初始化 / syncRuntimeState 写入 / createSession 派生 | -| kimi-code | `src/tui/commands/config.ts` | `performModelSwitch` 用 `thinkingLevel`;持久化走 `thinkingLevelToConfig` | -| kimi-code | `src/tui/commands/provider.ts` | `setDefaultModel` 同上 | -| kimi-code | `src/tui/commands/prompts.ts` | 适配 `ThinkingLevel`(删 boolean 转换) | -| kimi-code | `src/tui/controllers/auth-flow.ts` | 读 `config.thinking.enabled` | -| kimi-code | `src/tui/components/dialogs/model-selector.ts` | 提交前归一 `'on'`;`ModelSelection.thinking: ThinkingLevel` | -| kimi-code | `src/tui/components/dialogs/effort-selector.ts` | 类型 `ThinkingLevel` | -| kimi-code | `src/tui/components/chrome/footer.ts` | 从 `thinkingLevel` 派生 | -| kimi-code | `src/tui/components/messages/status-panel.ts` | 从 `thinkingLevel` 派生 | -| kimi-code | `src/tui/utils/thinking-config.ts`(新增) | `thinkingLevelToConfig` / `isThinkingOn` / `thinkingLabel` | -| docs | `en/configuration/config-files.md` | `[thinking]` 字段说明 | -| docs | `en/configuration/env-vars.md` | 删 `KIMI_MODEL_THINKING_MODE` 或更新 | -| docs | `zh/...` | 同步翻译 | - -## 8. 测试计划 - -就近扩展,不新增泛化测试文件。 - -- `packages/kosong`(kimi provider) - - `withThinking('high')` 在 effort 模型发 `{ type: 'enabled', effort: 'high' }`,`reasoning_effort === 'high'`。 - - `withThinking('max')` 同上,`effort === 'max'`。 - - `withThinking('xhigh')` 在 effort 模型发 `{ type: 'enabled' }`(不带 effort),`reasoning_effort === undefined`。 - - `withThinking('on')` 在 effort 模型发 `{ type: 'enabled' }`。 - - `withThinking('foo')` 在 effort 模型发 `{ type: 'enabled' }`。 - - `withThinking('on')` 在 boolean 模型发 `{ type: 'enabled' }`。 - - `withThinking('off')` 发 `{ type: 'disabled' }`,`reasoning_effort === undefined`。 - - `thinkingEffort` getter:`{enabled, effort:'max'}` → `'max'`;`{enabled}` → `'on'`;`{disabled}` → `'off'`;未设置 → `null`。 -- `packages/agent-core`(config / thinking) - - `defaultLevelFor`:不支持 thinking → `'off'`;effort 模型 → `defaultEffort` / 中位;boolean 模型 → `'on'`。 - - `resolveThinkingLevel`:requested 优先;`enabled=false` → `'off'`;always_thinking + `'off'` → 默认档。 - - `ThinkingConfigSchema` 拒绝 `mode`;`KimiConfigSchema` 拒绝 `defaultThinking`。 -- `apps/kimi-code` - - `model-selector.test.ts`:`'on'` 提交时归一为 `defaultLevelFor`;`ModelSelection.thinking` 类型 `ThinkingLevel`。 - - `effort-selector.test.ts`:类型 `ThinkingLevel`。 - - `thinking-config.test.ts`(新增或并入现有 commands 测试):`thinkingLevelToConfig('off')` → `{ enabled: false }`;`thinkingLevelToConfig('max')` → `{ enabled: true, effort: 'max' }`。 - - `auth-flow` 相关:`refreshConfigAfterLogin` 读 `thinking.enabled`。 - -跑测试: - -```bash -pnpm --filter @moonshot-ai/kosong test -- kimi -pnpm --filter @moonshot-ai/agent-core test -- thinking config -pnpm --filter @moonshot-ai/kimi-code test -- model-selector effort-selector -pnpm --filter @moonshot-ai/acp-adapter test -``` - -类型检查 / lint: - -```bash -pnpm --filter @moonshot-ai/kimi-code typecheck -pnpm lint -``` - -## 9. 范围外 / 后续 - -- 按模型分别记住 effort(`[thinking.model_efforts]`):当前用全局 `thinking.effort`,切模型时由 provider 归一;若需要精确记忆再加。 -- 真正实现 `mode=auto` 的"按任务/模型自适应":本期直接删除该值,未来按需设计。 -- kimi-web 端:同步 `ThinkingLevel` 类型与配置字段。 -- `setThinking` 类型严格化:当前 `ThinkingLevel` 塌缩为 `string`;若未来需要真正的运行时校验,可在 SDK 入口加 `isThinkingLevel`(非空 + 字符集白名单),但不依赖模型 `support_efforts`。 diff --git a/plan/thinking-test-coverage.md b/plan/thinking-test-coverage.md deleted file mode 100644 index ada0378bc..000000000 --- a/plan/thinking-test-coverage.md +++ /dev/null @@ -1,152 +0,0 @@ -# Thinking 模型重构 — 测试覆盖审查报告 - -> PR #1132(分支 `support-effort`)的测试缺口分析。生成于 2026-06-26,供后续补测试用。 -> -> 总体结论:**核心逻辑(resolveThinkingEffort、kimi/anthropic provider、ACP toggle、TUI commitEffort)覆盖较扎实;本次重构引入的若干"归一/兼容"分支存在行为契约未锁定的缺口。没有发现会导致运行时崩溃(P0)的缺口;所有缺口均为行为契约(P1)或 nice-to-have(P2)。** - ---- - -## 两个待确认的设计问题 - -### 1. `default_thinking` / `thinking.mode` 是「拒绝」还是「静默忽略」? - -- **当前实现**:静默忽略 + 下次写入时剥离(schema 非 strict,`packages/agent-core/src/config/toml.ts` 写入时 `delete out['mode']` / `delete out['default_thinking']`)。 -- **计划意图**:「直接删除,不做兼容读取」,breaking change。 -- **建议**:按"静默忽略 + 写入剥离"补测试锁定(P1 第 4 项)。如果要 fail-fast,需把 schema 改为 `.strict()` 并补 fail-fast 测试。 - -### 2. `commitEffort('on')` 当 `defaultEffort` 不在 `supportEfforts` 内时返回 `defaultEffort`,是否有意? - -- **当前实现**:`defaultThinkingEffortFor`(agent-core 和 TUI 内联)都是 `model.defaultEffort ?? middleOf(supportEfforts)`,**不校验 `defaultEffort` 是否在 `supportEfforts` 内**。 -- **为什么合理**:provider 端会归一(声明外的 effort 不下发 wire effort),所以即使 `defaultEffort` 不在声明里也不会导致 wire 错误。 -- **建议**:按"返回 defaultEffort,由 provider 归一"补测试锁定(P1 第 12 项)。 - ---- - -## P1 缺口(行为契约未锁定,建议本 PR 或 follow-up 尽快补) - -### kosong - -#### `openai-common.ts` — `thinkingEffortToReasoningEffort` -- **现有**:`off/low/medium/high/xhigh/max` + 未知 `'extreme'` → undefined(`openai-common-errors.test.ts:314-337`)。 -- **缺口**:`'on'` 没有显式断言(注释明确把 `'on'` 列为归一对象,但测试用 `'extreme'`)。 -- **建议**:把 `it('normalizes unknown effort to undefined')` 改成 `it.each(['on', 'extreme', 'foo'])`。 - -#### `anthropic.ts` — `clampEffort` -- **现有**:xhigh/max 在 opus-4-5/4-6/4-7/4-8/fable/sonnet/haiku 上的 clamp 与透传、adaptive vs budget、`off`、low/medium 透传(`anthropic.test.ts:1084-1400`)。 -- **缺口**:`'on'` / 未知 effort(如 `'foo'`)→ clamp 到 `'high'`(`anthropic.ts:342-350` 的最后 if 分支)**完全未覆盖**。 -- **建议**:`it.each(['on', 'foo'])` 在 adaptive 模型(如 `claude-opus-4-7`)上 → `output_config={effort:'high'}`;在非 adaptive 模型(如 `claude-sonnet-4-5`)上 → `thinking={type:'enabled',budget_tokens:32000}` 且无 `output_config`。 - -#### `google-genai.ts` — `withThinking` -- **现有**:非 gemini-3 的 `high`/`off`;gemini-3 的 `off/low/medium/high`;getter 反射(`google-genai.test.ts:729-836`)。 -- **缺口**: - - `'on'` / 未知 effort(`'foo'`)在 **gemini-3** 上 → 只 `include_thoughts:true`、不设 `thinking_level`(`google-genai.ts:829-852`)。未覆盖。 - - `'on'` / 未知 effort 在 **非 gemini-3** 上 → 只 `include_thoughts:true`、不设 `thinking_budget`(`google-genai.ts:853-873`)。未覆盖。 - - `'xhigh'`/`'max'` 在 **gemini-3** → `HIGH`(fall-through)。未覆盖。 -- **建议**:`it.each(['on','foo'])` 在 `gemini-3-pro-preview` 上 → `thinking_config={include_thoughts:true}`(无 `thinking_level`);在 `gemini-2.5-flash` 上 → `{include_thoughts:true}`(无 `thinking_budget`)。`it.each(['xhigh','max'])` 在 gemini-3 上 → `thinking_level:'HIGH'`。 - -#### `kimi.ts` — `withThinking` -- **现有**:非 effort 模型 / effort 模型 / `max` / `off` / `xhigh`/`on`/`foo` 不在 supportEfforts / getter / 重复调用(`kimi.test.ts:708-799`)。 -- **缺口**:空 `supportEfforts: []`(与 `undefined` 等价)未显式覆盖。 -- **建议**:加 `createProvider(false, []).withThinking('high')` → `thinking={type:'enabled'}`、无 `reasoning_effort`。 - -### agent-core - -#### `config/schema.ts` — 删除 `default_thinking` / `thinking.mode` -- **现有**:`[thinking] enabled/effort` 解析;patch merge thinking;`default_yolo` 的 drop-deprecated 范式(`configs.test.ts`)。 -- **缺口**:`default_thinking`(顶层)被忽略/剥离**无测试**;`thinking.mode` 被忽略/剥离**无测试**。 -- **建议**:`parseConfigString('default_thinking = true\n[thinking]\nmode = "always"\neffort="high"\n')` → `config.thinking` 不含 `mode`、顶层无 `defaultThinking`,且 `writeConfigFile` 后文本不含 `default_thinking` / `mode`。 - -#### `config/env-model.ts` — 删除 `KIMI_MODEL_THINKING_MODE` / `KIMI_MODEL_DEFAULT_THINKING` -- **现有**:`KIMI_MODEL_THINKING_EFFORT` 映射;`KIMI_MODEL_ADAPTIVE_THINKING`;write-back 隔离(`env-model.test.ts`)。 -- **缺口**:`KIMI_MODEL_THINKING_MODE` / `KIMI_MODEL_DEFAULT_THINKING` 被忽略**无测试**(回归保护,防止未来被误加回)。 -- **建议**:`applyEnvModelConfig(MIN, { KIMI_MODEL_THINKING_MODE:'always', KIMI_MODEL_DEFAULT_THINKING:'high' })` → `config.thinking` 为 `undefined`(或不含 effort/mode)。 - -#### `agent/config/index.ts` — `update()` clamp 集成 -- **现有**:always_thinking `'off'`→`'on'`;provider 构建 enabled;toggleable `'off'` 保持;切到 always_thinking re-clamp 旧 `'off'`(`config-state.test.ts:163-228`)。 -- **缺口**:`modelAlias` 变化 + 旧 effort=`'off'` + 新 always_thinking 模型 + `config.effort='max'` → 应 clamp 到 `'max'`(而非 `defaultEffort`)。`update()` 在 modelAlias 分支把 `this._thinkingEffort` 作为 requested 传入,clamp 时读 `config?.effort`——这条路径未在 ConfigState 集成层测。 -- **建议**:先 `update({modelAlias: toggleable, thinkingEffort:'off'})`,再 `update({modelAlias: deep})` 且 kimiConfig 带 `thinking:{effort:'max'}` → 期望 `'max'`。 - -#### `session/provider-manager.ts` — `supportEfforts` 透传给 kimi provider -- **现有**:无直接测试。kimi provider 单元覆盖了 supportEfforts 行为,但**接线层**(provider-manager 把 supportEfforts 写进 kimi provider config)未测。 -- **缺口**:effort-capable alias 经 `ProviderManager.resolveProviderConfig` → kimi `ProviderConfig` 应携带 `supportEfforts`。 -- **建议**:构造 `supportEfforts:['low','high','max']` 的 kimi alias,断言 `resolveProviderConfig(...).provider` 的 supportEfforts 透传(或经 `config.provider.thinkingEffort`/`modelParameters` 验证)。 - -### acp-adapter - -#### `server.ts` — `resolveCurrentThinkingEnabled` -- **现有**:无直接测试。仅经 `newSession` 间接走 `getConfig` 无 `thinking` 字段 → `false` 分支。 -- **缺口**(全 P1): - - `getConfig` 缺失(partial stub)→ `false` - - `thinking.enabled === true` → `true`;`=== false` → `false` - - `thinking.effort` 为非空 string(无 `enabled`)→ `true`(**本次新增核心分支**) - - `getConfig` 抛错 → `false` - - `thinking` 缺失 / `effort:''` → `false` -- **建议**:抽一个可直接调用 `resolveCurrentThinkingEnabled` 的测试(或经 `newSession` + 不同 `getConfig` 返回值断言 `configOptions.thinking.currentValue`),覆盖以上 5 条。 - -#### `server.ts` — `setupSessionFromExisting` resumedThinkingEffort 投影 -- **现有**:`thinkingEffort='high'` → toggle `on`(`session-resume.test.ts:142-189`)。 -- **缺口**:`thinkingEffort='off'` → `currentValue='off'` 未测;`thinkingEffort=''`(空串)→ `off` 未测。 -- **建议**:加 `thinkingEffort:'off'` 与 `thinkingEffort:''` 两条,断言 `thinking.currentValue==='off'`。 - -#### `session.ts` — `thinkingOnEffort` -- **现有**:默认返回 `'on'`(fixture model 无 `supportEfforts`/`defaultEffort`)。 -- **缺口**:effort-capable model(`defaultEffort='high'` 或 middle `supportEfforts`)→ 返回对应 effort,而非 `'on'`;`harness` 缺失(`undefined`)→ `'on'`。 -- **建议**:`makeHarness` 加 effort-capable model(`supportEfforts:['low','high','max']`、`defaultEffort:'high'`),断言 `setThinkingCalls` 收到 `'high'`;另加无 harness 的 `AcpSession.setThinking(true)` → `'on'`。 - -### apps/kimi-code(TUI) - -#### `tui/utils/thinking-config.ts` — `thinkingEffortToConfig` / `isThinkingOn` -- **现有**:无直接单元测试。仅经 `cli/provider.test.ts` 与 `kimi-tui-message-flow.test.ts` 间接覆盖。 -- **缺口**:`thinkingEffortToConfig('off')` → `{enabled:false}`;`('low')`→`{enabled:true,effort:'low'}`;`('on')`→`{enabled:true,effort:'on'}` 无直接单测。 -- **建议**:新建 `test/tui/utils/thinking-config.test.ts`,对 `thinkingEffortToConfig` / `isThinkingOn` 做参数化断言。 - -#### `tui/components/dialogs/model-selector.ts` — `commitEffort` -- **现有**:`'on'`→effort 模型 `defaultEffort`;`'on'`→middle;`'on'`→boolean `'on'`(`model-selector.test.ts`)。 -- **缺口**:`commitEffort('on')` 当 `defaultEffort` **不在** `supportEfforts` 内 → 返回 `defaultEffort`(即使模型未声明支持)。该行为与 agent-core 一致,但 TUI 层未锁定(见"待确认问题 2")。 -- **建议**:加 `effortModel(..., ['low','high'], 'max')` 非当前模型 → Enter → `onSelect` 收到 `thinking:'max'`(锁定现状)。 - -#### `tui/commands/config.ts` — `persistModelSelection` short-circuit -- **现有**:runtime 不变但 `defaultModel` 不同 → 仍写入;Alt+S 不持久化(`kimi-tui-message-flow.test.ts`)。 -- **缺口**:`defaultModel`、`thinking.enabled`、`thinking.effort` **三者完全相同** → 返回 `false`、不调用 `setConfig`(short-circuit `config.ts:467-473`)。未直接测。 -- **建议**:加一条 `getConfig` 返回 `defaultModel:'k2', thinking:{enabled:true,effort:'on'}`,选择 `k2` + `on` → `setConfig` 未被调用,且状态提示 "Already using ..."。 - ---- - -## P2 缺口(nice-to-have) - -- `provider.ts`:type-level 开放字符串断言(`const e: ThinkingEffort = 'any-custom-effort'`)。 -- `kimi.ts`:`keep` 字段在 `withThinking` 后保留(`withExtraBody({thinking:{keep}}).withThinking(...)`)。 -- `anthropic.ts`:`budgetTokensForEffort` 对 `'off'/'xhigh'/'max'` 的 `throw` 分支(生产路径被 clampEffort 保护)。 -- `google-genai.ts`:非 gemini-3 的 `low`/`medium`/`xhigh`/`max` budget 矩阵;gemini-3 getter 反射。 -- `thinking.ts`:`defaultEffort` 不在 `supportEfforts` 内的语义命名;`requested='off'` + always_thinking ± `config.effort` 组合。 -- `config-state.test.ts`:modelAlias 切换保留非 `'off'` effort;thinkingEffort + modelAlias 同时变化。 -- `schema.ts`:`thinking.enabled` 非 boolean、`thinking.effort` 非 string → 报错的类型校验。 -- `env-model.ts`:`KIMI_MODEL_THINKING_EFFORT` 与已有 `enabled` 的合并(base config 带 `thinking:{enabled:true}`,env 设 effort → `{enabled:true, effort}`)。 -- `session-resume.test.ts`:`thinkingEffort` 大写/带空格;mainConfig 缺 `thinkingEffort` 的 fallback 断言。 -- `session.ts` `thinkingOnEffort`:currentModelId 不在 catalog → `'on'`。 -- `promptThinkingSchema`:非 string(如 `number`/`boolean`)→ 拒绝。 -- `thinking-config.test.ts`:`isThinkingOn` 各分支。 -- `commitEffort`:非 `'on'` draft 透传。 -- `persistModelSelection`:defaultModel 相同但 effort 不同 → 写入。 - ---- - -## 建议的补充顺序 - -### 本 PR 内补(简单 + 高价值) -1. openai-common:`it.each(['on', 'extreme', 'foo'])` 显式含 `'on'` -2. anthropic `clampEffort`:`it.each(['on', 'foo'])` adaptive + budget 两条 -3. schema:`default_thinking` / `thinking.mode` 静默忽略 + 写入剥离 -4. env-model:`KIMI_MODEL_THINKING_MODE` / `KIMI_MODEL_DEFAULT_THINKING` 被忽略 -5. ACP `resolveCurrentThinkingEnabled`:5 条分支 -6. TUI `thinkingEffortToConfig`:直接单元测试(`off` / `on` / `low`) -7. kimi `withThinking`:空 `supportEfforts: []` -8. google-genai `withThinking`:`'on'` / 未知(gemini-3 + 非 gemini-3) - -### follow-up(避免本 PR 继续膨胀) -- provider-manager `supportEfforts` 透传 -- ConfigState.update() modelAlias 切换 + config.effort 集成 -- ACP resume `thinkingEffort='off'` / `''` -- commitEffort defaultEffort 不在声明内 -- persistModelSelection short-circuit -- 全部 P2 diff --git a/plan/vis-drag-drop-zip.md b/plan/vis-drag-drop-zip.md deleted file mode 100644 index 6f96d5d7f..000000000 --- a/plan/vis-drag-drop-zip.md +++ /dev/null @@ -1,63 +0,0 @@ -# vis 支持拖入 zip 调试包 - -## 1. 背景与目标 - -`apps/vis` 是 kimi-code 会话的可视化调试工具。服务端已经支持导入他人通过 -`/export-debug-zip` 导出的调试 zip(`POST /api/imports`,解压到 -`/imported//` 后按普通 session 展示)。Web 端目前只在 session 列表 -左上角提供了一个「⬆ import debug zip」按钮,点击后弹出系统文件选择器。 - -目标:让 vis 的 Web UI 支持**直接拖入** zip 调试包,省去点按钮、找文件的步骤。 - -## 2. 现状关键事实 - -- **服务端已就绪**:`apps/vis/server/src/routes/imports.ts` 的 `POST /` - 接收原始 zip 字节流,调用 `importSessionZip` 解压并校验(必须含 - `agents/main/wire.jsonl`),返回 `{ sessionId, importMeta }`。 -- **前端 API 已就绪**:`apps/vis/web/src/api.ts` 的 `api.importZip(file: File)` - 把文件作为 body POST 到 `/api/imports`;`useSession.ts` 的 `useImportZip()` - 在导入成功后 `invalidateQueries(['sessions'])` 刷新列表。 -- **唯一缺口**:交互入口只有按钮 + 文件选择器,没有拖放。 -- **现有导入入口**:`SessionRail` 的 `handleImport`(`apps/vis/web/src/components/sessions/SessionRail.tsx`) - 负责调用 `useImportZip` + `navigate` 到导入后的 session,失败时 `window.alert`。 - `SessionFilter` 的隐藏 `` 复用这个 handler。 - -## 3. 设计决策 - -| 决策点 | 结论 | -|---|---| -| 落区范围 | 整个窗口(`window` 级监听),任何位置拖入都可导入 | -| 拖入反馈 | 拖入文件时显示全屏 overlay:「drop debug zip to import」;上传中显示「importing…」 | -| 文件校验 | 前端按扩展名 / MIME 判断 `.zip`,非 zip 给 alert 提示;服务端仍做完整校验 | -| 成功后行为 | 复用现有逻辑:刷新列表并 `navigate` 到导入后的 session | -| 与按钮的关系 | 并存。拖放与文件选择器各用独立的 `useImportZip()`,互不阻塞 | - -## 4. 实现方案 - -新增 `apps/vis/web/src/components/shared/ZipDropOverlay.tsx`: - -- `useEffect` 在 `window` 上注册 `dragenter` / `dragover` / `dragleave` / `drop`。 -- 用 `depth` 计数器处理子元素 enter/leave,避免 overlay 闪烁。 -- 只在 `dataTransfer.types` 含 `Files` 时响应,避免拦截文本拖拽。 -- `dragover` 调 `preventDefault()` 并设 `dropEffect = 'copy'`,否则浏览器不会触发 `drop`。 -- `drop` 时取 `dataTransfer.files[0]`,非 zip 给 alert;否则调用 - `useImportZip().mutateAsync`,成功后 `navigate`,失败 alert。 -- 通过 `isPending` 在 overlay 上区分「拖入中」与「上传中」。 - -纯函数 `isZipFile({ name, type })` 单独导出,便于在 node 环境下做单元测试。 - -在 `AppShell` 末尾渲染 ``(`position: fixed`,不挤占布局)。 - -## 5. 验证 - -- `pnpm --filter @moonshot-ai/vis-web run typecheck` 通过。 -- `pnpm --filter @moonshot-ai/vis-web run test` 通过(含新增 `isZipFile` 单测)。 -- `pnpm --filter @moonshot-ai/vis-web run build` 通过。 -- 手动:`pnpm run vis` 后,把 `/export-debug-zip` 产物拖进窗口,出现 overlay、 - 松开后导入并跳到对应 session;拖入非 zip 文件出现提示且不导入。 - -## 6. 非目标 - -- 多文件批量导入(一次只处理第一个文件)。 -- 上传进度条(服务端一次性缓冲 zip 字节,没有分块进度)。 -- 把按钮与拖放的 `isPending` 状态合并(两者互斥触发,各自维护即可)。