feat(chat): load allowed llms per agent

main
sp mac bookpro 2605 2026-05-29 13:58:04 +08:00
parent 46875d6337
commit 1d79e929f9
4 changed files with 464 additions and 392 deletions

View File

@ -512,8 +512,17 @@ export interface LLMProvider {
updatedAt: number; updatedAt: number;
} }
export interface AgentAllowedLLMResponse {
agentId: string;
model: string;
}
export const LLMProviderAPI = { export const LLMProviderAPI = {
list: () => api.get<LLMProvider[]>('/llm-providers').then((r) => r.data), list: () => api.get<LLMProvider[]>('/llm-providers').then((r) => r.data),
allowedModels: (agentId: string) =>
api
.get<AgentAllowedLLMResponse>('/llm-providers', { params: { agent_id: agentId } })
.then((r) => r.data),
create: (payload: Partial<LLMProvider> & { apiKey?: string }) => create: (payload: Partial<LLMProvider> & { apiKey?: string }) =>
api.post<{ id: string }>('/llm-providers', payload).then((r) => r.data), api.post<{ id: string }>('/llm-providers', payload).then((r) => r.data),
update: (id: string, payload: Partial<LLMProvider> & { apiKey?: string }) => update: (id: string, payload: Partial<LLMProvider> & { apiKey?: string }) =>

View File

@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Button, Form, Input, InputNumber, Modal, Upload, App as AntApp, List, Popconfirm, Tag, Switch, Select, Collapse } from 'antd'; import { Button, Form, Input, InputNumber, Modal, Upload, App as AntApp, List, Popconfirm, Tag, Switch, Select, Collapse, Checkbox } from 'antd';
import type { UploadFile } from 'antd/es/upload/interface'; import type { UploadFile } from 'antd/es/upload/interface';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { Agent, AgentAPI, ImageAPI, KnowledgeStatus, SkillType, Team, TeamAPI, AiModel, ModelAPI } from '../api'; import { Agent, AgentAPI, ImageAPI, KnowledgeStatus, SkillType, Team, TeamAPI, AiModel, ModelAPI } from '../api';
@ -493,33 +493,30 @@ export default function AgentEditor() {
Array.isArray(value) ? value.map((item) => String(item).trim()).filter(Boolean).join(', ') : '' Array.isArray(value) ? value.map((item) => String(item).trim()).filter(Boolean).join(', ') : ''
} }
> >
<Select <Checkbox.Group className="agent-model-checkbox-group">
mode="multiple" {models.map((m) => {
placeholder="选择模型"
style={{ height: 42 }}
dropdownStyle={{ borderRadius: 12 }}
maxTagCount="responsive"
optionFilterProp="value"
options={models.map((m) => {
const inputPrice = 2 * m.model_ratio; const inputPrice = 2 * m.model_ratio;
const outputPrice = inputPrice * m.completion_ratio; const outputPrice = inputPrice * m.completion_ratio;
return { return (
value: m.model_name, <Checkbox key={m.model_name} value={m.model_name} className="agent-model-checkbox-item">
label: ( <div className="agent-model-checkbox-content">
<div className="flex items-center justify-between w-full py-1"> <div className="agent-model-checkbox-meta">
<div className="flex items-center gap-2" style={{ flex: 1, minWidth: 0 }}> <img
<img src={m.icon || DEFAULT_RH_40X40_GRAY} alt={m.model_name} style={{ width: 20, height: 20, objectFit: 'contain', borderRadius: 4, flexShrink: 0 }} /> src={m.icon || DEFAULT_RH_40X40_GRAY}
<span className="font-medium text-gray-800" style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{m.model_name}</span> alt={m.model_name}
className="agent-model-checkbox-icon"
/>
<span className="agent-model-checkbox-name">{m.model_name}</span>
</div> </div>
<div className="flex flex-col items-end justify-center text-[10px] text-gray-400" style={{ flexShrink: 0, marginLeft: 8 }}> <div className="agent-model-checkbox-price">
<span>: ${inputPrice.toFixed(2)}/M</span> <span>: ${inputPrice.toFixed(2)}/M</span>
<span>: ${outputPrice.toFixed(2)}/M</span> <span>: ${outputPrice.toFixed(2)}/M</span>
</div> </div>
</div> </div>
), </Checkbox>
}; );
})} })}
/> </Checkbox.Group>
</Form.Item> </Form.Item>
<Form.Item <Form.Item
name="temperature" name="temperature"

View File

@ -6,13 +6,13 @@ import ReactMarkdown from 'react-markdown';
import { import {
Agent, Agent,
AgentAPI, AgentAPI,
AgentAllowedLLMResponse,
BranchInfo, BranchInfo,
ChatAPI, ChatAPI,
ChatAttachment, ChatAttachment,
ChatAttachmentsAPI, ChatAttachmentsAPI,
ChatMessage, ChatMessage,
ImageAPI, ImageAPI,
LLMProvider,
LLMProviderAPI, LLMProviderAPI,
ModelOverrides, ModelOverrides,
RetrievedSnippet, RetrievedSnippet,
@ -32,6 +32,12 @@ interface StreamingState {
toolCalls: ToolCallTrace[]; toolCalls: ToolCallTrace[];
} }
const parseAllowedModels = (payload?: Pick<AgentAllowedLLMResponse, 'model'> | null) =>
String(payload?.model || '')
.split(',')
.map((item) => item.trim())
.filter(Boolean);
export default function ChatPage() { export default function ChatPage() {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
@ -52,7 +58,7 @@ export default function ChatPage() {
const [paramsDrawerOpen, setParamsDrawerOpen] = useState(false); const [paramsDrawerOpen, setParamsDrawerOpen] = useState(false);
const [tplDrawerOpen, setTplDrawerOpen] = useState(false); const [tplDrawerOpen, setTplDrawerOpen] = useState(false);
const [overrides, setOverrides] = useState<ModelOverrides>({}); const [overrides, setOverrides] = useState<ModelOverrides>({});
const [providers, setProviders] = useState<LLMProvider[]>([]); const [allowedModels, setAllowedModels] = useState<string[]>([]);
const [attachments, setAttachments] = useState<ChatAttachment[]>([]); const [attachments, setAttachments] = useState<ChatAttachment[]>([]);
const [imageUrls, setImageUrls] = useState<string[]>([]); const [imageUrls, setImageUrls] = useState<string[]>([]);
const [uploadingAtt, setUploadingAtt] = useState(false); const [uploadingAtt, setUploadingAtt] = useState(false);
@ -105,12 +111,13 @@ export default function ChatPage() {
} }
}; };
const loadProviders = async () => { const loadAllowedModels = async (agentId: string) => {
try { try {
const list = await LLMProviderAPI.list(); const result = await LLMProviderAPI.allowedModels(agentId);
setProviders(list.filter((item) => item.enabled)); setAllowedModels(parseAllowedModels(result));
} catch (e: any) { } catch (e: any) {
msg.error('加载模型列表失败:' + (e?.message ?? e)); setAllowedModels([]);
msg.error('加载智能体模型失败:' + (e?.message ?? e));
} }
}; };
@ -137,8 +144,16 @@ export default function ChatPage() {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!id) {
setAgent(null);
setMessages([]);
setAllowedModels([]);
setOverrides({});
return () => abortRef.current?.abort();
}
loadAgent(); loadAgent();
loadProviders(); loadAllowedModels(id);
setOverrides({});
return () => abortRef.current?.abort(); return () => abortRef.current?.abort();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]); }, [id]);
@ -398,37 +413,19 @@ export default function ChatPage() {
const isImageUrl = (url: string) => url?.startsWith('http') || url?.startsWith('/'); const isImageUrl = (url: string) => url?.startsWith('http') || url?.startsWith('/');
const agentModel = agent?.model || ''; const agentModel = agent?.model || '';
const activeModelValue = overrides.model || agentModel; const modelOptions = allowedModels.map((modelName) => ({
const defaultProvider = providers.find((item) => item.isDefault) || providers[0]; value: modelName,
const modelOptions = providers.flatMap((provider) => label: modelName
(provider.models?.length ? provider.models : [provider.defaultModel]).filter(Boolean).map((modelName) => ({ }));
value: `${provider.id}/${modelName}`, const activeModelValue = overrides.model || '';
label: `${modelName}`,
providerName: provider.name
}))
);
const activeModelOption = modelOptions.find((item) => item.value === activeModelValue); const activeModelOption = modelOptions.find((item) => item.value === activeModelValue);
const fallbackModelName = activeModelValue.includes('/') ? activeModelValue.split('/').slice(1).join('/') : activeModelValue; const defaultModelLabel = allowedModels.length > 0 ? allowedModels.join(', ') : agentModel || '默认模型';
const currentModelName = activeModelOption?.label || fallbackModelName || agentModel || defaultProvider?.defaultModel || '默认模型'; const currentModelName = activeModelOption?.label || activeModelValue || defaultModelLabel;
const currentProviderName =
activeModelOption?.providerName ||
(activeModelValue.includes('/') ? providers.find((item) => item.id === activeModelValue.split('/')[0])?.name : defaultProvider?.name) ||
(agentModel ? '当前 Agent 配置' : defaultProvider?.name || '系统默认');
return ( return (
<div className="chat-shell"> <div className="chat-shell">
{/* 1. 二级侧边栏:智能体列表 */} {/* 1. 二级侧边栏:智能体列表 */}
<aside <aside className="chat-side">
className="chat-side"
style={{
display: 'flex',
flexDirection: 'column',
gap: 0,
background: 'var(--color-surface)',
borderRight: '1px solid var(--color-border)',
width: 260
}}
>
<div style={{ padding: '16px', borderBottom: '1px solid var(--color-border)' }}> <div style={{ padding: '16px', borderBottom: '1px solid var(--color-border)' }}>
<Button block type="dashed" onClick={() => navigate('/agents/new')}>+ </Button> <Button block type="dashed" onClick={() => navigate('/agents/new')}>+ </Button>
</div> </div>
@ -483,7 +480,7 @@ export default function ChatPage() {
</aside> </aside>
{/* 2. 主聊天区 */} {/* 2. 主聊天区 */}
<section className="chat-main" style={{ flex: 1, display: 'flex', flexDirection: 'column', position: 'relative' }}> <section className="chat-main">
{!agent ? ( {!agent ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}> <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Empty description="请在左侧选择一个智能体开始对话" /> <Empty description="请在左侧选择一个智能体开始对话" />
@ -491,7 +488,7 @@ export default function ChatPage() {
) : ( ) : (
<> <>
{/* Header */} {/* Header */}
<div className="chat-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0 24px' }}> <div className="chat-header">
<div> <div>
<div style={{ fontWeight: 600, fontSize: 16, color: 'var(--color-text)' }}>{agent.name}</div> <div style={{ fontWeight: 600, fontSize: 16, color: 'var(--color-text)' }}>{agent.name}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)' }}> <div style={{ fontSize: 12, color: 'var(--color-text-tertiary)' }}>
@ -674,7 +671,7 @@ export default function ChatPage() {
trigger={['hover']} trigger={['hover']}
placement="topLeft" placement="topLeft"
menu={{ menu={{
selectedKeys: activeModelValue ? [activeModelValue] : [], selectedKeys: [activeModelValue || '__default__'],
onClick: ({ key }) => onClick: ({ key }) =>
setOverrides((o) => ({ setOverrides((o) => ({
...o, ...o,
@ -683,12 +680,11 @@ export default function ChatPage() {
items: [ items: [
{ {
key: '__default__', key: '__default__',
label: `跟随默认 · ${agentModel || defaultProvider?.defaultModel || '默认模型'}` label: `跟随默认 · ${defaultModelLabel}`
}, },
...modelOptions.map((item) => ({ ...modelOptions.map((item) => ({
key: item.value, key: item.value,
label: `${item.label}`, label: `${item.label}`
extra: item.providerName
})) }))
] ]
}} }}

View File

@ -463,12 +463,12 @@ body {
} }
.chat-side { .chat-side {
width: 280px; width: 260px;
border-right: 1px solid var(--color-border); border-right: 1px solid var(--color-border);
padding: 16px;
overflow: hidden; overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0;
height: 100%; height: 100%;
background: var(--color-surface); background: var(--color-surface);
} }
@ -479,6 +479,7 @@ body {
flex-direction: column; flex-direction: column;
background: var(--color-bg); background: var(--color-bg);
min-width: 0; min-width: 0;
position: relative;
} }
.chat-header { .chat-header {
@ -487,7 +488,7 @@ body {
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: space-between;
background: var(--color-surface); background: var(--color-surface);
} }
@ -560,6 +561,75 @@ body {
box-shadow: var(--shadow-focus); box-shadow: var(--shadow-focus);
} }
.agent-model-checkbox-group {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
}
.agent-model-checkbox-item {
display: flex;
align-items: flex-start;
margin-inline-start: 0;
padding: 12px 14px;
border: 1px solid var(--color-border);
border-radius: 12px;
background: var(--color-surface);
}
.agent-model-checkbox-item .ant-checkbox {
margin-top: 3px;
}
.agent-model-checkbox-item .ant-checkbox + span {
width: 100%;
padding-inline-start: 10px;
}
.agent-model-checkbox-content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
}
.agent-model-checkbox-meta {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
}
.agent-model-checkbox-icon {
width: 20px;
height: 20px;
object-fit: contain;
border-radius: 4px;
flex-shrink: 0;
}
.agent-model-checkbox-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
color: var(--color-text);
}
.agent-model-checkbox-price {
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: center;
flex-shrink: 0;
margin-left: 8px;
font-size: 10px;
color: var(--color-text-tertiary);
}
.chat-input-textarea { .chat-input-textarea {
border: none !important; border: none !important;
box-shadow: none !important; box-shadow: none !important;