feat: AI 对话支持 A2UI 表单/审查/图表与 Agent 工具

This commit is contained in:
2026-08-05 17:11:00 +08:00
parent 644c35ce53
commit 0e6e3e2d96
64 changed files with 8395 additions and 6434 deletions

View File

@@ -1,106 +1,40 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import {
App,
Card,
Form,
Input,
Button,
Select,
AutoComplete,
InputNumber,
Tag,
Descriptions,
Spin,
Alert,
Typography,
Space,
Steps,
Switch,
} from 'antd';
import {
SaveOutlined,
ApiOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
WarningOutlined,
ReloadOutlined,
CloudServerOutlined,
SafetyOutlined,
RobotOutlined,
} from '@ant-design/icons';
import { useQuery } from '@tanstack/react-query';
import { useApiMutation } from '../../hooks/useApiMutation';
import { validateResponse } from '../../utils/validate';
import { aiConfigEnvelopeSchema } from '../../api/schemas';
import { App, Alert, Button, Form, Space, Spin, Steps, Tag } from 'antd';
import api from '../../api';
import { message } from '../../ui/app-message';
import { usePermission } from '../../hooks/usePermission';
import type { AiProvider } from './helpers';
import {
PROVIDER_OPTIONS,
PROVIDER_DEFAULTS,
FIXED_PROVIDERS,
formatDateTime,
sourceLabel,
sourceColor,
shouldAutoSwapBaseUrl,
extractErrorMessage,
} from './helpers';
import { getErrorMessage } from '../../utils/error';
import {
ProviderStep,
KeyStep,
ModelStep,
SaveTestStep,
} from './AiConfigSteps';
import type { AiConfigData, FormValues, TestResult } from './AiConfigSteps';
import styles from './index.module.css';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface AiConfigData {
id: number;
provider: AiProvider;
baseUrl: string;
hasApiKey: boolean;
hasDatabaseKey: boolean;
maskedApiKey: string | null;
keySource: 'database' | 'environment' | 'none';
defaultModel: string | null;
enabled: boolean;
supportsVision: boolean;
timeoutMs: number;
reasoningEffort: string | null;
verified: boolean;
lastTestedAt: string | null;
lastTestLatencyMs: number | null;
createdAt: string;
updatedAt: string;
}
interface TestResult {
success: boolean;
latencyMs: number | null;
modelCount: number | null;
modelAvailable: boolean;
testedAt: string;
message: string;
}
interface FetchModelsResult {
success: boolean;
models: Array<{ id: string }>;
message?: string;
}
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
// ---------------------------------------------------------------------------
// Form state — mirrors all form fields, survives Step unmounts
// ---------------------------------------------------------------------------
interface FormValues {
provider: AiProvider;
baseUrl: string;
apiKey: string;
defaultModel: string;
timeoutMs: number;
supportsVision: boolean;
reasoningEffort: string;
interface FetchModelsResult {
success: boolean;
models: Array<{ id: string }>;
message?: string;
}
const DEFAULT_FORM_VALUES: FormValues = {
@@ -113,10 +47,6 @@ const DEFAULT_FORM_VALUES: FormValues = {
reasoningEffort: '',
};
// ---------------------------------------------------------------------------
// Step definitions
// ---------------------------------------------------------------------------
const STEP_ITEMS = [
{ title: '服务商', description: '选择 AI 服务商' },
{ title: '密钥', description: '配置 API 密钥' },
@@ -124,21 +54,15 @@ const STEP_ITEMS = [
{ title: '完成', description: '保存并测试连接' },
];
// ---------------------------------------------------------------------------
// Page Component
// ---------------------------------------------------------------------------
const AiConfigPage: React.FC = () => {
const { hasPermission } = usePermission();
const { modal } = App.useApp();
const [form] = Form.useForm();
const [currentStep, setCurrentStep] = useState(0);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [fetchingModels, setFetchingModels] = useState(false);
const [config, setConfig] = useState<AiConfigData | null>(null);
const [testResult, setTestResult] = useState<TestResult | null>(null);
const [modelOptions, setModelOptions] = useState<Array<{ value: string; label: string }>>([]);
const [error, setError] = useState<string | null>(null);
@@ -147,6 +71,39 @@ const AiConfigPage: React.FC = () => {
const [formValues, setFormValues] = useState<FormValues>(DEFAULT_FORM_VALUES);
const lastProviderRef = useRef<AiProvider | null>(null);
const skipNextSyncRef = useRef(false);
const appliedConfigRef = useRef<AiConfigData | null>(null);
const {
data: config,
isLoading: configLoading,
isFetching: configFetching,
refetch: refetchConfig,
} = useQuery<AiConfigData | null>({
queryKey: ['ai', 'config'],
queryFn: async () => {
try {
const res = await api.get<ApiResponse<AiConfigData>>('/ai/config');
return validateResponse<ApiResponse<AiConfigData>>(aiConfigEnvelopeSchema, res).data;
} catch (err: unknown) {
setError(getErrorMessage(err, '加载配置失败'));
return null;
}
},
});
const loading = configLoading || configFetching;
const refreshConfig = useCallback(() => {
skipNextSyncRef.current = true;
return refetchConfig();
}, [refetchConfig]);
const saveMutation = useApiMutation(
async (body: Record<string, unknown>) => api.put('/ai/config', body),
{ invalidate: [['ai', 'config']] },
);
const clearKeyMutation = useApiMutation(
async () => api.post('/ai/config/clear-key'),
{ invalidate: [['ai', 'config']] },
);
const canWrite = hasPermission('ai:config:write');
const canTest = hasPermission('ai:config:test');
@@ -154,57 +111,41 @@ const AiConfigPage: React.FC = () => {
// ── Sync form → state ──
const handleFormChange = useCallback((_changed: Partial<FormValues>, all: Partial<FormValues>) => {
setFormValues((prev) => ({ ...prev, ...all }));
}, []);
// ── Load config (full) — used on initial mount and after save ──
const loadConfig = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await api.get<ApiResponse<AiConfigData>>('/ai/config');
setConfig(res.data);
const initial: FormValues = {
provider: res.data.provider,
baseUrl: res.data.baseUrl,
apiKey: '',
defaultModel: res.data.defaultModel ?? '',
timeoutMs: res.data.timeoutMs,
supportsVision: res.data.supportsVision,
reasoningEffort: res.data.reasoningEffort ?? '',
};
form.setFieldsValue(initial);
setFormValues(initial);
lastProviderRef.current = res.data.provider;
if (res.data.defaultModel) {
setModelOptions([{ value: res.data.defaultModel, label: res.data.defaultModel }]);
}
} catch (err: unknown) {
setError(extractErrorMessage(err, '加载配置失败'));
} finally {
setLoading(false);
}
}, [form]);
// ── Refresh config (light) — only updates the config info display,
// does NOT touch form values. Used after test/fetch-models. ──
const refreshConfig = useCallback(async () => {
try {
const res = await api.get<ApiResponse<AiConfigData>>('/ai/config');
setConfig(res.data);
} catch {
// silent — config display refresh is non-critical
}
}, []);
const handleFormChange = useCallback(
(_changed: Partial<FormValues>, all: Partial<FormValues>) => {
setFormValues((prev) => ({ ...prev, ...all }));
},
[],
);
// 配置数据到位后同步进表单antd Form 属于外部系统);
// refreshConfig测试/拉模型后)只刷新展示,不覆盖用户表单输入。
useEffect(() => {
loadConfig();
}, [loadConfig]);
if (!config) return;
if (skipNextSyncRef.current) {
skipNextSyncRef.current = false;
appliedConfigRef.current = config;
return;
}
if (appliedConfigRef.current === config) return;
appliedConfigRef.current = config;
const initial: FormValues = {
provider: config.provider,
baseUrl: config.baseUrl,
apiKey: '',
defaultModel: config.defaultModel ?? '',
timeoutMs: config.timeoutMs,
supportsVision: config.supportsVision,
reasoningEffort: config.reasoningEffort ?? '',
};
form.setFieldsValue(initial);
setFormValues(initial);
lastProviderRef.current = config.provider;
if (config.defaultModel) {
setModelOptions([{ value: config.defaultModel, label: config.defaultModel }]);
}
setError(null);
}, [config, form]);
// ── Provider change → swap baseUrl ──
@@ -243,7 +184,7 @@ const AiConfigPage: React.FC = () => {
message.warning(res.message || '未获取到可用模型');
}
} catch (err: unknown) {
message.error(extractErrorMessage(err, '获取模型列表失败'));
message.error(getErrorMessage(err, '获取模型列表失败'));
} finally {
setFetchingModels(false);
}
@@ -256,8 +197,15 @@ const AiConfigPage: React.FC = () => {
// Validate fields (for UI error display) — actual values come from state
await form.validateFields(['provider', 'baseUrl', 'timeoutMs']);
const { provider, baseUrl, defaultModel, apiKey, timeoutMs, supportsVision, reasoningEffort } =
formValues;
const {
provider,
baseUrl,
defaultModel,
apiKey,
timeoutMs,
supportsVision,
reasoningEffort,
} = formValues;
if (provider === 'OPENAI_COMPATIBLE' && !baseUrl) {
message.error('OPENAI_COMPATIBLE 模式必须填写 Base URL');
@@ -282,24 +230,16 @@ const AiConfigPage: React.FC = () => {
body.apiKey = apiKey;
}
await api.put('/ai/config', body);
await saveMutation.mutateAsync(body);
message.success('配置已保存');
form.setFieldValue('apiKey', '');
setFormValues((prev) => ({ ...prev, apiKey: '' }));
} catch (err: unknown) {
message.error(extractErrorMessage(err, '保存失败'));
setSaving(false);
return;
}
try {
await loadConfig();
} catch (err: unknown) {
message.warning(extractErrorMessage(err, '配置已保存,但刷新失败'));
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setSaving(false);
}
}, [formValues, form, loadConfig]);
}, [formValues, form, saveMutation]);
// ── Test connection ──
@@ -331,12 +271,12 @@ const AiConfigPage: React.FC = () => {
modelCount: null,
modelAvailable: false,
testedAt: new Date().toISOString(),
message: extractErrorMessage(err, '测试请求失败'),
message: getErrorMessage(err, '测试请求失败'),
});
} finally {
setTesting(false);
}
}, [formValues, form, loadConfig, currentProvider]);
}, [formValues, form, currentProvider]);
// ── Clear key ──
@@ -352,25 +292,21 @@ const AiConfigPage: React.FC = () => {
cancelText: '取消',
onOk: async () => {
try {
await api.post('/ai/config/clear-key');
await clearKeyMutation.mutateAsync();
message.success('密钥已清除');
await loadConfig();
} catch (err: unknown) {
message.error(extractErrorMessage(err, '清除失败'));
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
});
}, [config, loadConfig, modal]);
}, [config, modal]);
// ── Step navigation ──
const goNext = useCallback(async () => {
// Validate current step fields before moving
try {
if (currentStep === 0) {
await form.validateFields(['provider', 'baseUrl', 'timeoutMs']);
} else if (currentStep === 1) {
// API key step — optional, no validation needed
} else if (currentStep === 2) {
await form.validateFields(['defaultModel']);
}
@@ -410,336 +346,44 @@ const AiConfigPage: React.FC = () => {
);
}
// ── Render step content ──
const renderStepContent = () => {
switch (currentStep) {
// Step 0: Provider + Base URL + Timeout
case 0:
return (
<Card
title={<span className={styles.cardTitle}></span>}
extra={<CloudServerOutlined />}
>
<Form.Item
name="provider"
label="Provider"
rules={[{ required: true, message: '请选择 Provider' }]}
preserve
>
<Select
options={PROVIDER_OPTIONS}
onChange={handleProviderChange}
disabled={!canWrite}
size="large"
/>
</Form.Item>
<Form.Item
name="baseUrl"
label="Base URL"
rules={[
{ required: true, message: '请输入 Base URL' },
{ type: 'url', message: '请输入合法的 URL' },
]}
preserve
>
<Input
placeholder={
config?.provider
? PROVIDER_DEFAULTS[config.provider]
: 'https://api.deepseek.com'
}
disabled={!canWrite || (isFixedProvider && canWrite)}
size="large"
/>
</Form.Item>
<Form.Item
name="timeoutMs"
label="请求超时 (毫秒)"
rules={[
{ required: true, message: '请输入超时时间' },
{ type: 'number', min: 1000, max: 120000, message: '范围: 1000-120000' },
]}
preserve
>
<InputNumber
min={1000}
max={120000}
step={1000}
style={{ width: '100%' }}
disabled={!canWrite}
size="large"
/>
</Form.Item>
</Card>
<ProviderStep
canWrite={canWrite}
isFixedProvider={isFixedProvider}
config={config}
onProviderChange={handleProviderChange}
/>
);
// Step 1: API Key
case 1:
return (
<Card
title={<span className={styles.cardTitle}></span>}
extra={<SafetyOutlined />}
>
<Form.Item name="apiKey" label="API Key" preserve>
<Input.Password
placeholder={config?.hasApiKey ? '已安全保存,留空则保持不变' : '请输入 API Key'}
disabled={!canWrite}
autoComplete="new-password"
size="large"
/>
</Form.Item>
{config && (
<Descriptions column={1} size="small" style={{ marginBottom: 12 }}>
<Descriptions.Item label="状态">
{config.hasApiKey ? (
<Tag color="green">{config.maskedApiKey || '••••'}</Tag>
) : (
<Tag color="default"></Tag>
)}
</Descriptions.Item>
<Descriptions.Item label="来源">
<Tag color={sourceColor(config.keySource)}>{sourceLabel(config.keySource)}</Tag>
{config.keySource === 'environment' && (
<span style={{ marginLeft: 8, fontSize: 12, color: '#999' }}>
</span>
)}
</Descriptions.Item>
<Descriptions.Item label="最后更新">
{formatDateTime(config.updatedAt)}
</Descriptions.Item>
</Descriptions>
)}
{config?.hasDatabaseKey && canWrite && (
<div style={{ marginBottom: 8 }}>
<Button danger size="small" onClick={handleClearKey}>
</Button>
</div>
)}
{config?.keySource === 'environment' && !config.hasDatabaseKey && (
<div style={{ marginBottom: 8, fontSize: 12, color: '#999' }}>
</div>
)}
<div className={styles.safetyNote}>
API Key 使 AES-256-GCM 使 IV HTTPS
</div>
<div className={styles.safetyNoteKey}>
<Typography.Text code>AI_API_KEY</Typography.Text>
</div>
</Card>
);
// Step 2: Model selection
return <KeyStep canWrite={canWrite} config={config} onClearKey={handleClearKey} />;
case 2:
return (
<Card
title={<span className={styles.cardTitle}></span>}
extra={<RobotOutlined />}
>
<div className={styles.modelFetchRow}>
<Button
icon={<ReloadOutlined />}
onClick={handleFetchModels}
loading={fetchingModels}
disabled={!canWrite}
>
</Button>
{modelOptions.length > 0 && (
<Tag color="blue">{modelOptions.length} </Tag>
)}
</div>
<Form.Item
name="defaultModel"
label="默认模型"
rules={[{ required: true, message: '请选择或输入默认模型' }]}
style={{ marginTop: 16 }}
preserve
>
<AutoComplete
options={modelOptions}
placeholder="选择或输入模型名称,如 deepseek-chat, gpt-4"
disabled={!canWrite}
size="large"
filterOption={(inputValue, option) =>
option?.value?.toLowerCase().includes(inputValue.toLowerCase()) ?? false
}
/>
</Form.Item>
<Form.Item
name="supportsVision"
label="图片理解"
valuePropName="checked"
extra="仅当所选模型确实支持图片输入时开启;关闭时 AI 助手会阻止发送图片。"
preserve
>
<Switch disabled={!canWrite} checkedChildren="已启用" unCheckedChildren="未启用" />
</Form.Item>
<Form.Item
name="reasoningEffort"
label="推理强度 (reasoning_effort)"
extra="OpenAI o 系列等支持该参数的模型生效DeepSeek 官方接口不支持,选择后也不会发送。"
preserve
>
<Select
disabled={!canWrite}
size="large"
options={[
{ value: '', label: '不设置(跟随模型默认)' },
{ value: 'low', label: '低 (low)' },
{ value: 'medium', label: '中 (medium)' },
{ value: 'high', label: '高 (high)' },
{ value: 'xhigh', label: '极高 (xhigh)' },
]}
/>
</Form.Item>
{config?.verified && (
<div style={{ marginTop: 8 }}>
<Tag icon={<CheckCircleOutlined />} color="success">
</Tag>
{config.lastTestLatencyMs != null && (
<span style={{ marginLeft: 8, fontSize: 12, color: '#999' }}>
: {config.lastTestLatencyMs}ms
</span>
)}
</div>
)}
</Card>
<ModelStep
canWrite={canWrite}
config={config}
onFetchModels={handleFetchModels}
fetchingModels={fetchingModels}
modelOptions={modelOptions}
/>
);
// Step 3: Save & Test
case 3:
return (
<Card
title={<span className={styles.cardTitle}></span>}
extra={<CheckCircleOutlined />}
>
<Alert
type="info"
message="配置预览"
description={
<Descriptions column={1} size="small" style={{ marginTop: 8 }}>
<Descriptions.Item label="服务商">
<Tag color="blue">
{PROVIDER_OPTIONS.find((o) => o.value === currentProvider)?.label ??
currentProvider ??
'-'}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Base URL">
<Typography.Text code>
{formValues.baseUrl || '-'}
</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="默认模型">
<Tag>{formValues.defaultModel || '未设置'}</Tag>
</Descriptions.Item>
<Descriptions.Item label="密钥">
{(() => {
const hasFormKey = formValues.apiKey && formValues.apiKey !== '••••';
if (config?.hasApiKey) {
return <Tag color="green">{config.maskedApiKey || '••••'}</Tag>;
}
if (hasFormKey) {
return <Tag color="blue"></Tag>;
}
return <Tag color="red"></Tag>;
})()}
</Descriptions.Item>
<Descriptions.Item label="超时">
{formValues.timeoutMs}ms
</Descriptions.Item>
<Descriptions.Item label="图片理解">
<Tag color={formValues.supportsVision ? 'blue' : 'default'}>
{formValues.supportsVision ? '已启用' : '未启用'}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={config?.enabled ? 'green' : 'default'}>
{config?.enabled ? '已启用' : '未启用'}
</Tag>
</Descriptions.Item>
</Descriptions>
}
style={{ marginBottom: 16 }}
/>
<Space>
{canWrite && (
<Button type="primary" icon={<SaveOutlined />} onClick={handleSave} loading={saving} size="large">
</Button>
)}
{canTest && (
<Button icon={<ApiOutlined />} onClick={handleTest} loading={testing} size="large">
</Button>
)}
</Space>
{/* Test result */}
{testResult && (
<Card size="small" className={styles.testResult}>
<Descriptions column={{ xs: 1, sm: 2 }} size="small">
<Descriptions.Item label="结果">
{testResult.success ? (
testResult.modelAvailable ? (
<Tag icon={<CheckCircleOutlined />} color="success">
</Tag>
) : (
<Tag icon={<WarningOutlined />} color="warning">
</Tag>
)
) : (
<Tag icon={<CloseCircleOutlined />} color="error">
</Tag>
)}
</Descriptions.Item>
<Descriptions.Item label="延迟">
{testResult.latencyMs != null ? `${testResult.latencyMs} ms` : '-'}
</Descriptions.Item>
<Descriptions.Item label="模型数量">
{testResult.modelCount != null ? testResult.modelCount : '-'}
</Descriptions.Item>
<Descriptions.Item label="测试时间">
{formatDateTime(testResult.testedAt)}
</Descriptions.Item>
</Descriptions>
<Alert
type={
testResult.success
? testResult.modelAvailable
? 'success'
: 'warning'
: 'error'
}
title={testResult.message}
style={{ marginTop: 8 }}
/>
</Card>
)}
</Card>
<SaveTestStep
canWrite={canWrite}
canTest={canTest}
config={config}
currentProvider={currentProvider}
formValues={formValues}
onSave={handleSave}
saving={saving}
onTest={handleTest}
testing={testing}
testResult={testResult}
/>
);
default:
return null;
}