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:
iven
2026-05-19 21:36:01 +08:00
parent 8fbe1543cb
commit c6d4e76b62
12 changed files with 514 additions and 122 deletions

View File

@@ -105,4 +105,14 @@ export const aiChatApi = {
closeSession: async (sessionId: string): Promise<void> => {
await client.post(`/ai/chat/sessions/${sessionId}/close`);
},
getSessionMessages: async (sessionId: string): Promise<Array<{
id: string;
role: string;
content: string | null;
created_at: string;
}>> => {
const resp = await client.get(`/ai/chat/sessions/${sessionId}/messages`);
return resp.data.data;
},
};

View File

@@ -14,9 +14,19 @@ export interface AiAnalysisDefaults {
max_tokens: number;
}
export interface AiProviderConfig {
provider_type: string;
enabled: boolean;
base_url: string;
api_key: string;
model: string;
}
export interface AiConfig {
agent: AiAgentConfig;
analysis_defaults: AiAnalysisDefaults;
default_provider: string;
providers: Record<string, AiProviderConfig>;
}
export const aiConfigApi = {

View File

@@ -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 () => {

View File

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