mirror of
https://github.com/alibaba/open-code-review.git
synced 2026-07-09 17:28:58 +00:00
feat(vscode): add full-width BYOK provider config panel (#204)
Some checks are pending
CI / test (push) Waiting to run
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:
parent
ce11f374fd
commit
b6da4e214f
31 changed files with 3860 additions and 1087 deletions
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
155
extensions/vscode/src/extension/providers/ConfigPanelProvider.ts
Normal file
155
extensions/vscode/src/extension/providers/ConfigPanelProvider.ts
Normal 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>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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) };
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
147
extensions/vscode/src/extension/services/configDraft.ts
Normal file
147
extensions/vscode/src/extension/services/configDraft.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
167
extensions/vscode/src/shared/configUtils.ts
Normal file
167
extensions/vscode/src/shared/configUtils.ts
Normal 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();
|
||||||
|
}
|
||||||
|
|
@ -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[] };
|
|
||||||
|
|
|
||||||
146
extensions/vscode/src/shared/providers.ts
Normal file
146
extensions/vscode/src/shared/providers.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
67
extensions/vscode/src/webview/ConfigPanelApp.tsx
Normal file
67
extensions/vscode/src/webview/ConfigPanelApp.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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'); // 主界面始终是 idle(review 界面)
|
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 结束 loading;filesLoading action 开启 loading', () => {
|
it('init / gitState / modeFiles 结束 loading;filesLoading 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, {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
211
extensions/vscode/src/webview/components/EnvSetupGuide.tsx
Normal file
211
extensions/vscode/src/webview/components/EnvSetupGuide.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
extensions/vscode/src/webview/components/PasswordInput.tsx
Normal file
53
extensions/vscode/src/webview/components/PasswordInput.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
extensions/vscode/src/webview/configPanel.tsx
Normal file
5
extensions/vscode/src/webview/configPanel.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { render } from 'preact';
|
||||||
|
import { ConfigPanelApp } from './ConfigPanelApp';
|
||||||
|
|
||||||
|
const root = document.getElementById('root');
|
||||||
|
if (root) render(<ConfigPanelApp />, root);
|
||||||
107
extensions/vscode/src/webview/configStore.ts
Normal file
107
extensions/vscode/src/webview/configStore.ts
Normal 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 };
|
||||||
|
|
@ -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
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue