fix(ai): AI 对话全链路修复 + 菜单配置 + 会话消息持久化
- 修复 ai_tenant_config Entity 表名错误(复数→单数)导致 budget_status 500
- 修复 ai_usage 表 SQL 引用不存在的 deleted_at 列
- 修复 risk_service SQL 列名/表名与实际数据库 schema 不匹配
- chat_handler provider 选择改为配置优先(default_provider→fallback chain)
- 新增 Ollama 非 FC provider 的 generate() 降级路径
- 新增 GET /ai/chat/sessions/{id}/messages 端点
- 前端 ChatPage 切换会话时从后端加载历史消息
- AiConfigPage 新增 default_provider 和 system_prompt 配置字段
- 迁移 000155-000156:AI 菜单调整 + AI 客服菜单 + 角色绑定
- 配额检查错误处理区分配额耗尽和 DB 异常
This commit is contained in:
@@ -91,9 +91,35 @@ export default function ChatPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectSession = (id: string) => {
|
||||
const handleSelectSession = async (id: string) => {
|
||||
setActiveId(id);
|
||||
setMessages([]);
|
||||
try {
|
||||
const msgs = await aiChatApi.getSessionMessages(id);
|
||||
const loaded: ChatMessage[] = msgs
|
||||
.filter((m) => m.content)
|
||||
.map((m) => ({
|
||||
id: m.id,
|
||||
role: m.role as 'user' | 'assistant',
|
||||
content: m.content ?? '',
|
||||
}));
|
||||
if (loaded.length === 0) {
|
||||
loaded.push({
|
||||
id: 'welcome',
|
||||
role: 'assistant',
|
||||
content: '你好!我是 AI 健康助手。有什么可以帮你的?',
|
||||
});
|
||||
}
|
||||
setMessages(loaded);
|
||||
} catch {
|
||||
setMessages([
|
||||
{
|
||||
id: 'welcome',
|
||||
role: 'assistant',
|
||||
content: '你好!我是 AI 健康助手。有什么可以帮你的?',
|
||||
},
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
|
||||
@@ -10,13 +10,24 @@ import {
|
||||
Divider,
|
||||
Spin,
|
||||
Tabs,
|
||||
Select,
|
||||
Switch,
|
||||
Collapse,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { SaveOutlined, UndoOutlined } from '@ant-design/icons';
|
||||
import { aiConfigApi, type AiConfig } from '../../api/ai/config';
|
||||
import { aiConfigApi, type AiConfig, type AiProviderConfig } from '../../api/ai/config';
|
||||
import { AuthButton } from '../../components/AuthButton';
|
||||
import { useThemeMode } from '../../hooks/useThemeMode';
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Text } = Typography;
|
||||
|
||||
const PROVIDER_LABELS: Record<string, string> = {
|
||||
claude: 'Claude (Anthropic)',
|
||||
openai: 'OpenAI 兼容',
|
||||
ollama: 'Ollama (本地)',
|
||||
};
|
||||
|
||||
export default function AiConfigPage() {
|
||||
const [config, setConfig] = useState<AiConfig | null>(null);
|
||||
@@ -39,6 +50,9 @@ export default function AiConfigPage() {
|
||||
analysisModel: data.analysis_defaults.model,
|
||||
analysisTemperature: data.analysis_defaults.temperature,
|
||||
analysisMaxTokens: data.analysis_defaults.max_tokens,
|
||||
defaultProvider: data.default_provider || 'claude',
|
||||
// Provider fields
|
||||
...buildProviderFields(data.providers),
|
||||
});
|
||||
} catch {
|
||||
message.error('加载 AI 配置失败');
|
||||
@@ -56,6 +70,17 @@ export default function AiConfigPage() {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
|
||||
const providers: Record<string, AiProviderConfig> = {};
|
||||
for (const name of ['claude', 'openai', 'ollama']) {
|
||||
providers[name] = {
|
||||
provider_type: name,
|
||||
enabled: values[`provider_${name}_enabled`] ?? false,
|
||||
base_url: values[`provider_${name}_base_url`] ?? '',
|
||||
api_key: values[`provider_${name}_api_key`] ?? '',
|
||||
model: values[`provider_${name}_model`] ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
const updated: AiConfig = {
|
||||
agent: {
|
||||
model: values.agentModel,
|
||||
@@ -69,6 +94,8 @@ export default function AiConfigPage() {
|
||||
temperature: values.analysisTemperature,
|
||||
max_tokens: values.analysisMaxTokens,
|
||||
},
|
||||
default_provider: values.defaultProvider || 'claude',
|
||||
providers,
|
||||
};
|
||||
|
||||
const result = await aiConfigApi.update(updated);
|
||||
@@ -125,6 +152,7 @@ export default function AiConfigPage() {
|
||||
{
|
||||
key: 'agent',
|
||||
label: 'Agent 对话配置',
|
||||
forceRender: true,
|
||||
children: (
|
||||
<Card
|
||||
title="AI 客服 Agent 参数"
|
||||
@@ -204,6 +232,7 @@ export default function AiConfigPage() {
|
||||
{
|
||||
key: 'analysis',
|
||||
label: '分析任务默认配置',
|
||||
forceRender: true,
|
||||
children: (
|
||||
<Card
|
||||
title="AI 分析任务默认参数"
|
||||
@@ -255,6 +284,93 @@ export default function AiConfigPage() {
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'providers',
|
||||
label: '模型供应商配置',
|
||||
forceRender: true,
|
||||
children: (
|
||||
<Card
|
||||
title="AI 模型供应商"
|
||||
size="small"
|
||||
style={cardStyle}
|
||||
extra="配置各 AI 供应商的连接参数,保存后立即生效"
|
||||
>
|
||||
<Form.Item
|
||||
label="默认供应商"
|
||||
name="defaultProvider"
|
||||
extra="系统优先使用的 AI 供应商"
|
||||
>
|
||||
<Select style={{ width: 240 }}>
|
||||
<Select.Option value="claude">Claude (Anthropic)</Select.Option>
|
||||
<Select.Option value="openai">OpenAI 兼容</Select.Option>
|
||||
<Select.Option value="ollama">Ollama (本地)</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Divider style={{ margin: '8px 0 16px' }} />
|
||||
|
||||
<Collapse
|
||||
items={(['claude', 'openai', 'ollama'] as const).map((name) => ({
|
||||
key: name,
|
||||
forceRender: true,
|
||||
label: (
|
||||
<Space>
|
||||
<Form.Item
|
||||
name={`provider_${name}_enabled`}
|
||||
valuePropName="checked"
|
||||
noStyle
|
||||
>
|
||||
<Switch size="small" />
|
||||
</Form.Item>
|
||||
<span>{PROVIDER_LABELS[name]}</span>
|
||||
{config?.providers?.[name]?.enabled && (
|
||||
<Text type="success">已启用</Text>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
children: (
|
||||
<>
|
||||
<Form.Item
|
||||
label="Base URL"
|
||||
name={`provider_${name}_base_url`}
|
||||
>
|
||||
<Input placeholder={
|
||||
name === 'claude' ? 'https://api.anthropic.com' :
|
||||
name === 'openai' ? 'https://api.openai.com' :
|
||||
'http://localhost:11434'
|
||||
} />
|
||||
</Form.Item>
|
||||
|
||||
{name !== 'ollama' && (
|
||||
<Form.Item
|
||||
label="API Key"
|
||||
name={`provider_${name}_api_key`}
|
||||
extra="已保存的 Key 会显示为掩码格式(****xxxx),输入新值即可覆盖"
|
||||
>
|
||||
<Input.Password
|
||||
placeholder="sk-..."
|
||||
visibilityToggle
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
label="默认模型"
|
||||
name={`provider_${name}_model`}
|
||||
>
|
||||
<Input placeholder={
|
||||
name === 'claude' ? 'claude-sonnet-4-6' :
|
||||
name === 'openai' ? 'gpt-4o' :
|
||||
'qwen3:8b'
|
||||
} />
|
||||
</Form.Item>
|
||||
</>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -263,14 +379,15 @@ export default function AiConfigPage() {
|
||||
<Button icon={<UndoOutlined />} onClick={handleReset}>
|
||||
恢复默认值
|
||||
</Button>
|
||||
<AuthButton
|
||||
permission="ai.config.manage"
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
loading={saving}
|
||||
onClick={handleSave}
|
||||
>
|
||||
保存配置
|
||||
<AuthButton code="ai.config.manage">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
loading={saving}
|
||||
onClick={handleSave}
|
||||
>
|
||||
保存配置
|
||||
</Button>
|
||||
</AuthButton>
|
||||
</Space>
|
||||
</div>
|
||||
@@ -278,3 +395,24 @@ export default function AiConfigPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const PROVIDER_DEFAULTS: Record<string, { base_url: string; model: string }> = {
|
||||
claude: { base_url: 'https://api.anthropic.com', model: 'claude-sonnet-4-6' },
|
||||
openai: { base_url: 'https://api.openai.com', model: 'gpt-4o' },
|
||||
ollama: { base_url: 'http://localhost:11434', model: 'qwen3:8b' },
|
||||
};
|
||||
|
||||
function buildProviderFields(
|
||||
providers: Record<string, AiProviderConfig> | undefined,
|
||||
): Record<string, unknown> {
|
||||
if (!providers) return {};
|
||||
const fields: Record<string, unknown> = {};
|
||||
for (const [name, cfg] of Object.entries(providers)) {
|
||||
const defaults = PROVIDER_DEFAULTS[name];
|
||||
fields[`provider_${name}_enabled`] = cfg.enabled;
|
||||
fields[`provider_${name}_base_url`] = cfg.base_url || defaults?.base_url || '';
|
||||
fields[`provider_${name}_api_key`] = cfg.api_key;
|
||||
fields[`provider_${name}_model`] = cfg.model || defaults?.model || '';
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user