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}
</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"

View File

@ -1,19 +1,19 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Button, Input, Space, Tag, App as AntApp, Popconfirm, Empty, Collapse, Switch, Drawer, Slider, InputNumber, Upload, Tooltip, Modal, Image as AntImage, Divider, Dropdown } from 'antd'; import { Button, Input, Space, Tag, App as AntApp, Popconfirm, Empty, Collapse, Switch, Drawer, Slider, InputNumber, Upload, Tooltip, Modal, Image as AntImage, Divider, Dropdown } from 'antd';
import { SettingOutlined, ApiOutlined, EditOutlined, DeleteOutlined, PaperClipOutlined, BookOutlined, ArrowUpOutlined, CloseOutlined, DownOutlined } from '@ant-design/icons'; import { SettingOutlined, ApiOutlined, EditOutlined, DeleteOutlined, PaperClipOutlined, BookOutlined, ArrowUpOutlined, CloseOutlined, DownOutlined } from '@ant-design/icons';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import ReactMarkdown from 'react-markdown'; 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,
SessionAPI, SessionAPI,
@ -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();
@ -46,13 +52,13 @@ export default function ChatPage() {
const [sessionId, setSessionId] = useState<string | null>(null); const [sessionId, setSessionId] = useState<string | null>(null);
const [highlightId, setHighlightId] = useState<string | null>(null); const [highlightId, setHighlightId] = useState<string | null>(null);
const [sessionRefresh, setSessionRefresh] = useState(0); const [sessionRefresh, setSessionRefresh] = useState(0);
const [agentList, setAgentList] = useState<Agent[]>([]); const [agentList, setAgentList] = useState<Agent[]>([]);
const [historyDrawerOpen, setHistoryDrawerOpen] = useState(false); const [historyDrawerOpen, setHistoryDrawerOpen] = useState(false);
const [mcpDrawerOpen, setMcpDrawerOpen] = useState(false); const [mcpDrawerOpen, setMcpDrawerOpen] = useState(false);
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);
@ -86,33 +92,34 @@ export default function ChatPage() {
}); });
}; };
const loadAgent = async () => { const loadAgent = async () => {
if (!id) { if (!id) {
setAgent(null); setAgent(null);
setMessages([]); setMessages([]);
return; return;
} }
const a = await AgentAPI.detail(id); const a = await AgentAPI.detail(id);
setAgent(a); setAgent(a);
}; };
const loadAgentList = async () => { const loadAgentList = async () => {
try { try {
const list = await AgentAPI.list(); const list = await AgentAPI.list();
setAgentList(list); setAgentList(list);
} catch (e) { } catch (e) {
// ignore // ignore
} }
}; };
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));
}; }
};
const loadMessages = async () => { const loadMessages = async () => {
if (!id || !sessionId) return; if (!id || !sessionId) return;
@ -132,21 +139,29 @@ export default function ChatPage() {
}); });
}; };
useEffect(() => { useEffect(() => {
loadAgentList(); loadAgentList();
}, []); }, []);
useEffect(() => { useEffect(() => {
loadAgent(); if (!id) {
loadProviders(); setAgent(null);
return () => abortRef.current?.abort(); setMessages([]);
// eslint-disable-next-line react-hooks/exhaustive-deps setAllowedModels([]);
}, [id]); setOverrides({});
return () => abortRef.current?.abort();
useEffect(() => { }
if (!id) return; loadAgent();
loadMessages(); loadAllowedModels(id);
// eslint-disable-next-line react-hooks/exhaustive-deps setOverrides({});
return () => abortRef.current?.abort();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
useEffect(() => {
if (!id) return;
loadMessages();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId, highlightId, id]); }, [sessionId, highlightId, id]);
/** 把附件文本拼成 system 注入字符串 */ /** 把附件文本拼成 system 注入字符串 */
@ -257,16 +272,16 @@ export default function ChatPage() {
}; };
setMessages((m) => [...m, tempUser]); setMessages((m) => [...m, tempUser]);
scrollBottom(); scrollBottom();
const attText = buildAttachmentsText(); const attText = buildAttachmentsText();
try { try {
const res = await ChatAPI.send( const res = await ChatAPI.send(
id, id,
text, text,
sessionId, sessionId,
overrides, overrides,
attText || undefined, attText || undefined,
imageUrls.length > 0 ? imageUrls : undefined imageUrls.length > 0 ? imageUrls : undefined
); );
setMessages((m) => [...m.filter((x) => x.id !== tempUser.id), res.user, res.assistant]); setMessages((m) => [...m.filter((x) => x.id !== tempUser.id), res.user, res.assistant]);
setSessionRefresh((t) => t + 1); setSessionRefresh((t) => t + 1);
setAttachments([]); setAttachments([]);
@ -396,148 +411,130 @@ 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}`, const activeModelOption = modelOptions.find((item) => item.value === activeModelValue);
providerName: provider.name const defaultModelLabel = allowedModels.length > 0 ? allowedModels.join(', ') : agentModel || '默认模型';
})) const currentModelName = activeModelOption?.label || activeModelValue || defaultModelLabel;
);
const activeModelOption = modelOptions.find((item) => item.value === activeModelValue); return (
const fallbackModelName = activeModelValue.includes('/') ? activeModelValue.split('/').slice(1).join('/') : activeModelValue; <div className="chat-shell">
const currentModelName = activeModelOption?.label || fallbackModelName || agentModel || defaultProvider?.defaultModel || '默认模型'; {/* 1. 二级侧边栏:智能体列表 */}
const currentProviderName = <aside className="chat-side">
activeModelOption?.providerName || <div style={{ padding: '16px', borderBottom: '1px solid var(--color-border)' }}>
(activeModelValue.includes('/') ? providers.find((item) => item.id === activeModelValue.split('/')[0])?.name : defaultProvider?.name) || <Button block type="dashed" onClick={() => navigate('/agents/new')}>+ </Button>
(agentModel ? '当前 Agent 配置' : defaultProvider?.name || '系统默认'); </div>
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 0' }}>
return ( {agentList.map((a) => {
<div className="chat-shell"> const isActive = a.id === id;
{/* 1. 二级侧边栏:智能体列表 */} return (
<aside <div
className="chat-side" key={a.id}
style={{ onClick={() => navigate(`/chat/${a.id}`)}
display: 'flex', style={{
flexDirection: 'column', display: 'flex',
gap: 0, alignItems: 'center',
background: 'var(--color-surface)', gap: 12,
borderRight: '1px solid var(--color-border)', padding: '10px 16px',
width: 260 cursor: 'pointer',
}} background: isActive ? 'var(--color-surface-2)' : 'transparent',
> borderLeft: `3px solid ${isActive ? 'var(--color-brand)' : 'transparent'}`,
<div style={{ padding: '16px', borderBottom: '1px solid var(--color-border)' }}> transition: 'background 0.2s'
<Button block type="dashed" onClick={() => navigate('/agents/new')}>+ </Button> }}
</div> >
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 0' }}> <div
{agentList.map((a) => { style={{
const isActive = a.id === id; width: 32,
return ( height: 32,
<div borderRadius: '50%',
key={a.id} background: a.avatar || 'var(--gradient-brand)',
onClick={() => navigate(`/chat/${a.id}`)} color: '#fff',
style={{ display: 'flex',
display: 'flex', alignItems: 'center',
alignItems: 'center', justifyContent: 'center',
gap: 12, fontWeight: 700,
padding: '10px 16px', fontSize: 14,
cursor: 'pointer', overflow: 'hidden'
background: isActive ? 'var(--color-surface-2)' : 'transparent', }}
borderLeft: `3px solid ${isActive ? 'var(--color-brand)' : 'transparent'}`, >
transition: 'background 0.2s' {isImageUrl(a.avatar) ? (
}} <img src={a.avatar} className="w-full h-full object-cover" alt="avatar" />
> ) : (
<div (a.name?.charAt(0) || '?').toUpperCase()
style={{ )}
width: 32, </div>
height: 32, <div style={{ flex: 1, minWidth: 0 }}>
borderRadius: '50%', <div style={{ fontWeight: isActive ? 600 : 500, fontSize: 14, color: 'var(--color-text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
background: a.avatar || 'var(--gradient-brand)', {a.name}
color: '#fff', </div>
display: 'flex', </div>
alignItems: 'center', </div>
justifyContent: 'center', );
fontWeight: 700, })}
fontSize: 14, </div>
overflow: 'hidden' </aside>
}}
> {/* 2. 主聊天区 */}
{isImageUrl(a.avatar) ? ( <section className="chat-main">
<img src={a.avatar} className="w-full h-full object-cover" alt="avatar" /> {!agent ? (
) : ( <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
(a.name?.charAt(0) || '?').toUpperCase() <Empty description="请在左侧选择一个智能体开始对话" />
)} </div>
</div> ) : (
<div style={{ flex: 1, minWidth: 0 }}> <>
<div style={{ fontWeight: isActive ? 600 : 500, fontSize: 14, color: 'var(--color-text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> {/* Header */}
{a.name} <div className="chat-header">
</div> <div>
</div> <div style={{ fontWeight: 600, fontSize: 16, color: 'var(--color-text)' }}>{agent.name}</div>
</div> <div style={{ fontSize: 12, color: 'var(--color-text-tertiary)' }}>
); {agent.model || '默认模型'} · T={agent.temperature}
})} </div>
</div> </div>
</aside> <Space>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginRight: 12 }}>
{/* 2. 主聊天区 */} <span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}></span>
<section className="chat-main" style={{ flex: 1, display: 'flex', flexDirection: 'column', position: 'relative' }}> <Switch size="small" checked={useStream} onChange={setUseStream} />
{!agent ? ( </div>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}> <Button size="small" onClick={() => setHistoryDrawerOpen(true)}></Button>
<Empty description="请在左侧选择一个智能体开始对话" /> <Dropdown
</div> menu={{
) : ( items: [
<> { key: 'params', label: '模型参数', icon: <SettingOutlined />, onClick: () => setParamsDrawerOpen(true) },
{/* Header */} { key: 'mcp', label: 'MCP 资源', icon: <ApiOutlined />, onClick: () => setMcpDrawerOpen(true) },
<div className="chat-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0 24px' }}> { key: 'edit', label: '管理 Agent', icon: <EditOutlined />, onClick: () => navigate(`/agents/${id}`) },
<div> { type: 'divider' },
<div style={{ fontWeight: 600, fontSize: 16, color: 'var(--color-text)' }}>{agent.name}</div> { key: 'clear', label: '清空对话', icon: <DeleteOutlined />, danger: true, onClick: () => {
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)' }}> Modal.confirm({
{agent.model || '默认模型'} · T={agent.temperature} title: '清空当前会话所有消息?',
</div> onOk: handleClear
</div> });
<Space> }
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginRight: 12 }}> }
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}></span> ]
<Switch size="small" checked={useStream} onChange={setUseStream} /> }}
</div> >
<Button size="small" onClick={() => setHistoryDrawerOpen(true)}></Button> <Button size="small"> <DownOutlined /></Button>
<Dropdown </Dropdown>
menu={{ </Space>
items: [ </div>
{ key: 'params', label: '模型参数', icon: <SettingOutlined />, onClick: () => setParamsDrawerOpen(true) },
{ key: 'mcp', label: 'MCP 资源', icon: <ApiOutlined />, onClick: () => setMcpDrawerOpen(true) }, {/* Body */}
{ key: 'edit', label: '管理 Agent', icon: <EditOutlined />, onClick: () => navigate(`/agents/${id}`) },
{ type: 'divider' },
{ key: 'clear', label: '清空对话', icon: <DeleteOutlined />, danger: true, onClick: () => {
Modal.confirm({
title: '清空当前会话所有消息?',
onOk: handleClear
});
}
}
]
}}
>
<Button size="small"> <DownOutlined /></Button>
</Dropdown>
</Space>
</div>
{/* Body */}
<div ref={bodyRef} className="chat-body"> <div ref={bodyRef} className="chat-body">
<div className="messages-container"> <div className="messages-container">
{messages.length === 0 && !streaming.active ? ( {messages.length === 0 && !streaming.active ? (
<div style={{ textAlign: 'center', marginTop: 120 }}> <div style={{ textAlign: 'center', marginTop: 120 }}>
<div <div
style={{ style={{
width: 68, height: 68, borderRadius: '50%', width: 68, height: 68, borderRadius: '50%',
background: agent.avatar || 'var(--gradient-brand)', background: agent.avatar || 'var(--gradient-brand)',
color: '#fff', display: 'flex', alignItems: 'center', color: '#fff', display: 'flex', alignItems: 'center',
justifyContent: 'center', fontWeight: 700, fontSize: 32, justifyContent: 'center', fontWeight: 700, fontSize: 32,
margin: '0 auto 20px', boxShadow: 'var(--shadow-lg)', margin: '0 auto 20px', boxShadow: 'var(--shadow-lg)',
overflow: 'hidden' overflow: 'hidden'
}}> }}>
{isImageUrl(agent.avatar) ? ( {isImageUrl(agent.avatar) ? (
@ -546,20 +543,20 @@ export default function ChatPage() {
(agent.name?.charAt(0) || '?').toUpperCase() (agent.name?.charAt(0) || '?').toUpperCase()
)} )}
</div> </div>
<h2 <h2
style={{ style={{
fontSize: 28, fontSize: 28,
fontWeight: 700, fontWeight: 700,
color: 'var(--color-text)', color: 'var(--color-text)',
marginBottom: 8, marginBottom: 8,
letterSpacing: '-0.02em' letterSpacing: '-0.02em'
}} }}
> >
</h2> </h2>
<p style={{ color: 'var(--color-text-secondary)', fontSize: 15, lineHeight: 1.7 }}> <p style={{ color: 'var(--color-text-secondary)', fontSize: 15, lineHeight: 1.7 }}>
{agent.description || '我是你的专属 AI 助手,随时准备为你服务。'} {agent.description || '我是你的专属 AI 助手,随时准备为你服务。'}
</p> </p>
</div> </div>
) : ( ) : (
<> <>
@ -583,7 +580,7 @@ export default function ChatPage() {
{streaming.text ? ( {streaming.text ? (
<ReactMarkdown>{streaming.text + '▍'}</ReactMarkdown> <ReactMarkdown>{streaming.text + '▍'}</ReactMarkdown>
) : ( ) : (
<span style={{ color: 'var(--color-text-tertiary)' }}></span> <span style={{ color: 'var(--color-text-tertiary)' }}></span>
)} )}
</div> </div>
{(streaming.retrieved.length > 0 || streaming.toolCalls.length > 0) && ( {(streaming.retrieved.length > 0 || streaming.toolCalls.length > 0) && (
@ -624,7 +621,7 @@ export default function ChatPage() {
src={u} src={u}
width={48} width={48}
height={48} height={48}
style={{ objectFit: 'cover', borderRadius: 8, border: '1px solid var(--color-border)' }} style={{ objectFit: 'cover', borderRadius: 8, border: '1px solid var(--color-border)' }}
/> />
<Button <Button
size="small" size="small"
@ -641,13 +638,13 @@ export default function ChatPage() {
))} ))}
</div> </div>
<div className="chat-input-card"> <div className="chat-input-card">
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, width: '100%' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 10, width: '100%' }}>
<Input.TextArea <Input.TextArea
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
placeholder="问我任何问题..." placeholder="问我任何问题..."
autoSize={{ minRows: 3, maxRows: 10 }} autoSize={{ minRows: 3, maxRows: 10 }}
onPressEnter={(e) => { onPressEnter={(e) => {
if (!e.shiftKey) { if (!e.shiftKey) {
e.preventDefault(); e.preventDefault();
@ -656,120 +653,119 @@ export default function ChatPage() {
}} }}
className="chat-input-textarea" className="chat-input-textarea"
disabled={sending} disabled={sending}
style={{ width: '100%' }} style={{ width: '100%' }}
/> />
<div <div
style={{ style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
gap: 12, gap: 12,
paddingTop: 8, paddingTop: 8,
borderTop: '1px solid var(--color-border)' borderTop: '1px solid var(--color-border)'
}} }}
> >
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flex: 1, minWidth: 0 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 12, flex: 1, minWidth: 0 }}>
<Dropdown <Dropdown
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,
model: String(key) === '__default__' ? undefined : String(key) model: String(key) === '__default__' ? undefined : String(key)
})), })),
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 }))
})) ]
] }}
}} >
> <button
<button type="button"
type="button" style={{
style={{ display: 'inline-flex',
display: 'inline-flex', alignItems: 'center',
alignItems: 'center', gap: 6,
gap: 6, minWidth: 0,
minWidth: 0, padding: '0 2px',
padding: '0 2px', background: 'transparent',
background: 'transparent', border: 'none',
border: 'none', color: 'var(--color-text-secondary)',
color: 'var(--color-text-secondary)', cursor: 'pointer',
cursor: 'pointer', fontSize: 13,
fontSize: 13, lineHeight: 1.2
lineHeight: 1.2 }}
}} >
> <span
<span style={{
style={{ maxWidth: 240,
maxWidth: 240, overflow: 'hidden',
overflow: 'hidden', textOverflow: 'ellipsis',
textOverflow: 'ellipsis', whiteSpace: 'nowrap',
whiteSpace: 'nowrap', fontWeight: 500,
fontWeight: 500, color: 'var(--color-text)'
color: 'var(--color-text)' }}
}} >
> {currentModelName}
{currentModelName} </span>
</span> <DownOutlined style={{ fontSize: 10, color: 'var(--color-text-tertiary)' }} />
<DownOutlined style={{ fontSize: 10, color: 'var(--color-text-tertiary)' }} /> </button>
</button> </Dropdown>
</Dropdown>
<Upload
<Upload multiple
multiple beforeUpload={(_f, files) => {
beforeUpload={(_f, files) => { handleAttach(files as File[]);
handleAttach(files as File[]); return false;
return false; }}
}} showUploadList={false}
showUploadList={false} accept=".txt,.md,.markdown,.json,.csv,.pdf,.docx,.html,.htm,image/png,image/jpeg,image/webp,image/gif"
accept=".txt,.md,.markdown,.json,.csv,.pdf,.docx,.html,.htm,image/png,image/jpeg,image/webp,image/gif" >
> <Button type="text" icon={<PaperClipOutlined style={{ fontSize: 18 }} />} />
<Button type="text" icon={<PaperClipOutlined style={{ fontSize: 18 }} />} /> </Upload>
</Upload> <Button type="text" icon={<BookOutlined style={{ fontSize: 18 }} />} onClick={() => setTplDrawerOpen(true)} />
<Button type="text" icon={<BookOutlined style={{ fontSize: 18 }} />} onClick={() => setTplDrawerOpen(true)} /> </div>
</div>
{sending ? (
{sending ? ( <Button
<Button danger
danger shape="circle"
shape="circle" onClick={handleStop}
onClick={handleStop} icon={<div style={{ width: 10, height: 10, background: 'currentColor', borderRadius: 2 }} />}
icon={<div style={{ width: 10, height: 10, background: 'currentColor', borderRadius: 2 }} />} style={{ width: 40, height: 40, flexShrink: 0 }}
style={{ width: 40, height: 40, flexShrink: 0 }} />
/> ) : (
) : ( <Button
<Button type="primary"
type="primary" shape="circle"
shape="circle" onClick={handleSend}
onClick={handleSend} icon={<ArrowUpOutlined />}
icon={<ArrowUpOutlined />} disabled={!input.trim()}
disabled={!input.trim()} style={{ background: 'var(--color-brand)', border: 'none', width: 40, height: 40, flexShrink: 0 }}
style={{ background: 'var(--color-brand)', border: 'none', width: 40, height: 40, flexShrink: 0 }} />
/> )}
)} </div>
</div>
</div> </div>
</div> </div>
<div style={{ textAlign: 'center', fontSize: 11, color: 'var(--color-text-tertiary)', marginTop: 8 }}> <div style={{ textAlign: 'center', fontSize: 11, color: 'var(--color-text-tertiary)', marginTop: 8 }}>
AI AI
</div> </div>
</div> </div>
</> </>
)} )}
</section> </section>
{/* ---- 抽屉组件保持不变 ---- */} {/* ---- 抽屉组件保持不变 ---- */}
{id && ( {id && (
<McpResourcesDrawer <McpResourcesDrawer
agentId={id} agentId={id}
open={mcpDrawerOpen} open={mcpDrawerOpen}
@ -799,17 +795,17 @@ export default function ChatPage() {
onClose={() => setParamsDrawerOpen(false)} onClose={() => setParamsDrawerOpen(false)}
width={380} width={380}
> >
<div <div
style={{ style={{
marginBottom: 16, marginBottom: 16,
padding: 12, padding: 12,
background: 'var(--color-warning-soft)', background: 'var(--color-warning-soft)',
borderRadius: 10, borderRadius: 10,
fontSize: 12, fontSize: 12,
color: 'var(--color-warning)', color: 'var(--color-warning)',
border: '1px solid var(--color-border)' border: '1px solid var(--color-border)'
}} }}
> >
💡 <b></b><br /> 💡 <b></b><br />
</div> </div>
@ -817,7 +813,7 @@ export default function ChatPage() {
<div style={{ marginBottom: 24 }}> <div style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<span>🌡 Temperature</span> <span>🌡 Temperature</span>
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}> <span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
{agent?.temperature ?? 0.7} {agent?.temperature ?? 0.7}
</span> </span>
</div> </div>
@ -839,7 +835,7 @@ export default function ChatPage() {
<div style={{ marginBottom: 24 }}> <div style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<span>🎯 Top P</span> <span>🎯 Top P</span>
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}></span> <span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}></span>
</div> </div>
<Slider <Slider
min={0.1} min={0.1}
@ -863,7 +859,7 @@ export default function ChatPage() {
<div style={{ marginBottom: 24 }}> <div style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<span>📏 Max Tokens</span> <span>📏 Max Tokens</span>
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}></span> <span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}></span>
</div> </div>
<InputNumber <InputNumber
value={overrides.maxTokens} value={overrides.maxTokens}
@ -875,34 +871,34 @@ export default function ChatPage() {
/> />
</div> </div>
<Button block onClick={() => setOverrides({})}> <Button block onClick={() => setOverrides({})}>
🔄 🔄
</Button> </Button>
</Drawer> </Drawer>
<Drawer <Drawer
title="历史对话" title="历史对话"
placement="right" placement="right"
width={320} width={320}
onClose={() => setHistoryDrawerOpen(false)} onClose={() => setHistoryDrawerOpen(false)}
open={historyDrawerOpen} open={historyDrawerOpen}
bodyStyle={{ padding: 0 }} bodyStyle={{ padding: 0 }}
> >
{id && ( {id && (
<SessionSidebar <SessionSidebar
agentId={id} agentId={id}
activeSessionId={sessionId} activeSessionId={sessionId}
onChange={(sid, opts) => { onChange={(sid, opts) => {
setSessionId(sid); setSessionId(sid);
setHighlightId(opts?.highlightMessageId || null); setHighlightId(opts?.highlightMessageId || null);
setHistoryDrawerOpen(false); setHistoryDrawerOpen(false);
}} }}
refreshTick={sessionRefresh} refreshTick={sessionRefresh}
/> />
)} )}
</Drawer> </Drawer>
</div> </div>
); );
} }
/** 输入栏子组件 */ /** 输入栏子组件 */

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;