feat(chat): load allowed llms per agent
parent
46875d6337
commit
1d79e929f9
|
|
@ -512,8 +512,17 @@ export interface LLMProvider {
|
|||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface AgentAllowedLLMResponse {
|
||||
agentId: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export const LLMProviderAPI = {
|
||||
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 }) =>
|
||||
api.post<{ id: string }>('/llm-providers', payload).then((r) => r.data),
|
||||
update: (id: string, payload: Partial<LLMProvider> & { apiKey?: string }) =>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 { useNavigate, useParams } from 'react-router-dom';
|
||||
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(', ') : ''
|
||||
}
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="选择模型"
|
||||
style={{ height: 42 }}
|
||||
dropdownStyle={{ borderRadius: 12 }}
|
||||
maxTagCount="responsive"
|
||||
optionFilterProp="value"
|
||||
options={models.map((m) => {
|
||||
<Checkbox.Group className="agent-model-checkbox-group">
|
||||
{models.map((m) => {
|
||||
const inputPrice = 2 * m.model_ratio;
|
||||
const outputPrice = inputPrice * m.completion_ratio;
|
||||
return {
|
||||
value: m.model_name,
|
||||
label: (
|
||||
<div className="flex items-center justify-between w-full py-1">
|
||||
<div className="flex items-center gap-2" style={{ flex: 1, minWidth: 0 }}>
|
||||
<img src={m.icon || DEFAULT_RH_40X40_GRAY} alt={m.model_name} style={{ width: 20, height: 20, objectFit: 'contain', borderRadius: 4, flexShrink: 0 }} />
|
||||
<span className="font-medium text-gray-800" style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{m.model_name}</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-end justify-center text-[10px] text-gray-400" style={{ flexShrink: 0, marginLeft: 8 }}>
|
||||
return (
|
||||
<Checkbox key={m.model_name} value={m.model_name} className="agent-model-checkbox-item">
|
||||
<div className="agent-model-checkbox-content">
|
||||
<div className="agent-model-checkbox-meta">
|
||||
<img
|
||||
src={m.icon || DEFAULT_RH_40X40_GRAY}
|
||||
alt={m.model_name}
|
||||
className="agent-model-checkbox-icon"
|
||||
/>
|
||||
<span className="agent-model-checkbox-name">{m.model_name}</span>
|
||||
</div>
|
||||
<div className="agent-model-checkbox-price">
|
||||
<span>输入: ${inputPrice.toFixed(2)}/M</span>
|
||||
<span>输出: ${outputPrice.toFixed(2)}/M</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
</Checkbox>
|
||||
);
|
||||
})}
|
||||
/>
|
||||
</Checkbox.Group>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="temperature"
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
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 { SettingOutlined, ApiOutlined, EditOutlined, DeleteOutlined, PaperClipOutlined, BookOutlined, ArrowUpOutlined, CloseOutlined, DownOutlined } from '@ant-design/icons';
|
||||
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 { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import {
|
||||
Agent,
|
||||
AgentAPI,
|
||||
AgentAllowedLLMResponse,
|
||||
BranchInfo,
|
||||
ChatAPI,
|
||||
ChatAttachment,
|
||||
ChatAttachmentsAPI,
|
||||
ChatMessage,
|
||||
ImageAPI,
|
||||
LLMProvider,
|
||||
LLMProviderAPI,
|
||||
LLMProviderAPI,
|
||||
ModelOverrides,
|
||||
RetrievedSnippet,
|
||||
SessionAPI,
|
||||
|
|
@ -32,6 +32,12 @@ interface StreamingState {
|
|||
toolCalls: ToolCallTrace[];
|
||||
}
|
||||
|
||||
const parseAllowedModels = (payload?: Pick<AgentAllowedLLMResponse, 'model'> | null) =>
|
||||
String(payload?.model || '')
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
export default function ChatPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -46,13 +52,13 @@ export default function ChatPage() {
|
|||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [highlightId, setHighlightId] = useState<string | null>(null);
|
||||
const [sessionRefresh, setSessionRefresh] = useState(0);
|
||||
const [agentList, setAgentList] = useState<Agent[]>([]);
|
||||
const [historyDrawerOpen, setHistoryDrawerOpen] = useState(false);
|
||||
const [agentList, setAgentList] = useState<Agent[]>([]);
|
||||
const [historyDrawerOpen, setHistoryDrawerOpen] = useState(false);
|
||||
const [mcpDrawerOpen, setMcpDrawerOpen] = useState(false);
|
||||
const [paramsDrawerOpen, setParamsDrawerOpen] = useState(false);
|
||||
const [tplDrawerOpen, setTplDrawerOpen] = useState(false);
|
||||
const [overrides, setOverrides] = useState<ModelOverrides>({});
|
||||
const [providers, setProviders] = useState<LLMProvider[]>([]);
|
||||
const [allowedModels, setAllowedModels] = useState<string[]>([]);
|
||||
const [attachments, setAttachments] = useState<ChatAttachment[]>([]);
|
||||
const [imageUrls, setImageUrls] = useState<string[]>([]);
|
||||
const [uploadingAtt, setUploadingAtt] = useState(false);
|
||||
|
|
@ -86,33 +92,34 @@ export default function ChatPage() {
|
|||
});
|
||||
};
|
||||
|
||||
const loadAgent = async () => {
|
||||
if (!id) {
|
||||
setAgent(null);
|
||||
setMessages([]);
|
||||
return;
|
||||
}
|
||||
const a = await AgentAPI.detail(id);
|
||||
setAgent(a);
|
||||
};
|
||||
|
||||
const loadAgentList = async () => {
|
||||
try {
|
||||
const list = await AgentAPI.list();
|
||||
setAgentList(list);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const loadProviders = async () => {
|
||||
try {
|
||||
const list = await LLMProviderAPI.list();
|
||||
setProviders(list.filter((item) => item.enabled));
|
||||
} catch (e: any) {
|
||||
msg.error('加载模型列表失败:' + (e?.message ?? e));
|
||||
}
|
||||
};
|
||||
const loadAgent = async () => {
|
||||
if (!id) {
|
||||
setAgent(null);
|
||||
setMessages([]);
|
||||
return;
|
||||
}
|
||||
const a = await AgentAPI.detail(id);
|
||||
setAgent(a);
|
||||
};
|
||||
|
||||
const loadAgentList = async () => {
|
||||
try {
|
||||
const list = await AgentAPI.list();
|
||||
setAgentList(list);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const loadAllowedModels = async (agentId: string) => {
|
||||
try {
|
||||
const result = await LLMProviderAPI.allowedModels(agentId);
|
||||
setAllowedModels(parseAllowedModels(result));
|
||||
} catch (e: any) {
|
||||
setAllowedModels([]);
|
||||
msg.error('加载智能体模型失败:' + (e?.message ?? e));
|
||||
}
|
||||
};
|
||||
|
||||
const loadMessages = async () => {
|
||||
if (!id || !sessionId) return;
|
||||
|
|
@ -132,21 +139,29 @@ export default function ChatPage() {
|
|||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadAgentList();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadAgent();
|
||||
loadProviders();
|
||||
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
|
||||
useEffect(() => {
|
||||
loadAgentList();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) {
|
||||
setAgent(null);
|
||||
setMessages([]);
|
||||
setAllowedModels([]);
|
||||
setOverrides({});
|
||||
return () => abortRef.current?.abort();
|
||||
}
|
||||
loadAgent();
|
||||
loadAllowedModels(id);
|
||||
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]);
|
||||
|
||||
/** 把附件文本拼成 system 注入字符串 */
|
||||
|
|
@ -257,16 +272,16 @@ export default function ChatPage() {
|
|||
};
|
||||
setMessages((m) => [...m, tempUser]);
|
||||
scrollBottom();
|
||||
const attText = buildAttachmentsText();
|
||||
const attText = buildAttachmentsText();
|
||||
try {
|
||||
const res = await ChatAPI.send(
|
||||
id,
|
||||
text,
|
||||
sessionId,
|
||||
overrides,
|
||||
attText || undefined,
|
||||
imageUrls.length > 0 ? imageUrls : undefined
|
||||
);
|
||||
const res = await ChatAPI.send(
|
||||
id,
|
||||
text,
|
||||
sessionId,
|
||||
overrides,
|
||||
attText || undefined,
|
||||
imageUrls.length > 0 ? imageUrls : undefined
|
||||
);
|
||||
setMessages((m) => [...m.filter((x) => x.id !== tempUser.id), res.user, res.assistant]);
|
||||
setSessionRefresh((t) => t + 1);
|
||||
setAttachments([]);
|
||||
|
|
@ -396,148 +411,130 @@ export default function ChatPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const isImageUrl = (url: string) => url?.startsWith('http') || url?.startsWith('/');
|
||||
const agentModel = agent?.model || '';
|
||||
const activeModelValue = overrides.model || agentModel;
|
||||
const defaultProvider = providers.find((item) => item.isDefault) || providers[0];
|
||||
const modelOptions = providers.flatMap((provider) =>
|
||||
(provider.models?.length ? provider.models : [provider.defaultModel]).filter(Boolean).map((modelName) => ({
|
||||
value: `${provider.id}/${modelName}`,
|
||||
label: `${modelName}`,
|
||||
providerName: provider.name
|
||||
}))
|
||||
);
|
||||
const activeModelOption = modelOptions.find((item) => item.value === activeModelValue);
|
||||
const fallbackModelName = activeModelValue.includes('/') ? activeModelValue.split('/').slice(1).join('/') : activeModelValue;
|
||||
const currentModelName = activeModelOption?.label || fallbackModelName || agentModel || defaultProvider?.defaultModel || '默认模型';
|
||||
const currentProviderName =
|
||||
activeModelOption?.providerName ||
|
||||
(activeModelValue.includes('/') ? providers.find((item) => item.id === activeModelValue.split('/')[0])?.name : defaultProvider?.name) ||
|
||||
(agentModel ? '当前 Agent 配置' : defaultProvider?.name || '系统默认');
|
||||
|
||||
return (
|
||||
<div className="chat-shell">
|
||||
{/* 1. 二级侧边栏:智能体列表 */}
|
||||
<aside
|
||||
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)' }}>
|
||||
<Button block type="dashed" onClick={() => navigate('/agents/new')}>+ 创建智能体</Button>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 0' }}>
|
||||
{agentList.map((a) => {
|
||||
const isActive = a.id === id;
|
||||
return (
|
||||
<div
|
||||
key={a.id}
|
||||
onClick={() => navigate(`/chat/${a.id}`)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '10px 16px',
|
||||
cursor: 'pointer',
|
||||
background: isActive ? 'var(--color-surface-2)' : 'transparent',
|
||||
borderLeft: `3px solid ${isActive ? 'var(--color-brand)' : 'transparent'}`,
|
||||
transition: 'background 0.2s'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: '50%',
|
||||
background: a.avatar || 'var(--gradient-brand)',
|
||||
color: '#fff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: 700,
|
||||
fontSize: 14,
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{isImageUrl(a.avatar) ? (
|
||||
<img src={a.avatar} className="w-full h-full object-cover" alt="avatar" />
|
||||
) : (
|
||||
(a.name?.charAt(0) || '?').toUpperCase()
|
||||
)}
|
||||
</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' }}>
|
||||
{a.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* 2. 主聊天区 */}
|
||||
<section className="chat-main" style={{ flex: 1, display: 'flex', flexDirection: 'column', position: 'relative' }}>
|
||||
{!agent ? (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Empty description="请在左侧选择一个智能体开始对话" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="chat-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0 24px' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 16, color: 'var(--color-text)' }}>{agent.name}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)' }}>
|
||||
{agent.model || '默认模型'} · T={agent.temperature}
|
||||
</div>
|
||||
</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>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'params', label: '模型参数', icon: <SettingOutlined />, onClick: () => setParamsDrawerOpen(true) },
|
||||
{ key: 'mcp', label: 'MCP 资源', icon: <ApiOutlined />, onClick: () => setMcpDrawerOpen(true) },
|
||||
{ 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 */}
|
||||
const isImageUrl = (url: string) => url?.startsWith('http') || url?.startsWith('/');
|
||||
const agentModel = agent?.model || '';
|
||||
const modelOptions = allowedModels.map((modelName) => ({
|
||||
value: modelName,
|
||||
label: modelName
|
||||
}));
|
||||
const activeModelValue = overrides.model || '';
|
||||
const activeModelOption = modelOptions.find((item) => item.value === activeModelValue);
|
||||
const defaultModelLabel = allowedModels.length > 0 ? allowedModels.join(', ') : agentModel || '默认模型';
|
||||
const currentModelName = activeModelOption?.label || activeModelValue || defaultModelLabel;
|
||||
|
||||
return (
|
||||
<div className="chat-shell">
|
||||
{/* 1. 二级侧边栏:智能体列表 */}
|
||||
<aside className="chat-side">
|
||||
<div style={{ padding: '16px', borderBottom: '1px solid var(--color-border)' }}>
|
||||
<Button block type="dashed" onClick={() => navigate('/agents/new')}>+ 创建智能体</Button>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 0' }}>
|
||||
{agentList.map((a) => {
|
||||
const isActive = a.id === id;
|
||||
return (
|
||||
<div
|
||||
key={a.id}
|
||||
onClick={() => navigate(`/chat/${a.id}`)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '10px 16px',
|
||||
cursor: 'pointer',
|
||||
background: isActive ? 'var(--color-surface-2)' : 'transparent',
|
||||
borderLeft: `3px solid ${isActive ? 'var(--color-brand)' : 'transparent'}`,
|
||||
transition: 'background 0.2s'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: '50%',
|
||||
background: a.avatar || 'var(--gradient-brand)',
|
||||
color: '#fff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: 700,
|
||||
fontSize: 14,
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{isImageUrl(a.avatar) ? (
|
||||
<img src={a.avatar} className="w-full h-full object-cover" alt="avatar" />
|
||||
) : (
|
||||
(a.name?.charAt(0) || '?').toUpperCase()
|
||||
)}
|
||||
</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' }}>
|
||||
{a.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* 2. 主聊天区 */}
|
||||
<section className="chat-main">
|
||||
{!agent ? (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Empty description="请在左侧选择一个智能体开始对话" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="chat-header">
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 16, color: 'var(--color-text)' }}>{agent.name}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)' }}>
|
||||
{agent.model || '默认模型'} · T={agent.temperature}
|
||||
</div>
|
||||
</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>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'params', label: '模型参数', icon: <SettingOutlined />, onClick: () => setParamsDrawerOpen(true) },
|
||||
{ key: 'mcp', label: 'MCP 资源', icon: <ApiOutlined />, onClick: () => setMcpDrawerOpen(true) },
|
||||
{ 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 className="messages-container">
|
||||
{messages.length === 0 && !streaming.active ? (
|
||||
<div style={{ textAlign: 'center', marginTop: 120 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 68, height: 68, borderRadius: '50%',
|
||||
background: agent.avatar || 'var(--gradient-brand)',
|
||||
color: '#fff', display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'center', fontWeight: 700, fontSize: 32,
|
||||
margin: '0 auto 20px', boxShadow: 'var(--shadow-lg)',
|
||||
<div style={{ textAlign: 'center', marginTop: 120 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 68, height: 68, borderRadius: '50%',
|
||||
background: agent.avatar || 'var(--gradient-brand)',
|
||||
color: '#fff', display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'center', fontWeight: 700, fontSize: 32,
|
||||
margin: '0 auto 20px', boxShadow: 'var(--shadow-lg)',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{isImageUrl(agent.avatar) ? (
|
||||
|
|
@ -546,20 +543,20 @@ export default function ChatPage() {
|
|||
(agent.name?.charAt(0) || '?').toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: 28,
|
||||
fontWeight: 700,
|
||||
color: 'var(--color-text)',
|
||||
marginBottom: 8,
|
||||
letterSpacing: '-0.02em'
|
||||
}}
|
||||
>
|
||||
你好,今天想一起完成什么?
|
||||
</h2>
|
||||
<p style={{ color: 'var(--color-text-secondary)', fontSize: 15, lineHeight: 1.7 }}>
|
||||
{agent.description || '我是你的专属 AI 助手,随时准备为你服务。'}
|
||||
</p>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: 28,
|
||||
fontWeight: 700,
|
||||
color: 'var(--color-text)',
|
||||
marginBottom: 8,
|
||||
letterSpacing: '-0.02em'
|
||||
}}
|
||||
>
|
||||
你好,今天想一起完成什么?
|
||||
</h2>
|
||||
<p style={{ color: 'var(--color-text-secondary)', fontSize: 15, lineHeight: 1.7 }}>
|
||||
{agent.description || '我是你的专属 AI 助手,随时准备为你服务。'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -583,7 +580,7 @@ export default function ChatPage() {
|
|||
{streaming.text ? (
|
||||
<ReactMarkdown>{streaming.text + '▍'}</ReactMarkdown>
|
||||
) : (
|
||||
<span style={{ color: 'var(--color-text-tertiary)' }}>思考中…</span>
|
||||
<span style={{ color: 'var(--color-text-tertiary)' }}>思考中…</span>
|
||||
)}
|
||||
</div>
|
||||
{(streaming.retrieved.length > 0 || streaming.toolCalls.length > 0) && (
|
||||
|
|
@ -624,7 +621,7 @@ export default function ChatPage() {
|
|||
src={u}
|
||||
width={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
|
||||
size="small"
|
||||
|
|
@ -641,13 +638,13 @@ export default function ChatPage() {
|
|||
))}
|
||||
</div>
|
||||
|
||||
<div className="chat-input-card">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, width: '100%' }}>
|
||||
<div className="chat-input-card">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, width: '100%' }}>
|
||||
<Input.TextArea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="问我任何问题..."
|
||||
autoSize={{ minRows: 3, maxRows: 10 }}
|
||||
autoSize={{ minRows: 3, maxRows: 10 }}
|
||||
onPressEnter={(e) => {
|
||||
if (!e.shiftKey) {
|
||||
e.preventDefault();
|
||||
|
|
@ -656,120 +653,119 @@ export default function ChatPage() {
|
|||
}}
|
||||
className="chat-input-textarea"
|
||||
disabled={sending}
|
||||
style={{ width: '100%' }}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
paddingTop: 8,
|
||||
borderTop: '1px solid var(--color-border)'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flex: 1, minWidth: 0 }}>
|
||||
<Dropdown
|
||||
trigger={['hover']}
|
||||
placement="topLeft"
|
||||
menu={{
|
||||
selectedKeys: activeModelValue ? [activeModelValue] : [],
|
||||
onClick: ({ key }) =>
|
||||
setOverrides((o) => ({
|
||||
...o,
|
||||
model: String(key) === '__default__' ? undefined : String(key)
|
||||
})),
|
||||
items: [
|
||||
{
|
||||
key: '__default__',
|
||||
label: `跟随默认 · ${agentModel || defaultProvider?.defaultModel || '默认模型'}`
|
||||
},
|
||||
...modelOptions.map((item) => ({
|
||||
key: item.value,
|
||||
label: `${item.label}`,
|
||||
extra: item.providerName
|
||||
}))
|
||||
]
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
minWidth: 0,
|
||||
padding: '0 2px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'var(--color-text-secondary)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.2
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
maxWidth: 240,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontWeight: 500,
|
||||
color: 'var(--color-text)'
|
||||
}}
|
||||
>
|
||||
{currentModelName}
|
||||
</span>
|
||||
<DownOutlined style={{ fontSize: 10, color: 'var(--color-text-tertiary)' }} />
|
||||
</button>
|
||||
</Dropdown>
|
||||
|
||||
<Upload
|
||||
multiple
|
||||
beforeUpload={(_f, files) => {
|
||||
handleAttach(files as File[]);
|
||||
return false;
|
||||
}}
|
||||
showUploadList={false}
|
||||
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 }} />} />
|
||||
</Upload>
|
||||
<Button type="text" icon={<BookOutlined style={{ fontSize: 18 }} />} onClick={() => setTplDrawerOpen(true)} />
|
||||
</div>
|
||||
|
||||
{sending ? (
|
||||
<Button
|
||||
danger
|
||||
shape="circle"
|
||||
onClick={handleStop}
|
||||
icon={<div style={{ width: 10, height: 10, background: 'currentColor', borderRadius: 2 }} />}
|
||||
style={{ width: 40, height: 40, flexShrink: 0 }}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
shape="circle"
|
||||
onClick={handleSend}
|
||||
icon={<ArrowUpOutlined />}
|
||||
disabled={!input.trim()}
|
||||
style={{ background: 'var(--color-brand)', border: 'none', width: 40, height: 40, flexShrink: 0 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
paddingTop: 8,
|
||||
borderTop: '1px solid var(--color-border)'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flex: 1, minWidth: 0 }}>
|
||||
<Dropdown
|
||||
trigger={['hover']}
|
||||
placement="topLeft"
|
||||
menu={{
|
||||
selectedKeys: [activeModelValue || '__default__'],
|
||||
onClick: ({ key }) =>
|
||||
setOverrides((o) => ({
|
||||
...o,
|
||||
model: String(key) === '__default__' ? undefined : String(key)
|
||||
})),
|
||||
items: [
|
||||
{
|
||||
key: '__default__',
|
||||
label: `跟随默认 · ${defaultModelLabel}`
|
||||
},
|
||||
...modelOptions.map((item) => ({
|
||||
key: item.value,
|
||||
label: `${item.label}`
|
||||
}))
|
||||
]
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
minWidth: 0,
|
||||
padding: '0 2px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'var(--color-text-secondary)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.2
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
maxWidth: 240,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontWeight: 500,
|
||||
color: 'var(--color-text)'
|
||||
}}
|
||||
>
|
||||
{currentModelName}
|
||||
</span>
|
||||
<DownOutlined style={{ fontSize: 10, color: 'var(--color-text-tertiary)' }} />
|
||||
</button>
|
||||
</Dropdown>
|
||||
|
||||
<Upload
|
||||
multiple
|
||||
beforeUpload={(_f, files) => {
|
||||
handleAttach(files as File[]);
|
||||
return false;
|
||||
}}
|
||||
showUploadList={false}
|
||||
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 }} />} />
|
||||
</Upload>
|
||||
<Button type="text" icon={<BookOutlined style={{ fontSize: 18 }} />} onClick={() => setTplDrawerOpen(true)} />
|
||||
</div>
|
||||
|
||||
{sending ? (
|
||||
<Button
|
||||
danger
|
||||
shape="circle"
|
||||
onClick={handleStop}
|
||||
icon={<div style={{ width: 10, height: 10, background: 'currentColor', borderRadius: 2 }} />}
|
||||
style={{ width: 40, height: 40, flexShrink: 0 }}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
shape="circle"
|
||||
onClick={handleSend}
|
||||
icon={<ArrowUpOutlined />}
|
||||
disabled={!input.trim()}
|
||||
style={{ background: 'var(--color-brand)', border: 'none', width: 40, height: 40, flexShrink: 0 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', fontSize: 11, color: 'var(--color-text-tertiary)', marginTop: 8 }}>
|
||||
AI 可能会产生错误信息,请核实重要信息。
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ---- 抽屉组件保持不变 ---- */}
|
||||
{id && (
|
||||
<div style={{ textAlign: 'center', fontSize: 11, color: 'var(--color-text-tertiary)', marginTop: 8 }}>
|
||||
AI 可能会产生错误信息,请核实重要信息。
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ---- 抽屉组件保持不变 ---- */}
|
||||
{id && (
|
||||
<McpResourcesDrawer
|
||||
agentId={id}
|
||||
open={mcpDrawerOpen}
|
||||
|
|
@ -799,17 +795,17 @@ export default function ChatPage() {
|
|||
onClose={() => setParamsDrawerOpen(false)}
|
||||
width={380}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
padding: 12,
|
||||
background: 'var(--color-warning-soft)',
|
||||
borderRadius: 10,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-warning)',
|
||||
border: '1px solid var(--color-border)'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
padding: 12,
|
||||
background: 'var(--color-warning-soft)',
|
||||
borderRadius: 10,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-warning)',
|
||||
border: '1px solid var(--color-border)'
|
||||
}}
|
||||
>
|
||||
💡 这里设置的参数会临时覆盖智能体的默认配置,<b>仅作用于当前浏览器会话</b>,不会写回到智能体本身。<br />
|
||||
关闭页面或清空字段即可恢复默认。
|
||||
</div>
|
||||
|
|
@ -817,7 +813,7 @@ export default function ChatPage() {
|
|||
<div style={{ marginBottom: 24 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
||||
<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}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -839,7 +835,7 @@ export default function ChatPage() {
|
|||
<div style={{ marginBottom: 24 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
||||
<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>
|
||||
<Slider
|
||||
min={0.1}
|
||||
|
|
@ -863,7 +859,7 @@ export default function ChatPage() {
|
|||
<div style={{ marginBottom: 24 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
||||
<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>
|
||||
<InputNumber
|
||||
value={overrides.maxTokens}
|
||||
|
|
@ -875,34 +871,34 @@ export default function ChatPage() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<Button block onClick={() => setOverrides({})}>
|
||||
🔄 全部恢复默认
|
||||
</Button>
|
||||
</Drawer>
|
||||
|
||||
<Drawer
|
||||
title="历史对话"
|
||||
placement="right"
|
||||
width={320}
|
||||
onClose={() => setHistoryDrawerOpen(false)}
|
||||
open={historyDrawerOpen}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
>
|
||||
{id && (
|
||||
<SessionSidebar
|
||||
agentId={id}
|
||||
activeSessionId={sessionId}
|
||||
onChange={(sid, opts) => {
|
||||
setSessionId(sid);
|
||||
setHighlightId(opts?.highlightMessageId || null);
|
||||
setHistoryDrawerOpen(false);
|
||||
}}
|
||||
refreshTick={sessionRefresh}
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
<Button block onClick={() => setOverrides({})}>
|
||||
🔄 全部恢复默认
|
||||
</Button>
|
||||
</Drawer>
|
||||
|
||||
<Drawer
|
||||
title="历史对话"
|
||||
placement="right"
|
||||
width={320}
|
||||
onClose={() => setHistoryDrawerOpen(false)}
|
||||
open={historyDrawerOpen}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
>
|
||||
{id && (
|
||||
<SessionSidebar
|
||||
agentId={id}
|
||||
activeSessionId={sessionId}
|
||||
onChange={(sid, opts) => {
|
||||
setSessionId(sid);
|
||||
setHighlightId(opts?.highlightMessageId || null);
|
||||
setHistoryDrawerOpen(false);
|
||||
}}
|
||||
refreshTick={sessionRefresh}
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 输入栏子组件 */
|
||||
|
|
|
|||
|
|
@ -463,12 +463,12 @@ body {
|
|||
}
|
||||
|
||||
.chat-side {
|
||||
width: 280px;
|
||||
width: 260px;
|
||||
border-right: 1px solid var(--color-border);
|
||||
padding: 16px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
height: 100%;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
|
@ -479,6 +479,7 @@ body {
|
|||
flex-direction: column;
|
||||
background: var(--color-bg);
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
|
|
@ -487,7 +488,7 @@ body {
|
|||
border-bottom: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
justify-content: space-between;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
|
|
@ -560,6 +561,75 @@ body {
|
|||
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 {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
|
|
|
|||
Loading…
Reference in New Issue