feat(vscode): add full-width BYOK provider config panel (#204)
Some checks are pending
CI / test (push) Waiting to run

* 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>
This commit is contained in:
xyJen 2026-06-25 10:37:25 +08:00 committed by GitHub
parent ce11f374fd
commit b6da4e214f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 3860 additions and 1087 deletions

View file

@ -20,6 +20,16 @@ func defaultConfigPath() (string, error) {
return filepath.Join(home, ".opencodereview", "config.json"), nil 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 { func runConfig(args []string) error {
if len(args) == 0 { if len(args) == 0 {
printConfigUsage() printConfigUsage()

View file

@ -29,7 +29,7 @@ func runLLM(args []string) error {
} }
func runLLMTest() error { func runLLMTest() error {
cfgPath, err := defaultConfigPath() cfgPath, err := resolveConfigPath()
if err != nil { if err != nil {
return err return err
} }

View file

@ -2,11 +2,16 @@ import * as vscode from 'vscode';
import { COMMANDS } from '../shared/constants'; import { COMMANDS } from '../shared/constants';
import { CommentProvider } from './providers/CommentProvider'; 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 subs: vscode.Disposable[] = [];
const reg = (id: string, fn: (...args: any[]) => any) => const reg = (id: string, fn: (...args: any[]) => any) =>
subs.push(vscode.commands.registerCommand(id, fn)); subs.push(vscode.commands.registerCommand(id, fn));
reg(COMMANDS.configOpen, openConfig);
// 标题栏按钮传入的是 CommentThread侧边栏 / Markdown 链接传入的是 index // 标题栏按钮传入的是 CommentThread侧边栏 / Markdown 链接传入的是 index
const idxOf = (arg: vscode.CommentThread | number): number => const idxOf = (arg: vscode.CommentThread | number): number =>
typeof arg === 'number' ? arg : comments.indexOfThread(arg); typeof arg === 'number' ? arg : comments.indexOfThread(arg);

View file

@ -5,6 +5,7 @@ import { ConfigService } from './services/ConfigService';
import { GitService } from './services/GitService'; import { GitService } from './services/GitService';
import { CommentProvider } from './providers/CommentProvider'; import { CommentProvider } from './providers/CommentProvider';
import { SidebarProvider } from './providers/SidebarProvider'; import { SidebarProvider } from './providers/SidebarProvider';
import { ConfigPanelProvider } from './providers/ConfigPanelProvider';
import { registerCommands } from './commands'; import { registerCommands } from './commands';
let disposables: vscode.Disposable[] = []; let disposables: vscode.Disposable[] = [];
@ -18,11 +19,13 @@ export function activate(context: vscode.ExtensionContext): void {
const comments = new CommentProvider(extensionUri); const comments = new CommentProvider(extensionUri);
const sidebar = new SidebarProvider(extensionUri, cli, config, git, comments); 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 viewReg = vscode.window.registerWebviewViewProvider(SIDEBAR_VIEW_ID, sidebar);
const cmdReg = registerCommands(comments, () => configPanel.open());
const cmdReg = registerCommands(comments); disposables.push(viewReg, cmdReg, comments, output, configPanel);
disposables.push(viewReg, cmdReg, comments, output);
context.subscriptions.push(...disposables); context.subscriptions.push(...disposables);
} }

View file

@ -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<void> {
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<void> {
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 `<!DOCTYPE html>
<html lang="zh-CN"><head>
<meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${webview.cspSource} 'unsafe-inline'; script-src 'nonce-${nonce}';">
</head><body><div id="root"></div>
<script nonce="${nonce}" src="${scriptUri}"></script>
</body></html>`;
}
}

View file

@ -1,4 +1,5 @@
import * as vscode from 'vscode'; import * as vscode from 'vscode';
import { ConfigPanelFocus } from '../../shared/configUtils';
import { HostToWebview, WebviewToHost } from '../../shared/messages'; import { HostToWebview, WebviewToHost } from '../../shared/messages';
import { FileChange } from '../../shared/types'; import { FileChange } from '../../shared/types';
import { CliService } from '../services/CliService'; import { CliService } from '../services/CliService';
@ -10,6 +11,7 @@ import { CommentProvider } from './CommentProvider';
export class SidebarProvider implements vscode.WebviewViewProvider { export class SidebarProvider implements vscode.WebviewViewProvider {
private view?: vscode.WebviewView; private view?: vscode.WebviewView;
private session?: ReviewSession; private session?: ReviewSession;
private openConfigPanel?: (focus?: ConfigPanelFocus) => void;
constructor( constructor(
private extensionUri: vscode.Uri, private extensionUri: vscode.Uri,
@ -21,6 +23,14 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
this.comments.onSync((states) => this.post({ type: 'commentSync', comments: states })); this.comments.onSync((states) => this.post({ type: 'commentSync', comments: states }));
} }
bindConfigPanel(open: (focus?: ConfigPanelFocus) => void): void {
this.openConfigPanel = open;
}
pushConfig(config: ReturnType<ConfigService['read']>): void {
this.post({ type: 'config', config });
}
resolveWebviewView(view: vscode.WebviewView): void { resolveWebviewView(view: vscode.WebviewView): void {
this.view = view; this.view = view;
view.webview.options = { enableScripts: true, localResourceRoots: [this.extensionUri] }; view.webview.options = { enableScripts: true, localResourceRoots: [this.extensionUri] };
@ -78,28 +88,12 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
case 'cancelReview': case 'cancelReview':
this.session?.cancel({ onState: (state) => this.post({ type: 'stateChange', state }) }); this.session?.cancel({ onState: (state) => this.post({ type: 'stateChange', state }) });
break; break;
case 'openConfigPanel':
this.openConfigPanel?.(msg.focus);
break;
case 'getConfig': case 'getConfig':
this.post({ type: 'config', config: this.config.read() }); this.post({ type: 'config', config: this.config.read() });
break; 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': case 'jumpToComment':
await this.comments.jumpTo(msg.index); await this.comments.jumpTo(msg.index);
break; break;

View file

@ -1,22 +1,66 @@
import { spawn } from 'child_process'; 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 { buildReviewArgs, extractCliError, parseCliResult, parseLogLine } from './cliParse';
import { getShellEnv, resolveBin } from './shellEnv'; import { getShellEnv, resolveBin } from './shellEnv';
export class CliService { export class CliService {
private current: ReturnType<typeof spawn> | null = null; private current: ReturnType<typeof spawn> | 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') {} 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<boolean> { async isAvailable(): Promise<boolean> {
const env = await this.checkEnvironment();
return env.ocr.ok;
}
private probeCommand(bin: string, args: string[]): Promise<{ ok: boolean; version?: string }> {
return new Promise((resolve) => { 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; let errored = false;
p.on('error', () => { errored = true; resolve(false); }); proc.stdout?.on('data', (d) => { stdout += d.toString(); });
p.on('close', () => { if (!errored) resolve(true); }); 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<EnvCheckResult> {
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 返回是否成功。 */ /** 全局安装 ocr CLI流式回显 npm 日志,按 exit code 返回是否成功。 */
install(onLog: (l: LogLine) => void): Promise<boolean> { install(onLog: (l: LogLine) => void): Promise<boolean> {
return new Promise((resolve) => { return new Promise((resolve) => {
@ -47,15 +91,24 @@ export class CliService {
proc.on('close', (code) => { proc.on('close', (code) => {
emitLines('', 'info', true); emitLines('', 'info', true);
onLog({ text: code === 0 ? '✓ 安装完成' : `✗ 安装失败 (exit ${code})`, level: code === 0 ? 'info' : 'error' }); onLog({ text: code === 0 ? '✓ 安装完成' : `✗ 安装失败 (exit ${code})`, level: code === 0 ? 'info' : 'error' });
if (code === 0) this.invalidateEnvironmentCache();
resolve(code === 0); resolve(code === 0);
}); });
}); });
} }
/** 运行任意参数,流式回调日志,结束返回 stdout 全文。退出码非 0 时 reject并带上 CLI 报错文本。 */ /** 运行任意参数,流式回调日志,结束返回 stdout 全文。退出码非 0 时 reject并带上 CLI 报错文本。 */
runRaw(args: string[], cwd: string, onLog: (l: LogLine) => void): Promise<string> { runRaw(
args: string[],
cwd: string,
onLog: (l: LogLine) => void,
envExtra?: Record<string, string>,
): Promise<string> {
return new Promise((resolve, reject) => { 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; this.current = proc;
let stdout = ''; let stdout = '';
let stderr = ''; let stderr = '';
@ -82,9 +135,16 @@ export class CliService {
return parseCliResult(stdout); 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<string, string> = {};
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 { try {
await this.runRaw(['llm', 'test'], process.cwd(), () => {}); await this.runRaw(['llm', 'test'], process.cwd(), () => {}, env);
return { ok: true }; return { ok: true };
} catch (e) { } catch (e) {
return { ok: false, message: e instanceof Error ? e.message : String(e) }; return { ok: false, message: e instanceof Error ? e.message : String(e) };

View file

@ -1,8 +1,10 @@
import { readFileSync, existsSync } from 'fs'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'fs';
import { homedir } from 'os'; import { homedir, tmpdir } from 'os';
import { join } from 'path'; import { dirname, join } from 'path';
import { ConfigEntry } from '../../shared/configUtils';
import { OcrConfig } from '../../shared/types'; import { OcrConfig } from '../../shared/types';
import { CliService } from './CliService'; import { CliService } from './CliService';
import { applyConfigEntries, RawConfig } from './configDraft';
import { parseConfig, toConfigSetArgs } from './configParse'; import { parseConfig, toConfigSetArgs } from './configParse';
export class ConfigService { 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<OcrConfig | null> { async set(key: string, value: string): Promise<OcrConfig | null> {
await this.cli.runRaw(toConfigSetArgs(key, value), process.cwd(), () => {}); await this.cli.runRaw(toConfigSetArgs(key, value), process.cwd(), () => {});
return this.read(); return this.read();
} }
async setMany(entries: { key: string; value: string }[]): Promise<OcrConfig | null> {
for (const entry of entries) {
await this.cli.runRaw(toConfigSetArgs(entry.key, entry.value), process.cwd(), () => {});
}
return this.read();
}
} }

View file

@ -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,
});
});
});

View file

@ -4,10 +4,25 @@ import { parseConfig, toConfigSetArgs } from '../configParse';
describe('parseConfig', () => { describe('parseConfig', () => {
it('完整 config 转 camelCase', () => { it('完整 config 转 camelCase', () => {
const raw = JSON.stringify({ 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' }, llm: { url: 'u', auth_token: 't', model: 'm', use_anthropic: true, auth_header: 'x-api-key' },
language: 'Chinese', language: 'Chinese',
}); });
expect(parseConfig(raw)).toEqual({ 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' }, llm: { url: 'u', authToken: 't', model: 'm', useAnthropic: true, authHeader: 'x-api-key' },
language: 'Chinese', language: 'Chinese',
}); });
@ -15,18 +30,27 @@ describe('parseConfig', () => {
it('缺字段时给默认值', () => { it('缺字段时给默认值', () => {
const cfg = parseConfig('{}'); const cfg = parseConfig('{}');
expect(cfg.llm.url).toBe(''); expect(cfg?.llm.url).toBe('');
expect(cfg.llm.useAnthropic).toBe(false); expect(cfg?.llm.useAnthropic).toBe(true);
expect(cfg.language).toBe('Chinese'); expect(cfg?.providers).toEqual({});
expect(cfg?.customProviders).toEqual({});
expect(cfg?.language).toBe('Chinese');
}); });
it('空字符串 → null', () => { it('空字符串 → null', () => {
expect(parseConfig('')).toBeNull(); expect(parseConfig('')).toBeNull();
}); });
it('providers 为数组时忽略', () => {
const cfg = parseConfig(JSON.stringify({ providers: ['bad'], custom_providers: [] }));
expect(cfg?.providers).toEqual({});
expect(cfg?.customProviders).toEqual({});
});
}); });
describe('toConfigSetArgs', () => { describe('toConfigSetArgs', () => {
it('生成 config set 参数', () => { it('生成 config set 参数', () => {
expect(toConfigSetArgs('llm.model', 'opus')).toEqual(['config', 'set', 'llm.model', 'opus']); 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']);
}); });
}); });

View file

@ -0,0 +1,147 @@
import { ConfigEntry } from '../../shared/configUtils';
import { isPresetProvider } from '../../shared/providers';
type RawProviderEntry = Record<string, unknown>;
export type RawConfig = {
provider?: string;
model?: string;
providers?: Record<string, RawProviderEntry>;
custom_providers?: Record<string, RawProviderEntry>;
llm?: Record<string, unknown>;
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;
}

View file

@ -1,15 +1,43 @@
import { OcrConfig } from '../../shared/types'; import { OcrConfig, ProviderEntry } from '../../shared/types';
function parseProviderEntry(raw: Record<string, unknown> | 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<string, unknown> | undefined): Record<string, ProviderEntry> {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
const out: Record<string, ProviderEntry> = {};
for (const [name, entry] of Object.entries(raw)) {
out[name] = parseProviderEntry(entry as Record<string, unknown>);
}
return out;
}
export function parseConfig(raw: string): OcrConfig | null { export function parseConfig(raw: string): OcrConfig | null {
if (!raw || !raw.trim()) return null; if (!raw || !raw.trim()) return null;
const j = JSON.parse(raw); const j = JSON.parse(raw);
const llm = j.llm || {}; const llm = j.llm || {};
return { 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: { llm: {
url: llm.url || '', url: llm.url || '',
authToken: llm.auth_token || '', authToken: llm.auth_token || '',
model: llm.model || '', model: llm.model || '',
useAnthropic: Boolean(llm.use_anthropic), useAnthropic: llm.use_anthropic !== false,
authHeader: llm.auth_header || '', authHeader: llm.auth_header || '',
}, },
language: j.language || 'Chinese', language: j.language || 'Chinese',

View file

@ -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();
}

View file

@ -1,10 +1,16 @@
import { import {
CliResult, CliRunOptions, CommentSyncState, FileChange, GitState, LogLine, CliResult, CliRunOptions, CommentSyncState, EnvCheckResult, FileChange, GitState, LogLine,
OcrConfig, ReviewMode, ReviewState, OcrConfig, ReviewMode, ReviewState,
} from './types'; } from './types';
import { ConfigPanelFocus } from './configUtils';
export type WebviewToHost = export type WebviewToHost =
| { type: 'ready' } | { type: 'ready' }
| { type: 'readyConfigPanel' }
| { type: 'openConfigPanel'; focus?: ConfigPanelFocus }
| { type: 'deleteCustomProvider'; name: string }
| { type: 'activateCustomProvider'; name: string }
| { type: 'closeConfigPanel' }
| { type: 'getGitState'; mode: ReviewMode } | { type: 'getGitState'; mode: ReviewMode }
| { type: 'getModeFiles'; mode: ReviewMode; from?: string; to?: string; commit?: string } | { 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 } | { 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: 'cancelReview' }
| { type: 'getConfig' } | { type: 'getConfig' }
| { type: 'setConfig'; key: string; value: string } | { 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: 'checkCli' }
| { type: 'checkEnvironment' }
| { type: 'installCli' } | { type: 'installCli' }
| { type: 'copyToClipboard'; text: string }
| { type: 'jumpToComment'; index: number } | { type: 'jumpToComment'; index: number }
| { type: 'commentAction'; index: number; action: 'apply' | 'discard' | 'falsePositive' }; | { type: 'commentAction'; index: number; action: 'apply' | 'discard' | 'falsePositive' };
@ -26,8 +35,16 @@ export type HostToWebview =
| { type: 'stateChange'; state: ReviewState; error?: string } | { type: 'stateChange'; state: ReviewState; error?: string }
| { type: 'reviewDone'; result: CliResult } | { type: 'reviewDone'; result: CliResult }
| { type: 'config'; config: OcrConfig | null } | { 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: 'connectionResult'; ok: boolean; message?: string }
| { type: 'cliStatus'; installed: boolean } | { type: 'cliStatus'; installed: boolean }
| { type: 'environmentResult'; env: EnvCheckResult }
| { type: 'copyDone' }
| { type: 'panelError'; message: string }
| { type: 'installLog'; line: LogLine } | { type: 'installLog'; line: LogLine }
| { type: 'installDone'; ok: boolean } | { type: 'installDone'; ok: boolean };
| { type: 'commentSync'; comments: CommentSyncState[] };

View file

@ -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<string>();
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;
}

View file

@ -38,7 +38,20 @@ export interface CliResult {
message?: string; message?: string;
} }
export interface ProviderEntry {
apiKey?: string;
url?: string;
protocol?: string;
model?: string;
models?: string[];
authHeader?: string;
}
export interface OcrConfig { export interface OcrConfig {
provider?: string;
model?: string;
providers: Record<string, ProviderEntry>;
customProviders: Record<string, ProviderEntry>;
llm: { llm: {
url: string; url: string;
authToken: string; authToken: string;
@ -72,6 +85,17 @@ export interface LogLine {
level: 'info' | 'warn' | 'error'; level: 'info' | 'warn' | 'error';
} }
export interface EnvToolStatus {
ok: boolean;
version?: string;
}
export interface EnvCheckResult {
node: EnvToolStatus;
npm: EnvToolStatus;
ocr: EnvToolStatus;
}
export interface CliRunOptions { export interface CliRunOptions {
mode: ReviewMode; mode: ReviewMode;
from?: string; from?: string;

View file

@ -1,25 +1,26 @@
import { useEffect, useReducer } from 'preact/hooks'; import { useEffect, useReducer } from 'preact/hooks';
import { reducer, initialState } from './store'; import { reducer, initialState } from './store';
import { bridge } from './bridge'; 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 { IdleView } from './views/IdleView';
import { RunningView } from './views/RunningView'; import { RunningView } from './views/RunningView';
import { DoneView } from './views/DoneView'; import { DoneView } from './views/DoneView';
import { EmptyView } from './views/EmptyView'; import { EmptyView } from './views/EmptyView';
import { CancelledView } from './views/CancelledView'; import { CancelledView } from './views/CancelledView';
import { FailedView } from './views/FailedView'; import { FailedView } from './views/FailedView';
import { ConfigView } from './views/ConfigView';
import './styles/global.css'; import './styles/global.css';
export function App() { export function App() {
const [state, dispatch] = useReducer(reducer, initialState); const [state, dispatch] = useReducer(reducer, initialState);
useEffect(() => { useEffect(() => {
bridge.onMessage((msg) => dispatch(msg)); const unsub = bridge.onMessage((msg) => dispatch(msg));
bridge.post({ type: 'ready' }); bridge.post({ type: 'ready' });
return unsub;
}, []); }, []);
const configured = Boolean(state.config); const configured = isConfigReady(state.config);
const start = (options: CliRunOptions) => { const start = (options: CliRunOptions) => {
dispatch({ type: 'startReview', mode: options.mode }); dispatch({ type: 'startReview', mode: options.mode });
bridge.post({ type: 'startReview', options }); 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 }); 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 ( return (
<div class="ocr-root"> <div class="ocr-root">
<button class="config-fab" onClick={openConfig} title="模型配置"></button>
<div class="action-region"> <div class="action-region">
<IdleView gitState={state.gitState} modeFiles={state.modeFiles} filesLoading={state.filesLoading} <IdleView gitState={state.gitState} modeFiles={state.modeFiles} filesLoading={state.filesLoading}
configured={configured} onModeChange={onModeChange} onRequestModeFiles={requestModeFiles} configured={configured} onModeChange={onModeChange} onRequestModeFiles={requestModeFiles}
onOpenFile={openFile} onStart={start} onOpenFile={openFile} onStart={start} onOpenConfig={() => bridge.post({ type: 'openConfigPanel' })}
onOpenCustomProviders={() => bridge.post({
type: 'openConfigPanel',
focus: { step: 2, tab: 'custom', customView: 'list' },
})}
running={state.view === 'running'} /> running={state.view === 'running'} />
{state.view !== 'idle' && ( {state.view !== 'idle' && (
@ -67,21 +64,6 @@ export function App() {
</div> </div>
)} )}
</div> </div>
{state.configOpen && (
<ConfigView
config={state.config}
cliStatus={state.cliStatus}
installing={state.installing}
installLogs={state.installLogs}
connTest={state.connTest}
onInstall={() => { 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' })}
/>
)}
</div> </div>
); );
} }

View file

@ -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 (
<div class="config-panel-root">
{state.copyHint && <div class="config-toast">{state.copyHint}</div>}
{state.errorHint && <div class="config-toast error">{state.errorHint}</div>}
<ConfigView
layout="panel"
panelFocus={state.panelFocus}
skipEnvCheck={state.skipEnvCheck}
config={state.config}
cliStatus={state.cliStatus}
envCheck={state.envCheck}
installing={state.installing}
installLogs={state.installLogs}
connTest={state.connTest}
onInstall={() => { 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' })}
/>
</div>
);
}

View file

@ -1,25 +1,33 @@
import { describeActiveProvider, isConfigReady } from '../../shared/configUtils';
import { initialState, reducer } from '../store'; import { initialState, reducer } from '../store';
const baseConfig = {
provider: '',
model: '',
providers: {},
customProviders: {},
llm: { url: 'u', authToken: 't', model: 'm', useAnthropic: false },
language: 'Chinese',
};
describe('reducer', () => { describe('reducer', () => {
it('init 设置 config 和 gitState', () => { it('init 设置 config 和 gitState', () => {
const s = reducer(initialState, { const s = reducer(initialState, {
type: 'init', type: 'init',
config: { llm: { url: 'u', authToken: '', model: 'm', useAnthropic: false }, language: 'Chinese' }, config: baseConfig,
gitState: { branches: [], currentBranch: 'main', recentCommits: [], workspaceFiles: [] }, gitState: { branches: [], currentBranch: 'main', recentCommits: [], workspaceFiles: [] },
}); });
expect(s.config?.llm.model).toBe('m'); expect(s.config?.llm.model).toBe('m');
expect(s.gitState.currentBranch).toBe('main'); expect(s.gitState.currentBranch).toBe('main');
expect(s.view).toBe('idle'); // 主界面始终是 idlereview 界面) expect(s.view).toBe('idle');
expect(s.configOpen).toBe(false); // 已配置 → 不弹配置浮层
}); });
it('init 时 config 为 null → 主界面仍是 idle,且不自动弹出配置浮层', () => { it('init 时 config 为 null → 主界面仍是 idle', () => {
const s = reducer(initialState, { const s = reducer(initialState, {
type: 'init', config: null, type: 'init', config: null,
gitState: { branches: [], currentBranch: '', recentCommits: [], workspaceFiles: [] }, gitState: { branches: [], currentBranch: '', recentCommits: [], workspaceFiles: [] },
}); });
expect(s.view).toBe('idle'); expect(s.view).toBe('idle');
expect(s.configOpen).toBe(false);
}); });
it('init / gitState / modeFiles 结束 loadingfilesLoading action 开启 loading', () => { it('init / gitState / modeFiles 结束 loadingfilesLoading action 开启 loading', () => {
@ -36,20 +44,9 @@ describe('reducer', () => {
expect(loaded.filesLoading).toBe(false); expect(loaded.filesLoading).toBe(false);
}); });
it('openConfig / closeConfig 切换配置浮层', () => { it('config 消息更新 config', () => {
const opened = reducer(initialState, { type: 'openConfig' }); const s = reducer(initialState, { type: 'config', config: baseConfig });
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' },
});
expect(s.config?.llm.model).toBe('m'); expect(s.config?.llm.model).toBe('m');
expect(s.configOpen).toBe(false);
}); });
it('stateChange running 清空旧日志并切到 running 视图', () => { 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 消息', () => { describe('modeFiles 消息', () => {
it('保存 mode 对应文件列表', () => { it('保存 mode 对应文件列表', () => {
const next = reducer(initialState, { const next = reducer(initialState, {

View file

@ -9,7 +9,9 @@ export const bridge = {
post(msg: WebviewToHost): void { post(msg: WebviewToHost): void {
vscode.postMessage(msg); vscode.postMessage(msg);
}, },
onMessage(handler: (msg: HostToWebview) => void): void { onMessage(handler: (msg: HostToWebview) => void): () => void {
window.addEventListener('message', (e) => handler(e.data as HostToWebview)); const listener = (e: MessageEvent) => handler(e.data as HostToWebview);
window.addEventListener('message', listener);
return () => window.removeEventListener('message', listener);
}, },
}; };

View file

@ -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 (
<div class="custom-provider-manager">
<div class="custom-provider-manager-header">
<div>
<h2 class="custom-provider-manager-title"> Provider</h2>
<p class="custom-provider-manager-desc"> LLM </p>
</div>
<button type="button" class="btn-primary" onClick={onAdd}></button>
</div>
{names.length === 0 ? (
<div class="custom-provider-empty">
<p> Provider</p>
<button type="button" class="btn-default" onClick={onAdd}> Provider</button>
</div>
) : (
<div class="custom-provider-list">
{names.map((name) => {
const entry = config?.customProviders[name];
if (!entry) return null;
const isActive = activeProvider === name;
return (
<div key={name} class={`custom-provider-card${isActive ? ' active' : ''}`}>
<div class="custom-provider-card-main">
<div class="custom-provider-card-title">
<span class="custom-provider-name">{name}</span>
{isActive && <span class="custom-provider-badge">使</span>}
</div>
<div class="custom-provider-card-meta">
<span>{entry.protocol || '—'}</span>
<span class="custom-provider-card-dot">·</span>
<span class="custom-provider-card-url" title={entry.url}>{entry.url || '—'}</span>
</div>
<div class="custom-provider-card-model">{formatModels(entry)}</div>
</div>
<div class="custom-provider-card-actions">
<button type="button" class="btn-text" onClick={() => onEdit(name)}></button>
{!isActive && (
<button type="button" class="btn-text" onClick={() => onActivate(name)}></button>
)}
<button type="button" class="btn-text danger" onClick={() => onDelete(name)}></button>
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View file

@ -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 (
<div class="wizard-body">
<EnvCheckingBanner label="正在安装 ocr CLI…" />
<LogViewer logs={installLogs} />
</div>
);
}
if (checking) {
return (
<div class="wizard-body">
<EnvCheckingBanner label="正在检测,请稍候…" />
<EnvChecklist checking />
</div>
);
}
if (cliStatus === 'installed' && envCheck) {
return (
<div class="wizard-body">
<EnvChecklist env={envCheck} />
<div class={`form-footer${layout === 'panel' ? ' page-footer' : ''}`}>
<div class={`form-actions${layout === 'panel' ? ' panel-actions' : ''}`}>
<button type="button" class="btn-primary" onClick={onNext}> Provider</button>
</div>
</div>
</div>
);
}
if (cliStatus === 'installed' && skipEnvCheck) {
return (
<div class="wizard-body">
<p class="env-guide-lead"> Provider</p>
<div class={`form-footer${layout === 'panel' ? ' page-footer' : ''}`}>
<div class={`form-actions${layout === 'panel' ? ' panel-actions' : ''}`}>
<button type="button" class="btn-primary" onClick={onNext}> Provider</button>
</div>
</div>
</div>
);
}
const nodeActive = true;
const npmActive = envCheck?.node.ok ?? false;
const ocrActive = npmActive && (envCheck?.npm.ok ?? false);
return (
<div class="wizard-body">
<p class="env-guide-lead"></p>
<div class="env-timeline">
<EnvTimelineItem
title="Node.js"
state={resolveStepState(nodeActive, false, envCheck?.node.ok)}
version={envCheck?.node.version}
command="node --version"
hint="未检测到 Node.js。请前往 nodejs.org 安装 LTS 版本,完成后重启 VS Code。"
/>
<EnvTimelineItem
title="npm"
state={resolveStepState(npmActive, false, envCheck?.npm.ok)}
version={envCheck?.npm.version}
command="npm --version"
hint="未检测到 npm。npm 通常随 Node 一起安装,请确认 Node 安装完整。"
/>
<EnvTimelineItem
title="ocr CLI"
state={resolveStepState(ocrActive, false, envCheck?.ocr.ok)}
version={envCheck?.ocr.version}
command={OCR_INSTALL_CMD}
hint="在终端全局安装 open-code-review或点击下方「一键安装」。"
onCopy={onCopy}
last
/>
</div>
{installLogs.length > 0 && <LogViewer logs={installLogs} />}
<div class={`form-footer${layout === 'panel' ? ' page-footer' : ''}`}>
<div class={`form-actions${layout === 'panel' ? ' panel-actions' : ''}`}>
<button type="button" class="btn-default" onClick={onCheckEnv}></button>
{ocrActive && !envCheck?.ocr.ok && (
<button type="button" class="btn-primary" onClick={onInstall}></button>
)}
</div>
</div>
</div>
);
}
function EnvCheckingBanner({ label }: { label: string }) {
return (
<div class="env-checking-banner">
<span class="env-spinner" aria-hidden="true" />
<span>{label}</span>
</div>
);
}
function EnvChecklist({ checking, env }: { checking?: boolean; env?: EnvCheckResult }) {
return (
<ul class={`env-checklist${checking ? ' is-checking' : ''}${env ? ' is-done' : ''}`}>
{CHECK_ITEMS.map(({ key, label }, i) => {
const item = env?.[key];
const ok = item?.ok;
const last = i === CHECK_ITEMS.length - 1;
return (
<li key={key} class={`env-checklist-item${checking ? ' loading' : ''}${ok ? ' ok' : env ? ' fail' : ''}${last ? ' last' : ''}`}>
<span class="env-checklist-marker" aria-hidden="true" />
<span class="env-checklist-label">{label}</span>
<span class="env-checklist-meta">
{checking && '检测中'}
{!checking && ok && (item?.version ?? '就绪')}
{!checking && env && !ok && '未就绪'}
</span>
</li>
);
})}
</ul>
);
}
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 (
<div class={`env-timeline-item ${state}${last ? ' last' : ''}`}>
<div class="env-timeline-track">
<span class="env-timeline-dot" aria-hidden="true" />
{!last && <span class="env-timeline-line" aria-hidden="true" />}
</div>
<div class="env-timeline-content">
<div class="env-timeline-head">
<span class="env-timeline-title">{title}</span>
<span class={`env-timeline-status ${state}`}>
{state === 'ok' && (version ?? '通过')}
{state === 'fail' && '未通过'}
{state === 'pending' && '等待上一步'}
{state === 'checking' && '检测中'}
</span>
</div>
{showDetail && (
<div class="env-timeline-detail">
{state === 'fail' && <p class="env-timeline-hint">{hint}</p>}
<div class="env-cmd-block">
<code>{command}</code>
{onCopy && (
<button type="button" class="env-cmd-copy" onClick={() => onCopy(command)}></button>
)}
</div>
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,53 @@
import { useState } from 'preact/hooks';
interface Props {
value: string;
placeholder?: string;
className?: string;
onInput: (value: string) => void;
}
function EyeIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
);
}
function EyeOffIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94" />
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19" />
<path d="M1 1l22 22" />
<path d="M14.12 14.12a3 3 0 1 1-4.24-4.24" />
</svg>
);
}
export function PasswordInput({ value, placeholder, className, onInput }: Props) {
const [visible, setVisible] = useState(false);
return (
<div class="password-input-wrap">
<input
class={`form-input password-input${className ? ` ${className}` : ''}`}
type={visible ? 'text' : 'password'}
value={value}
placeholder={placeholder}
onInput={(e) => onInput((e.target as HTMLInputElement).value)}
/>
<button
type="button"
class="password-toggle"
onClick={() => setVisible(!visible)}
aria-label={visible ? '隐藏密钥' : '显示密钥'}
tabIndex={-1}
>
{visible ? <EyeOffIcon /> : <EyeIcon />}
</button>
</div>
);
}

View file

@ -0,0 +1,5 @@
import { render } from 'preact';
import { ConfigPanelApp } from './ConfigPanelApp';
const root = document.getElementById('root');
if (root) render(<ConfigPanelApp />, root);

View file

@ -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 };

View file

@ -3,12 +3,8 @@ import { HostToWebview } from '../shared/messages';
export type AppView = 'idle' | 'running' | 'done' | 'empty' | 'cancelled' | 'failed'; 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 { export interface AppState {
view: AppView; view: AppView;
configOpen: boolean;
config: OcrConfig | null; config: OcrConfig | null;
gitState: GitState; gitState: GitState;
modeFiles: FileChange[]; modeFiles: FileChange[];
@ -16,16 +12,11 @@ export interface AppState {
logs: LogLine[]; logs: LogLine[];
session: { state: ReviewState; result: CliResult | null; error?: string }; session: { state: ReviewState; result: CliResult | null; error?: string };
commentStatus: Record<number, CommentStatus>; commentStatus: Record<number, CommentStatus>;
cliStatus: CliStatus;
installing: boolean;
installLogs: LogLine[];
connTest: ConnTest;
reviewMode: ReviewMode; reviewMode: ReviewMode;
} }
export const initialState: AppState = { export const initialState: AppState = {
view: 'idle', view: 'idle',
configOpen: false,
config: null, config: null,
gitState: { branches: [], currentBranch: '', recentCommits: [], workspaceFiles: [] }, gitState: { branches: [], currentBranch: '', recentCommits: [], workspaceFiles: [] },
modeFiles: [], modeFiles: [],
@ -33,10 +24,6 @@ export const initialState: AppState = {
logs: [], logs: [],
session: { state: 'idle', result: null }, session: { state: 'idle', result: null },
commentStatus: {}, commentStatus: {},
cliStatus: 'unknown',
installing: false,
installLogs: [],
connTest: { status: 'idle' },
reviewMode: 'workspace', reviewMode: 'workspace',
}; };
@ -46,36 +33,15 @@ const STATE_TO_VIEW: Record<ReviewState, AppView> = {
}; };
export type LocalAction = export type LocalAction =
| { type: 'openConfig' }
| { type: 'closeConfig' }
| { type: 'filesLoading' } | { type: 'filesLoading' }
| { type: 'checkingCli' }
| { type: 'installingCli' }
| { type: 'testingConn' }
| { type: 'startReview'; mode: ReviewMode }; | { type: 'startReview'; mode: ReviewMode };
export function reducer(state: AppState, msg: HostToWebview | LocalAction): AppState { export function reducer(state: AppState, msg: HostToWebview | LocalAction): AppState {
switch (msg.type) { switch (msg.type) {
case 'openConfig':
return { ...state, configOpen: true, connTest: { status: 'idle' } };
case 'closeConfig':
return { ...state, configOpen: false, installLogs: [], connTest: { status: 'idle' } };
case 'filesLoading': case 'filesLoading':
return { ...state, filesLoading: true }; 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': case 'startReview':
return { ...state, reviewMode: msg.mode }; 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': case 'init':
return { return {
...state, ...state,
@ -89,8 +55,7 @@ export function reducer(state: AppState, msg: HostToWebview | LocalAction): AppS
case 'modeFiles': case 'modeFiles':
return { ...state, modeFiles: msg.files, filesLoading: false }; return { ...state, modeFiles: msg.files, filesLoading: false };
case 'config': case 'config':
// 保存配置后:更新 config若已配置则关闭浮层 return { ...state, config: msg.config };
return { ...state, config: msg.config, configOpen: msg.config ? false : state.configOpen };
case 'stateChange': { case 'stateChange': {
const starting = msg.state === 'running'; const starting = msg.state === 'running';
return { 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; for (const c of msg.comments) commentStatus[c.index] = c.status;
return { ...state, commentStatus }; return { ...state, commentStatus };
} }
case 'connectionResult':
return { ...state, connTest: { status: msg.ok ? 'ok' : 'fail', message: msg.message } };
default: default:
return state; return state;
} }

File diff suppressed because it is too large Load diff

View file

@ -1,11 +1,19 @@
import { useState } from 'preact/hooks'; import { useEffect, useMemo, useState } from 'preact/hooks';
import { OcrConfig } from '../../shared/types'; import type { ComponentChildren } from 'preact';
import { CliStatus, ConnTest } from '../store'; import { ConfigEntry, ConfigPanelFocus, ProviderTab, buildCustomCreateSaveEntries, buildCustomUpdateSaveEntries, buildOfficialSaveEntries, describeActiveProvider, detectInitialTab, isConfigReady, listCustomProviderNames } from '../../shared/configUtils';
import { LogLine } from '../../shared/types'; 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 { LogViewer } from '../components/LogViewer';
import { PasswordInput } from '../components/PasswordInput';
import { Select } from '../components/Select'; import { Select } from '../components/Select';
interface Props { interface Props {
layout?: 'modal' | 'panel';
panelFocus?: ConfigPanelFocus | null;
skipEnvCheck?: boolean;
config: OcrConfig | null; config: OcrConfig | null;
cliStatus: CliStatus; cliStatus: CliStatus;
installing: boolean; installing: boolean;
@ -13,160 +21,523 @@ interface Props {
connTest: ConnTest; connTest: ConnTest;
onInstall: () => void; onInstall: () => void;
onCheckCli: () => void; onCheckCli: () => void;
onTest: () => void; onCheckEnv?: () => void;
onSave: (entries: { key: string; value: string }[]) => 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; 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({ export function ConfigView({
config, cliStatus, installing, installLogs, connTest, layout = 'modal',
onInstall, onCheckCli, onTest, onSave, onClose, panelFocus = null,
skipEnvCheck = false,
config, cliStatus, envCheck = null, installing, installLogs, connTest,
onInstall, onCheckCli, onCheckEnv, onCopy, onTest, onSave, onClearConnTest,
onDeleteCustomProvider, onActivateCustomProvider, onClose,
}: Props) { }: 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<ProviderTab>(initial.tab);
const [customView, setCustomView] = useState<'list' | 'form'>(initial.customView);
const [customSelection, setCustomSelection] = useState(initial.customSelection);
const [url, setUrl] = useState(config?.llm.url ?? ''); useEffect(() => {
const [token, setToken] = useState(config?.llm.authToken ?? ''); if (!panelFocus) return;
const [model, setModel] = useState(config?.llm.model ?? ''); const next = resolvePanelState(config, panelFocus);
const [useAnthropic, setUseAnthropic] = useState(config?.llm.useAnthropic ?? false); onClearConnTest?.();
const [authHeader, setAuthHeader] = useState(config?.llm.authHeader ?? ''); 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 = (
<div class="config-stepper">
<div class={`config-step-pill${step === 1 ? ' active' : ''}${cliStatus === 'installed' ? ' done' : ''}`}>
<span class="config-step-num">1</span>
<span></span>
</div>
<div class={`config-step-pill${step === 2 ? ' active' : ''}`}>
<span class="config-step-num">2</span>
<span>Provider </span>
</div>
</div>
);
const save = () => { const stepContent = step === 1 ? (
if (!canSave) return; <EnvSetupGuide
const entries = [ layout={layout}
{ key: 'llm.url', value: url.trim() }, cliStatus={cliStatus}
{ key: 'llm.auth_token', value: token.trim() }, envCheck={envCheck}
{ key: 'llm.model', value: model.trim() }, skipEnvCheck={skipEnvCheck}
{ key: 'llm.use_anthropic', value: String(useAnthropic) }, installing={installing}
]; installLogs={installLogs}
if (authHeader) entries.push({ key: 'llm.auth_header', value: authHeader }); onInstall={onInstall}
onSave(entries); onCheckEnv={onCheckEnv ?? onCheckCli}
}; onCopy={onCopy ?? (() => {})}
onNext={() => setStep(2)}
/>
) : (
<ProviderStep
wide={wide}
config={config}
tab={tab}
setTab={(next) => {
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 (
<div class="config-page">
<header class="config-page-header">
<div class="config-page-header-row">
<div>
<h1 class="config-page-title"></h1>
<p class="config-page-desc"> LLM Provider </p>
</div>
<OcrVersionMeta envCheck={envCheck} cliStatus={cliStatus} />
</div>
</header>
<div class="config-page-body">
{stepper}
{stepContent}
</div>
</div>
);
}
const body = (
<>
<div class="config-form-header">
<div class="config-form-heading">
<span class="config-form-title"></span>
<span class="config-form-subtitle"> LLM Provider </span>
</div>
<button type="button" class="config-list-close" onClick={onClose} aria-label="关闭">×</button>
</div>
{stepper}
{stepContent}
</>
);
return ( return (
<div class="modal-backdrop" onClick={onClose}> <div class="modal-backdrop" onClick={onClose}>
<div class="modal-panel" onClick={(e) => e.stopPropagation()}> <div class="modal-panel" onClick={(e) => e.stopPropagation()}>
<div class="config-form-header"> {body}
<span class="config-form-title"></span> </div>
<button class="config-list-close" onClick={onClose}>×</button> </div>
</div> );
}
<div class="wizard-steps"> function OcrVersionMeta({ envCheck, cliStatus }: { envCheck: EnvCheckResult | null; cliStatus: CliStatus }) {
<span class={`wizard-step${step === 1 ? ' active' : ''}${cliStatus === 'installed' ? ' done' : ''}`}>1 </span> let label = 'ocr 检测中…';
<span class="wizard-step-line"></span> if (envCheck?.ocr.ok && envCheck.ocr.version) {
<span class={`wizard-step${step === 2 ? ' active' : ''}`}>2 </span> label = envCheck.ocr.version.startsWith('v') ? envCheck.ocr.version : `v${envCheck.ocr.version}`;
</div> } else if (envCheck && !envCheck.ocr.ok) {
label = 'ocr 未安装';
} else if (cliStatus !== 'checking' && cliStatus === 'missing') {
label = 'ocr 未安装';
}
return (
<div class="config-page-meta" title="Open Code Review CLI 版本">
<span class="config-page-meta-label">OCR</span>
<span class="config-page-meta-value">{label}</span>
</div>
);
}
{step === 1 ? ( function ActiveProviderBanner({ config }: { config: OcrConfig | null }) {
<Step1 const active = describeActiveProvider(config);
cliStatus={cliStatus} installing={installing} installLogs={installLogs} if (!active) {
onInstall={onInstall} onCheckCli={onCheckCli} onNext={() => setStep(2)} return (
/> <div class="active-provider-banner empty">
) : ( <span class="active-provider-label">使</span>
<Step2 <span class="active-provider-empty-text"> Provider</span>
url={url} token={token} model={model} useAnthropic={useAnthropic} authHeader={authHeader} </div>
setUrl={setUrl} setToken={setToken} setModel={setModel} );
setUseAnthropic={setUseAnthropic} setAuthHeader={setAuthHeader} }
connTest={connTest} canSave={canSave} const kindLabel = active.kind === 'official' ? '官方' : active.kind === 'custom' ? '自定义' : 'Legacy';
onBack={() => setStep(1)} onTest={onTest} onSave={save} return (
<div class="active-provider-banner">
<span class="active-provider-label">使</span>
<span class="active-provider-badge">{kindLabel}</span>
<span class="active-provider-name">{active.displayName}</span>
<span class="active-provider-dot">·</span>
<span class="active-provider-model">{active.model}</span>
{active.detail && (
<>
<span class="active-provider-dot">·</span>
<span class="active-provider-detail" title={active.detail}>{active.detail}</span>
</>
)}
</div>
);
}
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 (
<div class="wizard-body provider-step">
<div class="segmented-control">
{([
['official', '官方 Provider'],
['custom', '自定义 Provider'],
] as const).map(([id, label]) => (
<button
key={id}
type="button"
class={`segmented-item${tab === id ? ' active' : ''}`}
onClick={() => setTab(id)}
>
{label}
</button>
))}
</div>
<ActiveProviderBanner config={config} />
{tab === 'official' && (
<OfficialForm wide={wide} config={config} connTest={connTest} onBack={onBack} onTest={onTest} onSave={onSave} />
)}
{tab === 'custom' && customView === 'list' && (
<CustomProviderManager
config={config}
onAdd={() => { 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' && (
<CustomForm
key={customSelection}
wide={wide}
config={config}
connTest={connTest}
selection={customSelection}
onBack={onBack}
onBackToList={() => { onClearConnTest?.(); setCustomView('list'); }}
onTest={onTest}
onSave={(entries) => {
onSave(entries);
setCustomView('list');
}}
/>
)}
</div>
);
}
function FormSection({ wide, children }: { wide?: boolean; children: ComponentChildren }) {
if (wide) return <div class="provider-form">{children}</div>;
return <>{children}</>;
}
function FormItem({
label, span = 1, optional, hint, children,
}: {
label: string;
span?: 1 | 2;
optional?: boolean;
hint?: string;
children: ComponentChildren;
}) {
return (
<div class={`form-item${span === 2 ? ' span-2' : ''}`}>
<label class="form-label">
{label}
{optional && <span class="optional"></span>}
</label>
{children}
{hint && <div class="form-hint">{hint}</div>}
</div>
);
}
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 (
<FormSection wide={wide}>
<FormItem label="Provider" span={wide ? 2 : 1}>
<Select
value={providerName}
onChange={(v) => {
setProviderName(v);
const nextPreset = PROVIDER_PRESETS.find((p) => p.name === v);
const entry = config?.providers[v];
const models = mergeModelLists(nextPreset?.models ?? [], entry?.models ?? []);
const m = entry?.model || models[0] || '';
setModelChoice(models.includes(m) ? m : MODEL_CUSTOM);
setCustomModel(models.includes(m) ? '' : m);
setApiKey('');
setApiKeyTouched(false);
}}
options={PROVIDER_PRESETS.map((p) => ({ value: p.name, label: p.displayName }))}
/>
</FormItem>
<FormItem label="模型">
<Select
value={modelChoice}
onChange={setModelChoice}
options={[
...modelOptions.map((m) => ({ value: m, label: m })),
{ value: MODEL_CUSTOM, label: '输入自定义模型…' },
]}
/>
{modelChoice === MODEL_CUSTOM && (
<input
class="form-input form-input-mt"
value={customModel}
onInput={(e) => setCustomModel((e.target as HTMLInputElement).value)}
placeholder="model name"
/> />
)} )}
</div> </FormItem>
</div>
<FormItem
label="API 密钥"
hint={`也可通过环境变量 ${preset.envVar} 提供密钥`}
>
<PasswordInput
value={apiKey}
onInput={(v) => { setApiKey(v); setApiKeyTouched(true); }}
placeholder={hasStoredKey && !apiKeyTouched ? '已保存(留空保持不变)' : 'sk-...'}
/>
</FormItem>
<ConnActions wide={wide} connTest={connTest} canSave={canSave} onBack={onBack} onTest={test} onSave={save} />
</FormSection>
); );
} }
function Step1({ cliStatus, installing, installLogs, onInstall, onCheckCli, onNext }: { function CustomForm({
cliStatus: CliStatus; installing: boolean; installLogs: LogLine[]; wide, config, connTest, selection, onBack, onBackToList, onTest, onSave,
onInstall: () => void; onCheckCli: () => void; onNext: () => void; }: FormProps & {
selection: string;
onBackToList?: () => void;
}) { }) {
if (installing) { const isCreate = selection === CUSTOM_NEW;
return ( const entry = !isCreate ? config?.customProviders[selection] : undefined;
<div class="wizard-body">
<div class="cli-status checking"> ocr CLI</div>
<LogViewer logs={installLogs} />
</div>
);
}
if (cliStatus === 'checking' || cliStatus === 'unknown') { const [name, setName] = useState(isCreate ? '' : selection);
return <div class="wizard-body"><div class="cli-status checking"> ocr </div></div>; 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') { const canSaveCreate = name.trim() !== '' && url.trim() !== '' && model.trim() !== '' && apiKey.trim() !== '';
return ( const canSaveEdit = !isCreate && name.trim() !== '' && url.trim() !== '' && model.trim() !== ''
<div class="wizard-body"> && Boolean(entry?.apiKey || apiKey.trim());
<div class="cli-status missing"> ocr </div> const canSave = isCreate ? canSaveCreate : canSaveEdit;
<div class="cli-hint"><code>npm install -g @alibaba-group/open-code-review</code></div>
{installLogs.length > 0 && <LogViewer logs={installLogs} />} const buildCreateEntries = () => buildCustomCreateSaveEntries({
<div class="form-actions"> name: name.trim(),
<button class="btn-cancel" onClick={onCheckCli}></button> protocol,
<button class="btn-save" onClick={onInstall}></button> url,
</div> model,
</div> 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 ( return (
<div class="wizard-body"> <FormSection wide={wide}>
<div class="cli-status ok"> ocr </div> {onBackToList && (
<div class="form-actions"> <div class="form-item span-2">
<button class="btn-save" onClick={onNext}></button> <button type="button" class="btn-text back-link" onClick={onBackToList}> </button>
</div> </div>
</div> )}
<FormItem label="Provider 名称">
<input
class="form-input"
value={name}
disabled={!isCreate}
onInput={(e) => setName((e.target as HTMLInputElement).value)}
placeholder="my-llm"
/>
</FormItem>
<FormItem label="协议">
<Select
value={protocol}
onChange={(v) => setProtocol(v as 'anthropic' | 'openai')}
options={[
{ value: 'anthropic', label: 'anthropic' },
{ value: 'openai', label: 'openai' },
]}
/>
</FormItem>
<FormItem label="Base URL" span={2}>
<input class="form-input" value={url} onInput={(e) => setUrl((e.target as HTMLInputElement).value)} placeholder="https://api.example.com/v1" />
</FormItem>
<FormItem label="模型">
<input class="form-input" value={model} onInput={(e) => setModel((e.target as HTMLInputElement).value)} placeholder="model name" />
</FormItem>
<FormItem label="模型列表" optional>
<input class="form-input" value={models} onInput={(e) => setModels((e.target as HTMLInputElement).value)} placeholder="逗号分隔,如 model-a, model-b" />
</FormItem>
<FormItem label="API 密钥" span={2}>
<PasswordInput
value={apiKey}
onInput={(v) => { setApiKey(v); setApiKeyTouched(true); }}
placeholder={!isCreate && entry?.apiKey && !apiKeyTouched ? '已保存(留空保持不变)' : 'sk-...'}
/>
</FormItem>
<FormItem label="Auth Header" optional hint="Anthropic 协议下可选 x-api-key 或 authorization">
<Select value={authHeader} placeholder="默认 (Authorization)" onChange={setAuthHeader}
options={[
{ value: '', label: '默认 (Authorization)' },
{ value: 'x-api-key', label: 'x-api-key' },
{ value: 'authorization', label: 'authorization' },
]} />
</FormItem>
<ConnActions wide={wide} connTest={connTest} canSave={canSave} onBack={onBack} onTest={test} onSave={save} />
</FormSection>
); );
} }
function Step2({ interface FormProps {
url, token, model, useAnthropic, authHeader, config: OcrConfig | null;
setUrl, setToken, setModel, setUseAnthropic, setAuthHeader, connTest: ConnTest;
connTest, canSave, onBack, onTest, onSave, wide?: boolean;
}: { onBack: () => void;
url: string; token: string; model: string; useAnthropic: boolean; authHeader: string; onTest: (entries: ConfigEntry[]) => void;
setUrl: (v: string) => void; setToken: (v: string) => void; setModel: (v: string) => void; onSave: (entries: ConfigEntry[]) => void;
setUseAnthropic: (v: boolean) => void; setAuthHeader: (v: string) => void; }
function ConnActions({ wide, connTest, canSave, onBack, onTest, onSave }: {
wide?: boolean;
connTest: ConnTest; canSave: boolean; connTest: ConnTest; canSave: boolean;
onBack: () => void; onTest: () => void; onSave: () => void; onBack: () => void; onTest: () => void; onSave: () => void;
}) { }) {
return ( return (
<div class="wizard-body"> <div class={`form-footer${wide ? ' page-footer' : ''}`}>
<div class="form-group">
<label class="form-label"></label>
<input class="form-input" value={url} onInput={(e) => setUrl((e.target as HTMLInputElement).value)} placeholder="https://api.anthropic.com/v1/messages" />
</div>
<div class="form-group">
<label class="form-label">API </label>
<input class="form-input" type="password" value={token} onInput={(e) => setToken((e.target as HTMLInputElement).value)} placeholder="sk-..." />
</div>
<div class="form-group">
<label class="form-label"></label>
<input class="form-input" value={model} onInput={(e) => setModel((e.target as HTMLInputElement).value)} placeholder="claude-opus-4-6" />
</div>
<div class="toggle-row">
<span class="toggle-label">使 Anthropic </span>
<button class={`toggle-switch${useAnthropic ? ' on' : ''}`} onClick={() => setUseAnthropic(!useAnthropic)}>
<span class="toggle-knob"></span>
</button>
</div>
<details class="advanced-section">
<summary></summary>
<div class="adv-content">
<div class="form-group">
<label class="form-label">Auth Header <span class="optional"></span></label>
<Select value={authHeader} placeholder="默认 (authorization)" onChange={setAuthHeader}
options={[
{ value: '', label: '默认 (authorization)' },
{ value: 'x-api-key', label: 'x-api-key' },
{ value: 'authorization', label: 'authorization' },
]} />
<div class="cli-hint"> sk-ant-* x-api-key</div>
</div>
</div>
</details>
{connTest.status !== 'idle' && ( {connTest.status !== 'idle' && (
<div class={`conn-result ${connTest.status}`}> <div class={`conn-result ${connTest.status}`}>
{connTest.status === 'testing' && '正在测试连接…'} {connTest.status === 'testing' && '正在测试连接…'}
@ -174,11 +545,10 @@ function Step2({
{connTest.status === 'fail' && `✗ 连接失败${connTest.message ? '' + connTest.message : ''}`} {connTest.status === 'fail' && `✗ 连接失败${connTest.message ? '' + connTest.message : ''}`}
</div> </div>
)} )}
<div class="form-actions"> <div class="form-actions">
<button class="btn-cancel" onClick={onBack}></button> <button type="button" class="btn-default" onClick={onBack}></button>
<button class="btn-cancel" disabled={connTest.status === 'testing'} onClick={onTest}></button> <button type="button" class="btn-default" disabled={connTest.status === 'testing' || !canSave} onClick={onTest}></button>
<button class="btn-save" disabled={!canSave} onClick={onSave}></button> <button type="button" class="btn-primary" disabled={!canSave} onClick={onSave}></button>
</div> </div>
</div> </div>
); );

View file

@ -3,6 +3,22 @@ import { GitState, ReviewMode, CliRunOptions, FileChange } from '../../shared/ty
import { FileList } from '../components/FileList'; import { FileList } from '../components/FileList';
import { Select } from '../components/Select'; import { Select } from '../components/Select';
function getPrimaryLabel(params: {
configured: boolean;
running?: boolean;
selectionReady: boolean;
mode: ReviewMode;
filesCount: number;
}): string {
if (!params.configured) return '请先配置模型';
if (params.running) return '审查中…';
if (!params.selectionReady) {
return params.mode === 'branch' ? '请选择对比分支' : '请选择提交';
}
if (params.filesCount === 0) return '无可审查文件';
return '审查所有变更';
}
interface Props { interface Props {
gitState: GitState; gitState: GitState;
modeFiles: FileChange[]; modeFiles: FileChange[];
@ -12,10 +28,12 @@ interface Props {
onRequestModeFiles: (mode: ReviewMode, from?: string, to?: string, commit?: string) => void; onRequestModeFiles: (mode: ReviewMode, from?: string, to?: string, commit?: string) => void;
onOpenFile: (file: FileChange, mode: ReviewMode, from?: string, to?: string, commit?: string) => void; onOpenFile: (file: FileChange, mode: ReviewMode, from?: string, to?: string, commit?: string) => void;
onStart: (options: CliRunOptions) => void; onStart: (options: CliRunOptions) => void;
onOpenConfig: () => void;
onOpenCustomProviders: () => void;
running?: boolean; running?: boolean;
} }
export function IdleView({ gitState, modeFiles, filesLoading, configured, onModeChange, onRequestModeFiles, onOpenFile, onStart, running }: Props) { export function IdleView({ gitState, modeFiles, filesLoading, configured, onModeChange, onRequestModeFiles, onOpenFile, onStart, onOpenConfig, onOpenCustomProviders, running }: Props) {
const [mode, setMode] = useState<ReviewMode>('workspace'); const [mode, setMode] = useState<ReviewMode>('workspace');
const [from, setFrom] = useState(''); const [from, setFrom] = useState('');
const [to, setTo] = useState(''); const [to, setTo] = useState('');
@ -42,6 +60,15 @@ export function IdleView({ gitState, modeFiles, filesLoading, configured, onMode
const selectionReady = const selectionReady =
mode === 'workspace' || (mode === 'branch' && !!from && !!to) || (mode === 'commit' && !!commit); mode === 'workspace' || (mode === 'branch' && !!from && !!to) || (mode === 'commit' && !!commit);
const canReview = configured && !running && !loading && selectionReady && files.length > 0; const canReview = configured && !running && !loading && selectionReady && files.length > 0;
const primaryDisabled = configured ? !canReview : running || loading;
const handlePrimary = () => {
if (!configured) {
onOpenConfig();
return;
}
onStart({ mode, from, to, commit, customPrompt: prompt });
};
return ( return (
<div class="setup"> <div class="setup">
@ -87,16 +114,20 @@ export function IdleView({ gitState, modeFiles, filesLoading, configured, onMode
<textarea class="mode-param-input" rows={3} placeholder="自定义审查提示词(可选)" <textarea class="mode-param-input" rows={3} placeholder="自定义审查提示词(可选)"
value={prompt} onInput={(e) => setPrompt((e.target as HTMLTextAreaElement).value)} /> value={prompt} onInput={(e) => setPrompt((e.target as HTMLTextAreaElement).value)} />
{configured && (
<div class="setup-secondary">
<button type="button" class="link-btn" onClick={onOpenCustomProviders}> Provider</button>
<span class="setup-secondary-sep">·</span>
<button type="button" class="link-btn" onClick={onOpenConfig}></button>
</div>
)}
{loading ? ( {loading ? (
<div class="primary-btn skeleton-btn"><div class="skeleton-bar" style={{ width: '40%' }} /></div> <div class="primary-btn skeleton-btn"><div class="skeleton-bar" style={{ width: '40%' }} /></div>
) : ( ) : (
<button class="primary-btn" disabled={!canReview} <button class={`primary-btn${!configured ? ' configure' : ''}`} disabled={primaryDisabled}
onClick={() => onStart({ mode, from, to, commit, customPrompt: prompt })}> onClick={handlePrimary}>
{!configured ? '请先配置模型' {getPrimaryLabel({ configured, running, selectionReady, mode, filesCount: files.length })}
: running ? '审查中…'
: !selectionReady ? (mode === 'branch' ? '请选择对比分支' : '请选择提交')
: files.length === 0 ? '无可审查文件'
: '审查所有变更'}
</button> </button>
)} )}
</div> </div>

View file

@ -47,4 +47,27 @@ const webviewConfig = {
devtool: 'source-map', devtool: 'source-map',
}; };
module.exports = [extensionConfig, webviewConfig]; /** @type {import('webpack').Configuration} */
const configPanelConfig = {
name: 'configPanel',
target: 'web',
entry: { configPanel: './src/webview/configPanel.tsx' },
output: {
path: path.resolve(__dirname, 'out'),
filename: '[name].js',
},
resolve: { extensions: ['.ts', '.tsx', '.js'] },
module: {
rules: [
{
test: /\.tsx?$/,
exclude: /node_modules/,
use: { loader: 'ts-loader', options: { configFile: 'tsconfig.webview.json' } },
},
{ test: /\.css$/, use: ['style-loader', 'css-loader'] },
],
},
devtool: 'source-map',
};
module.exports = [extensionConfig, webviewConfig, configPanelConfig];

File diff suppressed because it is too large Load diff