From b6da4e214f3609bb0024791ce2f6ae680cd7000d Mon Sep 17 00:00:00 2001 From: xyJen Date: Thu, 25 Jun 2026 10:37:25 +0800 Subject: [PATCH] feat(vscode): add full-width BYOK provider config panel (#204) * feat(vscode): add full-width BYOK provider config panel Align VS Code extension provider setup with ocr config provider CLI: editor panel for official/custom providers, custom provider manager, in-memory connection test via isolated temp config, active provider status bar, and OCR CLI version display. Add OCR_CONFIG_PATH support for draft config testing in the CLI. * fix(vscode): address PR #204 review comments (batch 1) * fix(vscode): polish PR #204 review follow-ups Add readonly ENV_CACHE_TTL_MS, responsive custom-provider URL width, remove config-panel-only messages from HostToWebview, fix App spacing. --------- Co-authored-by: xyJen <24266963+xyJen@users.noreply.github.com> --- cmd/opencodereview/config_cmd.go | 10 + cmd/opencodereview/llm_cmd.go | 2 +- extensions/vscode/src/extension/commands.ts | 7 +- extensions/vscode/src/extension/extension.ts | 9 +- .../providers/ConfigPanelProvider.ts | 155 ++ .../extension/providers/SidebarProvider.ts | 32 +- .../src/extension/services/CliService.ts | 76 +- .../src/extension/services/ConfigService.ts | 73 +- .../services/__tests__/configDraft.test.ts | 51 + .../services/__tests__/configParse.test.ts | 30 +- .../src/extension/services/configDraft.ts | 147 ++ .../src/extension/services/configParse.ts | 32 +- extensions/vscode/src/shared/configUtils.ts | 167 ++ extensions/vscode/src/shared/messages.ts | 25 +- extensions/vscode/src/shared/providers.ts | 146 ++ extensions/vscode/src/shared/types.ts | 24 + extensions/vscode/src/webview/App.tsx | 38 +- .../vscode/src/webview/ConfigPanelApp.tsx | 67 + .../src/webview/__tests__/store.test.ts | 100 +- extensions/vscode/src/webview/bridge.ts | 6 +- .../components/CustomProviderManager.tsx | 71 + .../src/webview/components/EnvSetupGuide.tsx | 211 +++ .../src/webview/components/PasswordInput.tsx | 53 + extensions/vscode/src/webview/configPanel.tsx | 5 + extensions/vscode/src/webview/configStore.ts | 107 ++ extensions/vscode/src/webview/store.ts | 39 +- .../vscode/src/webview/styles/global.css | 993 ++++++++++- .../vscode/src/webview/views/ConfigView.tsx | 632 +++++-- .../vscode/src/webview/views/IdleView.tsx | 47 +- extensions/vscode/webpack.config.js | 25 +- extensions/vscode/yarn.lock | 1567 ++++++++--------- 31 files changed, 3860 insertions(+), 1087 deletions(-) create mode 100644 extensions/vscode/src/extension/providers/ConfigPanelProvider.ts create mode 100644 extensions/vscode/src/extension/services/__tests__/configDraft.test.ts create mode 100644 extensions/vscode/src/extension/services/configDraft.ts create mode 100644 extensions/vscode/src/shared/configUtils.ts create mode 100644 extensions/vscode/src/shared/providers.ts create mode 100644 extensions/vscode/src/webview/ConfigPanelApp.tsx create mode 100644 extensions/vscode/src/webview/components/CustomProviderManager.tsx create mode 100644 extensions/vscode/src/webview/components/EnvSetupGuide.tsx create mode 100644 extensions/vscode/src/webview/components/PasswordInput.tsx create mode 100644 extensions/vscode/src/webview/configPanel.tsx create mode 100644 extensions/vscode/src/webview/configStore.ts diff --git a/cmd/opencodereview/config_cmd.go b/cmd/opencodereview/config_cmd.go index ccd3afe..ac2461c 100644 --- a/cmd/opencodereview/config_cmd.go +++ b/cmd/opencodereview/config_cmd.go @@ -20,6 +20,16 @@ func defaultConfigPath() (string, error) { return filepath.Join(home, ".opencodereview", "config.json"), nil } +// resolveConfigPath returns OCR_CONFIG_PATH when set, otherwise the default user config path. +// Intentionally used only by read-only commands (e.g. ocr llm test). Write paths such as +// config set and review keep defaultConfigPath() so a leaked OCR_CONFIG_PATH cannot redirect writes. +func resolveConfigPath() (string, error) { + if p := strings.TrimSpace(os.Getenv("OCR_CONFIG_PATH")); p != "" { + return p, nil + } + return defaultConfigPath() +} + func runConfig(args []string) error { if len(args) == 0 { printConfigUsage() diff --git a/cmd/opencodereview/llm_cmd.go b/cmd/opencodereview/llm_cmd.go index 3316233..9e33085 100644 --- a/cmd/opencodereview/llm_cmd.go +++ b/cmd/opencodereview/llm_cmd.go @@ -29,7 +29,7 @@ func runLLM(args []string) error { } func runLLMTest() error { - cfgPath, err := defaultConfigPath() + cfgPath, err := resolveConfigPath() if err != nil { return err } diff --git a/extensions/vscode/src/extension/commands.ts b/extensions/vscode/src/extension/commands.ts index 52858b2..6ff4f92 100644 --- a/extensions/vscode/src/extension/commands.ts +++ b/extensions/vscode/src/extension/commands.ts @@ -2,11 +2,16 @@ import * as vscode from 'vscode'; import { COMMANDS } from '../shared/constants'; import { CommentProvider } from './providers/CommentProvider'; -export function registerCommands(comments: CommentProvider): vscode.Disposable { +export function registerCommands( + comments: CommentProvider, + openConfig: () => void, +): vscode.Disposable { const subs: vscode.Disposable[] = []; const reg = (id: string, fn: (...args: any[]) => any) => subs.push(vscode.commands.registerCommand(id, fn)); + reg(COMMANDS.configOpen, openConfig); + // 标题栏按钮传入的是 CommentThread,侧边栏 / Markdown 链接传入的是 index const idxOf = (arg: vscode.CommentThread | number): number => typeof arg === 'number' ? arg : comments.indexOfThread(arg); diff --git a/extensions/vscode/src/extension/extension.ts b/extensions/vscode/src/extension/extension.ts index 33704f1..91c2b83 100644 --- a/extensions/vscode/src/extension/extension.ts +++ b/extensions/vscode/src/extension/extension.ts @@ -5,6 +5,7 @@ import { ConfigService } from './services/ConfigService'; import { GitService } from './services/GitService'; import { CommentProvider } from './providers/CommentProvider'; import { SidebarProvider } from './providers/SidebarProvider'; +import { ConfigPanelProvider } from './providers/ConfigPanelProvider'; import { registerCommands } from './commands'; let disposables: vscode.Disposable[] = []; @@ -18,11 +19,13 @@ export function activate(context: vscode.ExtensionContext): void { const comments = new CommentProvider(extensionUri); const sidebar = new SidebarProvider(extensionUri, cli, config, git, comments); + const configPanel = new ConfigPanelProvider(extensionUri, cli, config, (cfg) => sidebar.pushConfig(cfg)); + sidebar.bindConfigPanel((focus) => configPanel.open(focus)); + const viewReg = vscode.window.registerWebviewViewProvider(SIDEBAR_VIEW_ID, sidebar); + const cmdReg = registerCommands(comments, () => configPanel.open()); - const cmdReg = registerCommands(comments); - - disposables.push(viewReg, cmdReg, comments, output); + disposables.push(viewReg, cmdReg, comments, output, configPanel); context.subscriptions.push(...disposables); } diff --git a/extensions/vscode/src/extension/providers/ConfigPanelProvider.ts b/extensions/vscode/src/extension/providers/ConfigPanelProvider.ts new file mode 100644 index 0000000..2be41d8 --- /dev/null +++ b/extensions/vscode/src/extension/providers/ConfigPanelProvider.ts @@ -0,0 +1,155 @@ +import * as vscode from 'vscode'; +import { ConfigPanelFocus, isConfigReady } from '../../shared/configUtils'; +import { ConfigPanelHostToWebview, WebviewToHost } from '../../shared/messages'; +import { OcrConfig } from '../../shared/types'; +import { CliService } from '../services/CliService'; +import { ConfigService } from '../services/ConfigService'; + +const PANEL_VIEW_TYPE = 'ocr.configPanel'; + +export class ConfigPanelProvider implements vscode.Disposable { + private panel?: vscode.WebviewPanel; + private pendingFocus?: ConfigPanelFocus; + private messageDisposable?: vscode.Disposable; + + constructor( + private extensionUri: vscode.Uri, + private cli: CliService, + private config: ConfigService, + private onConfigChanged: (config: OcrConfig | null) => void, + ) {} + + open(focus?: ConfigPanelFocus): void { + this.pendingFocus = focus; + if (this.panel) { + this.post({ type: 'configPanelFocus', focus: focus ?? null }); + this.panel.reveal(vscode.ViewColumn.One); + this.pendingFocus = undefined; + return; + } + + this.panel = vscode.window.createWebviewPanel( + PANEL_VIEW_TYPE, + '模型配置', + vscode.ViewColumn.One, + { enableScripts: true, retainContextWhenHidden: true, localResourceRoots: [this.extensionUri] }, + ); + this.panel.iconPath = new vscode.ThemeIcon('sparkle'); + + this.panel.webview.html = this.html(this.panel.webview); + this.messageDisposable = this.panel.webview.onDidReceiveMessage((msg: WebviewToHost) => { + void this.handle(msg); + }); + this.panel.onDidDispose(() => { + this.messageDisposable?.dispose(); + this.messageDisposable = undefined; + this.panel = undefined; + }); + } + + dispose(): void { + this.messageDisposable?.dispose(); + this.messageDisposable = undefined; + this.panel?.dispose(); + this.panel = undefined; + } + + private post(msg: ConfigPanelHostToWebview): void { + this.panel?.webview.postMessage(msg); + } + + private notifyConfig(config: OcrConfig | null): void { + this.post({ type: 'config', config }); + this.onConfigChanged(config); + } + + private async handle(msg: WebviewToHost): Promise { + try { + await this.handleMessage(msg); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.post({ type: 'panelError', message }); + } + } + + private async handleMessage(msg: WebviewToHost): Promise { + switch (msg.type) { + case 'readyConfigPanel': { + const focus = this.pendingFocus; + this.pendingFocus = undefined; + const config = this.config.read(); + const cached = this.cli.getCachedEnvironment(); + const skipEnvCheck = focus?.step === 2 || isConfigReady(config); + this.post({ + type: 'configPanelInit', + config, + focus: focus ?? null, + env: cached, + skipEnvCheck, + }); + break; + } + case 'closeConfigPanel': + this.panel?.dispose(); + break; + case 'setConfig': + await this.config.set(msg.key, msg.value); + this.notifyConfig(this.config.read()); + break; + case 'setConfigBatch': + await this.config.setMany(msg.entries); + this.notifyConfig(this.config.read()); + break; + case 'testConnection': { + const r = await this.config.testWithEntries(msg.entries); + this.post({ type: 'connectionResult', ok: r.ok, message: r.message }); + break; + } + case 'deleteCustomProvider': { + const confirmed = await vscode.window.showWarningMessage( + `确定删除自定义 Provider「${msg.name}」?`, + { modal: true }, + '删除', + ); + if (confirmed !== '删除') break; + this.notifyConfig(this.config.deleteCustomProvider(msg.name)); + break; + } + case 'activateCustomProvider': + await this.config.set('provider', msg.name); + this.notifyConfig(this.config.read()); + break; + case 'checkCli': + case 'checkEnvironment': { + const env = await this.cli.checkEnvironment(true); + this.post({ type: 'environmentResult', env }); + break; + } + case 'copyToClipboard': + await vscode.env.clipboard.writeText(msg.text); + this.post({ type: 'copyDone' }); + break; + case 'installCli': { + const ok = await this.cli.install((line) => this.post({ type: 'installLog', line })); + this.post({ type: 'installDone', ok }); + const env = await this.cli.checkEnvironment(); + this.post({ type: 'environmentResult', env }); + break; + } + default: + break; + } + } + + private html(webview: vscode.Webview): string { + const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, 'out', 'configPanel.js')); + const nonce = String(Date.now()); + return ` + + + +
+ +`; + } +} diff --git a/extensions/vscode/src/extension/providers/SidebarProvider.ts b/extensions/vscode/src/extension/providers/SidebarProvider.ts index 20eeb27..f17de1d 100644 --- a/extensions/vscode/src/extension/providers/SidebarProvider.ts +++ b/extensions/vscode/src/extension/providers/SidebarProvider.ts @@ -1,4 +1,5 @@ import * as vscode from 'vscode'; +import { ConfigPanelFocus } from '../../shared/configUtils'; import { HostToWebview, WebviewToHost } from '../../shared/messages'; import { FileChange } from '../../shared/types'; import { CliService } from '../services/CliService'; @@ -10,6 +11,7 @@ import { CommentProvider } from './CommentProvider'; export class SidebarProvider implements vscode.WebviewViewProvider { private view?: vscode.WebviewView; private session?: ReviewSession; + private openConfigPanel?: (focus?: ConfigPanelFocus) => void; constructor( private extensionUri: vscode.Uri, @@ -21,6 +23,14 @@ export class SidebarProvider implements vscode.WebviewViewProvider { this.comments.onSync((states) => this.post({ type: 'commentSync', comments: states })); } + bindConfigPanel(open: (focus?: ConfigPanelFocus) => void): void { + this.openConfigPanel = open; + } + + pushConfig(config: ReturnType): void { + this.post({ type: 'config', config }); + } + resolveWebviewView(view: vscode.WebviewView): void { this.view = view; view.webview.options = { enableScripts: true, localResourceRoots: [this.extensionUri] }; @@ -78,28 +88,12 @@ export class SidebarProvider implements vscode.WebviewViewProvider { case 'cancelReview': this.session?.cancel({ onState: (state) => this.post({ type: 'stateChange', state }) }); break; + case 'openConfigPanel': + this.openConfigPanel?.(msg.focus); + break; case 'getConfig': this.post({ type: 'config', config: this.config.read() }); break; - case 'setConfig': - await this.config.set(msg.key, msg.value); - this.post({ type: 'config', config: this.config.read() }); - break; - case 'testConnection': { - const r = await this.cli.testConnection(); - this.post({ type: 'connectionResult', ok: r.ok, message: r.message }); - break; - } - case 'checkCli': { - this.post({ type: 'cliStatus', installed: await this.cli.isAvailable() }); - break; - } - case 'installCli': { - const ok = await this.cli.install((line) => this.post({ type: 'installLog', line })); - this.post({ type: 'installDone', ok }); - this.post({ type: 'cliStatus', installed: await this.cli.isAvailable() }); - break; - } case 'jumpToComment': await this.comments.jumpTo(msg.index); break; diff --git a/extensions/vscode/src/extension/services/CliService.ts b/extensions/vscode/src/extension/services/CliService.ts index 3a8972d..291385f 100644 --- a/extensions/vscode/src/extension/services/CliService.ts +++ b/extensions/vscode/src/extension/services/CliService.ts @@ -1,22 +1,66 @@ import { spawn } from 'child_process'; -import { CliResult, CliRunOptions, LogLine } from '../../shared/types'; +import { CliResult, CliRunOptions, EnvCheckResult, LogLine } from '../../shared/types'; import { buildReviewArgs, extractCliError, parseCliResult, parseLogLine } from './cliParse'; import { getShellEnv, resolveBin } from './shellEnv'; export class CliService { private current: ReturnType | null = null; + private envCache: { env: EnvCheckResult; at: number } | null = null; + private static readonly ENV_CACHE_TTL_MS = 5 * 60 * 1000; constructor(private cliPath: string = 'ocr') {} + invalidateEnvironmentCache(): void { + this.envCache = null; + } + + getCachedEnvironment(): EnvCheckResult | null { + if (!this.envCache) return null; + if (Date.now() - this.envCache.at > CliService.ENV_CACHE_TTL_MS) { + this.envCache = null; + return null; + } + return this.envCache.env; + } + async isAvailable(): Promise { + const env = await this.checkEnvironment(); + return env.ocr.ok; + } + + private probeCommand(bin: string, args: string[]): Promise<{ ok: boolean; version?: string }> { return new Promise((resolve) => { - const p = spawn(resolveBin(this.cliPath), ['--version'], { env: getShellEnv() }); + const proc = spawn(resolveBin(bin), args, { env: getShellEnv() }); + let stdout = ''; let errored = false; - p.on('error', () => { errored = true; resolve(false); }); - p.on('close', () => { if (!errored) resolve(true); }); + proc.stdout?.on('data', (d) => { stdout += d.toString(); }); + proc.on('error', () => { errored = true; resolve({ ok: false }); }); + proc.on('close', (code) => { + if (errored || code !== 0) { + resolve({ ok: false }); + return; + } + const version = stdout.trim().split('\n')[0]?.trim(); + resolve({ ok: true, version: version || undefined }); + }); }); } + async checkEnvironment(force = false): Promise { + if (!force) { + const cached = this.getCachedEnvironment(); + if (cached) return cached; + } + const node = await this.probeCommand('node', ['--version']); + const npm = node.ok ? await this.probeCommand('npm', ['--version']) : { ok: false }; + const ocr = node.ok && npm.ok + ? await this.probeCommand(this.cliPath, ['--version']) + : { ok: false }; + const env = { node, npm, ocr }; + this.envCache = { env, at: Date.now() }; + return env; + } + /** 全局安装 ocr CLI,流式回显 npm 日志,按 exit code 返回是否成功。 */ install(onLog: (l: LogLine) => void): Promise { return new Promise((resolve) => { @@ -47,15 +91,24 @@ export class CliService { proc.on('close', (code) => { emitLines('', 'info', true); onLog({ text: code === 0 ? '✓ 安装完成' : `✗ 安装失败 (exit ${code})`, level: code === 0 ? 'info' : 'error' }); + if (code === 0) this.invalidateEnvironmentCache(); resolve(code === 0); }); }); } /** 运行任意参数,流式回调日志,结束返回 stdout 全文。退出码非 0 时 reject,并带上 CLI 报错文本。 */ - runRaw(args: string[], cwd: string, onLog: (l: LogLine) => void): Promise { + runRaw( + args: string[], + cwd: string, + onLog: (l: LogLine) => void, + envExtra?: Record, + ): Promise { return new Promise((resolve, reject) => { - const proc = spawn(resolveBin(this.cliPath), args, { cwd, env: getShellEnv() }); + const proc = spawn(resolveBin(this.cliPath), args, { + cwd, + env: envExtra ? { ...getShellEnv(), ...envExtra } : getShellEnv(), + }); this.current = proc; let stdout = ''; let stderr = ''; @@ -82,9 +135,16 @@ export class CliService { return parseCliResult(stdout); } - async testConnection(): Promise<{ ok: boolean; message?: string }> { + async testConnection(options?: { configPath?: string; home?: string }): Promise<{ ok: boolean; message?: string }> { + const envExtra: Record = {}; + if (options?.home) { + envExtra.HOME = options.home; + if (process.platform === 'win32') envExtra.USERPROFILE = options.home; + } + if (options?.configPath) envExtra.OCR_CONFIG_PATH = options.configPath; + const env = Object.keys(envExtra).length > 0 ? envExtra : undefined; try { - await this.runRaw(['llm', 'test'], process.cwd(), () => {}); + await this.runRaw(['llm', 'test'], process.cwd(), () => {}, env); return { ok: true }; } catch (e) { return { ok: false, message: e instanceof Error ? e.message : String(e) }; diff --git a/extensions/vscode/src/extension/services/ConfigService.ts b/extensions/vscode/src/extension/services/ConfigService.ts index f2eb62b..b3490df 100644 --- a/extensions/vscode/src/extension/services/ConfigService.ts +++ b/extensions/vscode/src/extension/services/ConfigService.ts @@ -1,8 +1,10 @@ -import { readFileSync, existsSync } from 'fs'; -import { homedir } from 'os'; -import { join } from 'path'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'fs'; +import { homedir, tmpdir } from 'os'; +import { dirname, join } from 'path'; +import { ConfigEntry } from '../../shared/configUtils'; import { OcrConfig } from '../../shared/types'; import { CliService } from './CliService'; +import { applyConfigEntries, RawConfig } from './configDraft'; import { parseConfig, toConfigSetArgs } from './configParse'; export class ConfigService { @@ -22,8 +24,73 @@ export class ConfigService { } } + private readRaw(): RawConfig { + const p = this.configPath(); + if (!existsSync(p)) return {}; + try { + return JSON.parse(readFileSync(p, 'utf8')) as RawConfig; + } catch { + return {}; + } + } + + private writeRaw(raw: RawConfig): OcrConfig | null { + const p = this.configPath(); + const hasContent = Boolean( + raw.provider + || raw.model + || (raw.providers && Object.keys(raw.providers).length > 0) + || (raw.custom_providers && Object.keys(raw.custom_providers).length > 0) + || (raw.llm && Object.keys(raw.llm).length > 0), + ); + if (!hasContent) { + if (existsSync(p)) unlinkSync(p); + return null; + } + const dir = dirname(p); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o755 }); + writeFileSync(p, JSON.stringify(raw, null, 2), { encoding: 'utf8', mode: 0o600 }); + return this.read(); + } + + deleteCustomProvider(name: string): OcrConfig | null { + const raw = this.readRaw(); + if (!raw.custom_providers?.[name]) return this.read(); + delete raw.custom_providers[name]; + if (Object.keys(raw.custom_providers).length === 0) { + delete raw.custom_providers; + } + if (raw.provider === name) { + delete raw.provider; + delete raw.model; + } + return this.writeRaw(raw); + } + + /** 在隔离的临时 HOME 上运行 ocr llm test,不修改 ~/.opencodereview/config.json。 */ + async testWithEntries(entries: ConfigEntry[]): Promise<{ ok: boolean; message?: string }> { + const draft = applyConfigEntries(this.readRaw(), entries); + const testHome = mkdtempSync(join(tmpdir(), 'ocr-test-home-')); + const configDir = join(testHome, '.opencodereview'); + const configPath = join(configDir, 'config.json'); + mkdirSync(configDir, { recursive: true, mode: 0o700 }); + writeFileSync(configPath, JSON.stringify(draft, null, 2), { encoding: 'utf8', mode: 0o600 }); + try { + return await this.cli.testConnection({ home: testHome, configPath }); + } finally { + if (existsSync(testHome)) rmSync(testHome, { recursive: true, force: true }); + } + } + async set(key: string, value: string): Promise { await this.cli.runRaw(toConfigSetArgs(key, value), process.cwd(), () => {}); return this.read(); } + + async setMany(entries: { key: string; value: string }[]): Promise { + for (const entry of entries) { + await this.cli.runRaw(toConfigSetArgs(entry.key, entry.value), process.cwd(), () => {}); + } + return this.read(); + } } diff --git a/extensions/vscode/src/extension/services/__tests__/configDraft.test.ts b/extensions/vscode/src/extension/services/__tests__/configDraft.test.ts new file mode 100644 index 0000000..29bc127 --- /dev/null +++ b/extensions/vscode/src/extension/services/__tests__/configDraft.test.ts @@ -0,0 +1,51 @@ +import { applyConfigEntries } from '../configDraft'; + +describe('applyConfigEntries', () => { + it('合并官方 provider 条目', () => { + const draft = applyConfigEntries({}, [ + { key: 'provider', value: 'anthropic' }, + { key: 'providers.anthropic.model', value: 'claude-opus-4-8' }, + { key: 'providers.anthropic.api_key', value: 'sk-test' }, + ]); + expect(draft.provider).toBe('anthropic'); + expect(draft.providers?.anthropic).toEqual({ + model: 'claude-opus-4-8', + api_key: 'sk-test', + }); + }); + + it('合并自定义 provider 条目', () => { + const draft = applyConfigEntries({}, [ + { key: 'custom_providers.my-llm.protocol', value: 'openai' }, + { key: 'custom_providers.my-llm.url', value: 'https://api.example.com/v1' }, + { key: 'custom_providers.my-llm.model', value: 'gpt-4' }, + { key: 'custom_providers.my-llm.api_key', value: 'sk-custom' }, + { key: 'provider', value: 'my-llm' }, + ]); + expect(draft.provider).toBe('my-llm'); + expect(draft.custom_providers?.['my-llm']).toMatchObject({ + protocol: 'openai', + url: 'https://api.example.com/v1', + model: 'gpt-4', + api_key: 'sk-custom', + }); + }); + + it('合并 manual llm 条目并清空 provider', () => { + const draft = applyConfigEntries({ provider: 'anthropic' }, [ + { key: 'provider', value: '' }, + { key: 'model', value: '' }, + { key: 'llm.url', value: 'https://api.anthropic.com/v1/messages' }, + { key: 'llm.model', value: 'claude-opus-4-6' }, + { key: 'llm.auth_token', value: 'sk-manual' }, + { key: 'llm.use_anthropic', value: 'true' }, + ]); + expect(draft.provider).toBe(''); + expect(draft.llm).toMatchObject({ + url: 'https://api.anthropic.com/v1/messages', + model: 'claude-opus-4-6', + auth_token: 'sk-manual', + use_anthropic: true, + }); + }); +}); diff --git a/extensions/vscode/src/extension/services/__tests__/configParse.test.ts b/extensions/vscode/src/extension/services/__tests__/configParse.test.ts index 6095c5a..54733d7 100644 --- a/extensions/vscode/src/extension/services/__tests__/configParse.test.ts +++ b/extensions/vscode/src/extension/services/__tests__/configParse.test.ts @@ -4,10 +4,25 @@ import { parseConfig, toConfigSetArgs } from '../configParse'; describe('parseConfig', () => { it('完整 config 转 camelCase', () => { const raw = JSON.stringify({ + provider: 'anthropic', + providers: { + anthropic: { api_key: 'k', model: 'claude-opus-4-6', models: ['claude-opus-4-6'] }, + }, + custom_providers: { + 'my-llm': { url: 'https://x', protocol: 'openai', model: 'm', api_key: 'k2' }, + }, llm: { url: 'u', auth_token: 't', model: 'm', use_anthropic: true, auth_header: 'x-api-key' }, language: 'Chinese', }); expect(parseConfig(raw)).toEqual({ + provider: 'anthropic', + model: '', + providers: { + anthropic: { apiKey: 'k', url: '', protocol: '', model: 'claude-opus-4-6', models: ['claude-opus-4-6'], authHeader: '' }, + }, + customProviders: { + 'my-llm': { apiKey: 'k2', url: 'https://x', protocol: 'openai', model: 'm', authHeader: '' }, + }, llm: { url: 'u', authToken: 't', model: 'm', useAnthropic: true, authHeader: 'x-api-key' }, language: 'Chinese', }); @@ -15,18 +30,27 @@ describe('parseConfig', () => { it('缺字段时给默认值', () => { const cfg = parseConfig('{}'); - expect(cfg.llm.url).toBe(''); - expect(cfg.llm.useAnthropic).toBe(false); - expect(cfg.language).toBe('Chinese'); + expect(cfg?.llm.url).toBe(''); + expect(cfg?.llm.useAnthropic).toBe(true); + expect(cfg?.providers).toEqual({}); + expect(cfg?.customProviders).toEqual({}); + expect(cfg?.language).toBe('Chinese'); }); it('空字符串 → null', () => { expect(parseConfig('')).toBeNull(); }); + + it('providers 为数组时忽略', () => { + const cfg = parseConfig(JSON.stringify({ providers: ['bad'], custom_providers: [] })); + expect(cfg?.providers).toEqual({}); + expect(cfg?.customProviders).toEqual({}); + }); }); describe('toConfigSetArgs', () => { it('生成 config set 参数', () => { expect(toConfigSetArgs('llm.model', 'opus')).toEqual(['config', 'set', 'llm.model', 'opus']); + expect(toConfigSetArgs('providers.anthropic.api_key', 'sk')).toEqual(['config', 'set', 'providers.anthropic.api_key', 'sk']); }); }); diff --git a/extensions/vscode/src/extension/services/configDraft.ts b/extensions/vscode/src/extension/services/configDraft.ts new file mode 100644 index 0000000..bbf2ede --- /dev/null +++ b/extensions/vscode/src/extension/services/configDraft.ts @@ -0,0 +1,147 @@ +import { ConfigEntry } from '../../shared/configUtils'; +import { isPresetProvider } from '../../shared/providers'; + +type RawProviderEntry = Record; + +export type RawConfig = { + provider?: string; + model?: string; + providers?: Record; + custom_providers?: Record; + llm?: Record; + language?: string; +}; + +function parseModelList(value: string): string[] { + const trimmed = value.trim(); + if (!trimmed) return []; + if (trimmed.startsWith('[')) { + try { + const parsed = JSON.parse(trimmed) as unknown; + if (Array.isArray(parsed)) { + return parsed.filter((item): item is string => typeof item === 'string' && item.trim() !== ''); + } + } catch { + // fall through to comma split + } + } + return trimmed.split(',').map((item) => item.trim()).filter(Boolean); +} + +function applyProviderField(entry: RawProviderEntry, field: string, value: string): void { + switch (field) { + case 'api_key': + entry.api_key = value; + break; + case 'url': + entry.url = value; + break; + case 'protocol': + entry.protocol = value; + break; + case 'model': + entry.model = value; + break; + case 'models': + entry.models = parseModelList(value); + break; + case 'auth_header': + entry.auth_header = value; + break; + default: + break; + } +} + +function setCustomProviderField(cfg: RawConfig, name: string, field: string, value: string): void { + if (!cfg.custom_providers) cfg.custom_providers = {}; + const entry = { ...(cfg.custom_providers[name] ?? {}) }; + applyProviderField(entry, field, value); + cfg.custom_providers[name] = entry; +} + +function setProviderValue(cfg: RawConfig, key: string, value: string): void { + const parts = key.split('.'); + if (parts.length !== 3) return; + const name = parts[1]; + const field = parts[2]; + if (isPresetProvider(name)) { + if (!cfg.providers) cfg.providers = {}; + const entry = { ...(cfg.providers[name] ?? {}) }; + applyProviderField(entry, field, value); + cfg.providers[name] = entry; + return; + } + setCustomProviderField(cfg, name, field, value); +} + +function setConfigValue(cfg: RawConfig, key: string, value: string): void { + if (key.startsWith('providers.')) { + setProviderValue(cfg, key, value); + return; + } + if (key.startsWith('custom_providers.')) { + const parts = key.split('.'); + if (parts.length === 3) setCustomProviderField(cfg, parts[1], parts[2], value); + return; + } + + switch (key) { + case 'provider': + if (cfg.provider !== value) cfg.model = ''; + cfg.provider = value; + if (isPresetProvider(value)) { + if (!cfg.providers) cfg.providers = {}; + if (!cfg.providers[value]) cfg.providers[value] = {}; + } else if (value) { + if (!cfg.custom_providers) cfg.custom_providers = {}; + if (!cfg.custom_providers[value]) cfg.custom_providers[value] = {}; + } + break; + case 'model': + if (cfg.provider) { + if (isPresetProvider(cfg.provider)) { + if (!cfg.providers) cfg.providers = {}; + const entry = { ...(cfg.providers[cfg.provider] ?? {}) }; + entry.model = value; + cfg.providers[cfg.provider] = entry; + } else { + setCustomProviderField(cfg, cfg.provider, 'model', value); + } + } else { + cfg.model = value; + } + break; + case 'llm.url': + if (!cfg.llm) cfg.llm = {}; + cfg.llm.url = value; + break; + case 'llm.auth_token': + if (!cfg.llm) cfg.llm = {}; + cfg.llm.auth_token = value; + break; + case 'llm.auth_header': + if (!cfg.llm) cfg.llm = {}; + cfg.llm.auth_header = value; + break; + case 'llm.model': + if (!cfg.llm) cfg.llm = {}; + cfg.llm.model = value; + break; + case 'llm.use_anthropic': + if (!cfg.llm) cfg.llm = {}; + cfg.llm.use_anthropic = value === 'true'; + break; + default: + break; + } +} + +/** 在内存中将 config set 条目合并到原始 config JSON(不写磁盘)。 */ +export function applyConfigEntries(base: RawConfig, entries: ConfigEntry[]): RawConfig { + const draft: RawConfig = JSON.parse(JSON.stringify(base)); + for (const entry of entries) { + setConfigValue(draft, entry.key, entry.value); + } + return draft; +} diff --git a/extensions/vscode/src/extension/services/configParse.ts b/extensions/vscode/src/extension/services/configParse.ts index 4dcf14d..def07a6 100644 --- a/extensions/vscode/src/extension/services/configParse.ts +++ b/extensions/vscode/src/extension/services/configParse.ts @@ -1,15 +1,43 @@ -import { OcrConfig } from '../../shared/types'; +import { OcrConfig, ProviderEntry } from '../../shared/types'; + +function parseProviderEntry(raw: Record | undefined): ProviderEntry { + if (!raw) return {}; + const models = Array.isArray(raw.models) + ? raw.models.filter((m): m is string => typeof m === 'string') + : undefined; + return { + apiKey: typeof raw.api_key === 'string' ? raw.api_key : '', + url: typeof raw.url === 'string' ? raw.url : '', + protocol: typeof raw.protocol === 'string' ? raw.protocol : '', + model: typeof raw.model === 'string' ? raw.model : '', + models, + authHeader: typeof raw.auth_header === 'string' ? raw.auth_header : '', + }; +} + +function parseProviderMap(raw: Record | undefined): Record { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}; + const out: Record = {}; + for (const [name, entry] of Object.entries(raw)) { + out[name] = parseProviderEntry(entry as Record); + } + return out; +} export function parseConfig(raw: string): OcrConfig | null { if (!raw || !raw.trim()) return null; const j = JSON.parse(raw); const llm = j.llm || {}; return { + provider: typeof j.provider === 'string' ? j.provider : '', + model: typeof j.model === 'string' ? j.model : '', + providers: parseProviderMap(j.providers), + customProviders: parseProviderMap(j.custom_providers), llm: { url: llm.url || '', authToken: llm.auth_token || '', model: llm.model || '', - useAnthropic: Boolean(llm.use_anthropic), + useAnthropic: llm.use_anthropic !== false, authHeader: llm.auth_header || '', }, language: j.language || 'Chinese', diff --git a/extensions/vscode/src/shared/configUtils.ts b/extensions/vscode/src/shared/configUtils.ts new file mode 100644 index 0000000..d05b1f3 --- /dev/null +++ b/extensions/vscode/src/shared/configUtils.ts @@ -0,0 +1,167 @@ +import { isPresetProvider, lookupPreset } from './providers'; +import { OcrConfig } from './types'; + +export type ProviderTab = 'official' | 'custom'; + +export type ConfigPanelFocus = { + step?: 1 | 2; + tab?: ProviderTab; + customView?: 'list' | 'form'; + customSelection?: string; +}; + +export interface ConfigEntry { + key: string; + value: string; +} + +export function detectInitialTab(config: OcrConfig | null): ProviderTab { + if (!config) return 'official'; + if (config.provider) { + return isPresetProvider(config.provider) ? 'official' : 'custom'; + } + return 'official'; +} + +export type ActiveProviderKind = 'official' | 'custom' | 'legacy'; + +export interface ActiveProviderSummary { + kind: ActiveProviderKind; + name: string; + displayName: string; + model: string; + detail?: string; +} + +/** 描述 config.json 中当前生效的 Provider(非表单草稿)。 */ +export function describeActiveProvider(config: OcrConfig | null): ActiveProviderSummary | null { + if (!config) return null; + + if (config.provider) { + const preset = lookupPreset(config.provider); + if (preset) { + const entry = config.providers[config.provider]; + const model = entry?.model || config.model || ''; + if (!model) return null; + return { + kind: 'official', + name: config.provider, + displayName: preset.displayName, + model, + }; + } + const entry = config.customProviders[config.provider]; + if (!entry?.model) return null; + return { + kind: 'custom', + name: config.provider, + displayName: config.provider, + model: entry.model, + detail: entry.url, + }; + } + + if (config.llm.url && config.llm.model && config.llm.authToken) { + return { + kind: 'legacy', + name: 'legacy', + displayName: 'Legacy LLM 端点', + model: config.llm.model, + detail: config.llm.url, + }; + } + + return null; +} + +/** 判断配置是否足以发起审查(与 resolver 要求对齐,官方 provider 允许仅依赖环境变量中的 API Key) */ +export function isConfigReady(config: OcrConfig | null): boolean { + if (!config) return false; + + if (config.provider) { + const preset = lookupPreset(config.provider); + const entry = preset + ? config.providers[config.provider] + : config.customProviders[config.provider]; + if (!entry?.model) return false; + if (preset) return true; + return Boolean(entry.url && entry.protocol && entry.apiKey); + } + + return Boolean(config.llm.url && config.llm.model && config.llm.authToken); +} + +export function buildOfficialSaveEntries( + providerName: string, + model: string, + apiKey: string, + apiKeyChanged: boolean, +): ConfigEntry[] { + const entries: ConfigEntry[] = [ + { key: 'provider', value: providerName }, + { key: `providers.${providerName}.model`, value: model }, + ]; + if (apiKeyChanged && apiKey.trim()) { + entries.push({ key: `providers.${providerName}.api_key`, value: apiKey.trim() }); + } + return entries; +} + +export function buildCustomCreateSaveEntries(params: { + name: string; + protocol: string; + url: string; + model: string; + models: string; + apiKey: string; + authHeader: string; +}): ConfigEntry[] { + const entries: ConfigEntry[] = [ + { key: `custom_providers.${params.name}.protocol`, value: params.protocol }, + { key: `custom_providers.${params.name}.url`, value: params.url.trim() }, + { key: `custom_providers.${params.name}.model`, value: params.model.trim() }, + { key: `custom_providers.${params.name}.api_key`, value: params.apiKey.trim() }, + { key: 'provider', value: params.name.trim() }, + ]; + const models = params.models.trim(); + if (models) { + entries.splice(3, 0, { key: `custom_providers.${params.name}.models`, value: models }); + } + if (params.authHeader.trim()) { + entries.push({ key: `custom_providers.${params.name}.auth_header`, value: params.authHeader.trim() }); + } + return entries; +} + +export function buildCustomUpdateSaveEntries(params: { + name: string; + protocol: string; + url: string; + model: string; + models: string; + apiKey: string; + apiKeyChanged: boolean; + authHeader: string; +}): ConfigEntry[] { + const entries: ConfigEntry[] = [ + { key: `custom_providers.${params.name}.protocol`, value: params.protocol }, + { key: `custom_providers.${params.name}.url`, value: params.url.trim() }, + { key: `custom_providers.${params.name}.model`, value: params.model.trim() }, + { key: 'provider', value: params.name }, + ]; + const models = params.models.trim(); + if (models) { + entries.splice(3, 0, { key: `custom_providers.${params.name}.models`, value: models }); + } + if (params.apiKeyChanged && params.apiKey.trim()) { + entries.push({ key: `custom_providers.${params.name}.api_key`, value: params.apiKey.trim() }); + } + if (params.authHeader.trim()) { + entries.push({ key: `custom_providers.${params.name}.auth_header`, value: params.authHeader.trim() }); + } + return entries; +} + +export function listCustomProviderNames(config: OcrConfig | null): string[] { + return Object.keys(config?.customProviders ?? {}).sort(); +} diff --git a/extensions/vscode/src/shared/messages.ts b/extensions/vscode/src/shared/messages.ts index 742511c..a868c48 100644 --- a/extensions/vscode/src/shared/messages.ts +++ b/extensions/vscode/src/shared/messages.ts @@ -1,10 +1,16 @@ import { - CliResult, CliRunOptions, CommentSyncState, FileChange, GitState, LogLine, + CliResult, CliRunOptions, CommentSyncState, EnvCheckResult, FileChange, GitState, LogLine, OcrConfig, ReviewMode, ReviewState, } from './types'; +import { ConfigPanelFocus } from './configUtils'; export type WebviewToHost = | { type: 'ready' } + | { type: 'readyConfigPanel' } + | { type: 'openConfigPanel'; focus?: ConfigPanelFocus } + | { type: 'deleteCustomProvider'; name: string } + | { type: 'activateCustomProvider'; name: string } + | { type: 'closeConfigPanel' } | { type: 'getGitState'; mode: ReviewMode } | { type: 'getModeFiles'; mode: ReviewMode; from?: string; to?: string; commit?: string } | { type: 'openFileDiff'; path: string; status: FileChange['status']; mode: ReviewMode; from?: string; to?: string; commit?: string } @@ -12,9 +18,12 @@ export type WebviewToHost = | { type: 'cancelReview' } | { type: 'getConfig' } | { type: 'setConfig'; key: string; value: string } - | { type: 'testConnection' } + | { type: 'setConfigBatch'; entries: { key: string; value: string }[] } + | { type: 'testConnection'; entries: { key: string; value: string }[] } | { type: 'checkCli' } + | { type: 'checkEnvironment' } | { type: 'installCli' } + | { type: 'copyToClipboard'; text: string } | { type: 'jumpToComment'; index: number } | { type: 'commentAction'; index: number; action: 'apply' | 'discard' | 'falsePositive' }; @@ -26,8 +35,16 @@ export type HostToWebview = | { type: 'stateChange'; state: ReviewState; error?: string } | { type: 'reviewDone'; result: CliResult } | { type: 'config'; config: OcrConfig | null } + | { type: 'commentSync'; comments: CommentSyncState[] }; + +export type ConfigPanelHostToWebview = + | { type: 'configPanelInit'; config: OcrConfig | null; focus?: ConfigPanelFocus | null; env?: EnvCheckResult | null; skipEnvCheck?: boolean } + | { type: 'configPanelFocus'; focus?: ConfigPanelFocus | null } + | { type: 'config'; config: OcrConfig | null } | { type: 'connectionResult'; ok: boolean; message?: string } | { type: 'cliStatus'; installed: boolean } + | { type: 'environmentResult'; env: EnvCheckResult } + | { type: 'copyDone' } + | { type: 'panelError'; message: string } | { type: 'installLog'; line: LogLine } - | { type: 'installDone'; ok: boolean } - | { type: 'commentSync'; comments: CommentSyncState[] }; + | { type: 'installDone'; ok: boolean }; diff --git a/extensions/vscode/src/shared/providers.ts b/extensions/vscode/src/shared/providers.ts new file mode 100644 index 0000000..ef03f51 --- /dev/null +++ b/extensions/vscode/src/shared/providers.ts @@ -0,0 +1,146 @@ +export interface OcrProviderPreset { + name: string; + displayName: string; + protocol: 'anthropic' | 'openai'; + baseUrl: string; + authHeader?: string; + envVar: string; + models: string[]; +} + +/** 与 internal/llm/providers.go 内置 registry 对齐 */ +export const PROVIDER_PRESETS: OcrProviderPreset[] = [ + { + name: 'anthropic', + displayName: 'Anthropic Claude API', + protocol: 'anthropic', + baseUrl: 'https://api.anthropic.com', + authHeader: 'x-api-key', + envVar: 'ANTHROPIC_API_KEY', + models: ['claude-opus-4-8', 'claude-opus-4-7', 'claude-opus-4-6', 'claude-sonnet-4-6'], + }, + { + name: 'openai', + displayName: 'OpenAI API', + protocol: 'openai', + baseUrl: 'https://api.openai.com/v1', + envVar: 'OPENAI_API_KEY', + models: ['gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini'], + }, + { + name: 'dashscope', + displayName: 'Alibaba DashScope API', + protocol: 'openai', + baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + envVar: 'DASHSCOPE_API_KEY', + models: ['qwen3.7-max', 'qwen3.7-plus', 'qwen3.6-plus', 'qwen3.6-flash'], + }, + { + name: 'dashscope-tokenplan', + displayName: 'Alibaba DashScope Token Plan API', + protocol: 'openai', + baseUrl: 'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1', + envVar: 'DASHSCOPE_TOKENPLAN_KEY', + models: [ + 'qwen3.7-max', 'qwen3.7-plus', 'qwen3.6-plus', 'qwen3.6-flash', + 'deepseek-v4-pro', 'deepseek-v4-flash', 'kimi-k2.6', 'kimi-k2.5', + 'glm-5.2', 'glm-5.1', 'glm-5', 'MiniMax-M2.5', + ], + }, + { + name: 'volcengine', + displayName: 'Volcano Engine Ark API', + protocol: 'openai', + baseUrl: 'https://ark.cn-beijing.volces.com/api/v3', + envVar: 'ARK_API_KEY', + models: ['doubao-seed-2-0-lite-260428', 'doubao-seed-2-0-mini-260428', 'doubao-seed-2-0-pro-260215'], + }, + { + name: 'deepseek', + displayName: 'DeepSeek API', + protocol: 'openai', + baseUrl: 'https://api.deepseek.com', + envVar: 'DEEPSEEK_API_KEY', + models: ['deepseek-v4-pro', 'deepseek-v4-flash'], + }, + { + name: 'tencent-tokenhub', + displayName: 'Tencent TokenHub API', + protocol: 'openai', + baseUrl: 'https://tokenhub.tencentmaas.com/v1', + envVar: 'TENCENT_TOKENHUB_API_KEY', + models: ['hy3-preview'], + }, + { + name: 'hy-tokenplan', + displayName: 'Tencent Hunyuan Token Plan API', + protocol: 'openai', + baseUrl: 'https://api.lkeap.cloud.tencent.com/plan/v3', + envVar: 'TENCENT_HUNYUAN_TOKENPLAN_KEY', + models: ['hy3-preview'], + }, + { + name: 'kimi', + displayName: 'Kimi Moonshot API', + protocol: 'openai', + baseUrl: 'https://api.moonshot.cn/v1', + envVar: 'MOONSHOT_API_KEY', + models: ['kimi-k2.7-code', 'kimi-k2.6', 'kimi-k2.5'], + }, + { + name: 'z-ai', + displayName: 'Z.AI API', + protocol: 'openai', + baseUrl: 'https://open.bigmodel.cn/api/paas/v4', + envVar: 'Z_AI_API_KEY', + models: ['glm-5.2', 'glm-5.1', 'glm-5-turbo', 'glm-4.7'], + }, + { + name: 'mimo', + displayName: 'Xiaomi MiMo API', + protocol: 'openai', + baseUrl: 'https://api.xiaomimimo.com/v1', + envVar: 'MIMO_API_KEY', + models: ['mimo-v2.5-pro', 'mimo-v2.5', 'mimo-v2-pro', 'mimo-v2-omni', 'mimo-v2-flash'], + }, + { + name: 'minimax', + displayName: 'MiniMax API', + protocol: 'openai', + baseUrl: 'https://api.minimaxi.com/v1', + envVar: 'MINIMAX_API_KEY', + models: ['MiniMax-M3', 'MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed'], + }, + { + name: 'baidu-qianfan', + displayName: 'Baidu Qianfan API', + protocol: 'openai', + baseUrl: 'https://qianfan.baidubce.com/v2', + envVar: 'QIANFAN_API_KEY', + models: ['ernie-5.1', 'ernie-5.0', 'ernie-x1.1', 'ernie-x1-turbo-32k-preview', 'deepseek-v4-pro', 'deepseek-v4-flash'], + }, +]; + +const presetMap = new Map(PROVIDER_PRESETS.map((p) => [p.name.toLowerCase(), p])); + +export function lookupPreset(name: string): OcrProviderPreset | undefined { + return presetMap.get(name.trim().toLowerCase()); +} + +export function isPresetProvider(name: string): boolean { + return presetMap.has(name.trim().toLowerCase()); +} + +export function mergeModelLists(...lists: string[][]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const list of lists) { + for (const raw of list) { + const model = raw.trim(); + if (!model || seen.has(model)) continue; + seen.add(model); + out.push(model); + } + } + return out; +} diff --git a/extensions/vscode/src/shared/types.ts b/extensions/vscode/src/shared/types.ts index 8679335..b6e54ac 100644 --- a/extensions/vscode/src/shared/types.ts +++ b/extensions/vscode/src/shared/types.ts @@ -38,7 +38,20 @@ export interface CliResult { message?: string; } +export interface ProviderEntry { + apiKey?: string; + url?: string; + protocol?: string; + model?: string; + models?: string[]; + authHeader?: string; +} + export interface OcrConfig { + provider?: string; + model?: string; + providers: Record; + customProviders: Record; llm: { url: string; authToken: string; @@ -72,6 +85,17 @@ export interface LogLine { level: 'info' | 'warn' | 'error'; } +export interface EnvToolStatus { + ok: boolean; + version?: string; +} + +export interface EnvCheckResult { + node: EnvToolStatus; + npm: EnvToolStatus; + ocr: EnvToolStatus; +} + export interface CliRunOptions { mode: ReviewMode; from?: string; diff --git a/extensions/vscode/src/webview/App.tsx b/extensions/vscode/src/webview/App.tsx index 047e3de..3236268 100644 --- a/extensions/vscode/src/webview/App.tsx +++ b/extensions/vscode/src/webview/App.tsx @@ -1,25 +1,26 @@ import { useEffect, useReducer } from 'preact/hooks'; import { reducer, initialState } from './store'; import { bridge } from './bridge'; -import { ReviewMode, CliRunOptions, FileChange } from '../shared/types'; +import { isConfigReady } from '../shared/configUtils'; +import { CliRunOptions, FileChange, ReviewMode } from '../shared/types'; import { IdleView } from './views/IdleView'; import { RunningView } from './views/RunningView'; import { DoneView } from './views/DoneView'; import { EmptyView } from './views/EmptyView'; import { CancelledView } from './views/CancelledView'; import { FailedView } from './views/FailedView'; -import { ConfigView } from './views/ConfigView'; import './styles/global.css'; export function App() { const [state, dispatch] = useReducer(reducer, initialState); useEffect(() => { - bridge.onMessage((msg) => dispatch(msg)); + const unsub = bridge.onMessage((msg) => dispatch(msg)); bridge.post({ type: 'ready' }); + return unsub; }, []); - const configured = Boolean(state.config); + const configured = isConfigReady(state.config); const start = (options: CliRunOptions) => { dispatch({ type: 'startReview', mode: options.mode }); bridge.post({ type: 'startReview', options }); @@ -36,20 +37,16 @@ export function App() { bridge.post({ type: 'openFileDiff', path: file.path, status: file.status, mode, from, to, commit }); }; - const openConfig = () => { - dispatch({ type: 'openConfig' }); - dispatch({ type: 'checkingCli' }); - bridge.post({ type: 'checkCli' }); - }; - return (
- -
bridge.post({ type: 'openConfigPanel' })} + onOpenCustomProviders={() => bridge.post({ + type: 'openConfigPanel', + focus: { step: 2, tab: 'custom', customView: 'list' }, + })} running={state.view === 'running'} /> {state.view !== 'idle' && ( @@ -67,21 +64,6 @@ export function App() {
)}
- - {state.configOpen && ( - { dispatch({ type: 'installingCli' }); bridge.post({ type: 'installCli' }); }} - onCheckCli={() => { dispatch({ type: 'checkingCli' }); bridge.post({ type: 'checkCli' }); }} - onTest={() => { dispatch({ type: 'testingConn' }); bridge.post({ type: 'testConnection' }); }} - onSave={(entries) => entries.forEach((e) => bridge.post({ type: 'setConfig', key: e.key, value: e.value }))} - onClose={() => dispatch({ type: 'closeConfig' })} - /> - )} ); } diff --git a/extensions/vscode/src/webview/ConfigPanelApp.tsx b/extensions/vscode/src/webview/ConfigPanelApp.tsx new file mode 100644 index 0000000..9721c15 --- /dev/null +++ b/extensions/vscode/src/webview/ConfigPanelApp.tsx @@ -0,0 +1,67 @@ +import { useEffect, useReducer } from 'preact/hooks'; +import { bridge } from './bridge'; +import { ConfigView } from './views/ConfigView'; +import { configPanelInitialState, configPanelReducer } from './configStore'; +import './styles/global.css'; + +function runEnvCheck(dispatch: (action: { type: 'checkingEnv' }) => void): void { + dispatch({ type: 'checkingEnv' }); + bridge.post({ type: 'checkEnvironment' }); +} + +export function ConfigPanelApp() { + const [state, dispatch] = useReducer(configPanelReducer, configPanelInitialState); + + useEffect(() => { + const unsub = bridge.onMessage((msg) => dispatch(msg)); + bridge.post({ type: 'readyConfigPanel' }); + return unsub; + }, []); + + useEffect(() => { + if (state.skipEnvCheck) return; + if (state.envCheck !== null) return; + if (state.cliStatus === 'checking') return; + runEnvCheck(dispatch); + }, [state.envCheck, state.cliStatus, state.skipEnvCheck]); + + useEffect(() => { + if (!state.copyHint) return; + const t = setTimeout(() => dispatch({ type: 'clearCopyHint' }), 2000); + return () => clearTimeout(t); + }, [state.copyHint]); + + useEffect(() => { + if (!state.errorHint) return; + const t = setTimeout(() => dispatch({ type: 'clearErrorHint' }), 5000); + return () => clearTimeout(t); + }, [state.errorHint]); + + return ( +
+ {state.copyHint &&
{state.copyHint}
} + {state.errorHint &&
{state.errorHint}
} + { dispatch({ type: 'installingCli' }); bridge.post({ type: 'installCli' }); }} + onCheckCli={() => runEnvCheck(dispatch)} + onCheckEnv={() => runEnvCheck(dispatch)} + onCopy={(text) => bridge.post({ type: 'copyToClipboard', text })} + onTest={(entries) => { dispatch({ type: 'testingConn' }); bridge.post({ type: 'testConnection', entries }); }} + onSave={(entries) => bridge.post({ type: 'setConfigBatch', entries })} + onClearConnTest={() => dispatch({ type: 'clearConnTest' })} + onDeleteCustomProvider={(name) => bridge.post({ type: 'deleteCustomProvider', name })} + onActivateCustomProvider={(name) => bridge.post({ type: 'activateCustomProvider', name })} + onClose={() => bridge.post({ type: 'closeConfigPanel' })} + /> +
+ ); +} diff --git a/extensions/vscode/src/webview/__tests__/store.test.ts b/extensions/vscode/src/webview/__tests__/store.test.ts index 568a750..85f022c 100644 --- a/extensions/vscode/src/webview/__tests__/store.test.ts +++ b/extensions/vscode/src/webview/__tests__/store.test.ts @@ -1,25 +1,33 @@ +import { describeActiveProvider, isConfigReady } from '../../shared/configUtils'; import { initialState, reducer } from '../store'; +const baseConfig = { + provider: '', + model: '', + providers: {}, + customProviders: {}, + llm: { url: 'u', authToken: 't', model: 'm', useAnthropic: false }, + language: 'Chinese', +}; + describe('reducer', () => { it('init 设置 config 和 gitState', () => { const s = reducer(initialState, { type: 'init', - config: { llm: { url: 'u', authToken: '', model: 'm', useAnthropic: false }, language: 'Chinese' }, + config: baseConfig, gitState: { branches: [], currentBranch: 'main', recentCommits: [], workspaceFiles: [] }, }); expect(s.config?.llm.model).toBe('m'); expect(s.gitState.currentBranch).toBe('main'); - expect(s.view).toBe('idle'); // 主界面始终是 idle(review 界面) - expect(s.configOpen).toBe(false); // 已配置 → 不弹配置浮层 + expect(s.view).toBe('idle'); }); - it('init 时 config 为 null → 主界面仍是 idle,且不自动弹出配置浮层', () => { + it('init 时 config 为 null → 主界面仍是 idle', () => { const s = reducer(initialState, { type: 'init', config: null, gitState: { branches: [], currentBranch: '', recentCommits: [], workspaceFiles: [] }, }); expect(s.view).toBe('idle'); - expect(s.configOpen).toBe(false); }); it('init / gitState / modeFiles 结束 loading;filesLoading action 开启 loading', () => { @@ -36,20 +44,9 @@ describe('reducer', () => { expect(loaded.filesLoading).toBe(false); }); - it('openConfig / closeConfig 切换配置浮层', () => { - const opened = reducer(initialState, { type: 'openConfig' }); - expect(opened.configOpen).toBe(true); - const closed = reducer(opened, { type: 'closeConfig' }); - expect(closed.configOpen).toBe(false); - }); - - it('config 保存后更新 config 并关闭浮层', () => { - const s = reducer({ ...initialState, configOpen: true }, { - type: 'config', - config: { llm: { url: 'u', authToken: 't', model: 'm', useAnthropic: false }, language: 'Chinese' }, - }); + it('config 消息更新 config', () => { + const s = reducer(initialState, { type: 'config', config: baseConfig }); expect(s.config?.llm.model).toBe('m'); - expect(s.configOpen).toBe(false); }); it('stateChange running 清空旧日志并切到 running 视图', () => { @@ -82,6 +79,73 @@ describe('reducer', () => { }); }); +describe('isConfigReady', () => { + it('legacy llm 配置需要 url/model/token', () => { + expect(isConfigReady({ + ...baseConfig, + llm: { url: 'u', authToken: 't', model: 'm', useAnthropic: true }, + })).toBe(true); + expect(isConfigReady({ + ...baseConfig, + llm: { url: '', authToken: '', model: '', useAnthropic: true }, + })).toBe(false); + }); + + it('official provider 需要 model', () => { + expect(isConfigReady({ + ...baseConfig, + provider: 'anthropic', + providers: { anthropic: { model: 'claude-opus-4-6' } }, + })).toBe(true); + }); + + it('custom provider 需要 url/protocol/apiKey/model', () => { + expect(isConfigReady({ + ...baseConfig, + provider: 'my-llm', + customProviders: { + 'my-llm': { url: 'https://x', protocol: 'openai', model: 'm', apiKey: 'k' }, + }, + })).toBe(true); + }); +}); + +describe('describeActiveProvider', () => { + it('官方 provider', () => { + expect(describeActiveProvider({ + ...baseConfig, + provider: 'anthropic', + providers: { anthropic: { model: 'claude-opus-4-8' } }, + })).toMatchObject({ + kind: 'official', + displayName: 'Anthropic Claude API', + model: 'claude-opus-4-8', + }); + }); + + it('自定义 provider', () => { + expect(describeActiveProvider({ + ...baseConfig, + provider: 'my-llm', + customProviders: { + 'my-llm': { url: 'https://x', protocol: 'openai', model: 'gpt-4', apiKey: 'k' }, + }, + })).toMatchObject({ + kind: 'custom', + displayName: 'my-llm', + model: 'gpt-4', + detail: 'https://x', + }); + }); + + it('未配置时返回 null', () => { + expect(describeActiveProvider({ + ...baseConfig, + llm: { url: '', authToken: '', model: '', useAnthropic: true }, + })).toBeNull(); + }); +}); + describe('modeFiles 消息', () => { it('保存 mode 对应文件列表', () => { const next = reducer(initialState, { diff --git a/extensions/vscode/src/webview/bridge.ts b/extensions/vscode/src/webview/bridge.ts index 756f82d..8f52b17 100644 --- a/extensions/vscode/src/webview/bridge.ts +++ b/extensions/vscode/src/webview/bridge.ts @@ -9,7 +9,9 @@ export const bridge = { post(msg: WebviewToHost): void { vscode.postMessage(msg); }, - onMessage(handler: (msg: HostToWebview) => void): void { - window.addEventListener('message', (e) => handler(e.data as HostToWebview)); + onMessage(handler: (msg: HostToWebview) => void): () => void { + const listener = (e: MessageEvent) => handler(e.data as HostToWebview); + window.addEventListener('message', listener); + return () => window.removeEventListener('message', listener); }, }; diff --git a/extensions/vscode/src/webview/components/CustomProviderManager.tsx b/extensions/vscode/src/webview/components/CustomProviderManager.tsx new file mode 100644 index 0000000..ef1317a --- /dev/null +++ b/extensions/vscode/src/webview/components/CustomProviderManager.tsx @@ -0,0 +1,71 @@ +import { listCustomProviderNames } from '../../shared/configUtils'; +import { OcrConfig, ProviderEntry } from '../../shared/types'; + +interface Props { + config: OcrConfig | null; + onAdd: () => void; + onEdit: (name: string) => void; + onActivate: (name: string) => void; + onDelete: (name: string) => void; +} + +function formatModels(entry: ProviderEntry): string { + const models = entry.models ?? []; + if (models.length > 0) return models.join(', '); + return entry.model ?? '—'; +} + +export function CustomProviderManager({ config, onAdd, onEdit, onActivate, onDelete }: Props) { + const names = listCustomProviderNames(config); + const activeProvider = config?.provider ?? ''; + + return ( +
+
+
+

自定义 Provider

+

管理自建 LLM 网关与兼容端点,可切换为当前审查模型。

+
+ +
+ + {names.length === 0 ? ( +
+

暂无自定义 Provider

+ +
+ ) : ( +
+ {names.map((name) => { + const entry = config?.customProviders[name]; + if (!entry) return null; + const isActive = activeProvider === name; + return ( +
+
+
+ {name} + {isActive && 当前使用} +
+
+ {entry.protocol || '—'} + · + {entry.url || '—'} +
+
模型:{formatModels(entry)}
+
+
+ + {!isActive && ( + + )} + +
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/extensions/vscode/src/webview/components/EnvSetupGuide.tsx b/extensions/vscode/src/webview/components/EnvSetupGuide.tsx new file mode 100644 index 0000000..72af1f5 --- /dev/null +++ b/extensions/vscode/src/webview/components/EnvSetupGuide.tsx @@ -0,0 +1,211 @@ +import { EnvCheckResult, LogLine } from '../../shared/types'; +import { CliStatus } from '../configStore'; +import { LogViewer } from './LogViewer'; + +export const OCR_INSTALL_CMD = 'npm install -g @alibaba-group/open-code-review'; + +const CHECK_ITEMS = [ + { key: 'node', label: 'Node.js' }, + { key: 'npm', label: 'npm' }, + { key: 'ocr', label: 'ocr CLI' }, +] as const; + +interface Props { + layout?: 'modal' | 'panel'; + cliStatus: CliStatus; + envCheck: EnvCheckResult | null; + skipEnvCheck?: boolean; + installing: boolean; + installLogs: LogLine[]; + onInstall: () => void; + onCheckEnv: () => void; + onCopy: (text: string) => void; + onNext: () => void; +} + +type StepState = 'pending' | 'checking' | 'ok' | 'fail'; + +function resolveStepState( + active: boolean, + checking: boolean, + ok: boolean | undefined, +): StepState { + if (checking && active) return 'checking'; + if (!active) return 'pending'; + if (ok === undefined) return 'pending'; + return ok ? 'ok' : 'fail'; +} + +export function EnvSetupGuide({ + layout, cliStatus, envCheck, skipEnvCheck = false, installing, installLogs, + onInstall, onCheckEnv, onCopy, onNext, +}: Props) { + const checking = cliStatus === 'checking' || cliStatus === 'unknown'; + + if (installing) { + return ( +
+ + +
+ ); + } + + if (checking) { + return ( +
+ + +
+ ); + } + + if (cliStatus === 'installed' && envCheck) { + return ( +
+ +
+
+ +
+
+
+ ); + } + + if (cliStatus === 'installed' && skipEnvCheck) { + return ( +
+

环境已就绪,可继续配置 Provider。

+
+
+ +
+
+
+ ); + } + + const nodeActive = true; + const npmActive = envCheck?.node.ok ?? false; + const ocrActive = npmActive && (envCheck?.npm.ok ?? false); + + return ( +
+

按顺序完成环境准备,通过一项后再进行下一项。

+ +
+ + + +
+ + {installLogs.length > 0 && } + +
+
+ + {ocrActive && !envCheck?.ocr.ok && ( + + )} +
+
+
+ ); +} + +function EnvCheckingBanner({ label }: { label: string }) { + return ( +
+
+ ); +} + +function EnvChecklist({ checking, env }: { checking?: boolean; env?: EnvCheckResult }) { + return ( +
    + {CHECK_ITEMS.map(({ key, label }, i) => { + const item = env?.[key]; + const ok = item?.ok; + const last = i === CHECK_ITEMS.length - 1; + return ( +
  • +
  • + ); + })} +
+ ); +} + +function EnvTimelineItem({ + title, state, version, command, hint, onCopy, last, +}: { + title: string; + state: StepState; + version?: string; + command: string; + hint: string; + onCopy?: (text: string) => void; + last?: boolean; +}) { + const showDetail = state === 'fail' || state === 'ok'; + return ( +
+
+
+
+
+ {title} + + {state === 'ok' && (version ?? '通过')} + {state === 'fail' && '未通过'} + {state === 'pending' && '等待上一步'} + {state === 'checking' && '检测中'} + +
+ {showDetail && ( +
+ {state === 'fail' &&

{hint}

} +
+ {command} + {onCopy && ( + + )} +
+
+ )} +
+
+ ); +} diff --git a/extensions/vscode/src/webview/components/PasswordInput.tsx b/extensions/vscode/src/webview/components/PasswordInput.tsx new file mode 100644 index 0000000..a3381c7 --- /dev/null +++ b/extensions/vscode/src/webview/components/PasswordInput.tsx @@ -0,0 +1,53 @@ +import { useState } from 'preact/hooks'; + +interface Props { + value: string; + placeholder?: string; + className?: string; + onInput: (value: string) => void; +} + +function EyeIcon() { + return ( + + ); +} + +function EyeOffIcon() { + return ( + + ); +} + +export function PasswordInput({ value, placeholder, className, onInput }: Props) { + const [visible, setVisible] = useState(false); + + return ( +
+ onInput((e.target as HTMLInputElement).value)} + /> + +
+ ); +} diff --git a/extensions/vscode/src/webview/configPanel.tsx b/extensions/vscode/src/webview/configPanel.tsx new file mode 100644 index 0000000..c848d4a --- /dev/null +++ b/extensions/vscode/src/webview/configPanel.tsx @@ -0,0 +1,5 @@ +import { render } from 'preact'; +import { ConfigPanelApp } from './ConfigPanelApp'; + +const root = document.getElementById('root'); +if (root) render(, root); diff --git a/extensions/vscode/src/webview/configStore.ts b/extensions/vscode/src/webview/configStore.ts new file mode 100644 index 0000000..0602a51 --- /dev/null +++ b/extensions/vscode/src/webview/configStore.ts @@ -0,0 +1,107 @@ +import { ConfigPanelFocus, isConfigReady } from '../shared/configUtils'; +import { ConfigPanelHostToWebview, HostToWebview } from '../shared/messages'; +import { EnvCheckResult, OcrConfig, LogLine } from '../shared/types'; + +export type CliStatus = 'unknown' | 'checking' | 'installed' | 'missing'; +export type ConnTest = { status: 'idle' | 'testing' | 'ok' | 'fail'; message?: string }; + +export interface ConfigPanelState { + config: OcrConfig | null; + panelFocus: ConfigPanelFocus | null; + skipEnvCheck: boolean; + cliStatus: CliStatus; + envCheck: EnvCheckResult | null; + installing: boolean; + installLogs: LogLine[]; + connTest: ConnTest; + copyHint: string; + errorHint: string; +} + +export const configPanelInitialState: ConfigPanelState = { + config: null, + panelFocus: null, + skipEnvCheck: false, + cliStatus: 'unknown', + envCheck: null, + installing: false, + installLogs: [], + connTest: { status: 'idle' }, + copyHint: '', + errorHint: '', +}; + +export type ConfigPanelLocalAction = + | { type: 'checkingEnv' } + | { type: 'installingCli' } + | { type: 'testingConn' } + | { type: 'clearConnTest' } + | { type: 'clearCopyHint' } + | { type: 'clearErrorHint' }; + +function envToCliStatus(env: EnvCheckResult): CliStatus { + return env.node.ok && env.npm.ok && env.ocr.ok ? 'installed' : 'missing'; +} + +export function configPanelReducer( + state: ConfigPanelState, + msg: ConfigPanelHostToWebview | ConfigPanelLocalAction | HostToWebview, +): ConfigPanelState { + switch (msg.type) { + case 'checkingEnv': + return { ...state, cliStatus: 'checking', envCheck: null }; + case 'installingCli': + return { ...state, installing: true, installLogs: [] }; + case 'testingConn': + return { ...state, connTest: { status: 'testing' } }; + case 'clearConnTest': + return { ...state, connTest: { status: 'idle' } }; + case 'configPanelInit': { + const env = msg.env ?? null; + const skipEnvCheck = msg.skipEnvCheck ?? false; + return { + ...state, + config: msg.config, + panelFocus: msg.focus ?? null, + skipEnvCheck, + envCheck: env, + cliStatus: env ? envToCliStatus(env) : (skipEnvCheck ? 'installed' : 'unknown'), + }; + } + case 'configPanelFocus': + return { + ...state, + panelFocus: msg.focus ?? null, + skipEnvCheck: msg.focus?.step === 2 || isConfigReady(state.config), + connTest: { status: 'idle' }, + }; + case 'config': + return { ...state, config: msg.config, connTest: { status: 'idle' as const } }; + case 'environmentResult': + return { + ...state, + envCheck: msg.env, + cliStatus: envToCliStatus(msg.env), + }; + case 'cliStatus': + return { ...state, cliStatus: msg.installed ? 'installed' : 'missing' }; + case 'installLog': + return { ...state, installLogs: [...state.installLogs, msg.line] }; + case 'installDone': + return { ...state, installing: false }; + case 'copyDone': + return { ...state, copyHint: '已复制到剪贴板' }; + case 'clearCopyHint': + return { ...state, copyHint: '' }; + case 'clearErrorHint': + return { ...state, errorHint: '' }; + case 'panelError': + return { ...state, errorHint: msg.message }; + case 'connectionResult': + return { ...state, connTest: { status: msg.ok ? 'ok' : 'fail', message: msg.message } }; + default: + return state; + } +} + +export { isConfigReady }; diff --git a/extensions/vscode/src/webview/store.ts b/extensions/vscode/src/webview/store.ts index cab524e..64d57d9 100644 --- a/extensions/vscode/src/webview/store.ts +++ b/extensions/vscode/src/webview/store.ts @@ -3,12 +3,8 @@ import { HostToWebview } from '../shared/messages'; export type AppView = 'idle' | 'running' | 'done' | 'empty' | 'cancelled' | 'failed'; -export type CliStatus = 'unknown' | 'checking' | 'installed' | 'missing'; -export type ConnTest = { status: 'idle' | 'testing' | 'ok' | 'fail'; message?: string }; - export interface AppState { view: AppView; - configOpen: boolean; config: OcrConfig | null; gitState: GitState; modeFiles: FileChange[]; @@ -16,16 +12,11 @@ export interface AppState { logs: LogLine[]; session: { state: ReviewState; result: CliResult | null; error?: string }; commentStatus: Record; - cliStatus: CliStatus; - installing: boolean; - installLogs: LogLine[]; - connTest: ConnTest; reviewMode: ReviewMode; } export const initialState: AppState = { view: 'idle', - configOpen: false, config: null, gitState: { branches: [], currentBranch: '', recentCommits: [], workspaceFiles: [] }, modeFiles: [], @@ -33,10 +24,6 @@ export const initialState: AppState = { logs: [], session: { state: 'idle', result: null }, commentStatus: {}, - cliStatus: 'unknown', - installing: false, - installLogs: [], - connTest: { status: 'idle' }, reviewMode: 'workspace', }; @@ -46,36 +33,15 @@ const STATE_TO_VIEW: Record = { }; export type LocalAction = - | { type: 'openConfig' } - | { type: 'closeConfig' } | { type: 'filesLoading' } - | { type: 'checkingCli' } - | { type: 'installingCli' } - | { type: 'testingConn' } | { type: 'startReview'; mode: ReviewMode }; export function reducer(state: AppState, msg: HostToWebview | LocalAction): AppState { switch (msg.type) { - case 'openConfig': - return { ...state, configOpen: true, connTest: { status: 'idle' } }; - case 'closeConfig': - return { ...state, configOpen: false, installLogs: [], connTest: { status: 'idle' } }; case 'filesLoading': return { ...state, filesLoading: true }; - case 'checkingCli': - return { ...state, cliStatus: 'checking' }; - case 'installingCli': - return { ...state, installing: true, installLogs: [] }; - case 'testingConn': - return { ...state, connTest: { status: 'testing' } }; case 'startReview': return { ...state, reviewMode: msg.mode }; - case 'cliStatus': - return { ...state, cliStatus: msg.installed ? 'installed' : 'missing' }; - case 'installLog': - return { ...state, installLogs: [...state.installLogs, msg.line] }; - case 'installDone': - return { ...state, installing: false }; case 'init': return { ...state, @@ -89,8 +55,7 @@ export function reducer(state: AppState, msg: HostToWebview | LocalAction): AppS case 'modeFiles': return { ...state, modeFiles: msg.files, filesLoading: false }; case 'config': - // 保存配置后:更新 config,若已配置则关闭浮层 - return { ...state, config: msg.config, configOpen: msg.config ? false : state.configOpen }; + return { ...state, config: msg.config }; case 'stateChange': { const starting = msg.state === 'running'; return { @@ -110,8 +75,6 @@ export function reducer(state: AppState, msg: HostToWebview | LocalAction): AppS for (const c of msg.comments) commentStatus[c.index] = c.status; return { ...state, commentStatus }; } - case 'connectionResult': - return { ...state, connTest: { status: msg.ok ? 'ok' : 'fail', message: msg.message } }; default: return state; } diff --git a/extensions/vscode/src/webview/styles/global.css b/extensions/vscode/src/webview/styles/global.css index aa6513b..78f8dac 100644 --- a/extensions/vscode/src/webview/styles/global.css +++ b/extensions/vscode/src/webview/styles/global.css @@ -117,6 +117,34 @@ body { flex-shrink: 0; } +.setup-secondary { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin-top: 10px; + margin-bottom: 4px; +} + +.setup-secondary-sep { + color: var(--ink-faint); + font-size: 12px; +} + +.link-btn { + padding: 0; + border: none; + background: transparent; + color: var(--vscode-textLink-foreground, var(--mint)); + font-family: var(--font); + font-size: 12px; + cursor: pointer; +} + +.link-btn:hover { + text-decoration: underline; +} + .mode-tabs { display: flex; background: var(--vscode-input-background, var(--card-deeper)); @@ -577,6 +605,16 @@ body { cursor: not-allowed; } +.primary-btn.configure:not(:disabled) { + background: var(--card-soft); + color: var(--mint); + border: 1px solid var(--mint); +} + +.primary-btn.configure:not(:disabled):hover { + background: var(--mint-tint); +} + .skeleton-btn { background: var(--fill-soft); pointer-events: none; @@ -586,30 +624,6 @@ body { height: 11px; } -.config-fab { - position: absolute; - top: 8px; - right: 10px; - z-index: 5; - width: 28px; - height: 28px; - display: flex; - align-items: center; - justify-content: center; - background: none; - border: none; - border-radius: 6px; - color: var(--ink-quiet); - font-size: 15px; - cursor: pointer; - transition: background-color 0.2s, color 0.2s; -} - -.config-fab:hover { - background: var(--fill-soft); - color: var(--ink); -} - /* === 6. Action Region === */ .action-region { flex: 1; @@ -1143,6 +1157,815 @@ body { border-color: var(--rule); } +.provider-tab-bar { + display: flex; + gap: 6px; + margin-bottom: 4px; +} + +.provider-tab { + flex: 1; + padding: 8px 10px; + background: var(--card-soft); + border: 1px solid var(--rule); + border-radius: 8px; + font-family: var(--font); + font-size: 12px; + color: var(--ink-quiet); + cursor: pointer; + transition: background-color 0.15s, color 0.15s, border-color 0.15s; +} + +.provider-tab:hover { + background: var(--card-quiet); + color: var(--ink-soft); +} + +.provider-tab.active { + background: var(--mint-tint); + border-color: var(--mint); + color: var(--mint); + font-weight: 600; +} + +/* === Config Panel (editor area — antd page layout) === */ +.config-panel-root { + min-height: 100vh; + background: var(--vscode-editor-background, var(--bg)); +} + +.config-page { + display: flex; + flex-direction: column; + min-height: 100vh; + width: 100%; +} + +.config-page-header { + padding: 20px 32px 16px; + border-bottom: 1px solid var(--vscode-widget-border, var(--rule)); + background: var(--vscode-editor-background, var(--bg)); +} + +.config-page-header-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.config-page-meta { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 6px; + border: 1px solid var(--vscode-widget-border, var(--rule)); + background: var(--vscode-input-background, var(--fill-soft)); + font-size: 12px; + line-height: 1.4; +} + +.config-page-meta-label { + color: var(--vscode-descriptionForeground, var(--ink-quiet)); + font-weight: 500; +} + +.config-page-meta-value { + color: var(--vscode-foreground, var(--ink)); + font-family: var(--font-mono, monospace); +} + +.active-provider-banner { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + padding: 10px 14px; + border-radius: 6px; + border: 1px solid var(--vscode-widget-border, var(--rule)); + background: var(--vscode-input-background, var(--fill-soft)); + font-size: 13px; + line-height: 1.5; +} + +.active-provider-banner.empty { + color: var(--vscode-descriptionForeground, var(--ink-quiet)); +} + +.active-provider-label { + color: var(--vscode-descriptionForeground, var(--ink-quiet)); + font-size: 12px; +} + +.active-provider-badge { + padding: 1px 6px; + border-radius: 4px; + font-size: 11px; + font-weight: 500; + color: var(--vscode-badge-foreground, var(--ink)); + background: var(--vscode-badge-background, rgba(127, 127, 127, 0.25)); +} + +.active-provider-name { + font-weight: 500; + color: var(--vscode-foreground, var(--ink)); +} + +.active-provider-dot { + color: var(--vscode-descriptionForeground, var(--ink-quiet)); +} + +.active-provider-model { + color: var(--vscode-foreground, var(--ink)); + font-family: var(--font-mono, monospace); + font-size: 12px; +} + +.active-provider-detail { + max-width: 280px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--vscode-descriptionForeground, var(--ink-quiet)); + font-size: 12px; +} + +.active-provider-empty-text { + color: var(--vscode-descriptionForeground, var(--ink-quiet)); +} + +.config-page-title { + margin: 0; + font-size: 20px; + font-weight: 600; + line-height: 1.4; + color: var(--vscode-foreground, var(--ink)); + letter-spacing: 0; + text-transform: none; +} + +.config-page-title::before { + display: none; +} + +.config-page-desc { + margin: 4px 0 0; + font-size: 13px; + line-height: 1.5; + color: var(--vscode-descriptionForeground, var(--ink-quiet)); +} + +.config-page-body { + flex: 1; + width: 100%; + padding: 24px 32px 32px; + box-sizing: border-box; +} + +.config-page .config-stepper { + margin-bottom: 24px; +} + +.config-page .wizard-body { + gap: 20px; +} + +.config-page .env-checklist { + width: 100%; +} + +.config-page .env-checking-banner { + width: 100%; + justify-content: center; + box-sizing: border-box; +} + +/* Segmented control (antd) */ +.segmented-control { + display: flex; + width: 100%; + padding: 3px; + gap: 2px; + border-radius: 6px; + background: var(--vscode-input-background, var(--fill-soft)); + border: 1px solid var(--vscode-widget-border, var(--rule)); + box-sizing: border-box; +} + +.segmented-item { + flex: 1; + padding: 7px 12px; + border: none; + border-radius: 4px; + background: transparent; + font-family: var(--font); + font-size: 13px; + color: var(--vscode-descriptionForeground, var(--ink-quiet)); + cursor: pointer; + transition: background-color 0.15s, color 0.15s, box-shadow 0.15s; + white-space: nowrap; +} + +.segmented-item:hover { + color: var(--vscode-foreground, var(--ink)); +} + +.segmented-item.active { + background: var(--vscode-editor-background, var(--card)); + color: var(--vscode-foreground, var(--ink)); + font-weight: 500; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); +} + +/* Form grid */ +.provider-form { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px 24px; + width: 100%; +} + +.form-item { + min-width: 0; + margin-bottom: 0; +} + +.modal-panel .form-item { + margin-bottom: 16px; +} + +.provider-step .segmented-control { + margin-bottom: 4px; +} + +.form-item.span-2 { + grid-column: 1 / -1; +} + +.form-footer { + display: flex; + flex-direction: column; + gap: 12px; +} + +.form-footer.page-footer { + grid-column: 1 / -1; + margin-top: 8px; + padding-top: 20px; + border-top: 1px solid var(--vscode-widget-border, var(--rule)); +} + +.form-footer.page-footer .form-actions { + justify-content: flex-end; +} + +.form-meta { + font-size: 12px; + color: var(--ink-quiet); + padding: 8px 12px; + background: var(--fill-soft); + border-radius: 6px; +} + +.form-meta code { + font-family: var(--font-mono); + font-size: 11px; +} + +.config-page .form-label { + font-size: 14px; + letter-spacing: 0; + text-transform: none; + color: var(--vscode-foreground, var(--ink)); + font-weight: 400; + margin-bottom: 8px; +} + +.config-page .form-label .optional { + color: var(--vscode-descriptionForeground, var(--ink-faint)); + font-size: 13px; +} + +.config-page .form-hint { + margin-top: 6px; + font-size: 12px; + line-height: 1.5; + color: var(--vscode-descriptionForeground, var(--ink-quiet)); +} + +.config-page .form-hint code { + font-family: var(--font-mono); + font-size: 11px; +} + +.config-page .form-input, +.config-page .select-trigger { + min-height: 32px; + padding: 4px 11px; + border-radius: 6px; + font-size: 14px; + font-family: var(--font); + background: var(--vscode-input-background, var(--card-soft)); + border-color: var(--vscode-input-border, var(--rule)); +} + +.config-page .select-value { + font-family: var(--font); + font-size: 14px; +} + +.config-page .form-input:focus, +.config-page .select.open .select-trigger { + border-color: var(--mint); + box-shadow: 0 0 0 2px var(--mint-tint); +} + +.form-input-mt { + margin-top: 8px; +} + +/* Ant Design buttons */ +.btn-default { + padding: 4px 15px; + min-height: 32px; + background: var(--vscode-button-secondaryBackground, transparent); + border: 1px solid var(--vscode-button-border, var(--rule)); + border-radius: 6px; + font-family: var(--font); + font-size: 14px; + color: var(--vscode-foreground, var(--ink-soft)); + cursor: pointer; + transition: background-color 0.15s, border-color 0.15s, color 0.15s; +} + +.btn-default:hover:not(:disabled) { + color: var(--mint); + border-color: var(--mint); +} + +.btn-default:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.btn-primary { + padding: 4px 15px; + min-height: 32px; + background: var(--mint); + border: 1px solid var(--mint); + border-radius: 6px; + font-family: var(--font); + font-size: 14px; + font-weight: 500; + color: #0a1010; + cursor: pointer; + transition: background-color 0.15s, opacity 0.15s; +} + +.btn-primary:hover:not(:disabled) { + background: var(--mint-soft); + border-color: var(--mint-soft); +} + +.btn-primary:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.config-page .form-actions { + display: flex; + gap: 8px; + margin-top: 0; +} + +.config-page .form-actions .btn-default, +.config-page .form-actions .btn-primary { + min-width: 88px; +} + +.config-toast { + position: fixed; + top: 16px; + left: 50%; + transform: translateX(-50%); + z-index: 100; + padding: 8px 16px; + background: var(--card-quiet); + border: 1px solid var(--rule); + border-radius: 8px; + font-size: 12px; + color: var(--mint); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); +} + +.config-toast.error { + color: #e6a845; + border-color: rgba(230, 168, 69, 0.35); +} + +/* Custom provider manager */ +.custom-provider-manager { + display: flex; + flex-direction: column; + gap: 16px; + width: 100%; +} + +.custom-provider-manager-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.custom-provider-manager-title { + margin: 0; + font-size: 16px; + font-weight: 600; + color: var(--vscode-foreground, var(--ink)); +} + +.custom-provider-manager-desc { + margin: 4px 0 0; + font-size: 13px; + line-height: 1.5; + color: var(--vscode-descriptionForeground, var(--ink-quiet)); +} + +.custom-provider-empty { + padding: 32px 16px; + text-align: center; + border: 1px dashed var(--vscode-widget-border, var(--rule)); + border-radius: 8px; + color: var(--vscode-descriptionForeground, var(--ink-quiet)); +} + +.custom-provider-empty p { + margin: 0 0 12px; +} + +.custom-provider-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.custom-provider-card { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 14px 16px; + border: 1px solid var(--vscode-widget-border, var(--rule)); + border-radius: 8px; + background: var(--vscode-input-background, var(--card-soft)); +} + +.custom-provider-card.active { + border-color: var(--mint); + box-shadow: 0 0 0 1px var(--mint-tint); +} + +.custom-provider-card-title { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; +} + +.custom-provider-name { + font-size: 14px; + font-weight: 600; + color: var(--vscode-foreground, var(--ink)); +} + +.custom-provider-badge { + padding: 1px 8px; + border-radius: 999px; + font-size: 11px; + color: var(--mint); + background: var(--mint-tint); +} + +.custom-provider-card-meta { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--vscode-descriptionForeground, var(--ink-quiet)); + margin-bottom: 4px; +} + +.custom-provider-card-url { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: min(420px, 100%); +} + +.custom-provider-card-dot { + opacity: 0.6; +} + +.custom-provider-card-model { + font-size: 12px; + color: var(--vscode-descriptionForeground, var(--ink-quiet)); +} + +.custom-provider-card-actions { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 4px; +} + +.btn-text { + padding: 4px 8px; + border: none; + border-radius: 4px; + background: transparent; + font-family: var(--font); + font-size: 13px; + color: var(--vscode-textLink-foreground, var(--mint)); + cursor: pointer; +} + +.btn-text:hover { + background: var(--vscode-toolbar-hoverBackground, var(--fill-soft)); +} + +.btn-text.danger { + color: var(--vscode-errorForeground, #f48771); +} + +.btn-text.back-link { + padding-left: 0; + color: var(--vscode-descriptionForeground, var(--ink-quiet)); +} + +.btn-text.back-link:hover { + color: var(--vscode-foreground, var(--ink)); +} + +/* === Env Setup Guide === */ +.env-guide-lead { + margin: 0; + font-size: 12.5px; + color: var(--ink-quiet); + line-height: 1.55; +} + +.env-checking-banner { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + border-radius: 999px; + background: var(--fill-soft); + border: 1px solid var(--rule-soft); + font-size: 12.5px; + color: var(--ink-soft); + align-self: center; +} + +.env-spinner { + width: 14px; + height: 14px; + border: 2px solid var(--rule); + border-top-color: var(--mint); + border-radius: 50%; + animation: env-spin 0.7s linear infinite; + flex-shrink: 0; +} + +@keyframes env-spin { + to { transform: rotate(360deg); } +} + +.env-checklist { + list-style: none; + margin: 8px 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0; + border: 1px solid var(--rule); + border-radius: 12px; + overflow: hidden; + background: var(--card-soft); +} + +.env-checklist-item { + display: grid; + grid-template-columns: 20px 1fr auto; + align-items: center; + gap: 10px; + padding: 11px 14px; + border-bottom: 1px solid var(--rule-soft); + font-size: 13px; +} + +.env-checklist-item.last { + border-bottom: none; +} + +.env-checklist-marker { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--ink-faint); +} + +.env-checklist-item.loading .env-checklist-marker { + background: transparent; + border: 2px solid var(--rule); + border-top-color: var(--mint); + width: 12px; + height: 12px; + animation: env-spin 0.7s linear infinite; +} + +.env-checklist-item.ok .env-checklist-marker { + background: var(--mint); + box-shadow: 0 0 0 3px var(--mint-tint); +} + +.env-checklist-item.fail .env-checklist-marker { + background: #e6a845; +} + +.env-checklist-label { + color: var(--ink); + font-weight: 500; +} + +.env-checklist-meta { + font-size: 11.5px; + font-family: var(--font-mono); + color: var(--ink-quiet); +} + +.env-checklist-item.ok .env-checklist-meta { + color: var(--mint); +} + +.env-checklist-item.fail .env-checklist-meta { + color: #e6a845; +} + +.env-checklist-item.loading .env-checklist-meta { + color: var(--ink-faint); +} + +.env-timeline { + display: flex; + flex-direction: column; + gap: 0; +} + +.env-timeline-item { + display: grid; + grid-template-columns: 28px 1fr; + gap: 12px; + min-height: 48px; +} + +.env-timeline-track { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + padding-top: 4px; +} + +.env-timeline-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--ink-faint); + flex-shrink: 0; + z-index: 1; +} + +.env-timeline-item.ok .env-timeline-dot { + background: var(--mint); + box-shadow: 0 0 0 4px var(--mint-tint); +} + +.env-timeline-item.fail .env-timeline-dot { + background: #e6a845; + box-shadow: 0 0 0 4px rgba(230, 168, 69, 0.12); +} + +.env-timeline-item.pending .env-timeline-dot { + background: transparent; + border: 2px solid var(--rule); +} + +.env-timeline-line { + flex: 1; + width: 1px; + margin-top: 6px; + min-height: 24px; + background: var(--rule); +} + +.env-timeline-item.last .env-timeline-line { + display: none; +} + +.env-timeline-content { + padding-bottom: 18px; +} + +.env-timeline-item.last .env-timeline-content { + padding-bottom: 0; +} + +.env-timeline-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + min-height: 24px; +} + +.env-timeline-title { + font-size: 13px; + font-weight: 600; + color: var(--ink); +} + +.env-timeline-item.pending .env-timeline-title { + color: var(--ink-quiet); +} + +.env-timeline-status { + font-size: 11px; + font-family: var(--font-mono); + color: var(--ink-faint); + white-space: nowrap; +} + +.env-timeline-status.ok { + color: var(--mint); +} + +.env-timeline-status.fail { + color: #e6a845; +} + +.env-timeline-detail { + margin-top: 8px; +} + +.env-timeline-hint { + margin: 0 0 8px; + font-size: 12px; + color: var(--ink-quiet); + line-height: 1.55; +} + +.env-cmd-block { + display: flex; + align-items: stretch; + gap: 8px; + border: 1px solid var(--rule); + border-radius: 8px; + overflow: hidden; + background: var(--card-deeper); +} + +.env-cmd-block code { + flex: 1; + padding: 8px 10px; + font-family: var(--font-mono); + font-size: 11px; + color: var(--ink-soft); + word-break: break-all; + line-height: 1.45; +} + +.env-cmd-copy { + flex-shrink: 0; + padding: 0 12px; + border: none; + border-left: 1px solid var(--rule); + background: var(--card-soft); + font-family: var(--font); + font-size: 11px; + color: var(--ink-quiet); + cursor: pointer; + transition: background-color 0.15s, color 0.15s; +} + +.env-cmd-copy:hover { + background: var(--card-quiet); + color: var(--ink); +} + /* === Config Modal === */ .modal-backdrop { position: fixed; @@ -1173,12 +1996,88 @@ body { .config-form-header { display: flex; - align-items: center; + align-items: flex-start; justify-content: space-between; - margin-bottom: 18px; + gap: 12px; + margin-bottom: 20px; } -/* === Config Wizard === */ +.config-form-heading { + display: flex; + flex-direction: column; + gap: 4px; +} + +.config-form-subtitle { + font-size: 12px; + color: var(--ink-quiet); + line-height: 1.4; + letter-spacing: 0; + text-transform: none; + font-weight: 400; +} + +.config-stepper { + display: flex; + align-items: stretch; + gap: 6px; + width: 100%; + margin-bottom: 20px; + padding: 4px; + border-radius: 10px; + background: var(--fill-soft); + border: 1px solid var(--rule-soft); + box-sizing: border-box; +} + +.config-step-pill { + display: flex; + flex: 1; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px 12px; + border-radius: 7px; + font-size: 12px; + color: var(--ink-quiet); + transition: background-color 0.15s, color 0.15s; +} + +.config-step-pill.active { + background: var(--card-quiet); + color: var(--ink); + font-weight: 600; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12); +} + +.config-step-pill.done { + color: var(--mint); +} + +.config-step-num { + width: 18px; + height: 18px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: 700; + background: var(--fill); + color: var(--ink-quiet); +} + +.config-step-pill.active .config-step-num { + background: var(--mint-tint); + color: var(--mint); +} + +.config-step-pill.done .config-step-num { + background: var(--mint-tint); + color: var(--mint); +} + +/* === Config Wizard (legacy modal) === */ .wizard-steps { display: flex; align-items: center; @@ -1338,6 +2237,46 @@ body { color: var(--ink-faint); } +.password-input-wrap { + position: relative; + display: flex; + width: 100%; +} + +.password-input-wrap .password-input { + flex: 1; + width: 100%; + padding-right: 36px; +} + +.password-toggle { + position: absolute; + right: 2px; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: none; + border-radius: 4px; + background: transparent; + color: var(--vscode-descriptionForeground, var(--ink-quiet)); + cursor: pointer; + transition: color 0.15s, background-color 0.15s; +} + +.password-toggle:hover { + color: var(--vscode-foreground, var(--ink)); + background: var(--vscode-toolbar-hoverBackground, var(--fill-soft)); +} + +.config-page .password-input-wrap .form-input { + min-height: 32px; +} + .model-rows { margin-bottom: 8px; } diff --git a/extensions/vscode/src/webview/views/ConfigView.tsx b/extensions/vscode/src/webview/views/ConfigView.tsx index 250a123..4246248 100644 --- a/extensions/vscode/src/webview/views/ConfigView.tsx +++ b/extensions/vscode/src/webview/views/ConfigView.tsx @@ -1,11 +1,19 @@ -import { useState } from 'preact/hooks'; -import { OcrConfig } from '../../shared/types'; -import { CliStatus, ConnTest } from '../store'; -import { LogLine } from '../../shared/types'; +import { useEffect, useMemo, useState } from 'preact/hooks'; +import type { ComponentChildren } from 'preact'; +import { ConfigEntry, ConfigPanelFocus, ProviderTab, buildCustomCreateSaveEntries, buildCustomUpdateSaveEntries, buildOfficialSaveEntries, describeActiveProvider, detectInitialTab, isConfigReady, listCustomProviderNames } from '../../shared/configUtils'; +import { mergeModelLists, PROVIDER_PRESETS } from '../../shared/providers'; +import { EnvCheckResult, LogLine, OcrConfig } from '../../shared/types'; +import { CliStatus, ConnTest } from '../configStore'; +import { CustomProviderManager } from '../components/CustomProviderManager'; +import { EnvSetupGuide } from '../components/EnvSetupGuide'; import { LogViewer } from '../components/LogViewer'; +import { PasswordInput } from '../components/PasswordInput'; import { Select } from '../components/Select'; interface Props { + layout?: 'modal' | 'panel'; + panelFocus?: ConfigPanelFocus | null; + skipEnvCheck?: boolean; config: OcrConfig | null; cliStatus: CliStatus; installing: boolean; @@ -13,160 +21,523 @@ interface Props { connTest: ConnTest; onInstall: () => void; onCheckCli: () => void; - onTest: () => void; - onSave: (entries: { key: string; value: string }[]) => void; + onCheckEnv?: () => void; + onCopy?: (text: string) => void; + envCheck?: EnvCheckResult | null; + onTest: (entries: ConfigEntry[]) => void; + onSave: (entries: ConfigEntry[]) => void; + onClearConnTest?: () => void; + onDeleteCustomProvider?: (name: string) => void; + onActivateCustomProvider?: (name: string) => void; onClose: () => void; } +const CUSTOM_NEW = '__new__'; +const MODEL_CUSTOM = '__custom__'; + +function resolvePanelState(config: OcrConfig | null, panelFocus?: ConfigPanelFocus | null) { + const tab = panelFocus?.tab ?? detectInitialTab(config); + const step = panelFocus?.step ?? (isConfigReady(config) ? 2 : 1); + const customNames = listCustomProviderNames(config); + const customView = panelFocus?.customView + ?? (tab === 'custom' && customNames.length > 0 ? 'list' : 'form'); + const customSelection = panelFocus?.customSelection + ?? (panelFocus?.tab === 'custom' && panelFocus.customView === 'form' ? CUSTOM_NEW : customNames[0] ?? CUSTOM_NEW); + return { step, tab, customView, customSelection }; +} + export function ConfigView({ - config, cliStatus, installing, installLogs, connTest, - onInstall, onCheckCli, onTest, onSave, onClose, + layout = 'modal', + panelFocus = null, + skipEnvCheck = false, + config, cliStatus, envCheck = null, installing, installLogs, connTest, + onInstall, onCheckCli, onCheckEnv, onCopy, onTest, onSave, onClearConnTest, + onDeleteCustomProvider, onActivateCustomProvider, onClose, }: Props) { - const [step, setStep] = useState<1 | 2>(1); + const initial = resolvePanelState(config, panelFocus); + const [step, setStep] = useState<1 | 2>(initial.step); + const [tab, setTab] = useState(initial.tab); + const [customView, setCustomView] = useState<'list' | 'form'>(initial.customView); + const [customSelection, setCustomSelection] = useState(initial.customSelection); - const [url, setUrl] = useState(config?.llm.url ?? ''); - const [token, setToken] = useState(config?.llm.authToken ?? ''); - const [model, setModel] = useState(config?.llm.model ?? ''); - const [useAnthropic, setUseAnthropic] = useState(config?.llm.useAnthropic ?? false); - const [authHeader, setAuthHeader] = useState(config?.llm.authHeader ?? ''); + useEffect(() => { + if (!panelFocus) return; + const next = resolvePanelState(config, panelFocus); + onClearConnTest?.(); + setStep(next.step); + setTab(next.tab); + setCustomView(next.customView); + setCustomSelection(next.customSelection); + }, [panelFocus, config, onClearConnTest]); - const canSave = url.trim() !== '' && model.trim() !== ''; + const wide = layout === 'panel'; + const stepper = ( +
+
+ 1 + 环境检测 +
+
+ 2 + Provider 配置 +
+
+ ); - const save = () => { - if (!canSave) return; - const entries = [ - { key: 'llm.url', value: url.trim() }, - { key: 'llm.auth_token', value: token.trim() }, - { key: 'llm.model', value: model.trim() }, - { key: 'llm.use_anthropic', value: String(useAnthropic) }, - ]; - if (authHeader) entries.push({ key: 'llm.auth_header', value: authHeader }); - onSave(entries); - }; + const stepContent = step === 1 ? ( + {})} + onNext={() => setStep(2)} + /> + ) : ( + { + onClearConnTest?.(); + setTab(next); + if (next === 'custom' && listCustomProviderNames(config).length > 0) { + setCustomView('list'); + } + }} + customView={customView} + setCustomView={setCustomView} + customSelection={customSelection} + setCustomSelection={setCustomSelection} + connTest={connTest} + onBack={() => setStep(1)} + onTest={onTest} + onSave={onSave} + onDeleteCustomProvider={onDeleteCustomProvider} + onActivateCustomProvider={onActivateCustomProvider} + onClearConnTest={onClearConnTest} + /> + ); + + if (layout === 'panel') { + return ( +
+
+
+
+

模型配置

+

连接 LLM Provider 以开始代码审查

+
+ +
+
+
+ {stepper} + {stepContent} +
+
+ ); + } + + const body = ( + <> +
+
+ 模型配置 + 连接 LLM Provider 以开始代码审查 +
+ +
+ {stepper} + {stepContent} + + ); return ( + ); +} -
- 1 环境检测 - - 2 模型配置 -
+function OcrVersionMeta({ envCheck, cliStatus }: { envCheck: EnvCheckResult | null; cliStatus: CliStatus }) { + let label = 'ocr 检测中…'; + if (envCheck?.ocr.ok && envCheck.ocr.version) { + label = envCheck.ocr.version.startsWith('v') ? envCheck.ocr.version : `v${envCheck.ocr.version}`; + } else if (envCheck && !envCheck.ocr.ok) { + label = 'ocr 未安装'; + } else if (cliStatus !== 'checking' && cliStatus === 'missing') { + label = 'ocr 未安装'; + } + return ( +
+ OCR + {label} +
+ ); +} - {step === 1 ? ( - setStep(2)} - /> - ) : ( - setStep(1)} onTest={onTest} onSave={save} +function ActiveProviderBanner({ config }: { config: OcrConfig | null }) { + const active = describeActiveProvider(config); + if (!active) { + return ( +
+ 当前使用 + 尚未配置 Provider +
+ ); + } + const kindLabel = active.kind === 'official' ? '官方' : active.kind === 'custom' ? '自定义' : 'Legacy'; + return ( +
+ 当前使用 + {kindLabel} + {active.displayName} + · + {active.model} + {active.detail && ( + <> + · + {active.detail} + + )} +
+ ); +} + +function ProviderStep({ + wide, config, tab, setTab, customView, setCustomView, customSelection, setCustomSelection, + connTest, onBack, onTest, onSave, onDeleteCustomProvider, onActivateCustomProvider, onClearConnTest, +}: { + wide?: boolean; + config: OcrConfig | null; + tab: ProviderTab; + setTab: (t: ProviderTab) => void; + customView: 'list' | 'form'; + setCustomView: (v: 'list' | 'form') => void; + customSelection: string; + setCustomSelection: (v: string) => void; + connTest: ConnTest; + onBack: () => void; + onTest: (entries: ConfigEntry[]) => void; + onSave: (entries: ConfigEntry[]) => void; + onDeleteCustomProvider?: (name: string) => void; + onActivateCustomProvider?: (name: string) => void; + onClearConnTest?: () => void; +}) { + return ( +
+
+ {([ + ['official', '官方 Provider'], + ['custom', '自定义 Provider'], + ] as const).map(([id, label]) => ( + + ))} +
+ + + + {tab === 'official' && ( + + )} + {tab === 'custom' && customView === 'list' && ( + { onClearConnTest?.(); setCustomSelection(CUSTOM_NEW); setCustomView('form'); }} + onEdit={(name) => { onClearConnTest?.(); setCustomSelection(name); setCustomView('form'); }} + onActivate={(name) => onActivateCustomProvider?.(name)} + onDelete={(name) => onDeleteCustomProvider?.(name)} + /> + )} + {tab === 'custom' && customView === 'form' && ( + { onClearConnTest?.(); setCustomView('list'); }} + onTest={onTest} + onSave={(entries) => { + onSave(entries); + setCustomView('list'); + }} + /> + )} +
+ ); +} + +function FormSection({ wide, children }: { wide?: boolean; children: ComponentChildren }) { + if (wide) return
{children}
; + return <>{children}; +} + +function FormItem({ + label, span = 1, optional, hint, children, +}: { + label: string; + span?: 1 | 2; + optional?: boolean; + hint?: string; + children: ComponentChildren; +}) { + return ( +
+ + {children} + {hint &&
{hint}
} +
+ ); +} + +function OfficialForm({ wide, config, connTest, onBack, onTest, onSave }: FormProps) { + const initialProvider = config?.provider && PROVIDER_PRESETS.some((p) => p.name === config.provider) + ? config.provider + : PROVIDER_PRESETS[0].name; + const [providerName, setProviderName] = useState(initialProvider); + const preset = PROVIDER_PRESETS.find((p) => p.name === providerName) ?? PROVIDER_PRESETS[0]; + const savedEntry = config?.providers[providerName]; + + const modelOptions = useMemo( + () => mergeModelLists(preset.models, savedEntry?.models ?? []), + [preset.models, savedEntry?.models, providerName], + ); + + const initialModel = savedEntry?.model || modelOptions[0] || ''; + const [modelChoice, setModelChoice] = useState( + modelOptions.includes(initialModel) ? initialModel : MODEL_CUSTOM, + ); + const [customModel, setCustomModel] = useState( + modelOptions.includes(initialModel) ? '' : initialModel, + ); + const [apiKey, setApiKey] = useState(''); + const [apiKeyTouched, setApiKeyTouched] = useState(false); + const hasStoredKey = Boolean(savedEntry?.apiKey); + + const resolvedModel = modelChoice === MODEL_CUSTOM ? customModel.trim() : modelChoice; + const canSave = resolvedModel !== ''; + + const buildEntries = () => buildOfficialSaveEntries( + providerName, + resolvedModel, + apiKey, + apiKeyTouched || !hasStoredKey, + ); + + const save = () => { + if (!canSave) return; + onSave(buildEntries()); + }; + + const test = () => { + if (!canSave) return; + onTest(buildEntries()); + }; + + return ( + + + ({ value: m, label: m })), + { value: MODEL_CUSTOM, label: '输入自定义模型…' }, + ]} + /> + {modelChoice === MODEL_CUSTOM && ( + setCustomModel((e.target as HTMLInputElement).value)} + placeholder="model name" /> )} - - + + + + { setApiKey(v); setApiKeyTouched(true); }} + placeholder={hasStoredKey && !apiKeyTouched ? '已保存(留空保持不变)' : 'sk-...'} + /> + + + + ); } -function Step1({ cliStatus, installing, installLogs, onInstall, onCheckCli, onNext }: { - cliStatus: CliStatus; installing: boolean; installLogs: LogLine[]; - onInstall: () => void; onCheckCli: () => void; onNext: () => void; +function CustomForm({ + wide, config, connTest, selection, onBack, onBackToList, onTest, onSave, +}: FormProps & { + selection: string; + onBackToList?: () => void; }) { - if (installing) { - return ( -
-
正在安装 ocr CLI…
- -
- ); - } + const isCreate = selection === CUSTOM_NEW; + const entry = !isCreate ? config?.customProviders[selection] : undefined; - if (cliStatus === 'checking' || cliStatus === 'unknown') { - return
正在检测 ocr 命令…
; - } + const [name, setName] = useState(isCreate ? '' : selection); + const [protocol, setProtocol] = useState<'anthropic' | 'openai'>( + (entry?.protocol as 'anthropic' | 'openai') || 'openai', + ); + const [url, setUrl] = useState(entry?.url ?? ''); + const [model, setModel] = useState(entry?.model ?? ''); + const [models, setModels] = useState((entry?.models ?? []).join(', ')); + const [apiKey, setApiKey] = useState(''); + const [apiKeyTouched, setApiKeyTouched] = useState(false); + const [authHeader, setAuthHeader] = useState(entry?.authHeader ?? ''); - if (cliStatus === 'missing') { - return ( -
-
未检测到 ocr 命令。需要全局安装后才能进行代码审查。
-
将执行:npm install -g @alibaba-group/open-code-review
- {installLogs.length > 0 && } -
- - -
-
- ); - } + const canSaveCreate = name.trim() !== '' && url.trim() !== '' && model.trim() !== '' && apiKey.trim() !== ''; + const canSaveEdit = !isCreate && name.trim() !== '' && url.trim() !== '' && model.trim() !== '' + && Boolean(entry?.apiKey || apiKey.trim()); + const canSave = isCreate ? canSaveCreate : canSaveEdit; + + const buildCreateEntries = () => buildCustomCreateSaveEntries({ + name: name.trim(), + protocol, + url, + model, + models, + apiKey, + authHeader, + }); + + const buildEditEntries = () => buildCustomUpdateSaveEntries({ + name: name.trim(), + protocol, + url, + model, + models, + apiKey, + apiKeyChanged: apiKeyTouched || !entry?.apiKey, + authHeader, + }); + + const buildEntries = () => (isCreate ? buildCreateEntries() : buildEditEntries()); + + const save = () => { + if (!canSave) return; + onSave(buildEntries()); + }; + + const test = () => { + if (!canSave) return; + onTest(buildEntries()); + }; - // installed return ( -
-
✓ ocr 命令已安装
-
- -
-
+ + {onBackToList && ( +
+ +
+ )} + + + setName((e.target as HTMLInputElement).value)} + placeholder="my-llm" + /> + + + setUrl((e.target as HTMLInputElement).value)} placeholder="https://api.example.com/v1" /> + + + setModel((e.target as HTMLInputElement).value)} placeholder="model name" /> + + + setModels((e.target as HTMLInputElement).value)} placeholder="逗号分隔,如 model-a, model-b" /> + + + { setApiKey(v); setApiKeyTouched(true); }} + placeholder={!isCreate && entry?.apiKey && !apiKeyTouched ? '已保存(留空保持不变)' : 'sk-...'} + /> + + + setUrl((e.target as HTMLInputElement).value)} placeholder="https://api.anthropic.com/v1/messages" /> - -
- - setToken((e.target as HTMLInputElement).value)} placeholder="sk-..." /> -
-
- - setModel((e.target as HTMLInputElement).value)} placeholder="claude-opus-4-6" /> -
-
- 使用 Anthropic 协议 - -
- -
- 高级选项 -
-
- -