feat(chat): load allowed llms per agent
parent
46875d6337
commit
1d79e929f9
|
|
@ -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 }) =>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
</div>
|
className="agent-model-checkbox-icon"
|
||||||
<div className="flex flex-col items-end justify-center text-[10px] text-gray-400" style={{ flexShrink: 0, marginLeft: 8 }}>
|
/>
|
||||||
|
<span className="agent-model-checkbox-name">{m.model_name}</span>
|
||||||
|
</div>
|
||||||
|
<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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}))
|
}))
|
||||||
]
|
]
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue