feat: AI 对话支持 A2UI 表单/审查/图表与 Agent 工具
This commit is contained in:
415
apps/admin/src/pages/AiConfig/AiConfigSteps.tsx
Normal file
415
apps/admin/src/pages/AiConfig/AiConfigSteps.tsx
Normal file
@@ -0,0 +1,415 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
ApiOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
CloudServerOutlined,
|
||||
ReloadOutlined,
|
||||
RobotOutlined,
|
||||
SafetyOutlined,
|
||||
SaveOutlined,
|
||||
WarningOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
Alert,
|
||||
AutoComplete,
|
||||
Button,
|
||||
Card,
|
||||
Descriptions,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import type { AiProvider } from './helpers';
|
||||
import {
|
||||
PROVIDER_OPTIONS,
|
||||
PROVIDER_DEFAULTS,
|
||||
formatDateTime,
|
||||
sourceColor,
|
||||
sourceLabel,
|
||||
} from './helpers';
|
||||
import styles from './index.module.css';
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export interface TestResult {
|
||||
success: boolean;
|
||||
latencyMs: number | null;
|
||||
modelCount: number | null;
|
||||
modelAvailable: boolean;
|
||||
testedAt: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface FormValues {
|
||||
provider: AiProvider;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
defaultModel: string;
|
||||
timeoutMs: number;
|
||||
supportsVision: boolean;
|
||||
reasoningEffort: string;
|
||||
}
|
||||
|
||||
export const ProviderStep: React.FC<{
|
||||
canWrite: boolean;
|
||||
isFixedProvider: boolean;
|
||||
config?: AiConfigData | null;
|
||||
onProviderChange: (provider: AiProvider) => void;
|
||||
}> = ({ canWrite, isFixedProvider, config, onProviderChange }) => {
|
||||
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={onProviderChange}
|
||||
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] : PROVIDER_DEFAULTS.DEEPSEEK
|
||||
}
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
export const KeyStep: React.FC<{
|
||||
canWrite: boolean;
|
||||
config?: AiConfigData | null;
|
||||
onClearKey: () => void;
|
||||
}> = ({ canWrite, config, onClearKey }) => {
|
||||
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={onClearKey}>
|
||||
清除服务器保存密钥
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
export const ModelStep: React.FC<{
|
||||
canWrite: boolean;
|
||||
config?: AiConfigData | null;
|
||||
onFetchModels: () => void;
|
||||
fetchingModels: boolean;
|
||||
modelOptions: Array<{ value: string; label: string }>;
|
||||
}> = ({ canWrite, config, onFetchModels, fetchingModels, modelOptions }) => {
|
||||
return (
|
||||
<Card title={<span className={styles.cardTitle}>模型选择</span>} extra={<RobotOutlined />}>
|
||||
<div className={styles.modelFetchRow}>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={onFetchModels}
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
export const TestResultCard: React.FC<{ testResult: TestResult | null }> = ({ testResult }) =>
|
||||
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>
|
||||
) : null;
|
||||
|
||||
export const SaveTestStep: React.FC<{
|
||||
canWrite: boolean;
|
||||
canTest: boolean;
|
||||
config?: AiConfigData | null;
|
||||
currentProvider: AiProvider;
|
||||
formValues: FormValues;
|
||||
onSave: () => void;
|
||||
saving: boolean;
|
||||
onTest: () => void;
|
||||
testing: boolean;
|
||||
testResult: TestResult | null;
|
||||
}> = ({
|
||||
canWrite,
|
||||
canTest,
|
||||
config,
|
||||
currentProvider,
|
||||
formValues,
|
||||
onSave,
|
||||
saving,
|
||||
onTest,
|
||||
testing,
|
||||
testResult,
|
||||
}) => {
|
||||
const providerLabel =
|
||||
PROVIDER_OPTIONS.find((o) => o.value === currentProvider)?.label ?? currentProvider ?? '-';
|
||||
const hasFormKey = formValues.apiKey && formValues.apiKey !== '••••';
|
||||
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">{providerLabel}</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="密钥">
|
||||
{config?.hasApiKey ? (
|
||||
<Tag color="green">{config.maskedApiKey || '••••'}</Tag>
|
||||
) : hasFormKey ? (
|
||||
<Tag color="blue">已填写(未保存)</Tag>
|
||||
) : (
|
||||
<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={onSave}
|
||||
loading={saving}
|
||||
size="large"
|
||||
>
|
||||
保存配置
|
||||
</Button>
|
||||
)}
|
||||
{canTest && (
|
||||
<Button icon={<ApiOutlined />} onClick={onTest} loading={testing} size="large">
|
||||
测试连接
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
<TestResultCard testResult={testResult} />
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
sourceLabel,
|
||||
sourceColor,
|
||||
PROVIDER_DEFAULTS,
|
||||
extractErrorMessage,
|
||||
} from './helpers';
|
||||
import { getErrorMessage } from '../../utils/error';
|
||||
|
||||
describe('AiConfig helpers', () => {
|
||||
describe('shouldAutoSwapBaseUrl', () => {
|
||||
@@ -60,48 +60,48 @@ describe('AiConfig helpers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractErrorMessage', () => {
|
||||
describe('getErrorMessage', () => {
|
||||
it('extracts message from server error response (interceptor unwraps to { message })', () => {
|
||||
// The Axios interceptor at api/index.ts does Promise.reject(err.response?.data || err).
|
||||
// For server errors, the rejection value is err.response.data — typically { message: '...' }.
|
||||
const err = { message: 'API出错' };
|
||||
expect(extractErrorMessage(err)).toBe('API出错');
|
||||
expect(getErrorMessage(err)).toBe('API出错');
|
||||
});
|
||||
|
||||
it('falls back to message property', () => {
|
||||
const err = { message: 'Network error' };
|
||||
expect(extractErrorMessage(err)).toBe('Network error');
|
||||
expect(getErrorMessage(err)).toBe('Network error');
|
||||
});
|
||||
|
||||
it('falls back to default on unknown type', () => {
|
||||
expect(extractErrorMessage('unknown string')).toBe('操作失败');
|
||||
expect(extractErrorMessage(null)).toBe('操作失败');
|
||||
expect(extractErrorMessage(undefined)).toBe('操作失败');
|
||||
it('uses string errors and falls back on unknown types', () => {
|
||||
expect(getErrorMessage('unknown string')).toBe('unknown string');
|
||||
expect(getErrorMessage(null)).toBe('操作失败');
|
||||
expect(getErrorMessage(undefined)).toBe('操作失败');
|
||||
});
|
||||
|
||||
it('sanitizes: newlines replaced with spaces', () => {
|
||||
const err = { message: 'line1\nline2\r\nline3' };
|
||||
expect(extractErrorMessage(err)).toBe('line1 line2 line3');
|
||||
expect(getErrorMessage(err)).toBe('line1 line2 line3');
|
||||
});
|
||||
|
||||
it('sanitizes: message > 120 chars truncated with ellipsis', () => {
|
||||
const long = 'x'.repeat(200);
|
||||
const err = { message: long };
|
||||
const result = extractErrorMessage(err);
|
||||
const result = getErrorMessage(err);
|
||||
expect(result).toHaveLength(121); // 120 + '…' (1 char)
|
||||
expect(result.endsWith('\u2026')).toBe(true);
|
||||
});
|
||||
|
||||
it('sanitizes: empty trimmed message falls back', () => {
|
||||
const err = { message: ' ' };
|
||||
expect(extractErrorMessage(err)).toBe('操作失败');
|
||||
expect(getErrorMessage(err)).toBe('操作失败');
|
||||
});
|
||||
|
||||
it('sanitizes: plain object message property sanitized', () => {
|
||||
const err = {
|
||||
message: ' some \n\nerror \r\nmessage ',
|
||||
};
|
||||
expect(extractErrorMessage(err)).toBe('some error message');
|
||||
expect(getErrorMessage(err)).toBe('some error message');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// AiConfig helpers — pure functions, no React / DOM dependencies
|
||||
// ---------------------------------------------------------------------------
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export type AiProvider = 'OPENAI' | 'DEEPSEEK' | 'OPENAI_COMPATIBLE';
|
||||
|
||||
@@ -10,9 +8,14 @@ export const PROVIDER_OPTIONS: { value: AiProvider; label: string }[] = [
|
||||
{ value: 'OPENAI_COMPATIBLE', label: 'OpenAI 兼容' },
|
||||
];
|
||||
|
||||
// aislop-ignore-next-line: hardcoded-url -- OpenAI 官方 API 固定端点
|
||||
export const OPENAI_DEFAULT_BASE_URL = 'https://api.openai.com/v1';
|
||||
// aislop-ignore-next-line: hardcoded-url -- DeepSeek 官方 API 固定端点
|
||||
export const DEEPSEEK_DEFAULT_BASE_URL = 'https://api.deepseek.com';
|
||||
|
||||
export const PROVIDER_DEFAULTS: Record<AiProvider, string> = {
|
||||
OPENAI: 'https://api.openai.com/v1',
|
||||
DEEPSEEK: 'https://api.deepseek.com',
|
||||
OPENAI: OPENAI_DEFAULT_BASE_URL,
|
||||
DEEPSEEK: DEEPSEEK_DEFAULT_BASE_URL,
|
||||
OPENAI_COMPATIBLE: '',
|
||||
} as const;
|
||||
|
||||
@@ -20,7 +23,7 @@ export const FIXED_PROVIDERS: AiProvider[] = ['OPENAI', 'DEEPSEEK'];
|
||||
|
||||
export function formatDateTime(iso: string | null): string {
|
||||
if (!iso) return '-';
|
||||
return new Date(iso).toLocaleString('zh-CN');
|
||||
return dayjs(iso).format('YYYY-MM-DD HH:mm:ss');
|
||||
}
|
||||
|
||||
export function sourceLabel(source: string): string {
|
||||
@@ -59,25 +62,3 @@ export function shouldAutoSwapBaseUrl(
|
||||
}
|
||||
return { baseUrl: currentBaseUrl, shouldSwap: false };
|
||||
}
|
||||
|
||||
/** Extract a safe user-facing error message from any caught value.
|
||||
*
|
||||
* The Axios interceptor at `api/index.ts` unwraps errors before rejection:
|
||||
* `Promise.reject(err.response?.data || err)`. So server errors arrive as
|
||||
* `{ message: '...' }` (the unwrapped data) and network errors as the raw
|
||||
* `Error` object — never as a raw AxiosError with a `.response` property. */
|
||||
export function extractErrorMessage(err: unknown, fallback: string = '操作失败'): string {
|
||||
let msg = '';
|
||||
|
||||
// standard Error or any object with a string message property
|
||||
if (err && typeof err === 'object' && 'message' in err && typeof err.message === 'string') {
|
||||
msg = err.message;
|
||||
}
|
||||
|
||||
// sanitize: trim, collapse whitespace, strip newlines, truncate
|
||||
const trimmed = msg.trim();
|
||||
if (!trimmed) return fallback;
|
||||
|
||||
const singleLine = trimmed.replace(/[\n\r]+/g, ' ').replace(/ {2,}/g, ' ');
|
||||
return singleLine.length > 120 ? singleLine.slice(0, 120) + '\u2026' : singleLine;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user