Compare commits
43 Commits
feat/unify
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
08b310e1eb | |
|
|
409bbcc297 | |
|
|
46ddf0652a | |
|
|
cabea3f83a | |
|
|
b0de11aff6 | |
|
|
bb4c974b74 | |
|
|
ecbd687983 | |
|
|
7cf5e1406f | |
|
|
1b4812acae | |
|
|
b9cf3bc78a | |
|
|
7faf2cddcf | |
|
|
aabc979fec | |
|
|
23d1df700c | |
|
|
c7c580a388 | |
|
|
67e69d4d3b | |
|
|
49b48df182 | |
|
|
f1553be178 | |
|
|
67585d17ce | |
|
|
a4f5859c81 | |
|
|
eea224d759 | |
|
|
1482ef9cf3 | |
|
|
39349bfb8c | |
|
|
5eed97a79c | |
|
|
aaf3db611e | |
|
|
9da58f0cb1 | |
|
|
2a4dc72059 | |
|
|
e33a1c3741 | |
|
|
be0dae0719 | |
|
|
866a1d6141 | |
|
|
9c41dbbada | |
|
|
a03cc82dd0 | |
|
|
31eba7234a | |
|
|
fe3eb1c697 | |
|
|
9c208cd8e1 | |
|
|
5f947b4d64 | |
|
|
c02c83083d | |
|
|
7f5b7f0537 | |
|
|
16051c1f7a | |
|
|
b5d40e876b | |
|
|
44dba476c4 | |
|
|
bcb226c98b | |
|
|
86c923cc40 | |
|
|
b7c4159f1a |
|
|
@ -4,10 +4,12 @@ alwaysApply: true
|
||||||
|
|
||||||
## 核心规则
|
## 核心规则
|
||||||
|
|
||||||
1. **提交每次修改** — 每次任务执行后默认提交修改代码到远程仓库,或者用户说“提交代码”,执行git commit 并且要把commit 信息展示出来。默认是全部本地修改都提交,包括不是本次修改的也提交, 不需要提问用户确认。commit 信息你需要总结本次修改内容,不能是代码。例如:“修复登录页面的错误提示”.最后一定要执行 git push 推送到远程仓库。
|
1. **提交每次修改** — 每次任务执行后默认提交修改代码到远程仓库,或者用户说“提交代码”,执行git commit 并且要把commit 信息展示出来。默认是全部本地修改都提交,包括不是本次修改的也提交, 不需要提问用户确认。commit 信息你需要总结本次修改内容,不能是代码。例如:“修复登录页面的错误提示”.最后一定要执行 git push 推送到远程仓库。默认直接提交当前分支,包括main分支。
|
||||||
2. **方案性内容** - 当用户需求,如果是方案性内容,你首先要评估,有没有更好的方案,而不是执行。更好的方案,你可以搜索类似阿里、字节等大厂的方案来参考并给出建议。
|
2. **方案性内容** - 当用户需求,如果是方案性内容,你首先要评估,有没有更好的方案,而不是执行。更好的方案,你可以搜索类似anthropic、openai、google、meta、阿里、字节等大厂的方案来参考并给出建议。优先参考anthropic、openai、google的方案。如果当前项目方案或者设计明确不合理,应该强烈建议修正方案。如果是复杂的修改,可以给出短期和中期修正方案。尽可能都是短期内直接修正,避免问题遗留。
|
||||||
3. **代码修改** - 任何时候,当存在字段格式不对,变量名不对,表使用不对等,禁止做兼容修改,必须按唯一性修改。比如约定字段是string,正确:只能传string;错误:可以传int。 比如约定字段名是data,正确:只能传data;错误:可以传sourceData或者data。
|
3. **代码修改** - 任何时候,当存在字段格式不对,变量名不对,表使用不对等,禁止做兼容修改,必须按唯一性修改。比如约定字段是string,正确:只能传string;错误:可以传int。 比如约定字段名是data,正确:只能传data;错误:可以传sourceData或者data。
|
||||||
4. **语言** - 永远使用中文跟用户沟通,专有名词可以用英文。
|
4. **语言** - 永远使用中文跟用户沟通,专有名词可以用英文。
|
||||||
5. **文档** - 在用户没有明确要求先提供文档时,禁止创建文档。避免生产垃圾文件。用户明确要求提供文档时,才创建文档。
|
5. **文档** - 在用户没有明确要求先提供文档时,禁止创建文档。避免生产垃圾文件。用户明确要求提供文档时,才创建文档。
|
||||||
|
6. **注释** - 你生成的代码,尽可能完善中文注释。注释的格式需要按照Go语言的注释规范。要描述清楚代码的功能,参数,返回值,异常等。
|
||||||
|
7. **代码格式** - .go文件,.ts, .tsx 文件,单文件代码不超过300行,当超过300行时,需要做拆分,按功能模块拆分,同模块在同一个文件夹下,文件夹名要语义化。
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,10 @@ npm run dev # http://localhost:5173
|
||||||
如需切换:
|
如需切换:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
VITE_API_TARGET=http://localhost:4000 npm run dev
|
VITE_API_TARGET=http://localhost:4001 npm run dev
|
||||||
|
|
||||||
|
# 或添加本地环境变量 .env.local
|
||||||
|
VITE_API_TARGET=http://localhost:4001
|
||||||
```
|
```
|
||||||
|
|
||||||
## 构建
|
## 构建
|
||||||
|
|
|
||||||
35
src/App.tsx
35
src/App.tsx
|
|
@ -1,7 +1,7 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
|
import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { Button, Drawer, Spin } from 'antd';
|
import { Button, Drawer, Spin } from 'antd';
|
||||||
import { MenuOutlined, SearchOutlined } from '@ant-design/icons';
|
import { MenuOutlined, SearchOutlined, MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons';
|
||||||
import Sidebar from './components/Sidebar';
|
import Sidebar from './components/Sidebar';
|
||||||
import CommandPalette from './components/CommandPalette';
|
import CommandPalette from './components/CommandPalette';
|
||||||
import AgentList from './pages/AgentList';
|
import AgentList from './pages/AgentList';
|
||||||
|
|
@ -13,8 +13,12 @@ import PointsMallPage from './pages/PointsMallPage';
|
||||||
import TeamsPage from './pages/TeamsPage';
|
import TeamsPage from './pages/TeamsPage';
|
||||||
import PromptLibraryPage from './pages/PromptLibraryPage';
|
import PromptLibraryPage from './pages/PromptLibraryPage';
|
||||||
import StatsPage from './pages/StatsPage';
|
import StatsPage from './pages/StatsPage';
|
||||||
|
import ProfilePage from './pages/ProfilePage';
|
||||||
|
import PricingPage from './pages/PricingPage';
|
||||||
import SharedSessionPage from './pages/SharedSessionPage';
|
import SharedSessionPage from './pages/SharedSessionPage';
|
||||||
|
import ChatPagePure from './pages/chat/ChatPagePure';
|
||||||
import WorkflowsPage from './pages/WorkflowsPage';
|
import WorkflowsPage from './pages/WorkflowsPage';
|
||||||
|
import KnowledgeBasePage from './pages/KnowledgeBase';
|
||||||
import { useAuth } from './store/auth';
|
import { useAuth } from './store/auth';
|
||||||
import { AgentAPI } from './api';
|
import { AgentAPI } from './api';
|
||||||
import { useIsMobile } from './hooks/useIsMobile';
|
import { useIsMobile } from './hooks/useIsMobile';
|
||||||
|
|
@ -29,6 +33,7 @@ export default function App() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
// 全局快捷键 Ctrl/⌘ + K
|
// 全局快捷键 Ctrl/⌘ + K
|
||||||
|
|
@ -58,7 +63,11 @@ export default function App() {
|
||||||
<Route path="/teams" element={<TeamsPage />} />
|
<Route path="/teams" element={<TeamsPage />} />
|
||||||
<Route path="/prompts" element={<PromptLibraryPage />} />
|
<Route path="/prompts" element={<PromptLibraryPage />} />
|
||||||
<Route path="/stats" element={<StatsPage />} />
|
<Route path="/stats" element={<StatsPage />} />
|
||||||
|
<Route path="/profile" element={<ProfilePage />} />
|
||||||
|
<Route path="/pricing" element={<PricingPage />} />
|
||||||
|
<Route path="/pricing/pay/:tierId" element={<PricingPage />} />
|
||||||
<Route path="/workflows" element={<WorkflowsPage />} />
|
<Route path="/workflows" element={<WorkflowsPage />} />
|
||||||
|
<Route path="/knowledge" element={<KnowledgeBasePage />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
|
|
@ -73,13 +82,33 @@ export default function App() {
|
||||||
) : !user ? (
|
) : !user ? (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="*" element={<LoginPage />} />
|
<Route
|
||||||
|
path="*"
|
||||||
|
element={
|
||||||
|
<Navigate
|
||||||
|
to={`/login?next=${encodeURIComponent(
|
||||||
|
location.pathname + location.search
|
||||||
|
)}`}
|
||||||
|
replace
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
) : location.pathname.startsWith('/pure-chat') ? (
|
||||||
|
<Routes>
|
||||||
|
{/* 纯会话页:无全局 Sidebar,无 AgentSidebar,仅保留聊天主区域 */}
|
||||||
|
<Route path="/pure-chat" element={<ChatPagePure />} />
|
||||||
|
<Route path="/pure-chat/:id" element={<ChatPagePure />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
) : (
|
) : (
|
||||||
<div className="layout-shell">
|
<div className="layout-shell">
|
||||||
{/* 只有编辑器全屏显示,其他页面均保留侧边栏 */}
|
{/* 只有编辑器全屏显示,其他页面均保留侧边栏 */}
|
||||||
{!isMobile && (!location.pathname.startsWith('/agents/') || location.pathname.includes('/chat')) ? (
|
{!isMobile && (!location.pathname.startsWith('/agents/') || location.pathname.includes('/chat')) ? (
|
||||||
<Sidebar onOpenPalette={() => setPaletteOpen(true)} />
|
<Sidebar
|
||||||
|
onOpenPalette={() => setPaletteOpen(true)}
|
||||||
|
collapsed={sidebarCollapsed}
|
||||||
|
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<main className={`main${isMobile ? ' is-h5' : ''}`}>
|
<main className={`main${isMobile ? ' is-h5' : ''}`}>
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
|
|
|
||||||
|
|
@ -33,13 +33,64 @@ export interface SkillDetail extends SkillBrief {
|
||||||
config: string;
|
config: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ExternalToolApiRouting {
|
||||||
|
summary: string;
|
||||||
|
useWhen: string[];
|
||||||
|
doNotUseWhen?: string[];
|
||||||
|
domains?: string[];
|
||||||
|
intents?: string[];
|
||||||
|
requiredSlots?: string[];
|
||||||
|
optionalSlots?: string[];
|
||||||
|
examples?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExternalToolApi {
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||||
|
path: string;
|
||||||
|
headers?: Record<string, string> | null;
|
||||||
|
parametersSchema: Record<string, unknown>;
|
||||||
|
routing: ExternalToolApiRouting;
|
||||||
|
createdAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExternalToolPluginPayload {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
baseUrl: string;
|
||||||
|
authType: 'none' | 'bearer' | 'basic' | 'apiKey' | 'custom';
|
||||||
|
authConfig: Record<string, unknown>;
|
||||||
|
headers?: Record<string, string> | null;
|
||||||
|
apis: ExternalToolApi[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExternalToolPlugin extends ExternalToolPluginPayload {
|
||||||
|
id: string;
|
||||||
|
enabled: number;
|
||||||
|
createdAt: number;
|
||||||
|
isTemp?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentModelConfig {
|
||||||
|
model: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
role: string;
|
||||||
|
priority: number;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Agent {
|
export interface Agent {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
avatar: string;
|
avatar: string;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
model: string;
|
model?: string;
|
||||||
|
models?: AgentModelConfig[];
|
||||||
temperature: number;
|
temperature: number;
|
||||||
owner_id?: string | null;
|
owner_id?: string | null;
|
||||||
team_id?: string | null;
|
team_id?: string | null;
|
||||||
|
|
@ -50,6 +101,7 @@ export interface Agent {
|
||||||
updated_at: number;
|
updated_at: number;
|
||||||
knowledge?: KnowledgeFile[];
|
knowledge?: KnowledgeFile[];
|
||||||
skills?: SkillBrief[];
|
skills?: SkillBrief[];
|
||||||
|
plugins?: ExternalToolPlugin[];
|
||||||
_access?: 'owner' | 'team' | 'view' | 'none';
|
_access?: 'owner' | 'team' | 'view' | 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -83,6 +135,12 @@ export const AgentAPI = {
|
||||||
getSkill: (agentId: string, skillId: string) => api.get<SkillDetail>(`/agents/${agentId}/skills/${skillId}`).then((r) => r.data),
|
getSkill: (agentId: string, skillId: string) => api.get<SkillDetail>(`/agents/${agentId}/skills/${skillId}`).then((r) => r.data),
|
||||||
updateSkill: (agentId: string, skillId: string, payload: { content?: string; enabled?: boolean }) =>
|
updateSkill: (agentId: string, skillId: string, payload: { content?: string; enabled?: boolean }) =>
|
||||||
api.put(`/agents/${agentId}/skills/${skillId}`, payload).then((r) => r.data),
|
api.put(`/agents/${agentId}/skills/${skillId}`, payload).then((r) => r.data),
|
||||||
deleteSkill: (agentId: string, skillId: string) => api.delete(`/agents/${agentId}/skills/${skillId}`).then((r) => r.data)
|
deleteSkill: (agentId: string, skillId: string) => api.delete(`/agents/${agentId}/skills/${skillId}`).then((r) => r.data),
|
||||||
};
|
|
||||||
|
|
||||||
|
bindPlugin: (agentId: string, payload: ExternalToolPluginPayload) =>
|
||||||
|
api.post(`/agents/${agentId}/plugins`, payload).then((r) => r.data),
|
||||||
|
updatePlugin: (agentId: string, pluginId: string, payload: ExternalToolPluginPayload) =>
|
||||||
|
api.put(`/agents/${agentId}/plugins/${pluginId}`, payload).then((r) => r.data),
|
||||||
|
deletePlugin: (agentId: string, pluginId: string) =>
|
||||||
|
api.delete(`/agents/${agentId}/plugins/${pluginId}`).then((r) => r.data)
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,14 @@ export interface ToolCallTrace {
|
||||||
result: any;
|
result: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ModelOverrides {
|
||||||
|
model?: string;
|
||||||
|
model_id?: string;
|
||||||
|
temperature?: number;
|
||||||
|
topP?: number;
|
||||||
|
maxTokens?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
id: string;
|
id: string;
|
||||||
role: 'user' | 'assistant' | 'agent' | 'system';
|
role: 'user' | 'assistant' | 'agent' | 'system';
|
||||||
|
|
@ -48,13 +56,12 @@ export interface ChatHistoryResp {
|
||||||
export const ChatAPI = {
|
export const ChatAPI = {
|
||||||
history: (roomId: string) =>
|
history: (roomId: string) =>
|
||||||
api.get<ChatHistoryResp>(`/rooms/${roomId}/messages`).then((r) => r.data),
|
api.get<ChatHistoryResp>(`/rooms/${roomId}/messages`).then((r) => r.data),
|
||||||
send: (roomId: string, content: string, targetAgentId: string, model?: string, model_id?: string, imageUrls?: string[]) =>
|
send: (roomId: string, content: string, targetAgentId: string, overrides?: ModelOverrides, imageUrls?: string[]) =>
|
||||||
api
|
api
|
||||||
.post<{ user: ChatMessage; assistant: ChatMessage }>(`/rooms/${roomId}/messages`, {
|
.post<{ user: ChatMessage; assistant: ChatMessage }>(`/rooms/${roomId}/messages`, {
|
||||||
content,
|
content,
|
||||||
targetAgentId,
|
targetAgentId,
|
||||||
model,
|
...overrides,
|
||||||
model_id,
|
|
||||||
imageUrls
|
imageUrls
|
||||||
})
|
})
|
||||||
.then((r) => r.data),
|
.then((r) => r.data),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { clearUserStorage } from '../utils/storage';
|
import { clearUserStorage } from '../utils/storage';
|
||||||
|
|
||||||
export const API_BASE_URL = 'https://www.tianchaoai.cc/api/v1/';
|
export const API_BASE_URL = import.meta.env.DEV ? '/api/v1/' : 'https://www.tianchaoai.cc/api/v1/';
|
||||||
const APP_BASE = (import.meta.env.BASE_URL || '/').replace(/\/$/, '');
|
const APP_BASE = (import.meta.env.BASE_URL || '/').replace(/\/$/, '');
|
||||||
export const withAppBase = (path: string) => `${APP_BASE}${path.startsWith('/') ? path : `/${path}`}`;
|
export const withAppBase = (path: string) => `${APP_BASE}${path.startsWith('/') ? path : `/${path}`}`;
|
||||||
export const withApiBase = (path: string) => `${API_BASE_URL}${path.replace(/^\//, '')}`;
|
export const withApiBase = (path: string) => `${API_BASE_URL}${path.replace(/^\//, '')}`;
|
||||||
|
|
|
||||||
|
|
@ -15,4 +15,6 @@ export * from './stats';
|
||||||
export * from './llmProviders';
|
export * from './llmProviders';
|
||||||
export * from './streamChat';
|
export * from './streamChat';
|
||||||
export * from './workflows';
|
export * from './workflows';
|
||||||
|
export * from './membership';
|
||||||
|
export * from './knowledgeBase';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { api } from './http';
|
||||||
|
|
||||||
|
export interface KBDTO {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
ownerId: string;
|
||||||
|
isPublic: boolean;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
access: 'owner' | 'view' | 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KBFileDTO {
|
||||||
|
id: string;
|
||||||
|
originalName: string;
|
||||||
|
filename: string;
|
||||||
|
size: number;
|
||||||
|
status: 'indexing' | 'ready' | 'error';
|
||||||
|
chunkCount?: number;
|
||||||
|
createdAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const KnowledgeBaseAPI = {
|
||||||
|
// 核心管理
|
||||||
|
listKBs: () => api.get<{ data: KBDTO[] }>('/kbs').then((r) => r.data.data),
|
||||||
|
getKB: (id: string) => api.get<{ data: KBDTO }>(`/kbs/${id}`).then((r) => r.data.data),
|
||||||
|
createKB: (data: { name: string; description: string; isPublic: boolean }) =>
|
||||||
|
api.post<{ data: KBDTO }>('/kbs', data).then((r) => r.data.data),
|
||||||
|
updateKB: (id: string, data: Partial<{ name: string; description: string; isPublic: boolean }>) =>
|
||||||
|
api.patch<{ data: KBDTO }>(`/kbs/${id}`, data).then((r) => r.data.data),
|
||||||
|
deleteKB: (id: string) => api.delete(`/kbs/${id}`).then((r) => r.data),
|
||||||
|
|
||||||
|
// 文件管理
|
||||||
|
listFiles: (id: string) => api.get<{ data: KBFileDTO[] }>(`/kbs/${id}/files`).then((r) => r.data.data),
|
||||||
|
uploadFiles: (id: string, files: File[]) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
files.forEach((file) => formData.append('files', file));
|
||||||
|
return api.post<{ data: KBFileDTO[] }>(`/kbs/${id}/files`, formData).then((r) => r.data.data);
|
||||||
|
},
|
||||||
|
removeFile: (kbId: string, fileId: string) =>
|
||||||
|
api.delete(`/kbs/${kbId}/files/${fileId}`).then((r) => r.data),
|
||||||
|
|
||||||
|
// 授权与共享
|
||||||
|
shareKB: (id: string, data: { subjectType: 'user' | 'team'; subjectId: string }) =>
|
||||||
|
api.post(`/kbs/${id}/shares`, data).then((r) => r.data),
|
||||||
|
unshareKB: (id: string, data: { subjectType: 'user' | 'team'; subjectId: string }) =>
|
||||||
|
api.delete(`/kbs/${id}/shares`, { data }).then((r) => r.data),
|
||||||
|
|
||||||
|
// 智能体关联
|
||||||
|
mountToAgent: (agentId: string, kbId: string) =>
|
||||||
|
api.post(`/agents/${agentId}/kbs/${kbId}`).then((r) => r.data),
|
||||||
|
unmountFromAgent: (agentId: string, kbId: string) =>
|
||||||
|
api.delete(`/agents/${agentId}/kbs/${kbId}`).then((r) => r.data),
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,156 @@
|
||||||
|
import { api } from './http';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用接口响应包装
|
||||||
|
*/
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页列表包装
|
||||||
|
*/
|
||||||
|
export interface ApiListData<T> {
|
||||||
|
items: T[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会员信息接口定义 (匹配后端最新数据结构)
|
||||||
|
*/
|
||||||
|
export interface MembershipInfo {
|
||||||
|
tier: 'trial' | 'pro' | 'ultra' | 'ent_basic' | 'ent_standard' | 'custom';
|
||||||
|
tierName: string;
|
||||||
|
expireAt: number;
|
||||||
|
status: string;
|
||||||
|
isSub: boolean;
|
||||||
|
limits: {
|
||||||
|
tier: string;
|
||||||
|
name: string;
|
||||||
|
maxSubAccounts: number;
|
||||||
|
maxTokens: number;
|
||||||
|
maxAgents: number;
|
||||||
|
maxKBSize: number;
|
||||||
|
};
|
||||||
|
usage: {
|
||||||
|
subAccountsCount: number;
|
||||||
|
tokensTotal: number;
|
||||||
|
tokensUsed: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 子账号信息接口定义 (匹配后端 snake_case 字段)
|
||||||
|
*/
|
||||||
|
export interface ChildMember {
|
||||||
|
id: string; // 关系 ID
|
||||||
|
parent_user_id: string;
|
||||||
|
child_user_id: string;
|
||||||
|
child_name: string;
|
||||||
|
child_phone: string;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
allocatedTokens: number;
|
||||||
|
usedTokens: number;
|
||||||
|
authorizedAgentIds: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会员与账号管理 API
|
||||||
|
*/
|
||||||
|
export const MembershipAPI = {
|
||||||
|
/**
|
||||||
|
* 获取个人会员信息
|
||||||
|
*/
|
||||||
|
getMe: () => api.get<MembershipInfo>('/membership/me').then(r => r.data),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取子账号列表
|
||||||
|
*/
|
||||||
|
listMembers: () => api.get<ApiResponse<ApiListData<ChildMember>>>('/membership/members/list').then(r => r.data.data.items),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加子账号
|
||||||
|
* @param childId 用户ID
|
||||||
|
*/
|
||||||
|
addChild: (childId: string) => api.post<ApiResponse<any>>('/membership/members', { childId }).then(r => r.data),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除子账号
|
||||||
|
* @param relationId 关系ID
|
||||||
|
*/
|
||||||
|
removeChild: (relationId: string) => api.delete<ApiResponse<any>>(`/membership/members/${relationId}`).then(r => r.data),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分配算力配额
|
||||||
|
*/
|
||||||
|
allocateQuota: (payload: { userId: string; resourceType: 'tokens'; amount: number }) =>
|
||||||
|
api.post<ApiResponse<any>>('/membership/quota', payload).then(r => r.data),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 授权资源(智能体/知识库)访问
|
||||||
|
*/
|
||||||
|
authorizeResource: (payload: { userId: string; resourceType: 'agent' | 'knowledge'; resourceIds: string[]; level: 'read' | 'write' }) =>
|
||||||
|
api.post<ApiResponse<any>>('/membership/access', payload).then(r => r.data),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消资源授权
|
||||||
|
*/
|
||||||
|
revokeResource: (payload: { userId: string; resourceType: 'agent' | 'knowledge'; resourceIds: string[] }) =>
|
||||||
|
api.delete<ApiResponse<any>>('/membership/access', { data: payload }).then(r => r.data),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发起会员购买/升级
|
||||||
|
* @returns 返回订单详情及支付 URL
|
||||||
|
*/
|
||||||
|
subscribe: (payload: { tier: string; durationDays: number }) =>
|
||||||
|
api.post<ApiResponse<{
|
||||||
|
order_id: string;
|
||||||
|
biz_order_no: string;
|
||||||
|
pay_url: string;
|
||||||
|
pay_expire_at: string;
|
||||||
|
pay_is_expired: boolean;
|
||||||
|
message: string;
|
||||||
|
}>>('/membership/subscribe', payload).then(r => r.data.data),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询支付状态
|
||||||
|
*/
|
||||||
|
queryPayStatus: (orderId: string) =>
|
||||||
|
api.post<ApiResponse<{
|
||||||
|
pay_order_no: string;
|
||||||
|
status: 'PENDING' | 'SUCCESS' | 'CLOSED' | 'FAIL';
|
||||||
|
pay_expire_at: string;
|
||||||
|
pay_is_expired: boolean;
|
||||||
|
}>>('/membership/pay/query', { order_id: orderId }).then(r => r.data.data),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭支付订单
|
||||||
|
*/
|
||||||
|
closePayOrder: (orderId: string) =>
|
||||||
|
api.post<ApiResponse<any>>('/membership/pay/close', { order_id: orderId }).then(r => r.data),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取订单列表
|
||||||
|
*/
|
||||||
|
getOrders: () =>
|
||||||
|
api.get<ApiResponse<Array<{
|
||||||
|
id: string;
|
||||||
|
pay_order_no: string;
|
||||||
|
tier: string;
|
||||||
|
amount: string;
|
||||||
|
status: string;
|
||||||
|
pay_expire_at: string;
|
||||||
|
pay_is_expired: boolean;
|
||||||
|
created_at: number;
|
||||||
|
}>>>('/membership/orders').then(r => r.data.data),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有会员方案
|
||||||
|
*/
|
||||||
|
getPlans: () =>
|
||||||
|
api.get<ApiResponse<{ categories: any[] }>>('/membership/plans').then(r => r.data.data),
|
||||||
|
};
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { ChatMessage } from './chat';
|
import type { ChatMessage, ModelOverrides } from './chat';
|
||||||
import { API_BASE_URL } from './http';
|
import { API_BASE_URL } from './http';
|
||||||
|
|
||||||
export interface StreamEvents {
|
export interface StreamEvents {
|
||||||
|
|
@ -13,22 +13,13 @@ export interface StreamEvents {
|
||||||
onError?: (msg: string) => void;
|
onError?: (msg: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModelOverrides {
|
|
||||||
model?: string;
|
|
||||||
model_id?: string;
|
|
||||||
temperature?: number;
|
|
||||||
topP?: number;
|
|
||||||
maxTokens?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function streamChat(
|
export async function streamChat(
|
||||||
roomId: string,
|
roomId: string,
|
||||||
targetAgentId: string,
|
targetAgentId: string,
|
||||||
content: string,
|
content: string,
|
||||||
handlers: StreamEvents,
|
handlers: StreamEvents,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
model?: string,
|
overrides?: ModelOverrides,
|
||||||
modelId?: string,
|
|
||||||
imageUrls?: string[]
|
imageUrls?: string[]
|
||||||
) {
|
) {
|
||||||
const resp = await fetch(`${API_BASE_URL}rooms/${roomId}/messages/stream`, {
|
const resp = await fetch(`${API_BASE_URL}rooms/${roomId}/messages/stream`, {
|
||||||
|
|
@ -37,8 +28,7 @@ export async function streamChat(
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
content,
|
content,
|
||||||
targetAgentId,
|
targetAgentId,
|
||||||
model,
|
...overrides,
|
||||||
model_id: modelId,
|
|
||||||
imageUrls: imageUrls ?? []
|
imageUrls: imageUrls ?? []
|
||||||
}),
|
}),
|
||||||
signal,
|
signal,
|
||||||
|
|
@ -47,24 +37,6 @@ export async function streamChat(
|
||||||
return await consumeSSE(resp, handlers, signal);
|
return await consumeSSE(resp, handlers, signal);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function regenerateMessage(
|
|
||||||
agentId: string,
|
|
||||||
messageId: string,
|
|
||||||
handlers: StreamEvents,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
overrides?: ModelOverrides,
|
|
||||||
attachmentsText?: string
|
|
||||||
) {
|
|
||||||
const resp = await fetch(`${API_BASE_URL}chat/${agentId}/messages/${messageId}/regenerate`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' },
|
|
||||||
body: JSON.stringify({ overrides, attachmentsText }),
|
|
||||||
signal,
|
|
||||||
credentials: 'include'
|
|
||||||
});
|
|
||||||
return await consumeSSE(resp, handlers, signal);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function consumeSSE(resp: Response, h: StreamEvents, signal?: AbortSignal) {
|
async function consumeSSE(resp: Response, h: StreamEvents, signal?: AbortSignal) {
|
||||||
if (!resp.ok || !resp.body) {
|
if (!resp.ok || !resp.body) {
|
||||||
const txt = await resp.text().catch(() => '');
|
const txt = await resp.text().catch(() => '');
|
||||||
|
|
@ -150,4 +122,3 @@ async function consumeSSE(resp: Response, h: StreamEvents, signal?: AbortSignal)
|
||||||
reader.cancel().catch(() => {});
|
reader.cancel().catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ export const TeamAPI = {
|
||||||
detail: (id: string) => api.get<Team>(`/teams/${id}`).then((r) => r.data),
|
detail: (id: string) => api.get<Team>(`/teams/${id}`).then((r) => r.data),
|
||||||
create: (name: string) => api.post<Team>('/teams', { name }).then((r) => r.data),
|
create: (name: string) => api.post<Team>('/teams', { name }).then((r) => r.data),
|
||||||
rename: (id: string, name: string) => api.put(`/teams/${id}`, { name }).then((r) => r.data),
|
rename: (id: string, name: string) => api.put(`/teams/${id}`, { name }).then((r) => r.data),
|
||||||
|
addMember: (id: string, phone: string) => api.post(`/teams/${id}/members`, { phone }).then((r) => r.data),
|
||||||
remove: (id: string) => api.delete(`/teams/${id}`).then((r) => r.data),
|
remove: (id: string) => api.delete(`/teams/${id}`).then((r) => r.data),
|
||||||
removeMember: (id: string, userId: string) => api.delete(`/teams/${id}/members/${userId}`).then((r) => r.data)
|
removeMember: (id: string, userId: string) => api.delete(`/teams/${id}/members/${userId}`).then((r) => r.data)
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
ToolCallTrace
|
ToolCallTrace
|
||||||
} from '../api';
|
} from '../api';
|
||||||
import Markdown from './Markdown';
|
import Markdown from './Markdown';
|
||||||
|
import { parseAgentModels } from '../pages/chat/utils/agentModels';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
agent: Agent;
|
agent: Agent;
|
||||||
|
|
@ -74,8 +75,9 @@ export default function ChatPreview({ agent, agentId }: Props) {
|
||||||
sid = created.id;
|
sid = created.id;
|
||||||
setSessionId(sid);
|
setSessionId(sid);
|
||||||
}
|
}
|
||||||
const model = String(agent?.model || '').split(',')[0]?.trim() || undefined;
|
const agentModels = parseAgentModels(agent?.models ?? agent?.model);
|
||||||
const modelId = model ? undefined : undefined;
|
const model = agentModels[0]?.name || undefined;
|
||||||
|
const modelId = agentModels[0]?.id || undefined;
|
||||||
const targetAgentId = agentId;
|
const targetAgentId = agentId;
|
||||||
await streamChat(
|
await streamChat(
|
||||||
sid,
|
sid,
|
||||||
|
|
@ -117,8 +119,7 @@ export default function ChatPreview({ agent, agentId }: Props) {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
ctrl.signal,
|
ctrl.signal,
|
||||||
model,
|
{ model, model_id: modelId }
|
||||||
modelId
|
|
||||||
);
|
);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e?.name !== 'AbortError') {
|
if (e?.name !== 'AbortError') {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { Input } from 'antd';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface JsonEditorProps {
|
||||||
|
/** JSON 文本内容 */
|
||||||
|
value: string;
|
||||||
|
/** 内容变化回调 */
|
||||||
|
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON 编辑区域。
|
||||||
|
* 用户可直接粘贴或编辑完整的工具集配置 JSON,修改会自动同步到可视化表单。
|
||||||
|
*/
|
||||||
|
export default function JsonEditor({ value, onChange }: JsonEditorProps) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '4px 0 16px' }}>
|
||||||
|
<div style={{ marginBottom: 8, color: '#666', fontSize: 12 }}>
|
||||||
|
提示:您可以直接编辑下方 JSON 内容,可视化表单将自动同步更新。
|
||||||
|
</div>
|
||||||
|
<Input.TextArea
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
rows={25}
|
||||||
|
className="agent-editor-code-input"
|
||||||
|
placeholder="请输入完整的工具集配置 JSON"
|
||||||
|
style={{ fontFamily: 'monospace' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,281 @@
|
||||||
|
import { CopyOutlined, DownOutlined, MinusCircleOutlined, PlusOutlined, RightOutlined } from '@ant-design/icons';
|
||||||
|
import { Button, Card, Form, Input, Select, Space } from 'antd';
|
||||||
|
import { ToolApiFormValue, ToolPluginFormValue, EMPTY_API } from './types';
|
||||||
|
import { validateTrimmedText, validateRoutingList } from './utils';
|
||||||
|
|
||||||
|
interface VisualEditorProps {
|
||||||
|
form: ReturnType<typeof Form.useForm<ToolPluginFormValue>>[0];
|
||||||
|
apis: ToolApiFormValue[];
|
||||||
|
expandedApiIndexes: number[];
|
||||||
|
onToggleApiCard: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可视化编辑区域。
|
||||||
|
* 包含工具集基础信息、统一请求头、认证配置,以及 API 功能列表(含路由规则)。
|
||||||
|
*/
|
||||||
|
export default function VisualEditor({ form, apis, expandedApiIndexes, onToggleApiCard }: VisualEditorProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="agent-editor-tool-grid">
|
||||||
|
<Form.Item label="工具集名称" name="name" rules={[{ required: true, message: '请输入工具集名称' }]}>
|
||||||
|
<Input placeholder="例如:Hoyidata 工具集" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="API 基础地址" name="baseUrl" rules={[{ required: true, message: '请输入 API 基础地址' }]}>
|
||||||
|
<Input placeholder="https://api.hoyidata.com" />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
<Form.Item label="工具集描述" name="description">
|
||||||
|
<Input.TextArea rows={2} placeholder="说明该工具集提供的能力" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="统一请求头(JSON 或对象)" name="headers">
|
||||||
|
<Input.TextArea
|
||||||
|
rows={3}
|
||||||
|
className="agent-editor-code-input"
|
||||||
|
placeholder={'{\n "Authorization": "Bearer YOUR_TOKEN"\n}'}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<div className="agent-editor-tool-grid">
|
||||||
|
<Form.Item label="认证方式" name="authType" rules={[{ required: true }]}>
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{ value: 'none', label: '无需认证' },
|
||||||
|
{ value: 'bearer', label: 'Bearer Token' },
|
||||||
|
{ value: 'basic', label: 'Basic Auth' },
|
||||||
|
{ value: 'apiKey', label: 'API Key' },
|
||||||
|
{ value: 'custom', label: '自定义' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="认证配置(JSON 或对象)" name="authConfig">
|
||||||
|
<Input.TextArea rows={3} className="agent-editor-code-input" placeholder={'{\n token: "YOUR_API_KEY"\n}'} />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form.List name="apis">
|
||||||
|
{(fields, { add, remove }) => (
|
||||||
|
<Space direction="vertical" size={12} className="agent-editor-tool-list">
|
||||||
|
<div className="agent-editor-tool-list-header">
|
||||||
|
<div>
|
||||||
|
<strong>API 功能</strong>
|
||||||
|
<div className="agent-editor-tool-help">一个工具集可同时绑定多个 API 功能。</div>
|
||||||
|
</div>
|
||||||
|
<Button type="dashed" icon={<PlusOutlined />} onClick={() => add({ ...EMPTY_API })}>
|
||||||
|
添加 API
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fields.map((field, index) => {
|
||||||
|
const isExpanded = expandedApiIndexes.includes(index);
|
||||||
|
const apiName = apis[index]?.name;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={field.key}
|
||||||
|
size="small"
|
||||||
|
title={
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
onClick={() => onToggleApiCard(index)}
|
||||||
|
style={{ padding: 0, fontWeight: 500 }}
|
||||||
|
icon={isExpanded ? <DownOutlined /> : <RightOutlined />}
|
||||||
|
>
|
||||||
|
{apiName || `API-${index}`}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
className="agent-editor-tool-card"
|
||||||
|
extra={
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
const currentApis = form.getFieldValue('apis') || [];
|
||||||
|
const apiToCopy = currentApis[field.name];
|
||||||
|
if (apiToCopy) {
|
||||||
|
add({
|
||||||
|
...apiToCopy,
|
||||||
|
name: `${apiToCopy.name}-copy`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
复制
|
||||||
|
</Button>
|
||||||
|
{fields.length > 1 ? (
|
||||||
|
<Button type="text" danger size="small" icon={<MinusCircleOutlined />} onClick={() => remove(field.name)}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<>
|
||||||
|
<div className="agent-editor-tool-grid">
|
||||||
|
<Form.Item
|
||||||
|
label="name(工具名)"
|
||||||
|
name={[field.name, 'name']}
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请输入工具名' },
|
||||||
|
{ pattern: /^[A-Za-z0-9_]+$/, message: '仅支持字母、数字和下划线' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input placeholder="query_hot_selling_products" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="method(调用方法)" name={[field.name, 'method']} rules={[{ required: true }]}>
|
||||||
|
<Select options={['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].map((value) => ({ value, label: value }))} />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
<Form.Item
|
||||||
|
label="description(描述)"
|
||||||
|
name={[field.name, 'description']}
|
||||||
|
rules={[{ validator: validateTrimmedText('请输入描述') }]}
|
||||||
|
>
|
||||||
|
<Input.TextArea rows={2} placeholder="描述调用时机和工具能力" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="path(API 地址)"
|
||||||
|
name={[field.name, 'path']}
|
||||||
|
rules={[{ validator: validateTrimmedText('请输入 API 地址') }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="/v1/products/hot-selling" />
|
||||||
|
</Form.Item>
|
||||||
|
<div className="agent-editor-tool-grid">
|
||||||
|
<Form.Item label="headers(请求头 JSON 或对象)" name={[field.name, 'headers']}>
|
||||||
|
<Input.TextArea rows={7} className="agent-editor-code-input" placeholder={'{\n "X-Custom-Source": "aura-agent"\n}'} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="parametersSchema(依赖参数 JSON 或对象)"
|
||||||
|
name={[field.name, 'parametersSchema']}
|
||||||
|
rules={[{ required: true, message: '请输入依赖参数 Schema' }]}
|
||||||
|
>
|
||||||
|
<Input.TextArea rows={7} className="agent-editor-code-input" />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
<Card size="small" title="routing(路由规则)">
|
||||||
|
<Form.Item label="summary" name={[field.name, 'routing', 'summary']}>
|
||||||
|
<Input placeholder="查询商品维度数据" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.List name={[field.name, 'routing', 'useWhen']} rules={[{ validator: validateRoutingList('至少添加一条 useWhen', 1) }]}>
|
||||||
|
{(routingFields, { add: addUseWhen, remove: removeUseWhen }, { errors }) => (
|
||||||
|
<Space direction="vertical" size={8} className="agent-editor-tool-list">
|
||||||
|
<div className="agent-editor-tool-list-header">
|
||||||
|
<div>
|
||||||
|
<strong>useWhen</strong>
|
||||||
|
<div className="agent-editor-tool-help">至少一项,描述什么情况下应该调用这个 API。</div>
|
||||||
|
</div>
|
||||||
|
<Button type="dashed" icon={<PlusOutlined />} onClick={() => addUseWhen('')}>
|
||||||
|
添加条件
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{routingFields.map((routingField) => (
|
||||||
|
<Space key={routingField.key} align="start" className="agent-editor-tool-list">
|
||||||
|
<Form.Item
|
||||||
|
name={routingField.name}
|
||||||
|
className="flex-1 mb-0"
|
||||||
|
rules={[{ validator: validateTrimmedText('条件不能为空') }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="用户明确要求商品数据" style={{ width: 600 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
type="text"
|
||||||
|
icon={<MinusCircleOutlined />}
|
||||||
|
onClick={() => removeUseWhen(routingField.name)}
|
||||||
|
disabled={routingFields.length <= 1}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
))}
|
||||||
|
<Form.ErrorList errors={errors} />
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</Form.List>
|
||||||
|
<Form.List name={[field.name, 'routing', 'doNotUseWhen']} rules={[{ validator: validateRoutingList('列表项不能为空') }]}>
|
||||||
|
{(routingFields, { add: addDoNotUseWhen, remove: removeDoNotUseWhen }, { errors }) => (
|
||||||
|
<Space direction="vertical" size={8} className="agent-editor-tool-list" style={{ marginTop: 16 }}>
|
||||||
|
<div className="agent-editor-tool-list-header">
|
||||||
|
<div>
|
||||||
|
<strong>doNotUseWhen</strong>
|
||||||
|
<div className="agent-editor-tool-help">可为空,描述什么情况下不要调用这个 API。</div>
|
||||||
|
</div>
|
||||||
|
<Button type="dashed" icon={<PlusOutlined />} onClick={() => addDoNotUseWhen('')}>
|
||||||
|
添加条件
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{routingFields.map((routingField) => (
|
||||||
|
<Space key={routingField.key} align="start" className="agent-editor-tool-list">
|
||||||
|
<Form.Item
|
||||||
|
name={routingField.name}
|
||||||
|
className="flex-1 mb-0"
|
||||||
|
rules={[{ validator: validateTrimmedText('条件不能为空') }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="当前问题只需要其他维度数据" style={{ width: 600 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Button danger type="text" icon={<MinusCircleOutlined />} onClick={() => removeDoNotUseWhen(routingField.name)} />
|
||||||
|
</Space>
|
||||||
|
))}
|
||||||
|
<Form.ErrorList errors={errors} />
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</Form.List>
|
||||||
|
|
||||||
|
<div className="agent-editor-tool-grid" style={{ marginTop: 16 }}>
|
||||||
|
<Form.Item label="domains(领域)" name={[field.name, 'routing', 'domains']}>
|
||||||
|
<Select mode="tags" placeholder="例如:e-commerce, logistics" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="intents(意图)" name={[field.name, 'routing', 'intents']}>
|
||||||
|
<Select mode="tags" placeholder="例如:query_order, cancel_order" />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="agent-editor-tool-grid">
|
||||||
|
<Form.Item label="requiredSlots(必填槽位)" name={[field.name, 'routing', 'requiredSlots']}>
|
||||||
|
<Select mode="tags" placeholder="例如:order_id, user_id" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="optionalSlots(可选槽位)" name={[field.name, 'routing', 'optionalSlots']}>
|
||||||
|
<Select mode="tags" placeholder="例如:start_date, end_date" />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form.List name={[field.name, 'routing', 'examples']}>
|
||||||
|
{(exampleFields, { add: addExample, remove: removeExample }) => (
|
||||||
|
<Space direction="vertical" size={8} className="agent-editor-tool-list" style={{ marginTop: 16 }}>
|
||||||
|
<div className="agent-editor-tool-list-header">
|
||||||
|
<div>
|
||||||
|
<strong>examples(示例)</strong>
|
||||||
|
<div className="agent-editor-tool-help">描述用户可能的提问方式。</div>
|
||||||
|
</div>
|
||||||
|
<Button type="dashed" icon={<PlusOutlined />} onClick={() => addExample('')}>
|
||||||
|
添加示例
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{exampleFields.map((exampleField) => (
|
||||||
|
<Space key={exampleField.key} align="start" className="agent-editor-tool-list">
|
||||||
|
<Form.Item
|
||||||
|
name={exampleField.name}
|
||||||
|
className="flex-1 mb-0"
|
||||||
|
rules={[{ validator: validateTrimmedText('示例不能为空') }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="我想查一下最近的订单" style={{ width: 600 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Button danger type="text" icon={<MinusCircleOutlined />} onClick={() => removeExample(exampleField.name)} />
|
||||||
|
</Space>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</Form.List>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</Form.List>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,235 @@
|
||||||
|
import { App as AntApp, Form, Modal, Tabs } from 'antd';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { AgentAPI, ExternalToolPlugin } from '../../api';
|
||||||
|
import { EMPTY_API, ExternalToolEditorProps, ToolApiFormValue, ToolPluginFormValue } from './types';
|
||||||
|
import { formValueToJson, formValueToPayload, jsonToFormValue } from './utils';
|
||||||
|
import VisualEditor from './VisualEditor';
|
||||||
|
import JsonEditor from './JsonEditor';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 外部工具编辑弹窗。
|
||||||
|
* 支持可视化表单编辑与 JSON 直接编辑两种模式,双向实时同步。
|
||||||
|
*/
|
||||||
|
export default function ExternalToolEditor({ open, agentId, plugin, onClose, onSaved }: ExternalToolEditorProps) {
|
||||||
|
const { message } = AntApp.useApp();
|
||||||
|
const [form] = Form.useForm<ToolPluginFormValue>();
|
||||||
|
const isEditing = Boolean(plugin);
|
||||||
|
const apis = Form.useWatch('apis', form) || [];
|
||||||
|
const allValues = Form.useWatch([], form) as ToolPluginFormValue | undefined;
|
||||||
|
|
||||||
|
// 展开的 API 卡片索引
|
||||||
|
const [expandedApiIndexes, setExpandedApiIndexes] = useState<number[]>([]);
|
||||||
|
const previousApiCountRef = useRef(0);
|
||||||
|
|
||||||
|
// JSON 编辑相关状态
|
||||||
|
const [jsonContent, setJsonContent] = useState('');
|
||||||
|
const [activeTab, setActiveTab] = useState('visual');
|
||||||
|
// 是否正在由 JSON 侧同步表单,用于避免表单变更又反向写回 JSON 造成的循环
|
||||||
|
const isSyncingRef = useRef(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可视化表单变化 -> 同步到 JSON 文本
|
||||||
|
* 只有当不是由 JSON 侧触发的同步时才更新 JSON 内容,避免循环更新导致光标跳动
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!allValues || isSyncingRef.current) return;
|
||||||
|
try {
|
||||||
|
const jsonObj = formValueToJson(allValues);
|
||||||
|
setJsonContent(JSON.stringify(jsonObj, null, 2));
|
||||||
|
} catch (e) {
|
||||||
|
// 表单值不合法时不更新 JSON
|
||||||
|
}
|
||||||
|
}, [allValues]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 API 数量自动控制卡片展开状态。
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
previousApiCountRef.current = 0;
|
||||||
|
setExpandedApiIndexes([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiCount = apis.length;
|
||||||
|
if (apiCount < 1) {
|
||||||
|
previousApiCountRef.current = 0;
|
||||||
|
setExpandedApiIndexes([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousApiCount = previousApiCountRef.current;
|
||||||
|
if (previousApiCount === 0) {
|
||||||
|
setExpandedApiIndexes(apiCount === 1 ? [0] : []);
|
||||||
|
} else if (previousApiCount === 1 && apiCount > 1) {
|
||||||
|
setExpandedApiIndexes([]);
|
||||||
|
} else if (apiCount === 1) {
|
||||||
|
setExpandedApiIndexes([0]);
|
||||||
|
} else {
|
||||||
|
setExpandedApiIndexes((current) => current.filter((index) => index < apiCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
previousApiCountRef.current = apiCount;
|
||||||
|
}, [apis.length, open]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 JSON 编辑区内容变化:解析后回填表单。
|
||||||
|
*/
|
||||||
|
const handleJsonChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
setJsonContent(val);
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(val);
|
||||||
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||||
|
isSyncingRef.current = true;
|
||||||
|
form.setFieldsValue(jsonToFormValue(parsed));
|
||||||
|
// 下一个事件循环解除标记,恢复表单 -> JSON 的同步
|
||||||
|
setTimeout(() => {
|
||||||
|
isSyncingRef.current = false;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// JSON 格式不正确时不更新表单
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换 API 卡片展开/收起。
|
||||||
|
*/
|
||||||
|
const toggleApiCard = (index: number) => {
|
||||||
|
setExpandedApiIndexes((current) =>
|
||||||
|
current.includes(index) ? current.filter((item) => item !== index) : [...current, index],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提交表单。
|
||||||
|
*/
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
const payload = formValueToPayload(values);
|
||||||
|
|
||||||
|
if (plugin) {
|
||||||
|
await AgentAPI.updatePlugin(agentId, plugin.id, payload);
|
||||||
|
message.success('外部工具更新成功');
|
||||||
|
} else {
|
||||||
|
await AgentAPI.bindPlugin(agentId, payload);
|
||||||
|
message.success('外部工具绑定成功');
|
||||||
|
}
|
||||||
|
await onSaved?.();
|
||||||
|
onClose();
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.errorFields) return;
|
||||||
|
if (error instanceof SyntaxError) {
|
||||||
|
message.error('配置格式不正确,请检查认证配置、请求头或依赖参数(支持 JSON 或对象格式)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
message.error(error?.message || '外部工具绑定失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 弹窗打开时初始化表单值,并同步初始化 JSON 编辑器内容,
|
||||||
|
* 确保用户首次切换到 JSON 编辑标签时即可看到完整配置。
|
||||||
|
*/
|
||||||
|
const handleAfterOpenChange = (visible: boolean) => {
|
||||||
|
if (!visible) return;
|
||||||
|
const initial: ToolPluginFormValue = plugin
|
||||||
|
? {
|
||||||
|
name: plugin.name,
|
||||||
|
description: plugin.description,
|
||||||
|
baseUrl: plugin.baseUrl,
|
||||||
|
authType: plugin.authType,
|
||||||
|
authConfig: JSON.stringify(plugin.authConfig || {}, null, 2),
|
||||||
|
headers: JSON.stringify(plugin.headers || {}, null, 2),
|
||||||
|
apis: plugin.apis.map((item): ToolApiFormValue => ({
|
||||||
|
name: item.name,
|
||||||
|
description: item.description,
|
||||||
|
method: item.method,
|
||||||
|
path: item.path,
|
||||||
|
headers: JSON.stringify(item.headers || {}, null, 2),
|
||||||
|
parametersSchema: JSON.stringify(item.parametersSchema || { type: 'object', properties: {} }, null, 2),
|
||||||
|
routing: {
|
||||||
|
summary: item.routing?.summary || '',
|
||||||
|
useWhen: item.routing?.useWhen?.length ? item.routing.useWhen : [''],
|
||||||
|
doNotUseWhen: item.routing?.doNotUseWhen || [],
|
||||||
|
domains: item.routing?.domains || [],
|
||||||
|
intents: item.routing?.intents || [],
|
||||||
|
requiredSlots: item.routing?.requiredSlots || [],
|
||||||
|
optionalSlots: item.routing?.optionalSlots || [],
|
||||||
|
examples: item.routing?.examples || [],
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
baseUrl: '',
|
||||||
|
authType: 'bearer',
|
||||||
|
authConfig: JSON.stringify({ token: '' }, null, 2),
|
||||||
|
headers: JSON.stringify({}, null, 2),
|
||||||
|
apis: [{ ...EMPTY_API }],
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始化表单
|
||||||
|
form.setFieldsValue(initial);
|
||||||
|
// 直接初始化 JSON 内容,避免首次进入 JSON 编辑页时显示为空或不完整
|
||||||
|
try {
|
||||||
|
setJsonContent(JSON.stringify(formValueToJson(initial), null, 2));
|
||||||
|
} catch (e) {
|
||||||
|
setJsonContent(JSON.stringify(initial, null, 2));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title={isEditing ? '编辑外部工具集' : '配置外部工具集'}
|
||||||
|
width={920}
|
||||||
|
okText={isEditing ? '保存修改' : '绑定工具'}
|
||||||
|
cancelText="取消"
|
||||||
|
onCancel={onClose}
|
||||||
|
onOk={handleSubmit}
|
||||||
|
destroyOnHidden
|
||||||
|
afterOpenChange={handleAfterOpenChange}
|
||||||
|
maskClosable={false}
|
||||||
|
styles={{
|
||||||
|
body: {
|
||||||
|
maxHeight: '70vh',
|
||||||
|
overflowY: 'auto',
|
||||||
|
paddingRight: 12
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" requiredMark="optional" preserve={true}>
|
||||||
|
<Tabs
|
||||||
|
activeKey={activeTab}
|
||||||
|
onChange={setActiveTab}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'visual',
|
||||||
|
label: '可视化编辑',
|
||||||
|
children: (
|
||||||
|
<VisualEditor
|
||||||
|
form={form}
|
||||||
|
apis={apis}
|
||||||
|
expandedApiIndexes={expandedApiIndexes}
|
||||||
|
onToggleApiCard={toggleApiCard}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'json',
|
||||||
|
label: 'JSON 编辑',
|
||||||
|
children: <JsonEditor value={jsonContent} onChange={handleJsonChange} />,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保持原类型 ExternalToolPlugin 的引用,避免 tree-shaking 丢失(其他地方可能依赖该类型重导出)
|
||||||
|
export type { ExternalToolPlugin };
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { ExternalToolApi, ExternalToolApiRouting, ExternalToolPlugin, ExternalToolPluginPayload } from '../../api';
|
||||||
|
|
||||||
|
/** 组件 Props 定义 */
|
||||||
|
export interface ExternalToolEditorProps {
|
||||||
|
/** 弹窗是否可见 */
|
||||||
|
open: boolean;
|
||||||
|
/** 智能体 ID */
|
||||||
|
agentId: string;
|
||||||
|
/** 已有的外部工具插件,传入时为编辑模式 */
|
||||||
|
plugin?: ExternalToolPlugin | null;
|
||||||
|
/** 关闭弹窗回调 */
|
||||||
|
onClose: () => void;
|
||||||
|
/** 保存成功后的回调 */
|
||||||
|
onSaved?: () => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单个 API 路由规则的表单值(与 API 定义一致,仅做引用) */
|
||||||
|
export interface ToolApiRoutingFormValue extends ExternalToolApiRouting {}
|
||||||
|
|
||||||
|
/** 单个 API 的表单值
|
||||||
|
* - headers / parametersSchema 在表单中以字符串形式存储(Input.TextArea)
|
||||||
|
* - 提交时再通过 parseJsonObject 反序列化为对象
|
||||||
|
*/
|
||||||
|
export interface ToolApiFormValue extends Omit<ExternalToolApi, 'headers' | 'parametersSchema' | 'routing'> {
|
||||||
|
/** 请求头 JSON 字符串 */
|
||||||
|
headers?: string;
|
||||||
|
/** 依赖参数 Schema JSON 字符串 */
|
||||||
|
parametersSchema: string;
|
||||||
|
/** 路由规则 */
|
||||||
|
routing: ToolApiRoutingFormValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 整个工具集的表单值
|
||||||
|
* - authConfig / headers 在表单中以字符串形式存储
|
||||||
|
*/
|
||||||
|
export interface ToolPluginFormValue extends Omit<ExternalToolPluginPayload, 'authConfig' | 'apis' | 'headers'> {
|
||||||
|
/** 认证配置 JSON 字符串 */
|
||||||
|
authConfig?: string;
|
||||||
|
/** 统一请求头 JSON 字符串 */
|
||||||
|
headers?: string;
|
||||||
|
/** API 列表 */
|
||||||
|
apis: ToolApiFormValue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 空 API 模板 */
|
||||||
|
export const EMPTY_API: ToolApiFormValue = {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
method: 'GET',
|
||||||
|
path: '',
|
||||||
|
headers: '{}',
|
||||||
|
parametersSchema: JSON.stringify({ type: 'object', properties: {} }, null, 2),
|
||||||
|
routing: {
|
||||||
|
summary: '',
|
||||||
|
useWhen: [''],
|
||||||
|
doNotUseWhen: [],
|
||||||
|
domains: [],
|
||||||
|
intents: [],
|
||||||
|
requiredSlots: [],
|
||||||
|
optionalSlots: [],
|
||||||
|
examples: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,231 @@
|
||||||
|
import { ExternalToolApiRouting, ExternalToolPluginPayload } from '../../api';
|
||||||
|
import { ToolApiFormValue, ToolApiRoutingFormValue, ToolPluginFormValue } from './types';
|
||||||
|
|
||||||
|
/** 合法的认证类型枚举 */
|
||||||
|
const VALID_AUTH_TYPES = new Set(['none', 'bearer', 'basic', 'apiKey', 'custom']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 JSON 或类 JS 对象的字符串。
|
||||||
|
* - 优先尝试标准 JSON 解析
|
||||||
|
* - 失败后尝试作为 JS 对象字面量解析(支持无引号键、单引号等)
|
||||||
|
* - 再失败后尝试宽松解析(处理完全无引号的键值对)
|
||||||
|
* @param value 待解析的字符串
|
||||||
|
* @param fieldName 字段名称,用于错误提示
|
||||||
|
* @param optional 是否可选,为空时返回 undefined
|
||||||
|
* @returns 解析后的对象
|
||||||
|
* @throws Error 格式不正确时抛出异常
|
||||||
|
*/
|
||||||
|
export function parseJsonObject(value: string | undefined, fieldName: string, optional = false) {
|
||||||
|
const trimmedValue = value?.trim();
|
||||||
|
if (!trimmedValue) {
|
||||||
|
if (optional) return undefined;
|
||||||
|
throw new Error(`${fieldName}不能为空`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 优先尝试标准 JSON 解析
|
||||||
|
return JSON.parse(trimmedValue);
|
||||||
|
} catch (e) {
|
||||||
|
try {
|
||||||
|
// 2. 失败后尝试作为 JS 对象解析 (支持无引号键、单引号等)
|
||||||
|
// eslint-disable-next-line no-new-func
|
||||||
|
const parsed = new Function(`return (${trimmedValue})`)();
|
||||||
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
throw new Error();
|
||||||
|
} catch (e2) {
|
||||||
|
// 3. 尝试宽松解析 (处理类似 { Content-Type: application/json } 这种完全无引号的情况)
|
||||||
|
try {
|
||||||
|
const content = trimmedValue.replace(/^\{/, '').replace(/\}$/, '').trim();
|
||||||
|
const result: Record<string, any> = {};
|
||||||
|
const pairs = content.split(/[\n,]/);
|
||||||
|
let hasValidPair = false;
|
||||||
|
|
||||||
|
for (let pair of pairs) {
|
||||||
|
pair = pair.trim();
|
||||||
|
if (!pair) continue;
|
||||||
|
const colonIndex = pair.indexOf(':');
|
||||||
|
if (colonIndex > 0) {
|
||||||
|
const k = pair.substring(0, colonIndex).trim().replace(/^['"]|['"]$/g, '');
|
||||||
|
const v = pair.substring(colonIndex + 1).trim().replace(/^['"]|['"]$/g, '');
|
||||||
|
if (k) {
|
||||||
|
let finalVal: any = v;
|
||||||
|
if (v === 'true') finalVal = true;
|
||||||
|
else if (v === 'false') finalVal = false;
|
||||||
|
else if (v === 'null') finalVal = null;
|
||||||
|
else if (!isNaN(Number(v)) && v !== '') finalVal = Number(v);
|
||||||
|
result[k] = finalVal;
|
||||||
|
hasValidPair = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasValidPair) return result;
|
||||||
|
} catch (e3) {
|
||||||
|
// ignore and fall through to error
|
||||||
|
}
|
||||||
|
throw new Error(`${fieldName}格式不正确,请确保是有效的 JSON 或对象格式`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 规范化字符串列表:去重、去空、trim。
|
||||||
|
* @param values 原始字符串数组
|
||||||
|
* @returns 规范化后的字符串数组
|
||||||
|
*/
|
||||||
|
export function normalizeStringList(values?: string[]) {
|
||||||
|
return Array.from(new Set((values ?? []).map((item) => item?.trim()).filter((item): item is string => Boolean(item))));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成文本字段 trim 校验器。
|
||||||
|
* @param message 错误提示
|
||||||
|
* @returns 校验函数
|
||||||
|
*/
|
||||||
|
export function validateTrimmedText(message: string) {
|
||||||
|
return async (_: unknown, value: string | undefined) => {
|
||||||
|
if (!value?.trim()) {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成路由列表(字符串数组)校验器。
|
||||||
|
* @param message 错误提示
|
||||||
|
* @param min 最少项数
|
||||||
|
* @returns 校验函数
|
||||||
|
*/
|
||||||
|
export function validateRoutingList(message: string, min = 0) {
|
||||||
|
return async (_: unknown, value: string[] | undefined) => {
|
||||||
|
const normalized = normalizeStringList(value);
|
||||||
|
if (normalized.length < min) {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((value ?? []).some((item) => !item?.trim())) {
|
||||||
|
throw new Error('列表项不能为空');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 规范化单条 API 的路由规则。
|
||||||
|
* @param value 表单中的路由规则
|
||||||
|
* @param apiName API 名称(用于错误提示)
|
||||||
|
* @returns 规范化后的路由规则
|
||||||
|
*/
|
||||||
|
export function normalizeRouting(value: ToolApiRoutingFormValue | undefined, apiName: string): ExternalToolApiRouting {
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`API ${apiName} 的路由规则不能为空`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const useWhen = normalizeStringList(value.useWhen);
|
||||||
|
if (useWhen.length < 1) {
|
||||||
|
throw new Error(`API ${apiName} 的 useWhen 至少保留一项`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
summary: value.summary?.trim() || '',
|
||||||
|
useWhen,
|
||||||
|
doNotUseWhen: normalizeStringList(value.doNotUseWhen),
|
||||||
|
domains: normalizeStringList(value.domains),
|
||||||
|
intents: normalizeStringList(value.intents),
|
||||||
|
requiredSlots: normalizeStringList(value.requiredSlots),
|
||||||
|
optionalSlots: normalizeStringList(value.optionalSlots),
|
||||||
|
examples: normalizeStringList(value.examples),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将表单值转换为提交用的 Payload(字符串字段反序列化为对象)。
|
||||||
|
* @param values 表单值
|
||||||
|
* @returns 接口需要的 Payload
|
||||||
|
*/
|
||||||
|
export function formValueToPayload(values: ToolPluginFormValue): ExternalToolPluginPayload {
|
||||||
|
return {
|
||||||
|
name: values.name.trim(),
|
||||||
|
description: values.description?.trim(),
|
||||||
|
baseUrl: values.baseUrl.trim(),
|
||||||
|
authType: values.authType,
|
||||||
|
authConfig: parseJsonObject(values.authConfig, '认证配置', values.authType === 'none') || {},
|
||||||
|
headers: parseJsonObject(values.headers, '统一请求头', true),
|
||||||
|
apis: values.apis.map((item) => ({
|
||||||
|
name: item.name.trim(),
|
||||||
|
description: item.description.trim(),
|
||||||
|
method: item.method,
|
||||||
|
path: item.path.trim(),
|
||||||
|
headers: parseJsonObject(item.headers, `API ${item.name} 的请求头`, true),
|
||||||
|
parametersSchema: parseJsonObject(item.parametersSchema, `API ${item.name} 的依赖参数`),
|
||||||
|
routing: normalizeRouting(item.routing, item.name.trim() || '未命名 API'),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 JSON 对象(可能包含对象字段)转换为表单值(对象字段序列化为字符串)。
|
||||||
|
* 当用户在 JSON 编辑器中粘贴完整 JSON 后,调用此函数将其转换为可设置到 Form 的结构。
|
||||||
|
* @param json 原始 JSON 对象
|
||||||
|
* @returns 表单可用的值
|
||||||
|
*/
|
||||||
|
export function jsonToFormValue(json: Record<string, any>): ToolPluginFormValue {
|
||||||
|
const apisRaw: any[] = Array.isArray(json.apis) ? json.apis : [];
|
||||||
|
const apis: ToolApiFormValue[] = apisRaw.map((item) => ({
|
||||||
|
name: typeof item.name === 'string' ? item.name : '',
|
||||||
|
description: typeof item.description === 'string' ? item.description : '',
|
||||||
|
method: typeof item.method === 'string' ? item.method : 'GET',
|
||||||
|
path: typeof item.path === 'string' ? item.path : '',
|
||||||
|
headers: typeof item.headers === 'string' ? item.headers : JSON.stringify(item.headers || {}, null, 2),
|
||||||
|
parametersSchema:
|
||||||
|
typeof item.parametersSchema === 'string'
|
||||||
|
? item.parametersSchema
|
||||||
|
: JSON.stringify(item.parametersSchema || { type: 'object', properties: {} }, null, 2),
|
||||||
|
routing: {
|
||||||
|
summary: typeof item.routing?.summary === 'string' ? item.routing.summary : '',
|
||||||
|
useWhen: Array.isArray(item.routing?.useWhen) ? item.routing.useWhen : [''],
|
||||||
|
doNotUseWhen: Array.isArray(item.routing?.doNotUseWhen) ? item.routing.doNotUseWhen : [],
|
||||||
|
domains: Array.isArray(item.routing?.domains) ? item.routing.domains : [],
|
||||||
|
intents: Array.isArray(item.routing?.intents) ? item.routing.intents : [],
|
||||||
|
requiredSlots: Array.isArray(item.routing?.requiredSlots) ? item.routing.requiredSlots : [],
|
||||||
|
optionalSlots: Array.isArray(item.routing?.optionalSlots) ? item.routing.optionalSlots : [],
|
||||||
|
examples: Array.isArray(item.routing?.examples) ? item.routing.examples : [],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: typeof json.name === 'string' ? json.name : '',
|
||||||
|
description: typeof json.description === 'string' ? json.description : '',
|
||||||
|
baseUrl: typeof json.baseUrl === 'string' ? json.baseUrl : '',
|
||||||
|
authType: VALID_AUTH_TYPES.has(json.authType) ? json.authType : 'bearer',
|
||||||
|
authConfig: typeof json.authConfig === 'string' ? json.authConfig : JSON.stringify(json.authConfig || {}, null, 2),
|
||||||
|
headers: typeof json.headers === 'string' ? json.headers : JSON.stringify(json.headers || {}, null, 2),
|
||||||
|
apis: apis.length ? apis : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将表单值(字符串字段)转换为 JSON 编辑器显示的对象(字符串字段反序列化为对象)。
|
||||||
|
* 当可视化表单发生变化时,调用此函数生成 JSON 字符串展示给用户。
|
||||||
|
* @param values 表单值
|
||||||
|
* @returns 可序列化为 JSON 的对象
|
||||||
|
*/
|
||||||
|
export function formValueToJson(values: ToolPluginFormValue): Record<string, any> {
|
||||||
|
return {
|
||||||
|
name: values.name,
|
||||||
|
description: values.description,
|
||||||
|
baseUrl: values.baseUrl,
|
||||||
|
authType: values.authType,
|
||||||
|
authConfig: parseJsonObject(values.authConfig, '认证配置', true) || {},
|
||||||
|
headers: parseJsonObject(values.headers, '统一请求头', true) || {},
|
||||||
|
apis: (values.apis || []).map((item) => ({
|
||||||
|
name: item.name,
|
||||||
|
description: item.description,
|
||||||
|
method: item.method,
|
||||||
|
path: item.path,
|
||||||
|
headers: parseJsonObject(item.headers, `API ${item.name || ''} 的请求头`, true) || {},
|
||||||
|
parametersSchema: parseJsonObject(item.parametersSchema, `API ${item.name || ''} 的依赖参数`, true) || {},
|
||||||
|
routing: item.routing || {},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export interface IconProps extends React.SVGProps<SVGSVGElement> {
|
||||||
|
color?: string;
|
||||||
|
size?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 附件按钮图标
|
||||||
|
* @param color 图标颜色,默认为 #5C7480
|
||||||
|
* @param size 图标大小,默认为 32
|
||||||
|
*/
|
||||||
|
export function IconAttachment({ color = '#5C7480', size = 32, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 32 32"
|
||||||
|
fill="none"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill={color}
|
||||||
|
transform="matrix(1 0 0 1 8.875 8.6875)"
|
||||||
|
d="M6.9053 13.0928L12.9053 7.0928Q13.134 6.8769 13.125 6.5625Q13.134 6.2481 12.9053 6.0322Q12.6894 5.8035 12.375 5.8125Q12.0606 5.8035 11.8447 6.0322L5.8447 12.0322Q4.9393 12.9375 3.75 12.9375Q2.5607 12.9375 1.6553 12.0322Q0.75 11.1268 0.75 9.9375Q0.75 8.7482 1.6553 7.8428L8.4053 1.0928Q8.7482 0.75 9.375 0.75Q10.0018 0.75 10.3447 1.0928Q10.6875 1.4357 10.6875 2.0625Q10.6875 2.6893 10.3447 3.0322L3.5947 9.7822Q3.366 9.9981 3.375 10.3125Q3.366 10.6269 3.5947 10.8428Q3.8106 11.0715 4.125 11.0625Q4.4394 11.0715 4.6553 10.8428L11.4053 4.0928Q12.1875 3.3107 12.1875 2.0625Q12.1875 0.8143 11.4053 0.0322Q10.6232 -0.75 9.375 -0.75Q8.1268 -0.75 7.3447 0.0322L0.5947 6.7822Q-0.75 8.1268 -0.75 9.9375Q-0.75 11.7482 0.5947 13.0928Q1.9393 14.4375 3.75 14.4375Q5.5607 14.4375 6.9053 13.0928Z"
|
||||||
|
fillRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI按钮图标
|
||||||
|
* @param color 图标颜色,默认为 #5C7480
|
||||||
|
* @param size 图标大小,默认为 32
|
||||||
|
*/
|
||||||
|
export function IconPrompt({ color = '#5C7480', size = 32, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 32 32"
|
||||||
|
fill="none"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill={color}
|
||||||
|
transform="matrix(1 0 0 1 10 8.5)"
|
||||||
|
d="M8.0929 3.9071L6.7115 -0.2372Q6.6207 -0.5383 6.3354 -0.6708Q6.0582 -0.8195 5.7628 -0.7115Q5.5943 -0.6565 5.4697 -0.5303Q5.3435 -0.4057 5.2885 -0.2372L3.9071 3.9071L-0.2372 5.2885Q-0.5383 5.3793 -0.6708 5.6646Q-0.8195 5.9418 -0.7115 6.2372Q-0.6565 6.4057 -0.5303 6.5303Q-0.4057 6.6565 -0.2372 6.7115L3.9071 8.0929L5.2885 12.2372Q5.3793 12.5383 5.6646 12.6708Q5.9418 12.8195 6.2372 12.7115Q6.4057 12.6565 6.5303 12.5303Q6.6565 12.4057 6.7115 12.2372L8.0929 8.0929L12.2372 6.7115Q12.5383 6.6207 12.6708 6.3354Q12.8195 6.0582 12.7115 5.7628Q12.6565 5.5943 12.5303 5.4697Q12.4057 5.3435 12.2372 5.2885L8.0929 3.9071ZM6 2.3717L6.7885 4.7372Q6.8435 4.9057 6.9697 5.0303Q7.0943 5.1565 7.2628 5.2115L9.6283 6L7.2628 6.7885Q7.0943 6.8435 6.9697 6.9697Q6.8435 7.0943 6.7885 7.2628L6 9.6283L5.2115 7.2628Q5.1565 7.0943 5.0303 6.9697Q4.9057 6.8435 4.7372 6.7885L2.3717 6L4.7372 5.2115Q4.9057 5.1565 5.0303 5.0303Q5.1565 4.9057 5.2115 4.7372L6 2.3717Z"
|
||||||
|
fillRule="evenodd"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill={color}
|
||||||
|
transform="matrix(1 0 0 1 19 19)"
|
||||||
|
d="M3.4193 1.0807L2.7531 -0.2516Q2.6537 -0.4655 2.4279 -0.5336Q2.2063 -0.6146 1.9984 -0.5031Q1.9167 -0.4624 1.8523 -0.3977Q1.7876 -0.3333 1.7469 -0.2516L1.0807 1.0807L-0.2516 1.7469Q-0.4655 1.8463 -0.5336 2.0721Q-0.6146 2.2937 -0.5031 2.5016Q-0.4624 2.5833 -0.3977 2.6477Q-0.3333 2.7124 -0.2516 2.7531L1.0807 3.4193L1.7469 4.7516Q1.8463 4.9655 2.0721 5.0336Q2.2937 5.1146 2.5016 5.0031Q2.5833 4.9624 2.6477 4.8977Q2.7124 4.8333 2.7531 4.7516L3.4193 3.4193L4.7516 2.7531Q4.9655 2.6537 5.0336 2.4279Q5.1146 2.2063 5.0031 1.9984Q4.9624 1.9167 4.8977 1.8523Q4.8333 1.7876 4.7516 1.7469L3.4193 1.0807ZM2.25 1.2578L2.4969 1.7516Q2.5376 1.8333 2.6023 1.8977Q2.6667 1.9624 2.7484 2.0031L3.2422 2.25L2.7484 2.4969Q2.6667 2.5376 2.6023 2.6023Q2.5376 2.6667 2.4969 2.7484L2.25 3.2422L2.0031 2.7484Q1.9624 2.6667 1.8977 2.6023Q1.8333 2.5376 1.7516 2.4969L1.2578 2.25L1.7516 2.0031Q1.8333 1.9624 1.8977 1.8977Q1.9624 1.8333 2.0031 1.7516L2.25 1.2578Z"
|
||||||
|
fillRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,252 @@
|
||||||
|
.sidebar {
|
||||||
|
width: 240px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-right: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 16px 12px;
|
||||||
|
height: 100vh;
|
||||||
|
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
overflow: visible;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.is-collapsed {
|
||||||
|
width: 68px;
|
||||||
|
padding: 16px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.is-collapsed .sidebar-brand {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.is-collapsed .sidebar-search {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.is-collapsed .sidebar-search-input {
|
||||||
|
width: 36px;
|
||||||
|
padding: 0 !important;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.is-collapsed .sidebar-nav-item {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.is-collapsed .sidebar-nav-icon {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.is-collapsed .sidebar-user-card {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
padding: 8px 10px 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand-logo {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand-name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
transition: all 0.2s;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
position: absolute;
|
||||||
|
right: -12px;
|
||||||
|
top: 24px;
|
||||||
|
z-index: 100;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand-toggle:hover {
|
||||||
|
background: var(--color-surface-2);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-search {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-search-input {
|
||||||
|
width: 100%;
|
||||||
|
height: 36px;
|
||||||
|
background: var(--color-bg) !important;
|
||||||
|
border: 1px solid var(--color-border) !important;
|
||||||
|
border-radius: 10px !important;
|
||||||
|
padding: 0 12px !important;
|
||||||
|
font-size: 13px !important;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-search-input:hover {
|
||||||
|
border-color: var(--color-border-strong) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-search-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-search-placeholder {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-search-suffix {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
background: var(--color-surface);
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-scroll {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin: 0 -4px;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-scroll::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-scroll::-webkit-scrollbar-thumb {
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-scroll:hover::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
padding: 0 12px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav-item {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav-item:hover {
|
||||||
|
background: var(--color-surface-2);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav-item.active {
|
||||||
|
background: var(--color-brand-soft);
|
||||||
|
color: var(--color-brand);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-user {
|
||||||
|
padding: 12px 8px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-user-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
background: #F7FCFA;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-user-card:hover {
|
||||||
|
background: var(--color-surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-user .sidebar-user-avatar {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #5CCFC4 0%, #F2C94D 100%);
|
||||||
|
color: #ffffff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-user-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-user-name {
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-user-role {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
@ -9,17 +9,23 @@ import {
|
||||||
ApartmentOutlined,
|
ApartmentOutlined,
|
||||||
BarChartOutlined,
|
BarChartOutlined,
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
SunOutlined,
|
LogoutOutlined,
|
||||||
MoonOutlined,
|
LeftOutlined,
|
||||||
LogoutOutlined
|
RightOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
CreditCardOutlined,
|
||||||
|
ShoppingCartOutlined,
|
||||||
|
DatabaseOutlined
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useAuth } from '../store/auth';
|
import { useAuth } from '../store/auth';
|
||||||
import { useTheme } from '../main';
|
|
||||||
import kaiwuIcon from '../assets/brand/kaiwu-icon-gradient-transparent.png';
|
import kaiwuIcon from '../assets/brand/kaiwu-icon-gradient-transparent.png';
|
||||||
|
import './Sidebar.css';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onOpenPalette?: () => void;
|
onOpenPalette?: () => void;
|
||||||
onNavigate?: () => void;
|
onNavigate?: () => void;
|
||||||
|
collapsed?: boolean;
|
||||||
|
onToggleCollapse?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NAV_GROUPS: Array<{
|
const NAV_GROUPS: Array<{
|
||||||
|
|
@ -31,12 +37,13 @@ const NAV_GROUPS: Array<{
|
||||||
items: [
|
items: [
|
||||||
{ to: '/chat', icon: <MessageOutlined />, label: '聊天' },
|
{ to: '/chat', icon: <MessageOutlined />, label: '聊天' },
|
||||||
{ to: '/agents', icon: <RobotOutlined />, label: '我的智能体' },
|
{ to: '/agents', icon: <RobotOutlined />, label: '我的智能体' },
|
||||||
{ to: '/marketplace', icon: <CompassOutlined />, label: '智能体广场' }
|
// { to: '/marketplace', icon: <CompassOutlined />, label: '智能体广场' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '资源',
|
label: '资源',
|
||||||
items: [
|
items: [
|
||||||
|
{ to: '/knowledge', icon: <DatabaseOutlined />, label: '知识库' },
|
||||||
{ to: '/prompts', icon: <BookOutlined />, label: 'Prompt 模板库' },
|
{ to: '/prompts', icon: <BookOutlined />, label: 'Prompt 模板库' },
|
||||||
{ to: '/workflows', icon: <ApartmentOutlined />, label: '工作流' }
|
{ to: '/workflows', icon: <ApartmentOutlined />, label: '工作流' }
|
||||||
]
|
]
|
||||||
|
|
@ -51,68 +58,65 @@ const NAV_GROUPS: Array<{
|
||||||
{
|
{
|
||||||
label: '商城',
|
label: '商城',
|
||||||
items: [
|
items: [
|
||||||
{ to: '/points-mall', icon: <CompassOutlined />, label: 'Token商城' }
|
{ to: '/points-mall', icon: <ShoppingCartOutlined />, label: 'Token 商城' },
|
||||||
|
{ to: '/pricing', icon: <CreditCardOutlined />, label: '会员计划' }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Sidebar({ onOpenPalette, onNavigate }: Props) {
|
export default function Sidebar({ onOpenPalette, onNavigate, collapsed, onToggleCollapse }: Props) {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { mode, toggle } = useTheme();
|
|
||||||
|
|
||||||
const isMac =
|
const isMac =
|
||||||
typeof navigator !== 'undefined' && /mac|iphone|ipad|ipod/i.test(navigator.platform || '');
|
typeof navigator !== 'undefined' && /mac|iphone|ipad|ipod/i.test(navigator.platform || '');
|
||||||
const cmdKey = isMac ? '⌘' : 'Ctrl';
|
const cmdKey = isMac ? '⌘' : 'Ctrl';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="sidebar">
|
<aside className={`sidebar ${collapsed ? 'is-collapsed' : ''}`}>
|
||||||
<div className="brand">
|
<div className="sidebar-brand">
|
||||||
<img src={kaiwuIcon} alt="鲸域AI" className="brand-logo" />
|
<img src={kaiwuIcon} alt="鲸域AI" className="sidebar-brand-logo" />
|
||||||
<span>鲸域AI</span>
|
{!collapsed && <span className="sidebar-brand-name">鲸域AI</span>}
|
||||||
<div className="sidebar-brand-spacer" />
|
<div className="sidebar-brand-toggle" onClick={onToggleCollapse}>
|
||||||
<Tooltip title={mode === 'dark' ? '切换到明亮模式' : '切换到深色模式'}>
|
{collapsed ? <RightOutlined style={{ fontSize: 12 }} /> : <LeftOutlined style={{ fontSize: 12 }} />}
|
||||||
<button className="theme-toggle" onClick={toggle} aria-label="切换主题">
|
</div>
|
||||||
{mode === 'dark' ? <SunOutlined /> : <MoonOutlined />}
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div className="sidebar-search" onClick={onOpenPalette}>
|
||||||
onClick={() => {
|
<div className="sidebar-search-input">
|
||||||
onOpenPalette?.();
|
<SearchOutlined className="sidebar-search-icon" />
|
||||||
onNavigate?.();
|
{!collapsed && (
|
||||||
}}
|
<>
|
||||||
className="nav-item sidebar-search-action"
|
<span className="sidebar-search-placeholder">快速搜索</span>
|
||||||
>
|
<span className="sidebar-search-suffix">{cmdKey} K</span>
|
||||||
<span className="sidebar-search-label">
|
</>
|
||||||
<SearchOutlined className="nav-icon" />
|
)}
|
||||||
<span>快速搜索</span>
|
</div>
|
||||||
</span>
|
|
||||||
<span className="kbd">{cmdKey} K</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sidebar-scroll">
|
<div className="sidebar-scroll">
|
||||||
{NAV_GROUPS.map((group) => (
|
{NAV_GROUPS.map((group) => (
|
||||||
<div key={group.label}>
|
<div key={group.label} className="sidebar-nav-group">
|
||||||
<div className="nav-section-label">{group.label}</div>
|
{!collapsed && <div className="sidebar-nav-label">{group.label}</div>}
|
||||||
{group.items.map((it) => (
|
{group.items.map((it) => (
|
||||||
|
<Tooltip key={it.to} title={collapsed ? it.label : ''} placement="right">
|
||||||
<NavLink
|
<NavLink
|
||||||
key={it.to}
|
|
||||||
to={it.to}
|
to={it.to}
|
||||||
end={it.end}
|
end={it.end}
|
||||||
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
|
className={({ isActive }) => `sidebar-nav-item ${isActive ? 'active' : ''}`}
|
||||||
onClick={onNavigate}
|
onClick={onNavigate}
|
||||||
>
|
>
|
||||||
<span className="nav-icon">{it.icon}</span>
|
<span className="sidebar-nav-icon">{it.icon}</span>
|
||||||
<span>{it.label}</span>
|
{!collapsed && <span>{it.label}</span>}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
</Tooltip>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{user && (
|
{user && (
|
||||||
|
<div className="sidebar-user">
|
||||||
<Dropdown
|
<Dropdown
|
||||||
menu={{
|
menu={{
|
||||||
items: [
|
items: [
|
||||||
|
|
@ -121,11 +125,14 @@ export default function Sidebar({ onOpenPalette, onNavigate }: Props) {
|
||||||
label: <span className="sidebar-user-role">{user.phone}</span>,
|
label: <span className="sidebar-user-role">{user.phone}</span>,
|
||||||
disabled: true
|
disabled: true
|
||||||
},
|
},
|
||||||
{ type: 'divider' },
|
|
||||||
{
|
{
|
||||||
key: 'role',
|
key: 'profile',
|
||||||
label: `身份:${user.role === 'admin' ? '管理员' : '普通用户'}`,
|
icon: <UserOutlined />,
|
||||||
disabled: true
|
label: '个人中心',
|
||||||
|
onClick: () => {
|
||||||
|
navigate('/profile');
|
||||||
|
onNavigate?.();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{ type: 'divider' },
|
{ type: 'divider' },
|
||||||
{
|
{
|
||||||
|
|
@ -142,20 +149,21 @@ export default function Sidebar({ onOpenPalette, onNavigate }: Props) {
|
||||||
}}
|
}}
|
||||||
placement="topLeft"
|
placement="topLeft"
|
||||||
>
|
>
|
||||||
<div className="sidebar-user">
|
<div className="sidebar-user-card">
|
||||||
<Avatar size={32} className="sidebar-user-avatar">
|
<div className="sidebar-user-avatar">
|
||||||
{(user.name?.charAt(0) || '?').toUpperCase()}
|
{(user.name?.charAt(0) || '?').toUpperCase()}
|
||||||
</Avatar>
|
|
||||||
<div className="sidebar-user-main">
|
|
||||||
<div className="sidebar-user-name">
|
|
||||||
{user.name}
|
|
||||||
</div>
|
</div>
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="sidebar-user-info">
|
||||||
|
<div className="sidebar-user-name">{user.name}</div>
|
||||||
<div className="sidebar-user-role">
|
<div className="sidebar-user-role">
|
||||||
{user.role === 'admin' ? '管理员' : '成员'}
|
{user.role === 'admin' ? '管理员' : '成员'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -42,8 +42,8 @@ function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||||
() => ({
|
() => ({
|
||||||
algorithm: isDark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
|
algorithm: isDark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
|
||||||
token: {
|
token: {
|
||||||
colorPrimary: isDark ? '#55a5ff' : '#1167ff',
|
colorPrimary: isDark ? '#55a5ff' : '#5CCFC4',
|
||||||
colorInfo: isDark ? '#72b7ff' : '#1e86ff',
|
colorInfo: isDark ? '#72b7ff' : '#5CCFC4',
|
||||||
colorBgBase: isDark ? '#071126' : '#f5f9ff',
|
colorBgBase: isDark ? '#071126' : '#f5f9ff',
|
||||||
colorBgContainer: isDark ? '#0c1730' : '#ffffff',
|
colorBgContainer: isDark ? '#0c1730' : '#ffffff',
|
||||||
colorBgElevated: isDark ? '#111f3c' : '#ffffff',
|
colorBgElevated: isDark ? '#111f3c' : '#ffffff',
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,12 @@ interface CapabilitySettingsProps {
|
||||||
setAvatarSelectorOpen: (open: boolean) => void;
|
setAvatarSelectorOpen: (open: boolean) => void;
|
||||||
beforeUploadKnowledge: (file: any) => Promise<boolean>;
|
beforeUploadKnowledge: (file: any) => Promise<boolean>;
|
||||||
onDeleteKnowledge: (fileId: string) => Promise<void>;
|
onDeleteKnowledge: (fileId: string) => Promise<void>;
|
||||||
|
onCreateSkill: () => void;
|
||||||
|
onEditSkill: (skillId: string) => void;
|
||||||
|
onDeleteSkill: (skillId: string) => Promise<void>;
|
||||||
|
onCreateExternalTool: () => void;
|
||||||
|
onEditExternalTool: (pluginId: string) => void;
|
||||||
|
onDeleteExternalTool: (pluginId: string) => Promise<void>;
|
||||||
markDirty: () => void;
|
markDirty: () => void;
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -32,6 +38,12 @@ export default function CapabilitySettings({
|
||||||
setAvatarSelectorOpen,
|
setAvatarSelectorOpen,
|
||||||
beforeUploadKnowledge,
|
beforeUploadKnowledge,
|
||||||
onDeleteKnowledge,
|
onDeleteKnowledge,
|
||||||
|
onCreateSkill,
|
||||||
|
onEditSkill,
|
||||||
|
onDeleteSkill,
|
||||||
|
onCreateExternalTool,
|
||||||
|
onEditExternalTool,
|
||||||
|
onDeleteExternalTool,
|
||||||
markDirty,
|
markDirty,
|
||||||
isMobile = false,
|
isMobile = false,
|
||||||
}: CapabilitySettingsProps) {
|
}: CapabilitySettingsProps) {
|
||||||
|
|
@ -62,6 +74,12 @@ export default function CapabilitySettings({
|
||||||
agent={agent}
|
agent={agent}
|
||||||
beforeUploadKnowledge={beforeUploadKnowledge}
|
beforeUploadKnowledge={beforeUploadKnowledge}
|
||||||
onDeleteKnowledge={onDeleteKnowledge}
|
onDeleteKnowledge={onDeleteKnowledge}
|
||||||
|
onCreateSkill={onCreateSkill}
|
||||||
|
onEditSkill={onEditSkill}
|
||||||
|
onDeleteSkill={onDeleteSkill}
|
||||||
|
onCreateExternalTool={onCreateExternalTool}
|
||||||
|
onEditExternalTool={onEditExternalTool}
|
||||||
|
onDeleteExternalTool={onDeleteExternalTool}
|
||||||
/>
|
/>
|
||||||
<WebSearchCard />
|
<WebSearchCard />
|
||||||
</Form>
|
</Form>
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,21 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Button, Checkbox, Dropdown } from 'antd';
|
import { Button, Radio, Dropdown } from 'antd';
|
||||||
import { DownOutlined } from '@ant-design/icons';
|
import { DownOutlined } from '@ant-design/icons';
|
||||||
import { AiModel } from '../../../api';
|
import { AiModel } from '../../../api';
|
||||||
import { DEFAULT_RH_40X40_GRAY } from '../../../constants';
|
import { DEFAULT_RH_40X40_GRAY } from '../../../constants';
|
||||||
|
|
||||||
interface ModelCheckboxDropdownProps {
|
interface ModelSelectDropdownProps {
|
||||||
value?: string[];
|
value?: string;
|
||||||
onChange?: (value: string[]) => void;
|
onChange?: (value: string) => void;
|
||||||
models: AiModel[];
|
models: AiModel[];
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ModelCheckboxDropdown({ value = [], onChange, models, isMobile = false }: ModelCheckboxDropdownProps) {
|
export default function ModelSelectDropdown({ value, onChange, models, isMobile = false }: ModelSelectDropdownProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const summary = value.length ? `${value.length} 个已选` : '选择模型';
|
|
||||||
|
const selectedModel = models.find(m => m.id === value);
|
||||||
|
const summary = selectedModel ? selectedModel.model_name : '选择模型';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
|
|
@ -22,41 +24,44 @@ export default function ModelCheckboxDropdown({ value = [], onChange, models, is
|
||||||
onOpenChange={setOpen}
|
onOpenChange={setOpen}
|
||||||
popupRender={() => (
|
popupRender={() => (
|
||||||
<div className={`agent-model-dropdown-panel${isMobile ? ' is-h5-panel' : ''}`} onClick={(e) => e.stopPropagation()}>
|
<div className={`agent-model-dropdown-panel${isMobile ? ' is-h5-panel' : ''}`} onClick={(e) => e.stopPropagation()}>
|
||||||
<Checkbox.Group
|
<Radio.Group
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(checked) => onChange?.(checked.map((item) => String(item)))}
|
onChange={(e) => {
|
||||||
className="agent-model-checkbox-group"
|
onChange?.(e.target.value);
|
||||||
|
setOpen(false); // 单选选中后自动关闭
|
||||||
|
}}
|
||||||
|
className="agent-model-radio-group"
|
||||||
>
|
>
|
||||||
{models.map((m) => {
|
{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 (
|
||||||
<Checkbox key={m.id} value={m.id} className="agent-model-checkbox-item">
|
<Radio key={m.id} value={m.id} className="agent-model-radio-item">
|
||||||
<div className="agent-model-checkbox-content">
|
<div className="agent-model-radio-content">
|
||||||
<div className="agent-model-checkbox-meta">
|
<div className="agent-model-radio-meta">
|
||||||
<img
|
<img
|
||||||
src={m.icon || DEFAULT_RH_40X40_GRAY}
|
src={m.icon || DEFAULT_RH_40X40_GRAY}
|
||||||
alt={m.model_name}
|
alt={m.model_name}
|
||||||
className="agent-model-checkbox-icon"
|
className="agent-model-radio-icon"
|
||||||
/>
|
/>
|
||||||
<span className="agent-model-checkbox-name">{m.model_name}</span>
|
<span className="agent-model-radio-name">{m.model_name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="agent-model-checkbox-price">
|
<div className="agent-model-radio-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>
|
</Radio>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Checkbox.Group>
|
</Radio.Group>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Button type="text" block className="agent-model-dropdown-trigger">
|
<Button type="text" block className="agent-model-dropdown-trigger">
|
||||||
<span className="agent-model-dropdown-summary">{summary}</span>
|
<span className="agent-model-dropdown-summary">模型选择</span>
|
||||||
<span className="agent-model-dropdown-values">
|
<span className="agent-model-dropdown-values">
|
||||||
{value.length ? value.join(', ') : '未选择'}
|
{summary}
|
||||||
</span>
|
</span>
|
||||||
<DownOutlined className="agent-model-dropdown-arrow" />
|
<DownOutlined className="agent-model-dropdown-arrow" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Button, Collapse, Form, Input, List, Popconfirm, Tag } from 'antd';
|
import { ApiOutlined, DatabaseOutlined, DeleteOutlined, EditOutlined, PlusOutlined, ToolOutlined } from '@ant-design/icons';
|
||||||
import { DatabaseOutlined, ToolOutlined } from '@ant-design/icons';
|
import { Button, Card, Collapse, Input, List, Popconfirm, Space, Tag } from 'antd';
|
||||||
import { Agent } from '../../../../api';
|
import { Agent } from '../../../../api';
|
||||||
import { STATUS_TAG } from '../../constants';
|
import { STATUS_TAG } from '../../constants';
|
||||||
|
|
||||||
|
|
@ -7,13 +7,28 @@ interface KnowledgeSettingsPanelProps {
|
||||||
agent: Agent | null;
|
agent: Agent | null;
|
||||||
beforeUploadKnowledge: (file: any) => Promise<boolean>;
|
beforeUploadKnowledge: (file: any) => Promise<boolean>;
|
||||||
onDeleteKnowledge: (fileId: string) => Promise<void>;
|
onDeleteKnowledge: (fileId: string) => Promise<void>;
|
||||||
|
onCreateSkill: () => void;
|
||||||
|
onEditSkill: (skillId: string) => void;
|
||||||
|
onDeleteSkill: (skillId: string) => Promise<void>;
|
||||||
|
onCreateExternalTool: () => void;
|
||||||
|
onEditExternalTool: (pluginId: string) => void;
|
||||||
|
onDeleteExternalTool: (pluginId: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function KnowledgeSettingsPanel({
|
export default function KnowledgeSettingsPanel({
|
||||||
agent,
|
agent,
|
||||||
beforeUploadKnowledge,
|
beforeUploadKnowledge,
|
||||||
onDeleteKnowledge,
|
onDeleteKnowledge,
|
||||||
|
onCreateSkill,
|
||||||
|
onEditSkill,
|
||||||
|
onDeleteSkill,
|
||||||
|
onCreateExternalTool,
|
||||||
|
onEditExternalTool,
|
||||||
|
onDeleteExternalTool,
|
||||||
}: KnowledgeSettingsPanelProps) {
|
}: KnowledgeSettingsPanelProps) {
|
||||||
|
const skills = agent?.skills ?? [];
|
||||||
|
const plugins = agent?.plugins ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Collapse
|
<Collapse
|
||||||
ghost
|
ghost
|
||||||
|
|
@ -37,11 +52,11 @@ export default function KnowledgeSettingsPanel({
|
||||||
multiple
|
multiple
|
||||||
className="agent-editor-file-input"
|
className="agent-editor-file-input"
|
||||||
id="knowledge-upload"
|
id="knowledge-upload"
|
||||||
onChange={async (e) => {
|
onChange={async (event) => {
|
||||||
const files = e.target.files;
|
const files = event.target.files;
|
||||||
if (!files) return;
|
if (!files) return;
|
||||||
for (let i = 0; i < files.length; i++) {
|
for (let index = 0; index < files.length; index++) {
|
||||||
await beforeUploadKnowledge(files[i]);
|
await beforeUploadKnowledge(files[index]);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -79,8 +94,11 @@ export default function KnowledgeSettingsPanel({
|
||||||
<span className="agent-editor-indexing-label">索引中…</span>
|
<span className="agent-editor-indexing-label">索引中…</span>
|
||||||
</Tag>
|
</Tag>
|
||||||
) : (
|
) : (
|
||||||
<Tag color={STATUS_TAG[(item.status || 'ready')].color} className="m-0 text-[10px] px-1">
|
<Tag
|
||||||
{STATUS_TAG[(item.status || 'ready')].text}
|
color={STATUS_TAG[item.status as keyof typeof STATUS_TAG]?.color || 'default'}
|
||||||
|
className="m-0 text-[10px] px-1"
|
||||||
|
>
|
||||||
|
{STATUS_TAG[item.status as keyof typeof STATUS_TAG]?.text || item.status || '未知'}
|
||||||
</Tag>
|
</Tag>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -93,14 +111,248 @@ export default function KnowledgeSettingsPanel({
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'skills',
|
key: 'skills',
|
||||||
collapsible: 'disabled',
|
|
||||||
label: (
|
label: (
|
||||||
<div className="agent-editor-disabled-label" title="技能功能开发中">
|
<div className="agent-editor-collapse-label">
|
||||||
<ToolOutlined />
|
<ToolOutlined className="agent-editor-label-icon" />
|
||||||
技能 & 工具 (开发中)
|
技能 & 工具 ({skills.length + plugins.length})
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
children: (
|
||||||
|
<div className="px-1">
|
||||||
|
<div className="agent-editor-skill-actions">
|
||||||
|
<span className="text-xs text-gray-500">Markdown 技能与外部 API 工具集分别管理</span>
|
||||||
|
<Space wrap>
|
||||||
|
<Button size="small" icon={<PlusOutlined />} onClick={onCreateSkill}>
|
||||||
|
新建 Skill
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" ghost size="small" icon={<ApiOutlined />} onClick={onCreateExternalTool}>
|
||||||
|
添加外部工具
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="agent-editor-tool-section">
|
||||||
|
<div className="agent-editor-tool-section-title">
|
||||||
|
<span>Skills</span>
|
||||||
|
<Tag>{skills.length}</Tag>
|
||||||
|
</div>
|
||||||
|
<List
|
||||||
|
size="small"
|
||||||
|
locale={{ emptyText: '暂无 Markdown Skill' }}
|
||||||
|
dataSource={skills}
|
||||||
|
renderItem={(item) => (
|
||||||
|
<List.Item
|
||||||
|
className="agent-editor-skill-item"
|
||||||
|
actions={[
|
||||||
|
<Button key="edit" type="text" size="small" icon={<EditOutlined />} onClick={() => onEditSkill(item.id)}>
|
||||||
|
编辑
|
||||||
|
</Button>,
|
||||||
|
<Popconfirm key="delete" title="确认删除该 Skill?" onConfirm={() => onDeleteSkill(item.id)}>
|
||||||
|
<Button type="text" danger size="small" icon={<DeleteOutlined />}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<List.Item.Meta
|
||||||
|
title={<span className="agent-editor-skill-name">{item.filename || item.name}</span>}
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
<div>{item.description || '暂无描述'}</div>
|
||||||
|
{item.filename && item.filename !== item.name && <div className="agent-editor-tool-help">{item.name}</div>}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Space size={6} wrap>
|
||||||
|
<Tag color="blue">{item.type}</Tag>
|
||||||
|
<Tag color={item.enabled ? 'success' : 'default'}>{item.enabled ? '已启用' : '未启用'}</Tag>
|
||||||
|
</Space>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="agent-editor-tool-section">
|
||||||
|
<div className="agent-editor-tool-section-title">
|
||||||
|
<span>外部工具集</span>
|
||||||
|
<Tag>{plugins.length}</Tag>
|
||||||
|
</div>
|
||||||
|
{plugins.length === 0 ? (
|
||||||
|
<div className="agent-editor-tool-empty">暂无外部工具集</div>
|
||||||
|
) : (
|
||||||
|
<Space direction="vertical" size={10} className="agent-editor-tool-list">
|
||||||
|
{plugins.map((plugin) => (
|
||||||
|
<Card
|
||||||
|
key={plugin.id}
|
||||||
|
size="small"
|
||||||
|
className="agent-editor-plugin-card"
|
||||||
|
title={
|
||||||
|
<Space wrap>
|
||||||
|
<ApiOutlined className="agent-editor-label-icon" />
|
||||||
|
<span>{plugin.name}</span>
|
||||||
|
<Tag color={plugin.enabled ? 'success' : 'default'}>{plugin.enabled ? '已启用' : '未启用'}</Tag>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Space>
|
||||||
|
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => onEditExternalTool(plugin.id)}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Popconfirm title="确认删除该工具集及其全部 API?" onConfirm={() => onDeleteExternalTool(plugin.id)}>
|
||||||
|
<Button type="text" danger size="small" icon={<DeleteOutlined />}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="agent-editor-plugin-description">{plugin.description || '暂无描述'}</div>
|
||||||
|
<Space size={6} wrap className="agent-editor-plugin-meta">
|
||||||
|
<Tag>{plugin.authType || 'none'}</Tag>
|
||||||
|
<span>{plugin.baseUrl}</span>
|
||||||
|
<span>{plugin.apis?.length ?? 0} 个 API</span>
|
||||||
|
</Space>
|
||||||
|
<Collapse
|
||||||
|
ghost
|
||||||
|
size="small"
|
||||||
|
className="agent-editor-plugin-apis"
|
||||||
|
items={plugin.apis?.map((api) => ({
|
||||||
|
key: api.id || api.name,
|
||||||
|
label: (
|
||||||
|
<Space wrap>
|
||||||
|
<Tag color="geekblue">{api.method}</Tag>
|
||||||
|
<strong>{api.name}</strong>
|
||||||
|
<span className="agent-editor-tool-help">{api.path}</span>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
children: (
|
||||||
|
<div className="agent-editor-api-detail">
|
||||||
|
<div className="mb-2">{api.description || '暂无描述'}</div>
|
||||||
|
|
||||||
|
{api.routing?.requiredSlots?.length ? (
|
||||||
|
<div className="mb-3 p-2 bg-orange-50 border border-orange-100 rounded">
|
||||||
|
<div className="text-[10px] text-orange-400 mb-1 font-bold uppercase tracking-wider">调用前关键信息提示</div>
|
||||||
|
<Space wrap size={[4, 4]}>
|
||||||
|
{api.routing.requiredSlots.map((slot) => (
|
||||||
|
<Tag key={slot} color="orange" className="m-0 border-none bg-orange-200 text-orange-700">
|
||||||
|
{slot}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="agent-editor-api-config-grid">
|
||||||
|
<div>
|
||||||
|
<strong>headers</strong>
|
||||||
|
<pre>{JSON.stringify(api.headers || {}, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>parametersSchema</strong>
|
||||||
|
<pre>{JSON.stringify(api.parametersSchema || {}, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 pt-3 border-t border-gray-100">
|
||||||
|
<div className="text-[10px] text-gray-400 mb-2 font-bold uppercase tracking-wider">Routing (路由规则)</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-gray-600 mb-1">Summary</div>
|
||||||
|
<div className="text-xs text-gray-500">{api.routing?.summary || '-'}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{api.routing?.domains?.length ? (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-gray-600 mb-1">Domains</div>
|
||||||
|
<Space wrap size={[4, 4]}>
|
||||||
|
{api.routing.domains.map((d) => (
|
||||||
|
<Tag key={d} className="m-0 text-[10px] px-1 leading-4">
|
||||||
|
{d}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{api.routing?.intents?.length ? (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-gray-600 mb-1">Intents</div>
|
||||||
|
<Space wrap size={[4, 4]}>
|
||||||
|
{api.routing.intents.map((i) => (
|
||||||
|
<Tag key={i} className="m-0 text-[10px] px-1 leading-4">
|
||||||
|
{i}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{api.routing?.useWhen?.length ? (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-gray-600 mb-1">Use When</div>
|
||||||
|
<div className="text-[11px] text-gray-500 space-y-1">
|
||||||
|
{api.routing.useWhen.map((item, idx) => (
|
||||||
|
<div key={idx} className="flex gap-1">
|
||||||
|
<span>·</span>
|
||||||
|
<span>{item}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{api.routing?.doNotUseWhen?.length ? (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-gray-600 mb-1">Do Not Use When</div>
|
||||||
|
<div className="text-[11px] text-gray-500 space-y-1">
|
||||||
|
{api.routing.doNotUseWhen.map((item, idx) => (
|
||||||
|
<div key={idx} className="flex gap-1">
|
||||||
|
<span>·</span>
|
||||||
|
<span>{item}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{api.routing?.optionalSlots?.length ? (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-gray-600 mb-1">Optional Slots</div>
|
||||||
|
<Space wrap size={[4, 4]}>
|
||||||
|
{api.routing.optionalSlots.map((slot) => (
|
||||||
|
<Tag key={slot} className="m-0 text-[10px] px-1 leading-4">
|
||||||
|
{slot}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{api.routing?.examples?.length ? (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-gray-600 mb-1">Examples</div>
|
||||||
|
<div className="text-[11px] text-gray-500 italic space-y-1 bg-gray-50 p-2 rounded">
|
||||||
|
{api.routing.examples.map((ex, idx) => (
|
||||||
|
<div key={idx}>"{ex}"</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
})) || []}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
children: null,
|
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Form, InputNumber } from 'antd';
|
import { Form, InputNumber } from 'antd';
|
||||||
import { SettingOutlined } from '@ant-design/icons';
|
import { SettingOutlined } from '@ant-design/icons';
|
||||||
import { parseModelSelections } from '../../constants';
|
import { parseModelSelections } from '../../constants';
|
||||||
import ModelCheckboxDropdown from '../ModelCheckboxDropdown';
|
import ModelSelectDropdown from '../ModelSelectDropdown';
|
||||||
|
|
||||||
interface ModelSettingsCardProps {
|
interface ModelSettingsCardProps {
|
||||||
models: any[];
|
models: any[];
|
||||||
|
|
@ -22,17 +22,18 @@ export default function ModelSettingsCard({ models, isMobile = false }: ModelSet
|
||||||
required
|
required
|
||||||
rules={[
|
rules={[
|
||||||
{
|
{
|
||||||
validator: async (_rule, value) => {
|
required: true,
|
||||||
const selected = parseModelSelections(value);
|
message: '请选择一个模型',
|
||||||
if (selected.length > 0) return;
|
|
||||||
throw new Error('请选择至少一个模型');
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
className="mb-0"
|
className="mb-0"
|
||||||
getValueProps={(value) => ({ value: parseModelSelections(value) })}
|
getValueProps={(value) => {
|
||||||
|
// 如果后端返回的是数组或 JSON 字符串,取第一个作为单选值
|
||||||
|
const selected = parseModelSelections(value);
|
||||||
|
return { value: selected[0] || '' };
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<ModelCheckboxDropdown models={models} isMobile={isMobile} />
|
<ModelSelectDropdown models={models} isMobile={isMobile} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="temperature" label="Temperature" className="mb-0" hidden>
|
<Form.Item name="temperature" label="Temperature" className="mb-0" hidden>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
|
|
||||||
|
|
@ -51,20 +51,33 @@ export const TYPE_TAG: Record<SkillType, { color: string; icon: string; label: s
|
||||||
|
|
||||||
export const isImageUrl = (url: string | undefined) => url?.startsWith('http') || url?.startsWith('/');
|
export const isImageUrl = (url: string | undefined) => url?.startsWith('http') || url?.startsWith('/');
|
||||||
|
|
||||||
export const parseModelSelections = (value?: string | string[]) => {
|
// parseModelSelectionItem 将多种模型结构统一提取为模型 ID。
|
||||||
|
const parseModelSelectionItem = (item: any) => {
|
||||||
|
if (!item) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (typeof item === 'string') {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
if (typeof item === 'object' && item.model?.id) {
|
||||||
|
return String(item.model.id);
|
||||||
|
}
|
||||||
|
if (typeof item === 'object' && item.id) {
|
||||||
|
return String(item.id);
|
||||||
|
}
|
||||||
|
return String(item);
|
||||||
|
};
|
||||||
|
|
||||||
|
// parseModelSelections 负责把接口返回的模型配置统一转换为模型 ID 列表。
|
||||||
|
export const parseModelSelections = (value?: unknown) => {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
return value;
|
return value.map(parseModelSelectionItem).filter(Boolean);
|
||||||
}
|
}
|
||||||
// 尝试解析 JSON 格式
|
// 尝试解析 JSON 格式
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(String(value || '[]'));
|
const parsed = JSON.parse(String(value || '[]'));
|
||||||
if (Array.isArray(parsed)) {
|
if (Array.isArray(parsed)) {
|
||||||
return parsed.map((item: any) => {
|
return parsed.map(parseModelSelectionItem).filter(Boolean);
|
||||||
if (typeof item === 'object' && item.id) {
|
|
||||||
return item.id;
|
|
||||||
}
|
|
||||||
return String(item);
|
|
||||||
}).filter(Boolean);
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// 兼容旧格式:逗号分隔的字符串
|
// 兼容旧格式:逗号分隔的字符串
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { FormInstance } from 'antd';
|
import { FormInstance } from 'antd';
|
||||||
import { Agent, AgentAPI, Team, TeamAPI, AiModel, ModelAPI, ImageAPI } from '../../../api';
|
import { Agent, AgentAPI, AgentModelConfig, Team, TeamAPI, AiModel, ModelAPI, ImageAPI } from '../../../api';
|
||||||
import { DEFAULT_AVATAR } from '../constants';
|
import { DEFAULT_AVATAR, parseModelSelections } from '../constants';
|
||||||
|
|
||||||
interface UseAgentEditorOptions {
|
interface UseAgentEditorOptions {
|
||||||
id?: string;
|
id?: string;
|
||||||
|
|
@ -11,6 +11,31 @@ interface UseAgentEditorOptions {
|
||||||
navigate: any;
|
navigate: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildAgentModelsPayload 将表单里的单选模型值转换为后端需要的 models 数组结构。
|
||||||
|
const buildAgentModelsPayload = (modelId: string | undefined, models: AiModel[]): AgentModelConfig[] => {
|
||||||
|
if (!modelId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const selectedModel = models.find((item) => item.id === modelId);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
model: {
|
||||||
|
id: modelId,
|
||||||
|
name: selectedModel?.model_name || '',
|
||||||
|
},
|
||||||
|
role: 'primary',
|
||||||
|
priority: 1,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
// normalizeAgentFormValues 将接口返回的 models 字段映射为表单使用的 model 单值。
|
||||||
|
const normalizeAgentFormValues = (data: Agent) => ({
|
||||||
|
...data,
|
||||||
|
model: parseModelSelections(data.models)[0] || '',
|
||||||
|
});
|
||||||
|
|
||||||
export function useAgentEditor({ id, isNew, form, message, navigate }: UseAgentEditorOptions) {
|
export function useAgentEditor({ id, isNew, form, message, navigate }: UseAgentEditorOptions) {
|
||||||
const [agent, setAgent] = useState<Agent | null>(null);
|
const [agent, setAgent] = useState<Agent | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
@ -85,7 +110,7 @@ export function useAgentEditor({ id, isNew, form, message, navigate }: UseAgentE
|
||||||
setAgent(data);
|
setAgent(data);
|
||||||
if (force || autoSaveStatus !== 'dirty') {
|
if (force || autoSaveStatus !== 'dirty') {
|
||||||
hydratingRef.current = true;
|
hydratingRef.current = true;
|
||||||
form.setFieldsValue(data);
|
form.setFieldsValue(normalizeAgentFormValues(data));
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
hydratingRef.current = false;
|
hydratingRef.current = false;
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
@ -163,14 +188,15 @@ export function useAgentEditor({ id, isNew, form, message, navigate }: UseAgentE
|
||||||
Object.keys(values).forEach((key) => {
|
Object.keys(values).forEach((key) => {
|
||||||
const formValue = (values as any)[key];
|
const formValue = (values as any)[key];
|
||||||
const originalValue = (agent as any)?.[key];
|
const originalValue = (agent as any)?.[key];
|
||||||
// 特殊处理 model 字段:将 id 和 name 组合成 JSON 数组字符串
|
// model 字段在表单内是单值,提交给后端时需要转换为新的 models 数组结构。
|
||||||
if (key === 'model' && Array.isArray(formValue)) {
|
if (key === 'model') {
|
||||||
const modelObjects = formValue.map((modelId: string) => {
|
const nextModels = buildAgentModelsPayload(formValue, models);
|
||||||
const model = models.find((m) => m.id === modelId);
|
if (JSON.stringify(nextModels) !== JSON.stringify(agent?.models || [])) {
|
||||||
return { id: modelId, name: model?.model_name || '' };
|
changedFields.models = nextModels;
|
||||||
});
|
}
|
||||||
changedFields[key] = JSON.stringify(modelObjects);
|
return;
|
||||||
} else if (formValue !== originalValue) {
|
}
|
||||||
|
if (formValue !== originalValue) {
|
||||||
changedFields[key] = formValue;
|
changedFields[key] = formValue;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -181,7 +207,7 @@ export function useAgentEditor({ id, isNew, form, message, navigate }: UseAgentE
|
||||||
}
|
}
|
||||||
const updatedAgent = await AgentAPI.update(id!, changedFields);
|
const updatedAgent = await AgentAPI.update(id!, changedFields);
|
||||||
setAgent(updatedAgent);
|
setAgent(updatedAgent);
|
||||||
form.setFieldsValue(updatedAgent);
|
form.setFieldsValue(normalizeAgentFormValues(updatedAgent));
|
||||||
if (!silent) message.success('已保存');
|
if (!silent) message.success('已保存');
|
||||||
setAutoSaveStatus('saved');
|
setAutoSaveStatus('saved');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -223,6 +249,28 @@ export function useAgentEditor({ id, isNew, form, message, navigate }: UseAgentE
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteSkill = async (skillId: string) => {
|
||||||
|
if (!id) return;
|
||||||
|
try {
|
||||||
|
await AgentAPI.deleteSkill(id, skillId);
|
||||||
|
setAgent((prev) => prev ? { ...prev, skills: (prev.skills || []).filter((skill) => skill.id !== skillId) } : prev);
|
||||||
|
message.success('Skill 已删除');
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error('Skill 删除失败:' + (e?.message ?? e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeletePlugin = async (pluginId: string) => {
|
||||||
|
if (!id) return;
|
||||||
|
try {
|
||||||
|
await AgentAPI.deletePlugin(id, pluginId);
|
||||||
|
setAgent((prev) => prev ? { ...prev, plugins: (prev.plugins || []).filter((plugin) => plugin.id !== pluginId) } : prev);
|
||||||
|
message.success('外部工具已删除');
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error('外部工具删除失败:' + (e?.message ?? e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const beforeUploadKnowledge = async (file: any) => {
|
const beforeUploadKnowledge = async (file: any) => {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
message.warning('请先保存智能体基础信息后再上传');
|
message.warning('请先保存智能体基础信息后再上传');
|
||||||
|
|
@ -328,6 +376,8 @@ export function useAgentEditor({ id, isNew, form, message, navigate }: UseAgentE
|
||||||
beforeUploadEditAvatar,
|
beforeUploadEditAvatar,
|
||||||
handleAvatarSelect,
|
handleAvatarSelect,
|
||||||
handleDeleteKnowledge,
|
handleDeleteKnowledge,
|
||||||
|
handleDeleteSkill,
|
||||||
|
handleDeletePlugin,
|
||||||
liveAgent,
|
liveAgent,
|
||||||
currentName,
|
currentName,
|
||||||
markDirty,
|
markDirty,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { App as AntApp } from 'antd';
|
import { App as AntApp } from 'antd';
|
||||||
import { FileTextOutlined, SaveOutlined } from '@ant-design/icons';
|
import { FileTextOutlined, SaveOutlined } from '@ant-design/icons';
|
||||||
import SkillEditor from '../../components/SkillEditor';
|
import SkillEditor from '../../components/SkillEditor';
|
||||||
|
import ExternalToolEditor from '../../components/ExternalToolEditor';
|
||||||
import { useAgentEditor } from './hooks/useAgentEditor';
|
import { useAgentEditor } from './hooks/useAgentEditor';
|
||||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||||
import Header from './components/Header';
|
import Header from './components/Header';
|
||||||
|
|
@ -24,6 +25,8 @@ export default function AgentEditor() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { message } = AntApp.useApp();
|
const { message } = AntApp.useApp();
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
|
const [externalToolEditorOpen, setExternalToolEditorOpen] = useState(false);
|
||||||
|
const [editingPluginId, setEditingPluginId] = useState<string | null>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
agent,
|
agent,
|
||||||
|
|
@ -52,6 +55,8 @@ export default function AgentEditor() {
|
||||||
beforeUploadEditAvatar,
|
beforeUploadEditAvatar,
|
||||||
handleAvatarSelect,
|
handleAvatarSelect,
|
||||||
handleDeleteKnowledge,
|
handleDeleteKnowledge,
|
||||||
|
handleDeleteSkill,
|
||||||
|
handleDeletePlugin,
|
||||||
liveAgent,
|
liveAgent,
|
||||||
currentName,
|
currentName,
|
||||||
markDirty,
|
markDirty,
|
||||||
|
|
@ -85,6 +90,24 @@ export default function AgentEditor() {
|
||||||
setAvatarSelectorOpen={setAvatarSelectorOpen}
|
setAvatarSelectorOpen={setAvatarSelectorOpen}
|
||||||
beforeUploadKnowledge={beforeUploadKnowledge}
|
beforeUploadKnowledge={beforeUploadKnowledge}
|
||||||
onDeleteKnowledge={handleDeleteKnowledge}
|
onDeleteKnowledge={handleDeleteKnowledge}
|
||||||
|
onCreateSkill={() => {
|
||||||
|
setEditingSkillId(null);
|
||||||
|
setSkillEditorOpen(true);
|
||||||
|
}}
|
||||||
|
onEditSkill={(skillId) => {
|
||||||
|
setEditingSkillId(skillId);
|
||||||
|
setSkillEditorOpen(true);
|
||||||
|
}}
|
||||||
|
onDeleteSkill={handleDeleteSkill}
|
||||||
|
onCreateExternalTool={() => {
|
||||||
|
setEditingPluginId(null);
|
||||||
|
setExternalToolEditorOpen(true);
|
||||||
|
}}
|
||||||
|
onEditExternalTool={(pluginId) => {
|
||||||
|
setEditingPluginId(pluginId);
|
||||||
|
setExternalToolEditorOpen(true);
|
||||||
|
}}
|
||||||
|
onDeleteExternalTool={handleDeletePlugin}
|
||||||
markDirty={markDirty}
|
markDirty={markDirty}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
|
|
@ -120,13 +143,28 @@ export default function AgentEditor() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isNew && (
|
{!isNew && (
|
||||||
|
<>
|
||||||
<SkillEditor
|
<SkillEditor
|
||||||
open={skillEditorOpen}
|
open={skillEditorOpen}
|
||||||
agentId={id!}
|
agentId={id!}
|
||||||
skillId={editingSkillId}
|
skillId={editingSkillId}
|
||||||
onClose={() => setSkillEditorOpen(false)}
|
onClose={() => {
|
||||||
|
setSkillEditorOpen(false);
|
||||||
|
setEditingSkillId(null);
|
||||||
|
}}
|
||||||
onSaved={refresh}
|
onSaved={refresh}
|
||||||
/>
|
/>
|
||||||
|
<ExternalToolEditor
|
||||||
|
open={externalToolEditorOpen}
|
||||||
|
agentId={id!}
|
||||||
|
plugin={agent?.plugins?.find((plugin) => plugin.id === editingPluginId)}
|
||||||
|
onClose={() => {
|
||||||
|
setExternalToolEditorOpen(false);
|
||||||
|
setEditingPluginId(null);
|
||||||
|
}}
|
||||||
|
onSaved={refresh}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,138 @@
|
||||||
color: var(--color-brand);
|
color: var(--color-brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agent-editor-skill-actions,
|
||||||
|
.agent-editor-tool-list-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-editor-skill-item {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
padding: 0.625rem 0.75rem !important;
|
||||||
|
border: 1px solid #edf1f7;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-editor-skill-name {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-editor-tool-section + .agent-editor-tool-section {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-editor-tool-section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
margin-bottom: 0.625rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-editor-tool-empty {
|
||||||
|
padding: 1.25rem;
|
||||||
|
border: 1px dashed #d9e1ec;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-editor-plugin-card {
|
||||||
|
border-color: #e7edf6;
|
||||||
|
border-radius: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-editor-plugin-description {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-editor-plugin-meta {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-editor-plugin-apis {
|
||||||
|
margin-top: 0.625rem;
|
||||||
|
border-top: 1px solid #edf1f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-editor-api-detail {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.78125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-editor-api-config-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-editor-api-config-grid pre {
|
||||||
|
max-height: 12rem;
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
padding: 0.625rem;
|
||||||
|
overflow: auto;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: #f6f8fb;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-editor-tool-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
gap: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-editor-tool-list {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-editor-tool-list-header {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-editor-tool-help {
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-editor-tool-card {
|
||||||
|
border-radius: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-editor-code-input {
|
||||||
|
font-family: Consolas, Menlo, Monaco, monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.agent-editor-tool-grid,
|
||||||
|
.agent-editor-api-config-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-editor-skill-actions,
|
||||||
|
.agent-editor-tool-list-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.agent-editor-field-input,
|
.agent-editor-field-input,
|
||||||
.agent-editor-number-input {
|
.agent-editor-number-input {
|
||||||
height: 2.625rem;
|
height: 2.625rem;
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,23 @@
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Agent, AgentAPI } from '../../api';
|
import { Agent, AgentAPI, ModelAPI, AiModel } from '../../api';
|
||||||
import { useAuth } from '../../store/auth';
|
import { useAuth } from '../../store/auth';
|
||||||
|
|
||||||
export function useAgentListLogic() {
|
export function useAgentListLogic() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [list, setList] = useState<Agent[]>([]);
|
const [list, setList] = useState<Agent[]>([]);
|
||||||
|
const [models, setModels] = useState<AiModel[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
if (!user?.phone) return;
|
if (!user?.phone) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
setList(await AgentAPI.mine(user.phone));
|
const [agentList, modelList] = await Promise.all([
|
||||||
|
AgentAPI.mine(user.phone),
|
||||||
|
ModelAPI.list()
|
||||||
|
]);
|
||||||
|
setList(agentList);
|
||||||
|
setModels(modelList);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -29,9 +35,17 @@ export function useAgentListLogic() {
|
||||||
const isImageUrl = (url?: string): boolean => !!(url?.startsWith('http') || url?.startsWith('/'));
|
const isImageUrl = (url?: string): boolean => !!(url?.startsWith('http') || url?.startsWith('/'));
|
||||||
|
|
||||||
const getModelLabel = (value: unknown): string => {
|
const getModelLabel = (value: unknown): string => {
|
||||||
|
const findModelName = (id: string) => {
|
||||||
|
const model = models.find(m => m.id === id);
|
||||||
|
return model ? model.model_name : id;
|
||||||
|
};
|
||||||
|
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
const names = value
|
const names = value
|
||||||
.map((item: any) => (typeof item === 'string' ? item : item?.name))
|
.map((item: any) => {
|
||||||
|
if (typeof item === 'string') return findModelName(item);
|
||||||
|
return item?.name || findModelName(item?.id);
|
||||||
|
})
|
||||||
.map((v) => String(v || '').trim())
|
.map((v) => String(v || '').trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
return names.join('、');
|
return names.join('、');
|
||||||
|
|
@ -43,7 +57,10 @@ export function useAgentListLogic() {
|
||||||
const parsed = JSON.parse(raw);
|
const parsed = JSON.parse(raw);
|
||||||
if (Array.isArray(parsed)) {
|
if (Array.isArray(parsed)) {
|
||||||
const names = parsed
|
const names = parsed
|
||||||
.map((item: any) => (typeof item === 'string' ? item : item?.name))
|
.map((item: any) => {
|
||||||
|
if (typeof item === 'string') return findModelName(item);
|
||||||
|
return item?.name || findModelName(item?.id);
|
||||||
|
})
|
||||||
.map((v) => String(v || '').trim())
|
.map((v) => String(v || '').trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
return names.join('、');
|
return names.join('、');
|
||||||
|
|
@ -54,11 +71,11 @@ export function useAgentListLogic() {
|
||||||
if (raw.includes(',')) {
|
if (raw.includes(',')) {
|
||||||
return raw
|
return raw
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((s) => s.trim())
|
.map((s) => findModelName(s.trim()))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join('、');
|
.join('、');
|
||||||
}
|
}
|
||||||
return raw;
|
return findModelName(raw);
|
||||||
};
|
};
|
||||||
|
|
||||||
const publicCount = useMemo(() => list.filter((a) => a.visibility === 'public').length, [list]);
|
const publicCount = useMemo(() => list.filter((a) => a.visibility === 'public').length, [list]);
|
||||||
|
|
|
||||||
|
|
@ -38,10 +38,10 @@ export default function AgentListH5({ logic }: Props) {
|
||||||
type="primary"
|
type="primary"
|
||||||
size="middle"
|
size="middle"
|
||||||
icon={<CompassOutlined />}
|
icon={<CompassOutlined />}
|
||||||
onClick={() => navigate('/marketplace')}
|
onClick={() => navigate('/agents/new')}
|
||||||
className="h5-stats-marketplace-btn"
|
className="h5-stats-marketplace-btn"
|
||||||
>
|
>
|
||||||
前往智能体广场
|
创建智能体
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -69,8 +69,8 @@ export default function AgentListH5({ logic }: Props) {
|
||||||
{!loading && list.length === 0 ? (
|
{!loading && list.length === 0 ? (
|
||||||
<div className="empty-state h5-empty-state">
|
<div className="empty-state h5-empty-state">
|
||||||
<Empty description="你还没有任何智能体">
|
<Empty description="你还没有任何智能体">
|
||||||
<Button type="primary" onClick={() => navigate('/marketplace')} style={{ borderRadius: 8 }}>
|
<Button type="primary" onClick={() => navigate('/agents/new')} style={{ borderRadius: 8 }}>
|
||||||
前往广场创建
|
创建智能体
|
||||||
</Button>
|
</Button>
|
||||||
</Empty>
|
</Empty>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -126,14 +126,14 @@
|
||||||
|
|
||||||
.agent-card-desc-container {
|
.agent-card-desc-container {
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
padding: 16px 16px 18px;
|
padding: 16px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
background: linear-gradient(180deg, rgba(248,250,252,0.9) 0%, rgba(255,255,255,0.95) 100%);
|
background: linear-gradient(180deg, rgba(248,250,252,0.9) 0%, rgba(255,255,255,0.95) 100%);
|
||||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||||
}
|
}
|
||||||
|
|
||||||
.agent-card-desc {
|
.agent-card-desc {
|
||||||
min-height: 66px;
|
min-height: 48px;
|
||||||
font-size: 13.5px;
|
font-size: 13.5px;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
|
|
|
||||||
|
|
@ -42,10 +42,10 @@ export default function AgentListWeb({ logic }: Props) {
|
||||||
type="primary"
|
type="primary"
|
||||||
size="large"
|
size="large"
|
||||||
icon={<CompassOutlined />}
|
icon={<CompassOutlined />}
|
||||||
onClick={() => navigate('/marketplace')}
|
onClick={() => navigate('/agents/new')}
|
||||||
style={{ borderRadius: 14, height: 46, padding: '0 18px', fontWeight: 600 }}
|
style={{ borderRadius: 14, height: 46, padding: '0 18px', fontWeight: 600 }}
|
||||||
>
|
>
|
||||||
前往智能体广场
|
创建智能体
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -73,14 +73,16 @@ export default function AgentListWeb({ logic }: Props) {
|
||||||
{!loading && list.length === 0 ? (
|
{!loading && list.length === 0 ? (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<Empty description="你还没有任何智能体">
|
<Empty description="你还没有任何智能体">
|
||||||
<Button type="primary" onClick={() => navigate('/marketplace')} style={{ borderRadius: 10 }}>
|
<Button type="primary" onClick={() => navigate('/agents/new')} style={{ borderRadius: 10 }}>
|
||||||
前往广场创建
|
创建智能体
|
||||||
</Button>
|
</Button>
|
||||||
</Empty>
|
</Empty>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Row gutter={[18, 18]}>
|
<Row gutter={[18, 18]}>
|
||||||
{list.map((a) => (
|
{list.map((a) => {
|
||||||
|
const modelLabel = getModelLabel(a.model);
|
||||||
|
return (
|
||||||
<Col xs={24} sm={12} md={8} lg={6} key={a.id}>
|
<Col xs={24} sm={12} md={8} lg={6} key={a.id}>
|
||||||
<div className="agent-card">
|
<div className="agent-card">
|
||||||
<div className="agent-card-header">
|
<div className="agent-card-header">
|
||||||
|
|
@ -124,14 +126,14 @@ export default function AgentListWeb({ logic }: Props) {
|
||||||
私有
|
私有
|
||||||
</Tag>
|
</Tag>
|
||||||
)}
|
)}
|
||||||
{getModelLabel(a.model) && (
|
{modelLabel && (
|
||||||
<Tag
|
<Tag
|
||||||
bordered={false}
|
bordered={false}
|
||||||
className="agent-card-tag-model"
|
className="agent-card-tag-model"
|
||||||
style={{ background: 'var(--color-brand-soft)', color: 'var(--color-brand)', borderRadius: 999, margin: 0, maxWidth: '100%' }}
|
style={{ background: 'var(--color-brand-soft)', color: 'var(--color-brand)', borderRadius: 999, margin: 0, maxWidth: '100%' }}
|
||||||
>
|
>
|
||||||
<span className="agent-card-tag-model-text">
|
<span className="agent-card-tag-model-text">
|
||||||
{getModelLabel(a.model)}
|
{modelLabel}
|
||||||
</span>
|
</span>
|
||||||
</Tag>
|
</Tag>
|
||||||
)}
|
)}
|
||||||
|
|
@ -168,7 +170,7 @@ export default function AgentListWeb({ logic }: Props) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
))}
|
)})}
|
||||||
</Row>
|
</Row>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,8 @@ export default function AgentListWebBase({ logic, viewport }: AgentListWebVarian
|
||||||
把你的 AI 助手沉淀成一组可管理、可协作、可持续进化的能力单元。创建入口统一在智能体广场,这里负责查看、进入和运营它们。
|
把你的 AI 助手沉淀成一组可管理、可协作、可持续进化的能力单元。创建入口统一在智能体广场,这里负责查看、进入和运营它们。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button type="primary" size="large" icon={<CompassOutlined />} onClick={() => navigate('/marketplace')} className="agent-list-web-market-btn">
|
<Button type="primary" size="large" icon={<CompassOutlined />} onClick={() => navigate('/agents/new')} className="agent-list-web-market-btn">
|
||||||
前往智能体广场
|
创建智能体
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -53,8 +53,8 @@ export default function AgentListWebBase({ logic, viewport }: AgentListWebVarian
|
||||||
{!loading && list.length === 0 ? (
|
{!loading && list.length === 0 ? (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<Empty description="你还没有任何智能体">
|
<Empty description="你还没有任何智能体">
|
||||||
<Button type="primary" onClick={() => navigate('/marketplace')} className="agent-list-web-empty-btn">
|
<Button type="primary" onClick={() => navigate('/agents/new')} className="agent-list-web-empty-btn">
|
||||||
前往广场创建
|
创建智能体
|
||||||
</Button>
|
</Button>
|
||||||
</Empty>
|
</Empty>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,150 @@
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { message } from 'antd';
|
||||||
|
import { KnowledgeBaseAPI, KBDTO, KBFileDTO } from '../../api/knowledgeBase';
|
||||||
|
import { useAuth } from '../../store/auth';
|
||||||
|
|
||||||
|
export function useKnowledgeBaseLogic() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [kbs, setKbs] = useState<KBDTO[]>([]);
|
||||||
|
const [activeKb, setActiveKb] = useState<KBDTO | null>(null);
|
||||||
|
const [files, setFiles] = useState<KBFileDTO[]>([]);
|
||||||
|
const [filesLoading, setFilesLoading] = useState(false);
|
||||||
|
|
||||||
|
// 弹窗状态
|
||||||
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
|
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||||
|
const [isShareModalOpen, setIsShareModalOpen] = useState(false);
|
||||||
|
const [isUploadModalOpen, setIsUploadModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// 获取知识库列表
|
||||||
|
const loadKBs = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await KnowledgeBaseAPI.listKBs();
|
||||||
|
setKbs(data);
|
||||||
|
// 如果没有选中的,默认选第一个
|
||||||
|
if (data.length > 0 && !activeKb) {
|
||||||
|
setActiveKb(data[0]);
|
||||||
|
} else if (activeKb) {
|
||||||
|
// 刷新当前选中的 KB 信息
|
||||||
|
const updated = data.find(kb => kb.id === activeKb.id);
|
||||||
|
if (updated) setActiveKb(updated);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
message.error('加载知识库失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [activeKb]);
|
||||||
|
|
||||||
|
// 获取当前 KB 的文件列表
|
||||||
|
const loadFiles = useCallback(async (kbId: string) => {
|
||||||
|
setFilesLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await KnowledgeBaseAPI.listFiles(kbId);
|
||||||
|
setFiles(data);
|
||||||
|
} catch (err) {
|
||||||
|
message.error('加载文件列表失败');
|
||||||
|
} finally {
|
||||||
|
setFilesLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadKBs();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeKb) {
|
||||||
|
loadFiles(activeKb.id);
|
||||||
|
} else {
|
||||||
|
setFiles([]);
|
||||||
|
}
|
||||||
|
}, [activeKb?.id, loadFiles]);
|
||||||
|
|
||||||
|
// CRUD 操作
|
||||||
|
const handleCreate = async (values: { name: string; description: string; isPublic: boolean }) => {
|
||||||
|
try {
|
||||||
|
const newKb = await KnowledgeBaseAPI.createKB(values);
|
||||||
|
message.success('创建成功');
|
||||||
|
setIsCreateModalOpen(false);
|
||||||
|
await loadKBs();
|
||||||
|
setActiveKb(newKb);
|
||||||
|
} catch (err) {
|
||||||
|
message.error('创建失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdate = async (values: Partial<KBDTO>) => {
|
||||||
|
if (!activeKb) return;
|
||||||
|
try {
|
||||||
|
await KnowledgeBaseAPI.updateKB(activeKb.id, values);
|
||||||
|
message.success('更新成功');
|
||||||
|
setIsEditModalOpen(false);
|
||||||
|
await loadKBs();
|
||||||
|
} catch (err) {
|
||||||
|
message.error('更新失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await KnowledgeBaseAPI.deleteKB(id);
|
||||||
|
message.success('删除成功');
|
||||||
|
if (activeKb?.id === id) {
|
||||||
|
setActiveKb(null);
|
||||||
|
}
|
||||||
|
await loadKBs();
|
||||||
|
} catch (err) {
|
||||||
|
message.error('删除失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpload = async (fileList: File[]) => {
|
||||||
|
if (!activeKb) return;
|
||||||
|
try {
|
||||||
|
await KnowledgeBaseAPI.uploadFiles(activeKb.id, fileList);
|
||||||
|
message.success('上传成功');
|
||||||
|
setIsUploadModalOpen(false);
|
||||||
|
await loadFiles(activeKb.id);
|
||||||
|
} catch (err) {
|
||||||
|
message.error('上传失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveFile = async (fileId: string) => {
|
||||||
|
if (!activeKb) return;
|
||||||
|
try {
|
||||||
|
await KnowledgeBaseAPI.removeFile(activeKb.id, fileId);
|
||||||
|
message.success('文件已移除');
|
||||||
|
await loadFiles(activeKb.id);
|
||||||
|
} catch (err) {
|
||||||
|
message.error('移除失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading,
|
||||||
|
kbs,
|
||||||
|
activeKb,
|
||||||
|
setActiveKb,
|
||||||
|
files,
|
||||||
|
filesLoading,
|
||||||
|
isCreateModalOpen,
|
||||||
|
setIsCreateModalOpen,
|
||||||
|
isEditModalOpen,
|
||||||
|
setIsEditModalOpen,
|
||||||
|
isShareModalOpen,
|
||||||
|
setIsShareModalOpen,
|
||||||
|
isUploadModalOpen,
|
||||||
|
setIsUploadModalOpen,
|
||||||
|
handleCreate,
|
||||||
|
handleUpdate,
|
||||||
|
handleDelete,
|
||||||
|
handleUpload,
|
||||||
|
handleRemoveFile,
|
||||||
|
refreshKBs: loadKBs,
|
||||||
|
refreshFiles: () => activeKb && loadFiles(activeKb.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,196 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Button, Table, Space, Tag, Popconfirm, Tooltip, Dropdown, MenuProps } from 'antd';
|
||||||
|
import {
|
||||||
|
UploadOutlined,
|
||||||
|
ShareAltOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
EllipsisOutlined,
|
||||||
|
RobotOutlined
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { KBFileDTO } from '../../../api/knowledgeBase';
|
||||||
|
import { AgentAPI, Agent } from '../../../api/agents';
|
||||||
|
import { useAuth } from '../../../store/auth';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
logic: any; // 简化类型,实际开发中建议定义完整接口
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function KBDetail({ logic }: Props) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const {
|
||||||
|
activeKb,
|
||||||
|
files,
|
||||||
|
filesLoading,
|
||||||
|
setIsEditModalOpen,
|
||||||
|
setIsShareModalOpen,
|
||||||
|
setIsUploadModalOpen,
|
||||||
|
handleDelete,
|
||||||
|
handleRemoveFile
|
||||||
|
} = logic;
|
||||||
|
|
||||||
|
const [agents, setAgents] = useState<Agent[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 获取智能体列表,用于挂载
|
||||||
|
const fetchAgents = async () => {
|
||||||
|
if (!user?.phone) return;
|
||||||
|
try {
|
||||||
|
const data = await AgentAPI.mine(user.phone);
|
||||||
|
setAgents(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取智能体列表失败', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchAgents();
|
||||||
|
}, [user?.phone]);
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '文件名',
|
||||||
|
dataIndex: 'originalName',
|
||||||
|
key: 'originalName',
|
||||||
|
render: (text: string) => (
|
||||||
|
<Space>
|
||||||
|
<FileTextOutlined style={{ color: 'var(--color-text-tertiary)' }} />
|
||||||
|
<span className="kb-file-name">{text}</span>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '大小',
|
||||||
|
dataIndex: 'size',
|
||||||
|
key: 'size',
|
||||||
|
width: 100,
|
||||||
|
render: (size: number) => {
|
||||||
|
if (size < 1024) return `${size} B`;
|
||||||
|
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '分块数',
|
||||||
|
dataIndex: 'chunkCount',
|
||||||
|
key: 'chunkCount',
|
||||||
|
width: 80,
|
||||||
|
render: (count: number) => count ?? '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '上传时间',
|
||||||
|
dataIndex: 'createdAt',
|
||||||
|
key: 'createdAt',
|
||||||
|
width: 160,
|
||||||
|
render: (time: number) => time ? dayjs(time).format('YYYY-MM-DD HH:mm') : '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
width: 100,
|
||||||
|
render: (status: KBFileDTO['status']) => {
|
||||||
|
const config = {
|
||||||
|
indexing: { color: 'processing', text: '索引中' },
|
||||||
|
ready: { color: 'success', text: '就绪' },
|
||||||
|
error: { color: 'error', text: '失败' },
|
||||||
|
};
|
||||||
|
const item = config[status] || config.indexing;
|
||||||
|
return <Tag color={item.color} bordered={false}>{item.text}</Tag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: 80,
|
||||||
|
render: (_: any, record: KBFileDTO) => (
|
||||||
|
<Popconfirm
|
||||||
|
title="确定要移除此文件吗?"
|
||||||
|
description="移除后将无法在对话中检索此文件内容。"
|
||||||
|
onConfirm={() => handleRemoveFile(record.id)}
|
||||||
|
okText="确定"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Button type="text" danger icon={<DeleteOutlined />} size="small" />
|
||||||
|
</Popconfirm>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const menuItems: MenuProps['items'] = [
|
||||||
|
{
|
||||||
|
key: 'edit',
|
||||||
|
label: '编辑信息',
|
||||||
|
icon: <EditOutlined />,
|
||||||
|
onClick: () => setIsEditModalOpen(true),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'share',
|
||||||
|
label: '共享设置',
|
||||||
|
icon: <ShareAltOutlined />,
|
||||||
|
onClick: () => setIsShareModalOpen(true),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'divider',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'delete',
|
||||||
|
label: '删除知识库',
|
||||||
|
icon: <DeleteOutlined />,
|
||||||
|
danger: true,
|
||||||
|
onClick: () => handleDelete(activeKb.id),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="kb-detail">
|
||||||
|
<div className="kb-detail-header">
|
||||||
|
<div className="kb-detail-info">
|
||||||
|
<h2 className="kb-detail-title">{activeKb.name}</h2>
|
||||||
|
<p className="kb-detail-desc">{activeKb.description || '暂无描述'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="kb-detail-actions">
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
icon={<RobotOutlined />}
|
||||||
|
onClick={() => setIsShareModalOpen(true)} // 暂时复用共享弹窗或后续独立
|
||||||
|
>
|
||||||
|
授权智能体
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
icon={<ShareAltOutlined />}
|
||||||
|
onClick={() => setIsShareModalOpen(true)}
|
||||||
|
>
|
||||||
|
共享
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<UploadOutlined />}
|
||||||
|
onClick={() => setIsUploadModalOpen(true)}
|
||||||
|
>
|
||||||
|
上传文件
|
||||||
|
</Button>
|
||||||
|
<Dropdown menu={{ items: menuItems }} placement="bottomRight">
|
||||||
|
<Button icon={<EllipsisOutlined />} />
|
||||||
|
</Dropdown>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="kb-detail-content">
|
||||||
|
<div className="kb-detail-section-head">
|
||||||
|
<h3 className="kb-detail-section-title">文件列表</h3>
|
||||||
|
<span className="kb-detail-section-count">共 {files.length} 个文件</span>
|
||||||
|
</div>
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={files}
|
||||||
|
rowKey="id"
|
||||||
|
loading={filesLoading}
|
||||||
|
pagination={{ pageSize: 10, hideOnSinglePage: true }}
|
||||||
|
className="kb-files-table"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { Spin, Tag, Empty } from 'antd';
|
||||||
|
import { GlobalOutlined, LockOutlined, TeamOutlined } from '@ant-design/icons';
|
||||||
|
import { KBDTO } from '../../../api/knowledgeBase';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
kbs: KBDTO[];
|
||||||
|
activeId?: string;
|
||||||
|
onSelect: (kb: KBDTO) => void;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function KBList({ kbs, activeId, onSelect, loading }: Props) {
|
||||||
|
if (loading && kbs.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="kb-page-web-list-loading">
|
||||||
|
<Spin />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kbs.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="kb-page-web-list-empty">
|
||||||
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无知识库" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="kb-page-web-list-items">
|
||||||
|
{kbs.map((kb) => (
|
||||||
|
<button
|
||||||
|
key={kb.id}
|
||||||
|
className={`kb-page-web-nav-item ${activeId === kb.id ? 'active' : ''}`}
|
||||||
|
onClick={() => onSelect(kb)}
|
||||||
|
>
|
||||||
|
<div className="kb-page-web-nav-item-content">
|
||||||
|
<div className="kb-page-web-nav-item-top">
|
||||||
|
<span className="kb-page-web-nav-item-name">{kb.name}</span>
|
||||||
|
{kb.isPublic ? (
|
||||||
|
<GlobalOutlined className="kb-page-web-nav-item-type-icon public" />
|
||||||
|
) : kb.access === 'owner' ? (
|
||||||
|
<LockOutlined className="kb-page-web-nav-item-type-icon private" />
|
||||||
|
) : (
|
||||||
|
<TeamOutlined className="kb-page-web-nav-item-type-icon team" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="kb-page-web-nav-item-bottom">
|
||||||
|
<span className="kb-page-web-nav-item-desc">{kb.description || '暂无描述'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="kb-page-web-nav-item-tags">
|
||||||
|
<Tag color={kb.access === 'owner' ? 'blue' : 'orange'} bordered={false} className="kb-page-web-access-tag">
|
||||||
|
{kb.access === 'owner' ? '所有者' : '查看者'}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,274 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Switch,
|
||||||
|
Button,
|
||||||
|
Upload,
|
||||||
|
Tabs,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
message as antdMessage
|
||||||
|
} from 'antd';
|
||||||
|
import { InboxOutlined, UserOutlined, TeamOutlined, RobotOutlined } from '@ant-design/icons';
|
||||||
|
import { KnowledgeBaseAPI } from '../../../api/knowledgeBase';
|
||||||
|
import { TeamAPI, Team } from '../../../api/teams';
|
||||||
|
import { AgentAPI, Agent } from '../../../api/agents';
|
||||||
|
import { useAuth } from '../../../store/auth';
|
||||||
|
|
||||||
|
const { Dragger } = Upload;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
logic: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function KBModals({ logic }: Props) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const {
|
||||||
|
activeKb,
|
||||||
|
isCreateModalOpen, setIsCreateModalOpen,
|
||||||
|
isEditModalOpen, setIsEditModalOpen,
|
||||||
|
isShareModalOpen, setIsShareModalOpen,
|
||||||
|
isUploadModalOpen, setIsUploadModalOpen,
|
||||||
|
handleCreate,
|
||||||
|
handleUpdate,
|
||||||
|
handleUpload,
|
||||||
|
refreshFiles
|
||||||
|
} = logic;
|
||||||
|
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [editForm] = Form.useForm();
|
||||||
|
const [fileList, setFileList] = useState<any[]>([]);
|
||||||
|
const [teams, setTeams] = useState<Team[]>([]);
|
||||||
|
const [agents, setAgents] = useState<Agent[]>([]);
|
||||||
|
const [sharingLoading, setSharingLoading] = useState(false);
|
||||||
|
|
||||||
|
// 选中的目标状态
|
||||||
|
const [selectedAgentId, setSelectedAgentId] = useState<string>();
|
||||||
|
const [selectedTeamId, setSelectedTeamId] = useState<string>();
|
||||||
|
const [selectedUserId, setSelectedUserId] = useState<string>('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEditModalOpen && activeKb) {
|
||||||
|
editForm.setFieldsValue(activeKb);
|
||||||
|
}
|
||||||
|
}, [isEditModalOpen, activeKb, editForm]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isShareModalOpen) {
|
||||||
|
TeamAPI.list().then(setTeams).catch(() => {});
|
||||||
|
if (user?.phone) {
|
||||||
|
AgentAPI.mine(user.phone).then(setAgents).catch(() => {});
|
||||||
|
}
|
||||||
|
// 重置选择
|
||||||
|
setSelectedAgentId(undefined);
|
||||||
|
setSelectedTeamId(undefined);
|
||||||
|
setSelectedUserId('');
|
||||||
|
}
|
||||||
|
}, [isShareModalOpen, user?.phone]);
|
||||||
|
|
||||||
|
const onShare = async (type: 'user' | 'team', id: string) => {
|
||||||
|
if (!activeKb) return;
|
||||||
|
setSharingLoading(true);
|
||||||
|
try {
|
||||||
|
await KnowledgeBaseAPI.shareKB(activeKb.id, { subjectType: type, subjectId: id });
|
||||||
|
antdMessage.success('共享成功');
|
||||||
|
} catch (err) {
|
||||||
|
antdMessage.error('共享失败');
|
||||||
|
} finally {
|
||||||
|
setSharingLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMount = async (agentId: string) => {
|
||||||
|
if (!activeKb) return;
|
||||||
|
setSharingLoading(true);
|
||||||
|
try {
|
||||||
|
await KnowledgeBaseAPI.mountToAgent(agentId, activeKb.id);
|
||||||
|
antdMessage.success('已挂载到智能体');
|
||||||
|
} catch (err) {
|
||||||
|
antdMessage.error('挂载失败');
|
||||||
|
} finally {
|
||||||
|
setSharingLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Create Modal */}
|
||||||
|
<Modal
|
||||||
|
title="创建知识库"
|
||||||
|
open={isCreateModalOpen}
|
||||||
|
onCancel={() => setIsCreateModalOpen(false)}
|
||||||
|
footer={null}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" onFinish={handleCreate}>
|
||||||
|
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}>
|
||||||
|
<Input placeholder="例如:技术产品文档" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="description" label="描述">
|
||||||
|
<Input.TextArea placeholder="简要描述知识库的用途" rows={3} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="isPublic" label="公开访问" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item style={{ marginBottom: 0, textAlign: 'right' }}>
|
||||||
|
<Space>
|
||||||
|
<Button onClick={() => setIsCreateModalOpen(false)}>取消</Button>
|
||||||
|
<Button type="primary" htmlType="submit">创建</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Edit Modal */}
|
||||||
|
<Modal
|
||||||
|
title="编辑知识库"
|
||||||
|
open={isEditModalOpen}
|
||||||
|
onCancel={() => setIsEditModalOpen(false)}
|
||||||
|
footer={null}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Form form={editForm} layout="vertical" onFinish={handleUpdate}>
|
||||||
|
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="description" label="描述">
|
||||||
|
<Input.TextArea rows={3} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="isPublic" label="公开访问" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item style={{ marginBottom: 0, textAlign: 'right' }}>
|
||||||
|
<Space>
|
||||||
|
<Button onClick={() => setIsEditModalOpen(false)}>取消</Button>
|
||||||
|
<Button type="primary" htmlType="submit">保存</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Upload Modal */}
|
||||||
|
<Modal
|
||||||
|
title="上传文件"
|
||||||
|
open={isUploadModalOpen}
|
||||||
|
onCancel={() => setIsUploadModalOpen(false)}
|
||||||
|
onOk={() => handleUpload(fileList.map(f => f.originFileObj))}
|
||||||
|
okText="开始上传"
|
||||||
|
cancelText="取消"
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Dragger
|
||||||
|
multiple
|
||||||
|
fileList={fileList}
|
||||||
|
onChange={({ fileList }) => setFileList(fileList)}
|
||||||
|
beforeUpload={() => false}
|
||||||
|
>
|
||||||
|
<p className="ant-upload-drag-icon"><InboxOutlined /></p>
|
||||||
|
<p className="ant-upload-text">点击或拖拽文件到此区域上传</p>
|
||||||
|
<p className="ant-upload-hint">支持多文件上传,单个文件最大支持 50MB</p>
|
||||||
|
</Dragger>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Share & Mount Modal */}
|
||||||
|
<Modal
|
||||||
|
title="共享与授权"
|
||||||
|
open={isShareModalOpen}
|
||||||
|
onCancel={() => setIsShareModalOpen(false)}
|
||||||
|
footer={null}
|
||||||
|
width={600}
|
||||||
|
>
|
||||||
|
<Tabs items={[
|
||||||
|
{
|
||||||
|
key: 'agent',
|
||||||
|
label: <span><RobotOutlined />授权智能体</span>,
|
||||||
|
children: (
|
||||||
|
<div style={{ padding: '10px 0' }}>
|
||||||
|
<p style={{ color: 'var(--color-text-secondary)', marginBottom: 12 }}>
|
||||||
|
挂载到智能体后,该智能体的对话将自动检索此知识库。
|
||||||
|
</p>
|
||||||
|
<Space.Compact style={{ width: '100%' }}>
|
||||||
|
<Select
|
||||||
|
showSearch
|
||||||
|
placeholder="选择智能体"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
value={selectedAgentId}
|
||||||
|
onChange={setSelectedAgentId}
|
||||||
|
options={agents.map(a => ({ label: a.name, value: a.id }))}
|
||||||
|
filterOption={(input, option) =>
|
||||||
|
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
loading={sharingLoading}
|
||||||
|
disabled={!selectedAgentId}
|
||||||
|
onClick={() => selectedAgentId && onMount(selectedAgentId)}
|
||||||
|
>
|
||||||
|
确认授权
|
||||||
|
</Button>
|
||||||
|
</Space.Compact>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'team',
|
||||||
|
label: <span><TeamOutlined />共享至团队</span>,
|
||||||
|
children: (
|
||||||
|
<div style={{ padding: '10px 0' }}>
|
||||||
|
<p style={{ color: 'var(--color-text-secondary)', marginBottom: 12 }}>
|
||||||
|
共享后,团队内的成员将拥有此知识库的查看权限。
|
||||||
|
</p>
|
||||||
|
<Space.Compact style={{ width: '100%' }}>
|
||||||
|
<Select
|
||||||
|
placeholder="选择团队"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
value={selectedTeamId}
|
||||||
|
onChange={setSelectedTeamId}
|
||||||
|
options={teams.map(t => ({ label: t.name, value: t.id }))}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
loading={sharingLoading}
|
||||||
|
disabled={!selectedTeamId}
|
||||||
|
onClick={() => selectedTeamId && onShare('team', selectedTeamId)}
|
||||||
|
>
|
||||||
|
确认共享
|
||||||
|
</Button>
|
||||||
|
</Space.Compact>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'user',
|
||||||
|
label: <span><UserOutlined />共享至个人</span>,
|
||||||
|
children: (
|
||||||
|
<div style={{ padding: '10px 0' }}>
|
||||||
|
<p style={{ color: 'var(--color-text-secondary)', marginBottom: 12 }}>
|
||||||
|
输入用户的手机号或 ID 进行共享。
|
||||||
|
</p>
|
||||||
|
<Space.Compact style={{ width: '100%' }}>
|
||||||
|
<Input
|
||||||
|
placeholder="输入用户标识"
|
||||||
|
value={selectedUserId}
|
||||||
|
onChange={(e) => setSelectedUserId(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
loading={sharingLoading}
|
||||||
|
disabled={!selectedUserId.trim()}
|
||||||
|
onClick={() => selectedUserId.trim() && onShare('user', selectedUserId.trim())}
|
||||||
|
>
|
||||||
|
确认共享
|
||||||
|
</Button>
|
||||||
|
</Space.Compact>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]} />
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button, List, Tag, Space, Empty, Spin } from 'antd';
|
||||||
|
import {
|
||||||
|
PlusOutlined,
|
||||||
|
DatabaseOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
ShareAltOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
ArrowLeftOutlined
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { useKnowledgeBaseLogic } from '../KnowledgeBaseLogic';
|
||||||
|
import KBModals from './KBModals';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import '../styles/knowledge-base.css';
|
||||||
|
|
||||||
|
export default function KnowledgeBaseH5() {
|
||||||
|
const logic = useKnowledgeBaseLogic();
|
||||||
|
const {
|
||||||
|
loading,
|
||||||
|
kbs,
|
||||||
|
activeKb,
|
||||||
|
setActiveKb,
|
||||||
|
files,
|
||||||
|
filesLoading,
|
||||||
|
setIsCreateModalOpen,
|
||||||
|
setIsUploadModalOpen,
|
||||||
|
setIsShareModalOpen,
|
||||||
|
handleRemoveFile
|
||||||
|
} = logic;
|
||||||
|
|
||||||
|
const [view, setView] = useState<'list' | 'detail'>('list');
|
||||||
|
|
||||||
|
const handleSelect = (kb: any) => {
|
||||||
|
setActiveKb(kb);
|
||||||
|
setView('detail');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (view === 'detail' && activeKb) {
|
||||||
|
return (
|
||||||
|
<div className="kb-page-h5-detail">
|
||||||
|
<div className="kb-h5-header">
|
||||||
|
<Button icon={<ArrowLeftOutlined />} type="text" onClick={() => setView('list')} />
|
||||||
|
<span className="kb-h5-header-title">{activeKb.name}</span>
|
||||||
|
<Button icon={<PlusOutlined />} type="text" onClick={() => setIsUploadModalOpen(true)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="kb-h5-content">
|
||||||
|
<div className="kb-h5-actions">
|
||||||
|
<Space>
|
||||||
|
<Button icon={<ShareAltOutlined />} onClick={() => setIsShareModalOpen(true)}>共享</Button>
|
||||||
|
<Button danger icon={<DeleteOutlined />} onClick={() => logic.handleDelete(activeKb.id)}>删除</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="kb-h5-section-title">文件列表 ({files.length})</div>
|
||||||
|
{filesLoading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: 40 }}><Spin /></div>
|
||||||
|
) : (
|
||||||
|
<List
|
||||||
|
dataSource={files}
|
||||||
|
renderItem={(file: any) => (
|
||||||
|
<List.Item
|
||||||
|
actions={[
|
||||||
|
<Button
|
||||||
|
key="del"
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={() => handleRemoveFile(file.id)}
|
||||||
|
/>
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<List.Item.Meta
|
||||||
|
avatar={<FileTextOutlined style={{ fontSize: 24, color: '#999' }} />}
|
||||||
|
title={file.originalName}
|
||||||
|
description={
|
||||||
|
<div className="kb-h5-file-desc">
|
||||||
|
<span>{(file.size / 1024).toFixed(1)} KB</span>
|
||||||
|
{file.chunkCount && <span> · {file.chunkCount} 分块</span>}
|
||||||
|
{file.createdAt && <span> · {dayjs(file.createdAt).format('MM-DD HH:mm')}</span>}
|
||||||
|
<span> · {file.status}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<KBModals logic={logic} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="kb-page-h5">
|
||||||
|
<div className="kb-h5-hero">
|
||||||
|
<h1 className="kb-h5-title">知识库</h1>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
shape="circle"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => setIsCreateModalOpen(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="kb-h5-list">
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: 40 }}><Spin /></div>
|
||||||
|
) : kbs.length === 0 ? (
|
||||||
|
<Empty description="暂无知识库" />
|
||||||
|
) : (
|
||||||
|
<List
|
||||||
|
dataSource={kbs}
|
||||||
|
renderItem={(kb: any) => (
|
||||||
|
<List.Item onClick={() => handleSelect(kb)} className="kb-h5-list-item">
|
||||||
|
<List.Item.Meta
|
||||||
|
avatar={<DatabaseOutlined style={{ fontSize: 24, color: 'var(--color-brand)' }} />}
|
||||||
|
title={kb.name}
|
||||||
|
description={kb.description || '暂无描述'}
|
||||||
|
/>
|
||||||
|
<Tag bordered={false}>{kb.access === 'owner' ? '所有者' : '查看者'}</Tag>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<KBModals logic={logic} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
import { Button, Spin, Empty } from 'antd';
|
||||||
|
import { PlusOutlined, DatabaseOutlined } from '@ant-design/icons';
|
||||||
|
import { useKnowledgeBaseLogic } from '../KnowledgeBaseLogic';
|
||||||
|
import KBList from './KBList';
|
||||||
|
import KBDetail from './KBDetail';
|
||||||
|
import KBModals from './KBModals';
|
||||||
|
import '../styles/knowledge-base.css';
|
||||||
|
|
||||||
|
export default function KnowledgeBaseWeb() {
|
||||||
|
const logic = useKnowledgeBaseLogic();
|
||||||
|
const {
|
||||||
|
loading,
|
||||||
|
kbs,
|
||||||
|
activeKb,
|
||||||
|
setActiveKb,
|
||||||
|
setIsCreateModalOpen
|
||||||
|
} = logic;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="kb-page-web">
|
||||||
|
{/* Hero Section */}
|
||||||
|
<header className="kb-page-web-hero">
|
||||||
|
<div className="kb-page-web-hero-header">
|
||||||
|
<div className="kb-page-web-hero-copy">
|
||||||
|
<div className="kb-page-web-badge">
|
||||||
|
<DatabaseOutlined />
|
||||||
|
<span>KNOWLEDGE BASE</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="kb-page-web-title">知识库管理</h1>
|
||||||
|
<p className="kb-page-web-subtitle">
|
||||||
|
管理您的团队和个人知识库,支持多文件上传、共享和智能体挂载,让 AI 更懂您的业务。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
size="large"
|
||||||
|
className="kb-page-web-create-btn"
|
||||||
|
onClick={() => setIsCreateModalOpen(true)}
|
||||||
|
>
|
||||||
|
创建知识库
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="kb-page-web-main-grid">
|
||||||
|
{/* Left Sidebar: KB List */}
|
||||||
|
<aside className="kb-page-web-list-panel">
|
||||||
|
<div className="kb-page-web-list-head">
|
||||||
|
<h3 className="kb-page-web-section-title">我的知识库</h3>
|
||||||
|
<p className="kb-page-web-section-desc">共 {kbs.length} 个资源</p>
|
||||||
|
</div>
|
||||||
|
<KBList
|
||||||
|
kbs={kbs}
|
||||||
|
activeId={activeKb?.id}
|
||||||
|
onSelect={setActiveKb}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Right Content: KB Detail */}
|
||||||
|
<main className="kb-page-web-detail-container">
|
||||||
|
{loading ? (
|
||||||
|
<div className="kb-page-web-loading">
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
) : activeKb ? (
|
||||||
|
<KBDetail logic={logic} />
|
||||||
|
) : (
|
||||||
|
<div className="kb-page-web-empty">
|
||||||
|
<Empty
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
description="选择一个知识库以查看详情"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modals */}
|
||||||
|
<KBModals logic={logic} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||||
|
import KnowledgeBaseWeb from './components/KnowledgeBaseWeb';
|
||||||
|
import KnowledgeBaseH5 from './components/KnowledgeBaseH5';
|
||||||
|
|
||||||
|
export default function KnowledgeBasePage() {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
return isMobile ? <KnowledgeBaseH5 /> : <KnowledgeBaseWeb />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,307 @@
|
||||||
|
.kb-page-web {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-page-web-hero {
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 30px 30px 26px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
background: linear-gradient(135deg, rgba(255,255,255,0.98), rgba(236,253,245,0.92) 42%, rgba(239,246,255,0.96));
|
||||||
|
border: 1px solid rgba(8, 145, 178, 0.12);
|
||||||
|
box-shadow: 0 20px 48px rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-page-web-hero-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-page-web-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
background: rgba(255,255,255,0.78);
|
||||||
|
border: 1px solid rgba(8, 145, 178, 0.1);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-page-web-badge .anticon {
|
||||||
|
color: var(--color-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-page-web-title {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 850;
|
||||||
|
color: var(--color-text);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-page-web-subtitle {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14.5px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
max-width: 580px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-page-web-create-btn {
|
||||||
|
border-radius: 14px !important;
|
||||||
|
height: 46px !important;
|
||||||
|
padding: 0 18px !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-page-web-main-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 280px minmax(0, 1fr);
|
||||||
|
gap: 24px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-page-web-list-panel {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 22px;
|
||||||
|
padding: 16px;
|
||||||
|
height: fit-content;
|
||||||
|
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.045);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-page-web-list-head {
|
||||||
|
padding: 0 8px 16px;
|
||||||
|
border-bottom: 1px solid var(--color-border-faint);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-page-web-section-title {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-page-web-section-desc {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-page-web-list-items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-page-web-nav-item {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-page-web-nav-item:hover {
|
||||||
|
background: var(--color-surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-page-web-nav-item.active {
|
||||||
|
background: var(--color-brand-soft);
|
||||||
|
border-color: rgba(8, 145, 178, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-page-web-nav-item-top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-page-web-nav-item-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-page-web-nav-item-type-icon {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-page-web-nav-item-type-icon.public { color: var(--color-success); }
|
||||||
|
.kb-page-web-nav-item-type-icon.private { color: var(--color-brand); }
|
||||||
|
.kb-page-web-nav-item-type-icon.team { color: var(--color-info); }
|
||||||
|
|
||||||
|
.kb-page-web-nav-item-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 1;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-page-web-nav-item-tags {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-page-web-access-tag {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-page-web-detail-container {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 22px;
|
||||||
|
min-height: 500px;
|
||||||
|
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.045);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-detail {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-detail-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 1px solid var(--color-border-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-detail-title {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-detail-desc {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-detail-section-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-detail-section-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-detail-section-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-file-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-files-table .ant-table-thead > tr > th {
|
||||||
|
background: transparent;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-page-web-loading,
|
||||||
|
.kb-page-web-empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode adjustments if needed */
|
||||||
|
[data-theme='dark'] .kb-page-web-hero {
|
||||||
|
background: linear-gradient(135deg, #1e293b, #0f172a);
|
||||||
|
border-color: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] .kb-page-web-list-panel,
|
||||||
|
[data-theme='dark'] .kb-page-web-detail-container {
|
||||||
|
background: #1e293b;
|
||||||
|
border-color: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* H5 Styles */
|
||||||
|
.kb-page-h5 {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-h5-hero {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-h5-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 800;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-h5-list-item {
|
||||||
|
padding: 16px !important;
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
border: 1px solid var(--color-border) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-h5-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: white;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-h5-header-title {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-h5-content {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-h5-actions {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-h5-section-title {
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
@ -42,7 +42,6 @@ export default function LoginFormCard({ logic }: { logic: LoginPageLogic }) {
|
||||||
label="手机号"
|
label="手机号"
|
||||||
rules={[
|
rules={[
|
||||||
{ required: true, message: '请填写手机号' },
|
{ required: true, message: '请填写手机号' },
|
||||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号格式' }
|
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Input placeholder="13800138000" size="large" autoFocus />
|
<Input placeholder="13800138000" size="large" autoFocus />
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { PlusOutlined, SearchOutlined, CompassOutlined, FireOutlined } from '@an
|
||||||
import { Col, Row, Empty, Button, Tag, Space, Input, Spin } from 'antd';
|
import { Col, Row, Empty, Button, Tag, Space, Input, Spin } from 'antd';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import type { MarketplacePageLogicOutput } from '../MarketplacePageLogic';
|
import type { MarketplacePageLogicOutput } from '../MarketplacePageLogic';
|
||||||
|
import '../styles/marketplace-page-web.css';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
logic: MarketplacePageLogicOutput;
|
logic: MarketplacePageLogicOutput;
|
||||||
|
|
@ -12,35 +13,26 @@ export default function MarketplacePageWeb({ logic }: Props) {
|
||||||
const { loading, q, filtered, setQ, handleFork, isImageUrl } = logic;
|
const { loading, q, filtered, setQ, handleFork, isImageUrl } = logic;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="page-container">
|
||||||
<div className="page-hero">
|
<div className="marketplace-header">
|
||||||
<div style={{ maxWidth: 1240, margin: '0 auto' }}>
|
<div className="marketplace-header-content">
|
||||||
<div
|
<div className="marketplace-intro">
|
||||||
style={{
|
<div className="marketplace-badge">
|
||||||
display: 'inline-flex',
|
<CompassOutlined className="marketplace-badge-icon" />
|
||||||
alignItems: 'center',
|
|
||||||
gap: 8,
|
|
||||||
padding: '6px 10px',
|
|
||||||
borderRadius: 999,
|
|
||||||
background: 'var(--color-surface)',
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
color: 'var(--color-text-secondary)',
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 500,
|
|
||||||
marginBottom: 18,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CompassOutlined style={{ color: 'var(--color-brand)' }} />
|
|
||||||
探索社区智能体
|
探索社区智能体
|
||||||
</div>
|
</div>
|
||||||
<h1 className="hero-title">找到更适合你的 AI 伙伴</h1>
|
|
||||||
<p className="hero-subtitle">
|
<h2 className="page-title" style={{ marginBottom: 10 }}>
|
||||||
|
找到更适合你的 AI 伙伴
|
||||||
|
</h2>
|
||||||
|
<div className="page-subtitle" style={{ marginTop: 0, fontSize: 15, lineHeight: 1.75 }}>
|
||||||
浏览社区创建的智能体,快速复制、微调并投入你的日常工作流。
|
浏览社区创建的智能体,快速复制、微调并投入你的日常工作流。
|
||||||
</p>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="page-container" style={{ paddingTop: 28 }}>
|
<div style={{ paddingTop: 0 }}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|
|
||||||
|
|
@ -4,28 +4,43 @@
|
||||||
@import './marketplace-page-web-large-2k.css';
|
@import './marketplace-page-web-large-2k.css';
|
||||||
@import './marketplace-page-web-ultra-4k.css';
|
@import './marketplace-page-web-ultra-4k.css';
|
||||||
|
|
||||||
.marketplace-web-hero-inner {
|
.marketplace-header {
|
||||||
max-width: var(--marketplace-max-width, 1240px);
|
border-radius: 24px;
|
||||||
margin: 0 auto;
|
padding: 30px 30px 26px;
|
||||||
|
background: linear-gradient(135deg, rgba(255,255,255,0.98) 0%, rgba(236,253,245,0.92) 48%, rgba(239,246,255,0.96) 100%);
|
||||||
|
border: 1px solid rgba(8, 145, 178, 0.12);
|
||||||
|
box-shadow: 0 20px 48px rgba(15, 23, 42, 0.06);
|
||||||
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.marketplace-web-badge {
|
.marketplace-header-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketplace-intro {
|
||||||
|
max-width: 620px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marketplace-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 6px 10px;
|
padding: 6px 12px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
margin-bottom: 18px;
|
background: rgba(255,255,255,0.78);
|
||||||
background: var(--color-surface);
|
border: 1px solid rgba(8, 145, 178, 0.10);
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.marketplace-web-container {
|
.marketplace-badge-icon {
|
||||||
max-width: var(--marketplace-max-width, 1240px);
|
color: var(--color-brand);
|
||||||
padding-top: 28px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.marketplace-web-toolbar {
|
.marketplace-web-toolbar {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,326 @@
|
||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { App as AntApp } from 'antd';
|
||||||
|
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||||
|
import { MembershipAPI, MembershipInfo } from '../../api/membership';
|
||||||
|
|
||||||
|
export interface PricingTier {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
badge?: string;
|
||||||
|
description: string;
|
||||||
|
billing: {
|
||||||
|
monthly: {
|
||||||
|
price: number;
|
||||||
|
originalPrice?: number;
|
||||||
|
text: string;
|
||||||
|
subtext?: string;
|
||||||
|
discountText?: string;
|
||||||
|
discountRate?: number;
|
||||||
|
};
|
||||||
|
yearly?: {
|
||||||
|
price: number;
|
||||||
|
originalPrice?: number;
|
||||||
|
text: string;
|
||||||
|
subtext?: string;
|
||||||
|
discountText?: string;
|
||||||
|
discountRate?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
features: Array<{
|
||||||
|
icon: string;
|
||||||
|
text: string;
|
||||||
|
}>;
|
||||||
|
details: string[];
|
||||||
|
limits: {
|
||||||
|
tier: string;
|
||||||
|
name: string;
|
||||||
|
maxSubAccounts: number;
|
||||||
|
maxTokens: number;
|
||||||
|
maxAgents: number;
|
||||||
|
maxKBSize: number;
|
||||||
|
};
|
||||||
|
type: 'personal' | 'enterprise';
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIER_WEIGHTS: Record<string, number> = {
|
||||||
|
'trial': 1,
|
||||||
|
'pro': 2,
|
||||||
|
'ultra': 3,
|
||||||
|
'ent_basic': 10,
|
||||||
|
'ent_standard': 11,
|
||||||
|
'custom': 99
|
||||||
|
};
|
||||||
|
|
||||||
|
const CUSTOM_PLAN: PricingTier = {
|
||||||
|
id: 'custom',
|
||||||
|
name: '按需定制',
|
||||||
|
badge: '专享',
|
||||||
|
description: '适合大型团队 / 行业方案',
|
||||||
|
billing: {
|
||||||
|
monthly: {
|
||||||
|
price: 0,
|
||||||
|
text: '面议',
|
||||||
|
subtext: '专属优惠与支持',
|
||||||
|
discountRate: 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
features: [
|
||||||
|
{ icon: 'Zap', text: '独立部署 / 专属网络环境' },
|
||||||
|
{ icon: 'Check', text: '统一登录 / 操作记录可追溯' },
|
||||||
|
{ icon: 'Crown', text: '行业专属插件与技能' },
|
||||||
|
{ icon: 'Infinity', text: '服务保障与 7×24 专属支持' }
|
||||||
|
],
|
||||||
|
details: [
|
||||||
|
'企业积分池 面议',
|
||||||
|
'高质量模型配额 不限量',
|
||||||
|
'子账号上限 不限量'
|
||||||
|
],
|
||||||
|
limits: {
|
||||||
|
tier: 'custom',
|
||||||
|
name: '按需定制',
|
||||||
|
maxSubAccounts: -1,
|
||||||
|
maxTokens: -1,
|
||||||
|
maxAgents: -1,
|
||||||
|
maxKBSize: -1
|
||||||
|
},
|
||||||
|
type: 'enterprise'
|
||||||
|
};
|
||||||
|
|
||||||
|
export function usePricingLogic() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { tierId } = useParams();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const cycle = searchParams.get('cycle') || 'monthly';
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<'personal' | 'enterprise'>('personal');
|
||||||
|
const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>(cycle as any);
|
||||||
|
const [membership, setMembership] = useState<MembershipInfo | null>(null);
|
||||||
|
const [plans, setPlans] = useState<PricingTier[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [orderInfo, setOrderInfo] = useState<{
|
||||||
|
order_id: string;
|
||||||
|
pay_url: string;
|
||||||
|
pay_expire_at: string;
|
||||||
|
pay_is_expired: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
const [payStatus, setPayStatus] = useState<'PENDING' | 'SUCCESS' | 'CLOSED' | 'FAIL' | 'EXPIRED'>('PENDING');
|
||||||
|
const [timeLeft, setTimeLeft] = useState(0);
|
||||||
|
const { message } = AntApp.useApp();
|
||||||
|
|
||||||
|
// 分类方案
|
||||||
|
const PERSONAL_TIERS = useMemo(() => plans.filter(p => p.type === 'personal'), [plans]);
|
||||||
|
const ENTERPRISE_TIERS = useMemo(() => plans.filter(p => p.type === 'enterprise'), [plans]);
|
||||||
|
|
||||||
|
// 根据 URL 参数计算当前的支付信息
|
||||||
|
const tierInfo = useMemo(() => {
|
||||||
|
if (!tierId || plans.length === 0) return null;
|
||||||
|
const tier = plans.find(t => t.id === tierId);
|
||||||
|
if (!tier) return null;
|
||||||
|
return {
|
||||||
|
tier,
|
||||||
|
duration: billingCycle === 'monthly' ? 30 : 365
|
||||||
|
};
|
||||||
|
}, [tierId, billingCycle, plans]);
|
||||||
|
|
||||||
|
// 综合支付信息
|
||||||
|
const paymentInfo = useMemo(() => {
|
||||||
|
if (!tierInfo || !orderInfo) return null;
|
||||||
|
return {
|
||||||
|
...tierInfo,
|
||||||
|
...orderInfo,
|
||||||
|
status: payStatus,
|
||||||
|
};
|
||||||
|
}, [tierInfo, orderInfo, payStatus]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadMembership();
|
||||||
|
loadPlans();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'enterprise') {
|
||||||
|
setBillingCycle('monthly');
|
||||||
|
}
|
||||||
|
}, [activeTab]);
|
||||||
|
|
||||||
|
const loadPlans = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await MembershipAPI.getPlans();
|
||||||
|
// res 结构: { categories: [ { id: 'personal', plans: [...] }, ... ] }
|
||||||
|
const categories = res.categories || [];
|
||||||
|
const allPlans: PricingTier[] = [];
|
||||||
|
|
||||||
|
categories.forEach((cat: any) => {
|
||||||
|
const catPlans = (cat.plans || []).map((p: any) => ({
|
||||||
|
...p,
|
||||||
|
type: cat.id // 注入 personal 或 enterprise
|
||||||
|
}));
|
||||||
|
allPlans.push(...catPlans);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 追加静态的“按需定制”方案到企业版
|
||||||
|
if (!allPlans.find(p => p.id === 'custom')) {
|
||||||
|
allPlans.push(CUSTOM_PLAN);
|
||||||
|
}
|
||||||
|
|
||||||
|
setPlans(allPlans);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load plans', e);
|
||||||
|
message.error('加载会员方案失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 当进入支付路由时,发起订阅请求
|
||||||
|
useEffect(() => {
|
||||||
|
if (tierId && tierInfo && !orderInfo) {
|
||||||
|
createOrder();
|
||||||
|
}
|
||||||
|
}, [tierId, tierInfo]);
|
||||||
|
|
||||||
|
// 倒计时逻辑
|
||||||
|
useEffect(() => {
|
||||||
|
let timer: any;
|
||||||
|
if (orderInfo && timeLeft > 0 && payStatus === 'PENDING') {
|
||||||
|
timer = setInterval(() => {
|
||||||
|
setTimeLeft((prev) => {
|
||||||
|
if (prev <= 1) {
|
||||||
|
setPayStatus('EXPIRED');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return prev - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [orderInfo, timeLeft, payStatus]);
|
||||||
|
|
||||||
|
// 轮询支付状态
|
||||||
|
useEffect(() => {
|
||||||
|
let pollTimer: any;
|
||||||
|
if (orderInfo && payStatus === 'PENDING') {
|
||||||
|
pollTimer = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const res = await MembershipAPI.queryPayStatus(orderInfo.order_id);
|
||||||
|
if (res.status === 'SUCCESS') {
|
||||||
|
setPayStatus('SUCCESS');
|
||||||
|
message.success('支付成功!');
|
||||||
|
loadMembership(); // 刷新会员信息
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
} else if (res.status === 'CLOSED' || res.status === 'FAIL') {
|
||||||
|
setPayStatus(res.status);
|
||||||
|
message.error(`支付失败: ${res.status}`);
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
} else if (res.pay_is_expired) {
|
||||||
|
setPayStatus('EXPIRED');
|
||||||
|
message.warning('支付已过期,请重新下单');
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Polling payment status failed', e);
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
return () => clearInterval(pollTimer);
|
||||||
|
}, [orderInfo, payStatus]);
|
||||||
|
|
||||||
|
const createOrder = async () => {
|
||||||
|
if (!tierId || !tierInfo) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await MembershipAPI.subscribe({
|
||||||
|
tier: tierId,
|
||||||
|
durationDays: tierInfo.duration,
|
||||||
|
});
|
||||||
|
setOrderInfo(res);
|
||||||
|
setPayStatus('PENDING');
|
||||||
|
|
||||||
|
// 计算剩余秒数
|
||||||
|
const expireTime = new Date(res.pay_expire_at).getTime();
|
||||||
|
const now = new Date().getTime();
|
||||||
|
const diff = Math.floor((expireTime - now) / 1000);
|
||||||
|
setTimeLeft(diff > 0 ? diff : 0);
|
||||||
|
|
||||||
|
if (res.pay_is_expired) {
|
||||||
|
setPayStatus('EXPIRED');
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e.response?.data?.message || '创建订单失败,请稍后重试');
|
||||||
|
navigate('/pricing');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMembership = async () => {
|
||||||
|
try {
|
||||||
|
const info = await MembershipAPI.getMe();
|
||||||
|
setMembership(info);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load membership', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubscribe = async (tier: PricingTier) => {
|
||||||
|
// 基础校验
|
||||||
|
if (tier.id === 'custom') {
|
||||||
|
message.info('请联系您的专属大客户经理或拨打客服热线进行面议');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const price = billingCycle === 'yearly' && tier.billing.yearly
|
||||||
|
? tier.billing.yearly.price
|
||||||
|
: tier.billing.monthly.price;
|
||||||
|
|
||||||
|
if (tier.id === 'trial' && membership?.tier === 'trial') {
|
||||||
|
message.info('您当前已在试用期内');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用子路由跳转
|
||||||
|
navigate(`/pricing/pay/${tier.id}?cycle=${billingCycle}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelPayment = async () => {
|
||||||
|
if (orderInfo) {
|
||||||
|
try {
|
||||||
|
await MembershipAPI.closePayOrder(orderInfo.order_id);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to close order', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setOrderInfo(null);
|
||||||
|
setPayStatus('PENDING');
|
||||||
|
navigate('/pricing');
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTierStatus = (targetTierId: string) => {
|
||||||
|
if (!membership) return 'none';
|
||||||
|
const currentWeight = TIER_WEIGHTS[membership.tier] || 0;
|
||||||
|
const targetWeight = TIER_WEIGHTS[targetTierId] || 0;
|
||||||
|
|
||||||
|
if (membership.tier === targetTierId) return 'current';
|
||||||
|
if (currentWeight > targetWeight) return 'included';
|
||||||
|
return 'none';
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeTab,
|
||||||
|
setActiveTab,
|
||||||
|
billingCycle,
|
||||||
|
setBillingCycle,
|
||||||
|
membership,
|
||||||
|
loading,
|
||||||
|
handleSubscribe,
|
||||||
|
paymentInfo,
|
||||||
|
timeLeft,
|
||||||
|
cancelPayment,
|
||||||
|
PERSONAL_TIERS,
|
||||||
|
ENTERPRISE_TIERS,
|
||||||
|
getTierStatus,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PricingLogicOutput = ReturnType<typeof usePricingLogic>;
|
||||||
|
|
@ -0,0 +1,273 @@
|
||||||
|
import {
|
||||||
|
CheckOutlined,
|
||||||
|
ArrowLeftOutlined,
|
||||||
|
LoadingOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
CloseCircleOutlined,
|
||||||
|
ExclamationCircleOutlined,
|
||||||
|
ThunderboltOutlined,
|
||||||
|
CrownOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
RadiusSettingOutlined
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { Button, Spin, QRCode, Divider, Result, Tag } from 'antd';
|
||||||
|
import type { PricingLogicOutput } from '../PricingLogic';
|
||||||
|
import '../styles/pricing.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
logic: PricingLogicOutput;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FeatureIcon = ({ name }: { name: string }) => {
|
||||||
|
switch (name) {
|
||||||
|
case 'Check': return <CheckOutlined className="feature-icon-check" />;
|
||||||
|
case 'Zap': return <ThunderboltOutlined className="feature-icon-zap" />;
|
||||||
|
case 'Crown': return <CrownOutlined className="feature-icon-crown" />;
|
||||||
|
case 'Users': return <TeamOutlined className="feature-icon-users" />;
|
||||||
|
case 'Infinity': return <RadiusSettingOutlined className="feature-icon-infinity" />;
|
||||||
|
default: return <CheckOutlined className="feature-icon-check" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const DiamondIcon = () => (
|
||||||
|
<span style={{ fontSize: 16, color: 'var(--color-text-tertiary)', marginRight: 8 }}>◇</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function PricingH5({ logic }: Props) {
|
||||||
|
const {
|
||||||
|
activeTab,
|
||||||
|
setActiveTab,
|
||||||
|
billingCycle,
|
||||||
|
setBillingCycle,
|
||||||
|
membership,
|
||||||
|
loading,
|
||||||
|
handleSubscribe,
|
||||||
|
paymentInfo,
|
||||||
|
timeLeft,
|
||||||
|
cancelPayment,
|
||||||
|
PERSONAL_TIERS,
|
||||||
|
ENTERPRISE_TIERS,
|
||||||
|
getTierStatus,
|
||||||
|
} = logic;
|
||||||
|
|
||||||
|
if (paymentInfo) {
|
||||||
|
const { tier, duration, status, pay_url } = paymentInfo;
|
||||||
|
const minutes = Math.floor(timeLeft / 60);
|
||||||
|
const seconds = timeLeft % 60;
|
||||||
|
const billing = billingCycle === 'yearly' && tier.billing.yearly ? tier.billing.yearly : tier.billing.monthly;
|
||||||
|
const price = billing.price;
|
||||||
|
|
||||||
|
if (status === 'SUCCESS') {
|
||||||
|
return (
|
||||||
|
<div className="pricing-page pricing-page-h5">
|
||||||
|
<div className="payment-container">
|
||||||
|
<Result
|
||||||
|
status="success"
|
||||||
|
title="支付成功"
|
||||||
|
subTitle={`${tier.name} 已开通`}
|
||||||
|
extra={[
|
||||||
|
<Button type="primary" key="back" block onClick={cancelPayment}>
|
||||||
|
完成
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'CLOSED' || status === 'FAIL' || status === 'EXPIRED') {
|
||||||
|
return (
|
||||||
|
<div className="pricing-page pricing-page-h5">
|
||||||
|
<div className="payment-container">
|
||||||
|
<Result
|
||||||
|
status="error"
|
||||||
|
title={status === 'EXPIRED' ? '支付已过期' : '支付失败'}
|
||||||
|
subTitle={status === 'EXPIRED' ? '请重新发起下单' : '请重试'}
|
||||||
|
extra={[
|
||||||
|
<Button type="primary" key="retry" block onClick={cancelPayment}>
|
||||||
|
返回重试
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pricing-page pricing-page-h5">
|
||||||
|
<div className="payment-container">
|
||||||
|
<div className="payment-header">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<ArrowLeftOutlined />}
|
||||||
|
onClick={cancelPayment}
|
||||||
|
className="back-btn"
|
||||||
|
>
|
||||||
|
返回方案选择
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="payment-content">
|
||||||
|
<div className="payment-info-card">
|
||||||
|
<h2 className="payment-title">确认订单</h2>
|
||||||
|
<div className="payment-detail-item">
|
||||||
|
<span className="label">方案</span>
|
||||||
|
<span className="value">{tier.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="payment-detail-item">
|
||||||
|
<span className="label">时长</span>
|
||||||
|
<span className="value">{duration === 30 ? '30天' : '365天'}</span>
|
||||||
|
</div>
|
||||||
|
<Divider style={{ margin: '16px 0' }} />
|
||||||
|
<div className="payment-total">
|
||||||
|
<span className="label">应付金额</span>
|
||||||
|
<span className="total-amount">
|
||||||
|
<span className="symbol">¥</span>
|
||||||
|
{price}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="payment-qr-card">
|
||||||
|
<div className="qr-wrapper">
|
||||||
|
<QRCode value={pay_url} size={180} />
|
||||||
|
<div className="qr-status">
|
||||||
|
<LoadingOutlined /> 正在等待支付结果...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="payment-countdown">
|
||||||
|
支付剩余时间:
|
||||||
|
<span className="time">
|
||||||
|
{String(minutes).padStart(2, '0')}:{String(seconds).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="payment-tips">
|
||||||
|
<p>请长按识别或保存二维码支付</p>
|
||||||
|
<p>支付完成后系统将自动为您开通</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tiers = activeTab === 'personal' ? PERSONAL_TIERS : ENTERPRISE_TIERS;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pricing-page pricing-page-h5">
|
||||||
|
<div className="pricing-header">
|
||||||
|
<h1 className="pricing-title">选择套餐</h1>
|
||||||
|
|
||||||
|
<div className="pricing-tabs-row">
|
||||||
|
<div className="pricing-tabs">
|
||||||
|
<div
|
||||||
|
className={`pricing-tab-item ${activeTab === 'personal' ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('personal')}
|
||||||
|
>
|
||||||
|
个人版
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`pricing-tab-item ${activeTab === 'enterprise' ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('enterprise')}
|
||||||
|
>
|
||||||
|
企业版
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'personal' && (
|
||||||
|
<div className="billing-toggle-row">
|
||||||
|
<div className="billing-toggle">
|
||||||
|
<div
|
||||||
|
className={`billing-toggle-item ${billingCycle === 'monthly' ? 'active' : ''}`}
|
||||||
|
onClick={() => setBillingCycle('monthly')}
|
||||||
|
>
|
||||||
|
月付
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`billing-toggle-item ${billingCycle === 'yearly' ? 'active' : ''}`}
|
||||||
|
onClick={() => setBillingCycle('yearly')}
|
||||||
|
>
|
||||||
|
年付 <span className="discount-tag">更优</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ padding: '60px 0' }}><Spin size="large" /></div>
|
||||||
|
) : (
|
||||||
|
<div className="pricing-grid">
|
||||||
|
{tiers.map((tier) => {
|
||||||
|
const billing = billingCycle === 'yearly' && tier.billing.yearly ? tier.billing.yearly : tier.billing.monthly;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={tier.id} className={`pricing-card ${tier.badge === '最受欢迎' ? 'highlight' : ''}`}>
|
||||||
|
{tier.badge && <div className="pricing-card-tag">{tier.badge}</div>}
|
||||||
|
|
||||||
|
<div className="pricing-card-name">{tier.name}</div>
|
||||||
|
|
||||||
|
<div className="pricing-card-price">
|
||||||
|
<span className="price-amount">{billing.text.split(' ')[0]}</span>
|
||||||
|
<span className="price-unit">{billing.text.split(' ')[1] + ' ' + billing.text.split(' ')[2]}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{billing.subtext && (
|
||||||
|
<div className="pricing-card-price-subtext">
|
||||||
|
{billing.subtext}
|
||||||
|
{billing.discountText && <Tag color="red" style={{ marginLeft: 4 }}>{billing.discountText}</Tag>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="pricing-card-desc">{tier.description}</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type={tier.badge === '最受欢迎' ? 'primary' : 'default'}
|
||||||
|
className="pricing-card-btn"
|
||||||
|
onClick={() => handleSubscribe(tier)}
|
||||||
|
block
|
||||||
|
disabled={getTierStatus(tier.id) !== 'none'}
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
|
const status = getTierStatus(tier.id);
|
||||||
|
if (status === 'current') return '当前订阅';
|
||||||
|
if (status === 'included') return '已包含';
|
||||||
|
if (tier.id === 'trial') return '立即开启';
|
||||||
|
if (tier.id === 'custom') return '联系客服';
|
||||||
|
return '立即订阅';
|
||||||
|
})()}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<ul className="pricing-card-features">
|
||||||
|
{tier.features.map((feature, idx) => (
|
||||||
|
<li key={idx} className="feature-item">
|
||||||
|
<FeatureIcon name={feature.icon} />
|
||||||
|
<span>{feature.text}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<Divider style={{ margin: '12px 0' }} />
|
||||||
|
|
||||||
|
<div className="pricing-card-details">
|
||||||
|
{tier.details.map((detail, idx) => (
|
||||||
|
<div key={idx} className="detail-item">
|
||||||
|
<DiamondIcon />
|
||||||
|
<span>{detail}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,275 @@
|
||||||
|
import {
|
||||||
|
CheckOutlined,
|
||||||
|
ArrowLeftOutlined,
|
||||||
|
LoadingOutlined,
|
||||||
|
ThunderboltOutlined,
|
||||||
|
CrownOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
RadiusSettingOutlined
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { Button, Spin, QRCode, Divider, Result, Tag } from 'antd';
|
||||||
|
import type { PricingLogicOutput } from '../PricingLogic';
|
||||||
|
import '../styles/pricing.css';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
logic: PricingLogicOutput;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FeatureIcon = ({ name }: { name: string }) => {
|
||||||
|
switch (name) {
|
||||||
|
case 'Check': return <CheckOutlined className="feature-icon-check" />;
|
||||||
|
case 'Zap': return <ThunderboltOutlined className="feature-icon-zap" />;
|
||||||
|
case 'Crown': return <CrownOutlined className="feature-icon-crown" />;
|
||||||
|
case 'Users': return <TeamOutlined className="feature-icon-users" />;
|
||||||
|
case 'Infinity': return <RadiusSettingOutlined className="feature-icon-infinity" />;
|
||||||
|
default: return <CheckOutlined className="feature-icon-check" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const DiamondIcon = () => (
|
||||||
|
<span style={{ fontSize: 16, color: 'var(--color-text-tertiary)', marginRight: 8 }}>◇</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function PricingWeb({ logic }: Props) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const {
|
||||||
|
activeTab,
|
||||||
|
setActiveTab,
|
||||||
|
billingCycle,
|
||||||
|
setBillingCycle,
|
||||||
|
membership,
|
||||||
|
loading,
|
||||||
|
handleSubscribe,
|
||||||
|
paymentInfo,
|
||||||
|
timeLeft,
|
||||||
|
cancelPayment,
|
||||||
|
PERSONAL_TIERS,
|
||||||
|
ENTERPRISE_TIERS,
|
||||||
|
getTierStatus,
|
||||||
|
} = logic;
|
||||||
|
|
||||||
|
if (paymentInfo) {
|
||||||
|
const { tier, duration, status, pay_url } = paymentInfo;
|
||||||
|
const minutes = Math.floor(timeLeft / 60);
|
||||||
|
const seconds = timeLeft % 60;
|
||||||
|
const billing = billingCycle === 'yearly' && tier.billing.yearly ? tier.billing.yearly : tier.billing.monthly;
|
||||||
|
const price = billing.price;
|
||||||
|
|
||||||
|
if (status === 'SUCCESS') {
|
||||||
|
return (
|
||||||
|
<div className="pricing-page">
|
||||||
|
<div className="payment-container">
|
||||||
|
<Result
|
||||||
|
status="success"
|
||||||
|
title="支付成功"
|
||||||
|
subTitle={`您已成功订阅 ${tier.name},会员权限已即时生效。`}
|
||||||
|
extra={[
|
||||||
|
<Button type="primary" key="back" onClick={() => navigate('/chat')}>
|
||||||
|
去对话
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'CLOSED' || status === 'FAIL' || status === 'EXPIRED') {
|
||||||
|
return (
|
||||||
|
<div className="pricing-page">
|
||||||
|
<div className="payment-container">
|
||||||
|
<Result
|
||||||
|
status="error"
|
||||||
|
title={status === 'EXPIRED' ? '支付已过期' : '支付失败'}
|
||||||
|
subTitle={status === 'EXPIRED' ? '订单已超时,请重新发起订阅。' : '支付过程中遇到问题,请重试。'}
|
||||||
|
extra={[
|
||||||
|
<Button type="primary" key="retry" onClick={cancelPayment}>
|
||||||
|
返回重新下单
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pricing-page">
|
||||||
|
<div className="payment-container">
|
||||||
|
<div className="payment-header">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<ArrowLeftOutlined />}
|
||||||
|
onClick={cancelPayment}
|
||||||
|
className="back-btn"
|
||||||
|
>
|
||||||
|
返回方案选择
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="payment-content">
|
||||||
|
<div className="payment-info-card">
|
||||||
|
<h2 className="payment-title">确认订单并支付</h2>
|
||||||
|
<div className="payment-detail-item">
|
||||||
|
<span className="label">订阅方案</span>
|
||||||
|
<span className="value">{tier.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="payment-detail-item">
|
||||||
|
<span className="label">订阅时长</span>
|
||||||
|
<span className="value">{duration === 30 ? '30 天' : '365 天'}</span>
|
||||||
|
</div>
|
||||||
|
<Divider />
|
||||||
|
<div className="payment-total">
|
||||||
|
<span className="label">应付金额</span>
|
||||||
|
<span className="total-amount">
|
||||||
|
<span className="symbol">¥</span>
|
||||||
|
{price}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="payment-qr-card">
|
||||||
|
<div className="qr-wrapper">
|
||||||
|
<QRCode value={pay_url} size={200} />
|
||||||
|
<div className="qr-status">
|
||||||
|
<LoadingOutlined /> 正在等待支付结果...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="payment-countdown">
|
||||||
|
支付剩余时间:
|
||||||
|
<span className="time">
|
||||||
|
{String(minutes).padStart(2, '0')}:{String(seconds).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="payment-tips">
|
||||||
|
<p>请使用微信或支付宝扫描上方二维码进行支付</p>
|
||||||
|
<p>支付完成后,系统将自动为您开通相应权限</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tiers = activeTab === 'personal' ? PERSONAL_TIERS : ENTERPRISE_TIERS;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pricing-page">
|
||||||
|
<div className="pricing-header">
|
||||||
|
<h1 className="pricing-title">选择适合你工作方式的套餐</h1>
|
||||||
|
|
||||||
|
<div className="pricing-tabs-row">
|
||||||
|
<div className="pricing-tabs">
|
||||||
|
<div
|
||||||
|
className={`pricing-tab-item ${activeTab === 'personal' ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('personal')}
|
||||||
|
>
|
||||||
|
个人版
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`pricing-tab-item ${activeTab === 'enterprise' ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('enterprise')}
|
||||||
|
>
|
||||||
|
企业版
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'personal' && (
|
||||||
|
<div className="billing-toggle-row">
|
||||||
|
<div className="billing-toggle">
|
||||||
|
<div
|
||||||
|
className={`billing-toggle-item ${billingCycle === 'monthly' ? 'active' : ''}`}
|
||||||
|
onClick={() => setBillingCycle('monthly')}
|
||||||
|
>
|
||||||
|
按月支付
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`billing-toggle-item ${billingCycle === 'yearly' ? 'active' : ''}`}
|
||||||
|
onClick={() => setBillingCycle('yearly')}
|
||||||
|
>
|
||||||
|
按年支付 <span className="discount-tag">更优惠</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ padding: '100px 0' }}><Spin size="large" /></div>
|
||||||
|
) : (
|
||||||
|
<div className="pricing-grid">
|
||||||
|
{tiers.map((tier) => {
|
||||||
|
const billing = billingCycle === 'yearly' && tier.billing.yearly ? tier.billing.yearly : tier.billing.monthly;
|
||||||
|
const showPrice = tier.type === 'enterprise' ? (billing.price > 0 ? billing.price : '面议') : (billing.price > 0 ? billing.price : '免费');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={tier.id} className={`pricing-card ${['pro', 'ent_standard'].includes(tier.id) ? 'highlight' : ''}`}>
|
||||||
|
{tier.badge && <div className="pricing-card-tag">{tier.badge}</div>}
|
||||||
|
|
||||||
|
<div className="pricing-card-name">{tier.name}</div>
|
||||||
|
|
||||||
|
<div className="pricing-card-price">
|
||||||
|
<span className="price-amount">{showPrice}</span>
|
||||||
|
{billing.price > 0 && <span className="price-unit">{billing.text.split(' ')[1] + ' ' + billing.text.split(' ')[2]}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{billing.subtext && (
|
||||||
|
<div className="pricing-card-price-subtext">
|
||||||
|
{billing.subtext}
|
||||||
|
{billing.discountText && <Tag color="red" style={{ marginLeft: 8 }}>{billing.discountText}</Tag>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="pricing-card-desc">{tier.description}</div>
|
||||||
|
|
||||||
|
<div className="pricing-card-actions">
|
||||||
|
<Button
|
||||||
|
type={tier.badge === '最受欢迎' ? 'primary' : 'default'}
|
||||||
|
className="pricing-card-btn"
|
||||||
|
onClick={() => handleSubscribe(tier)}
|
||||||
|
size="large"
|
||||||
|
disabled={getTierStatus(tier.id) !== 'none'}
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
|
const status = getTierStatus(tier.id);
|
||||||
|
if (status === 'current') return '当前订阅';
|
||||||
|
if (status === 'included') return '已包含';
|
||||||
|
if (tier.id === 'trial') return '立即开启';
|
||||||
|
if (tier.id === 'custom') return '联系客服';
|
||||||
|
return '立即订阅';
|
||||||
|
})()}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="pricing-card-features">
|
||||||
|
{tier.features.map((feature, idx) => (
|
||||||
|
<li key={idx} className="feature-item">
|
||||||
|
<FeatureIcon name={feature.icon} />
|
||||||
|
<span>{feature.text}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<Divider style={{ margin: '16px 0' }} />
|
||||||
|
|
||||||
|
<div className="pricing-card-details">
|
||||||
|
{tier.details.map((detail, idx) => (
|
||||||
|
<div key={idx} className="detail-item">
|
||||||
|
<DiamondIcon />
|
||||||
|
<span>{detail}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,374 @@
|
||||||
|
.pricing-page {
|
||||||
|
padding: 40px 20px;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-header {
|
||||||
|
margin-bottom: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-title {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-tabs {
|
||||||
|
display: inline-flex;
|
||||||
|
background: var(--color-fill-secondary);
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-tab-item {
|
||||||
|
padding: 8px 24px;
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.3s;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-tab-item.active {
|
||||||
|
background: #1f2937;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.billing-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
background: var(--color-fill-secondary);
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 48px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.billing-toggle-item {
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.billing-toggle-item.active {
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.billing-toggle-item .discount-tag {
|
||||||
|
color: var(--color-brand);
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 32px 24px;
|
||||||
|
text-align: left;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: all 0.3s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-card.highlight {
|
||||||
|
border-color: var(--color-brand);
|
||||||
|
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-card-tag {
|
||||||
|
position: absolute;
|
||||||
|
top: 32px;
|
||||||
|
right: 24px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: var(--color-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-card-name {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-card-price {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-symbol {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-amount {
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-unit {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-card-price-yearly {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
margin-top: -4px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-card-desc {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-card-btn {
|
||||||
|
width: 100%;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
background: #1f2937;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-card-btn:hover:not(:disabled) {
|
||||||
|
background: #374151 !important;
|
||||||
|
color: #fff !important;
|
||||||
|
border-color: #374151 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-card.highlight .pricing-card-btn {
|
||||||
|
background: var(--color-brand);
|
||||||
|
border-color: var(--color-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-card.highlight .pricing-card-btn:hover:not(:disabled) {
|
||||||
|
background: #059669 !important;
|
||||||
|
border-color: #059669 !important;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-card-btn:disabled,
|
||||||
|
.pricing-card-btn.ant-btn-disabled {
|
||||||
|
background: #f3f4f6 !important;
|
||||||
|
color: #9ca3af !important;
|
||||||
|
border-color: #e5e7eb !important;
|
||||||
|
cursor: not-allowed !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.pricing-card-features {
|
||||||
|
margin-top: 24px;
|
||||||
|
padding-top: 24px;
|
||||||
|
border-top: 1px solid var(--color-border-secondary);
|
||||||
|
list-style: none;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-icon-check { color: var(--color-text-tertiary); }
|
||||||
|
.feature-icon-diamond { color: var(--color-text-tertiary); margin-top: 2px; }
|
||||||
|
|
||||||
|
.pricing-card-metrics {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* H5 Styles */
|
||||||
|
.pricing-page-h5 {
|
||||||
|
padding: 20px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-page-h5 .pricing-title {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-page-h5 .pricing-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Payment View Styles */
|
||||||
|
.payment-container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
text-align: left;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-header {
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-bottom: 1px solid var(--color-border-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-header .back-btn {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-content {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.2fr 1fr;
|
||||||
|
padding: 40px;
|
||||||
|
gap: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-detail-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-detail-item .label {
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-detail-item .value {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-total {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-total .label {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-total .total-amount {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--color-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-total .symbol {
|
||||||
|
font-size: 18px;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-qr-card {
|
||||||
|
background: var(--color-fill-secondary);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 32px;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-wrapper {
|
||||||
|
background: #fff;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-status {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-brand);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-countdown {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-countdown .time {
|
||||||
|
font-family: monospace;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-tips {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-tips p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.payment-content {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
padding: 24px;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-container {
|
||||||
|
border-radius: 0;
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { usePricingLogic } from './Pricing/PricingLogic';
|
||||||
|
import PricingWeb from './Pricing/components/PricingWeb';
|
||||||
|
import PricingH5 from './Pricing/components/PricingH5';
|
||||||
|
|
||||||
|
const isMobileDevice = () => {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
return window.innerWidth < 768;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PricingPage() {
|
||||||
|
const logic = usePricingLogic();
|
||||||
|
const [isMobile, setIsMobile] = useState(isMobileDevice());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => {
|
||||||
|
setIsMobile(isMobileDevice());
|
||||||
|
};
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return isMobile ? <PricingH5 logic={logic} /> : <PricingWeb logic={logic} />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,148 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { App as AntApp } from 'antd';
|
||||||
|
import { MembershipAPI, MembershipInfo, ChildMember } from '../../api/membership';
|
||||||
|
import { AgentAPI, Agent } from '../../api/agents';
|
||||||
|
import { AuthAPI, AuthUser } from '../../api/auth';
|
||||||
|
|
||||||
|
export function useProfileLogic() {
|
||||||
|
const [user, setUser] = useState<AuthUser | null>(null);
|
||||||
|
const [membership, setMembership] = useState<MembershipInfo | null>(null);
|
||||||
|
const [members, setMembers] = useState<ChildMember[]>([]);
|
||||||
|
const [myAgents, setMyAgents] = useState<Agent[]>([]);
|
||||||
|
const [orders, setOrders] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { message, modal } = AntApp.useApp();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
init();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// 1. 先获取基础的用户信息和会员状态
|
||||||
|
const [u, mInfo] = await Promise.all([
|
||||||
|
AuthAPI.me(),
|
||||||
|
MembershipAPI.getMe(),
|
||||||
|
]);
|
||||||
|
setUser(u);
|
||||||
|
setMembership(mInfo);
|
||||||
|
|
||||||
|
// 2. 根据会员等级判断是否需要获取子账号列表
|
||||||
|
// 只有企业版才支持子账号管理,其他版本调用会返回 403
|
||||||
|
if (mInfo.tier === 'ent_basic' || mInfo.tier === 'ent_standard') {
|
||||||
|
try {
|
||||||
|
const mList = await MembershipAPI.listMembers();
|
||||||
|
setMembers(mList);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch members', e);
|
||||||
|
setMembers([]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setMembers([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 获取个人拥有的智能体列表
|
||||||
|
if (u.phone) {
|
||||||
|
try {
|
||||||
|
const agents = await AgentAPI.mine(u.phone);
|
||||||
|
setMyAgents(agents);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch agents', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 获取订单记录
|
||||||
|
try {
|
||||||
|
const orderList = await MembershipAPI.getOrders();
|
||||||
|
setOrders(orderList);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch orders', e);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to init profile', e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddChild = async (childId: string) => {
|
||||||
|
try {
|
||||||
|
await MembershipAPI.addChild(childId);
|
||||||
|
message.success('添加子账号成功');
|
||||||
|
const mList = await MembershipAPI.listMembers();
|
||||||
|
setMembers(mList);
|
||||||
|
return true;
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.response?.data?.error ?? e?.message ?? '添加失败');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveChild = async (relationId: string) => {
|
||||||
|
modal.confirm({
|
||||||
|
title: '确认移除',
|
||||||
|
content: '确定要移除该子账号吗?移除后将取消所有资源授权。',
|
||||||
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
await MembershipAPI.removeChild(relationId);
|
||||||
|
message.success('移除成功');
|
||||||
|
const mList = await MembershipAPI.listMembers();
|
||||||
|
setMembers(mList);
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.response?.data?.error ?? e?.message ?? '移除失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAllocateQuota = async (userId: string, amount: number) => {
|
||||||
|
try {
|
||||||
|
await MembershipAPI.allocateQuota({ userId, resourceType: 'tokens', amount });
|
||||||
|
message.success('分配成功');
|
||||||
|
const mList = await MembershipAPI.listMembers();
|
||||||
|
setMembers(mList);
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.response?.data?.error ?? e?.message ?? '分配失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAuthorizeAgent = async (userId: string, agentIds: string[], level: 'read' | 'write') => {
|
||||||
|
try {
|
||||||
|
await MembershipAPI.authorizeResource({ userId, resourceType: 'agent', resourceIds: agentIds, level });
|
||||||
|
message.success('授权成功');
|
||||||
|
const mList = await MembershipAPI.listMembers();
|
||||||
|
setMembers(mList);
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.response?.data?.error ?? e?.message ?? '授权失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevokeAgent = async (userId: string, agentIds: string[]) => {
|
||||||
|
try {
|
||||||
|
await MembershipAPI.revokeResource({ userId, resourceType: 'agent', resourceIds: agentIds });
|
||||||
|
message.success('取消授权成功');
|
||||||
|
const mList = await MembershipAPI.listMembers();
|
||||||
|
setMembers(mList);
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.response?.data?.error ?? e?.message ?? '操作失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
membership,
|
||||||
|
members,
|
||||||
|
myAgents,
|
||||||
|
orders,
|
||||||
|
loading,
|
||||||
|
init,
|
||||||
|
handleAddChild,
|
||||||
|
handleRemoveChild,
|
||||||
|
handleAllocateQuota,
|
||||||
|
handleAuthorizeAgent,
|
||||||
|
handleRevokeAgent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProfileLogicOutput = ReturnType<typeof useProfileLogic>;
|
||||||
|
|
@ -0,0 +1,304 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button, Modal, Input, Select, Space, Tag, InputNumber, Popconfirm, List, Card, Checkbox, Divider, Tabs } from 'antd';
|
||||||
|
import { UserAddOutlined, DeleteOutlined, KeyOutlined, DashboardOutlined, HistoryOutlined, TeamOutlined } from '@ant-design/icons';
|
||||||
|
import type { ProfileLogicOutput } from '../ProfileLogic';
|
||||||
|
import '../styles/profile.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
logic: ProfileLogicOutput;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProfileH5({ logic }: Props) {
|
||||||
|
const {
|
||||||
|
user,
|
||||||
|
membership,
|
||||||
|
members,
|
||||||
|
myAgents,
|
||||||
|
orders,
|
||||||
|
loading,
|
||||||
|
handleAddChild,
|
||||||
|
handleRemoveChild,
|
||||||
|
handleAllocateQuota,
|
||||||
|
handleAuthorizeAgent,
|
||||||
|
handleRevokeAgent,
|
||||||
|
} = logic;
|
||||||
|
|
||||||
|
const [addModalVisible, setAddModalVisible] = useState(false);
|
||||||
|
const [addLoading, setAddLoading] = useState(false);
|
||||||
|
const [quotaModalVisible, setQuotaModalVisible] = useState(false);
|
||||||
|
const [authModalVisible, setAuthModalVisible] = useState(false);
|
||||||
|
|
||||||
|
const [newChildId, setNewChildId] = useState('');
|
||||||
|
const [selectedMember, setSelectedMember] = useState<any>(null);
|
||||||
|
const [quotaAmount, setQuotaAmount] = useState<number>(100000);
|
||||||
|
const [selectedAgentIds, setSelectedAgentIds] = useState<string[]>([]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="profile-page profile-page-h5">
|
||||||
|
<div className="profile-header-card">
|
||||||
|
<div className="profile-info">
|
||||||
|
<div className="profile-avatar">
|
||||||
|
{user?.name?.charAt(0) || user?.phone?.slice(-4) || '?'}
|
||||||
|
</div>
|
||||||
|
<div className="profile-details">
|
||||||
|
<h2>
|
||||||
|
{user?.name || user?.phone}
|
||||||
|
<span className="profile-tier-badge">
|
||||||
|
{membership?.tierName || '普通用户'}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<div style={{ color: 'var(--color-text-secondary)', fontSize: 12 }}>
|
||||||
|
至:{membership?.expireAt ? new Date(membership.expireAt).toLocaleDateString() : '永久'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="profile-stats">
|
||||||
|
<div className="stat-item">
|
||||||
|
<span className="stat-value">{((membership?.usage?.tokensTotal || 0) / 1000).toFixed(1)}k</span>
|
||||||
|
<span className="stat-label">总算力</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-item">
|
||||||
|
<span className="stat-value">{((membership?.usage?.tokensUsed || 0) / 1000).toFixed(1)}k</span>
|
||||||
|
<span className="stat-label">已使用</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-item">
|
||||||
|
<span className="stat-value">{membership?.usage?.subAccountsCount || 0}/{membership?.limits?.maxSubAccounts || 0}</span>
|
||||||
|
<span className="stat-label">子账号</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="profile-content-tabs">
|
||||||
|
<Tabs
|
||||||
|
defaultActiveKey="members"
|
||||||
|
centered
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'members',
|
||||||
|
label: (
|
||||||
|
<span>
|
||||||
|
<TeamOutlined />
|
||||||
|
成员
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
children: (
|
||||||
|
<div className="profile-section">
|
||||||
|
<div className="section-title">
|
||||||
|
<h3>子账号管理</h3>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
icon={<UserAddOutlined />}
|
||||||
|
onClick={() => setAddModalVisible(true)}
|
||||||
|
disabled={membership?.usage?.subAccountsCount === membership?.limits?.maxSubAccounts}
|
||||||
|
>
|
||||||
|
添加
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<List
|
||||||
|
loading={loading}
|
||||||
|
dataSource={members}
|
||||||
|
renderItem={(record: any) => (
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
style={{ marginBottom: 12, borderRadius: 12 }}
|
||||||
|
actions={[
|
||||||
|
<DashboardOutlined key="quota" onClick={() => { setSelectedMember(record); setQuotaModalVisible(true); }} />,
|
||||||
|
<KeyOutlined
|
||||||
|
key="auth"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedMember(record);
|
||||||
|
setSelectedAgentIds(record.authorizedAgentIds || []);
|
||||||
|
setAuthModalVisible(true);
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
<Popconfirm key="delete" title="确定移除吗?" onConfirm={() => handleRemoveChild(record.id)}>
|
||||||
|
<DeleteOutlined style={{ color: 'var(--color-error)' }} />
|
||||||
|
</Popconfirm>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Card.Meta
|
||||||
|
avatar={<div className="member-avatar">{record.child_name?.charAt(0)}</div>}
|
||||||
|
title={record.child_name}
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 12 }}>{record.child_phone}</div>
|
||||||
|
<div className="quota-display" style={{ fontSize: 12, margin: '4px 0' }}>
|
||||||
|
{record.allocatedTokens?.toLocaleString()} Tokens
|
||||||
|
</div>
|
||||||
|
<div className="resource-tags">
|
||||||
|
{record.authorizedAgentIds?.map((id: string) => {
|
||||||
|
const agent = myAgents.find(a => a.id === id);
|
||||||
|
return (
|
||||||
|
<Tag
|
||||||
|
key={id}
|
||||||
|
closable
|
||||||
|
onClose={() => handleRevokeAgent(record.child_user_id, [id])}
|
||||||
|
style={{ borderRadius: 4, fontSize: 10, margin: '2px', display: 'flex', width: 'fit-content' }}
|
||||||
|
>
|
||||||
|
🤖 {agent?.name || '未知'}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{record.authorizedAgentIds?.length > 0 && (
|
||||||
|
<Popconfirm
|
||||||
|
title="一键取消全部授权?"
|
||||||
|
onConfirm={() => handleRevokeAgent(record.child_user_id, record.authorizedAgentIds)}
|
||||||
|
>
|
||||||
|
<Button type="link" size="small" danger style={{ fontSize: 10, padding: '0 2px', height: 'auto' }}>
|
||||||
|
全部移除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'orders',
|
||||||
|
label: (
|
||||||
|
<span>
|
||||||
|
<HistoryOutlined />
|
||||||
|
订单
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
children: (
|
||||||
|
<div className="profile-section">
|
||||||
|
<div className="section-title">
|
||||||
|
<h3>订阅记录</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<List
|
||||||
|
loading={loading}
|
||||||
|
dataSource={orders}
|
||||||
|
renderItem={(record: any) => {
|
||||||
|
const tierNames: Record<string, string> = {
|
||||||
|
trial: '个人试用版',
|
||||||
|
pro: '个人专业版',
|
||||||
|
ultra: '个人旗舰版',
|
||||||
|
ent_basic: '企业入门版',
|
||||||
|
ent_standard: '企业标准版'
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Card size="small" style={{ marginBottom: 12, borderRadius: 12 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600 }}>{tierNames[record.tier] || record.tier}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginTop: 4 }}>
|
||||||
|
订单号: {record.pay_order_no}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginTop: 2 }}>
|
||||||
|
{new Date(record.created_at).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<div style={{ fontWeight: 600, color: 'var(--color-primary)' }}>¥{record.amount}</div>
|
||||||
|
{(() => {
|
||||||
|
const statusMap: Record<string, { color: string; text: string }> = {
|
||||||
|
PENDING: { color: 'processing', text: '待支付' },
|
||||||
|
SUCCESS: { color: 'success', text: '已支付' },
|
||||||
|
FAIL: { color: 'error', text: '失败' },
|
||||||
|
CLOSED: { color: 'default', text: '关闭' }
|
||||||
|
};
|
||||||
|
const config = statusMap[record.status] || { color: 'default', text: record.status };
|
||||||
|
return (
|
||||||
|
<Tag color={config.color} style={{ marginTop: 8, marginRight: 0 }}>
|
||||||
|
{config.text}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modals are shared with Web version, but could be optimized for H5 if needed */}
|
||||||
|
<Modal
|
||||||
|
title="添加子账号"
|
||||||
|
open={addModalVisible}
|
||||||
|
confirmLoading={addLoading}
|
||||||
|
onOk={async () => {
|
||||||
|
if (!newChildId.trim()) return;
|
||||||
|
setAddLoading(true);
|
||||||
|
const success = await handleAddChild(newChildId);
|
||||||
|
setAddLoading(false);
|
||||||
|
if (success) {
|
||||||
|
setAddModalVisible(false);
|
||||||
|
setNewChildId('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setAddModalVisible(false);
|
||||||
|
setNewChildId('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Input placeholder="用户手机号" value={newChildId} onChange={e => setNewChildId(e.target.value)} />
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={`为 ${selectedMember?.child_name} 分配算力`}
|
||||||
|
open={quotaModalVisible}
|
||||||
|
onOk={() => { handleAllocateQuota(selectedMember.child_user_id, quotaAmount); setQuotaModalVisible(false); }}
|
||||||
|
onCancel={() => setQuotaModalVisible(false)}
|
||||||
|
>
|
||||||
|
<InputNumber style={{ width: '100%' }} min={1000} step={10000} value={quotaAmount} onChange={val => setQuotaAmount(val || 0)} />
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={`为 ${selectedMember?.child_name} 授权资源`}
|
||||||
|
open={authModalVisible}
|
||||||
|
onOk={() => {
|
||||||
|
handleAuthorizeAgent(selectedMember.child_user_id, selectedAgentIds, 'read');
|
||||||
|
setAuthModalVisible(false);
|
||||||
|
setSelectedAgentIds([]);
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setAuthModalVisible(false);
|
||||||
|
setSelectedAgentIds([]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 500 }}>选择智能体</span>
|
||||||
|
<Checkbox
|
||||||
|
indeterminate={selectedAgentIds.length > 0 && selectedAgentIds.length < myAgents.length}
|
||||||
|
checked={selectedAgentIds.length === myAgents.length && myAgents.length > 0}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelectedAgentIds(e.target.checked ? myAgents.map(a => a.id) : []);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
全选
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
|
<Divider style={{ margin: '8px 0' }} />
|
||||||
|
<div style={{ maxHeight: 300, overflowY: 'auto' }}>
|
||||||
|
<Checkbox.Group
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={selectedAgentIds}
|
||||||
|
onChange={(vals) => setSelectedAgentIds(vals as string[])}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||||
|
{myAgents.map(a => (
|
||||||
|
<Checkbox key={a.id} value={a.id}>{a.name}</Checkbox>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Checkbox.Group>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,396 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button, Table, Modal, Input, Select, Space, Tag, InputNumber, Popconfirm, Checkbox, Divider, Tabs } from 'antd';
|
||||||
|
import { UserAddOutlined, DeleteOutlined, KeyOutlined, DashboardOutlined, HistoryOutlined, TeamOutlined } from '@ant-design/icons';
|
||||||
|
import type { ProfileLogicOutput } from '../ProfileLogic';
|
||||||
|
import '../styles/profile.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
logic: ProfileLogicOutput;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProfileWeb({ logic }: Props) {
|
||||||
|
const {
|
||||||
|
user,
|
||||||
|
membership,
|
||||||
|
members,
|
||||||
|
myAgents,
|
||||||
|
orders,
|
||||||
|
loading,
|
||||||
|
handleAddChild,
|
||||||
|
handleRemoveChild,
|
||||||
|
handleAllocateQuota,
|
||||||
|
handleAuthorizeAgent,
|
||||||
|
handleRevokeAgent,
|
||||||
|
} = logic;
|
||||||
|
|
||||||
|
const [addModalVisible, setAddModalVisible] = useState(false);
|
||||||
|
const [addLoading, setAddLoading] = useState(false);
|
||||||
|
const [quotaModalVisible, setQuotaModalVisible] = useState(false);
|
||||||
|
const [authModalVisible, setAuthModalVisible] = useState(false);
|
||||||
|
|
||||||
|
const [newChildId, setNewChildId] = useState('');
|
||||||
|
const [selectedMember, setSelectedMember] = useState<any>(null);
|
||||||
|
const [quotaAmount, setQuotaAmount] = useState<number>(100000);
|
||||||
|
const [selectedAgentIds, setSelectedAgentIds] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '成员',
|
||||||
|
key: 'member',
|
||||||
|
render: (_: any, record: any) => (
|
||||||
|
<div className="member-info">
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600 }}>{record.child_name}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)' }}>{record.child_phone}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '算力配额',
|
||||||
|
key: 'quota',
|
||||||
|
render: (_: any, record: any) => (
|
||||||
|
<span className="quota-display">{record.allocatedTokens?.toLocaleString() || 0} Tokens</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '已授权资源',
|
||||||
|
key: 'resources',
|
||||||
|
render: (_: any, record: any) => (
|
||||||
|
<div className="resource-tags-container">
|
||||||
|
<div className="resource-tags" style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 4 }}>
|
||||||
|
{record.authorizedAgentIds?.map((id: string) => {
|
||||||
|
const agent = myAgents.find(a => a.id === id);
|
||||||
|
return (
|
||||||
|
<Tag
|
||||||
|
key={id}
|
||||||
|
closable
|
||||||
|
onClose={() => handleRevokeAgent(record.child_user_id, [id])}
|
||||||
|
style={{ borderRadius: 4, background: 'var(--color-fill-secondary)', border: 'none', margin: 0 }}
|
||||||
|
>
|
||||||
|
🤖 {agent?.name || '未知智能体'}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{(!record.authorizedAgentIds || record.authorizedAgentIds.length === 0) ? (
|
||||||
|
<span style={{ color: 'var(--color-text-tertiary)', fontSize: 12 }}>暂无授权</span>
|
||||||
|
) : (
|
||||||
|
<Popconfirm
|
||||||
|
title="确定取消该成员的所有智能体授权吗?"
|
||||||
|
onConfirm={() => handleRevokeAgent(record.child_user_id, record.authorizedAgentIds)}
|
||||||
|
>
|
||||||
|
<Button type="link" size="small" danger style={{ padding: '0 4px', fontSize: 12 }}>
|
||||||
|
全部移除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '加入时间',
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
key: 'created_at',
|
||||||
|
render: (val: string) => val ? new Date(val).toLocaleDateString() : '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
render: (_: any, record: any) => (
|
||||||
|
<Space size="middle">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<DashboardOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedMember(record);
|
||||||
|
setQuotaModalVisible(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
分配
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<KeyOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedMember(record);
|
||||||
|
setSelectedAgentIds(record.authorizedAgentIds || []);
|
||||||
|
setAuthModalVisible(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
授权
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="确定移除该子账号吗?"
|
||||||
|
onConfirm={() => handleRemoveChild(record.id)}
|
||||||
|
>
|
||||||
|
<Button type="text" size="small" danger icon={<DeleteOutlined />}>
|
||||||
|
移除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const orderColumns = [
|
||||||
|
{
|
||||||
|
title: '订单编号',
|
||||||
|
dataIndex: 'id',
|
||||||
|
key: 'id',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '订阅方案',
|
||||||
|
dataIndex: 'tier',
|
||||||
|
key: 'tier',
|
||||||
|
render: (tier: string) => {
|
||||||
|
const tierNames: Record<string, string> = {
|
||||||
|
trial: '个人试用版',
|
||||||
|
pro: '个人专业版',
|
||||||
|
ultra: '个人旗舰版',
|
||||||
|
ent_basic: '企业入门版',
|
||||||
|
ent_standard: '企业标准版'
|
||||||
|
};
|
||||||
|
return tierNames[tier] || tier;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '金额',
|
||||||
|
dataIndex: 'amount',
|
||||||
|
key: 'amount',
|
||||||
|
render: (amount: string) => `¥${amount}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
render: (status: string) => {
|
||||||
|
const statusMap: Record<string, { color: string; text: string }> = {
|
||||||
|
PENDING: { color: 'processing', text: '待支付' },
|
||||||
|
SUCCESS: { color: 'success', text: '支付成功' },
|
||||||
|
FAIL: { color: 'error', text: '支付失败' },
|
||||||
|
CLOSED: { color: 'default', text: '已关闭' }
|
||||||
|
};
|
||||||
|
const config = statusMap[status] || { color: 'default', text: status };
|
||||||
|
return <Tag color={config.color}>{config.text}</Tag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '下单时间',
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
key: 'created_at',
|
||||||
|
render: (val: number) => val ? new Date(val).toLocaleString() : '-',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="profile-page">
|
||||||
|
<div className="profile-header-card">
|
||||||
|
<div className="profile-info">
|
||||||
|
<div className="profile-avatar">
|
||||||
|
{user?.name?.charAt(0) || user?.phone?.slice(-4) || '?'}
|
||||||
|
</div>
|
||||||
|
<div className="profile-details">
|
||||||
|
<h2>
|
||||||
|
{user?.name || user?.phone}
|
||||||
|
<span className="profile-tier-badge">
|
||||||
|
{membership?.tierName || '普通用户'}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<div style={{ color: 'var(--color-text-secondary)' }}>
|
||||||
|
有效期至:{membership?.expireAt ? new Date(membership.expireAt).toLocaleDateString() : '永久有效'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="profile-stats">
|
||||||
|
<div className="stat-item">
|
||||||
|
<span className="stat-value">{membership?.usage?.tokensTotal.toLocaleString() || 0}</span>
|
||||||
|
<span className="stat-label">总算力 (Tokens)</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-item">
|
||||||
|
<span className="stat-value">{membership?.usage?.tokensUsed.toLocaleString() || 0}</span>
|
||||||
|
<span className="stat-label">已使用</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-item">
|
||||||
|
<span className="stat-value">{membership?.usage?.subAccountsCount || 0} / {membership?.limits?.maxSubAccounts || 0}</span>
|
||||||
|
<span className="stat-label">子账号</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="profile-content-tabs">
|
||||||
|
<Tabs
|
||||||
|
defaultActiveKey="members"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'members',
|
||||||
|
label: (
|
||||||
|
<span>
|
||||||
|
<TeamOutlined />
|
||||||
|
子账号管理
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
children: (
|
||||||
|
<div className="profile-section">
|
||||||
|
<div className="section-title">
|
||||||
|
<h3>子账号管理</h3>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<UserAddOutlined />}
|
||||||
|
onClick={() => setAddModalVisible(true)}
|
||||||
|
disabled={membership?.usage?.subAccountsCount === membership?.limits?.maxSubAccounts}
|
||||||
|
>
|
||||||
|
添加子账号
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={members}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
pagination={false}
|
||||||
|
className="member-list-table"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'orders',
|
||||||
|
label: (
|
||||||
|
<span>
|
||||||
|
<HistoryOutlined />
|
||||||
|
订阅记录
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
children: (
|
||||||
|
<div className="profile-section">
|
||||||
|
<div className="section-title">
|
||||||
|
<h3>订阅记录</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
columns={orderColumns}
|
||||||
|
dataSource={orders}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
pagination={{ pageSize: 10 }}
|
||||||
|
className="order-list-table"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add Member Modal */}
|
||||||
|
<Modal
|
||||||
|
title="添加子账号"
|
||||||
|
open={addModalVisible}
|
||||||
|
confirmLoading={addLoading}
|
||||||
|
onOk={async () => {
|
||||||
|
if (!newChildId.trim()) return;
|
||||||
|
setAddLoading(true);
|
||||||
|
const success = await handleAddChild(newChildId);
|
||||||
|
setAddLoading(false);
|
||||||
|
if (success) {
|
||||||
|
setAddModalVisible(false);
|
||||||
|
setNewChildId('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setAddModalVisible(false);
|
||||||
|
setNewChildId('');
|
||||||
|
}}
|
||||||
|
maskClosable={false}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: 8 }}>请输入子账号的用户手机号:</div>
|
||||||
|
<Input
|
||||||
|
placeholder="用户手机号"
|
||||||
|
value={newChildId}
|
||||||
|
onChange={e => setNewChildId(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Quota Modal */}
|
||||||
|
<Modal
|
||||||
|
title={`为 ${selectedMember?.child_name} 分配算力`}
|
||||||
|
open={quotaModalVisible}
|
||||||
|
onOk={() => {
|
||||||
|
handleAllocateQuota(selectedMember.child_user_id, quotaAmount);
|
||||||
|
setQuotaModalVisible(false);
|
||||||
|
}}
|
||||||
|
onCancel={() => setQuotaModalVisible(false)}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: 8 }}>分配数量 (Tokens):</div>
|
||||||
|
<InputNumber
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
min={1000}
|
||||||
|
step={10000}
|
||||||
|
value={quotaAmount}
|
||||||
|
onChange={val => setQuotaAmount(val || 0)}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Auth Modal */}
|
||||||
|
<Modal
|
||||||
|
title={`为 ${selectedMember?.child_name} 授权资源`}
|
||||||
|
open={authModalVisible}
|
||||||
|
width={520}
|
||||||
|
onOk={async () => {
|
||||||
|
// 计算新增的和移除的,但根据接口逻辑,通常是全量覆盖或者增量。
|
||||||
|
// 这里假设 handleAuthorizeAgent 是设置最终状态,或者后端支持 resourceIds 覆盖。
|
||||||
|
// 之前的 handleAuthorizeAgent 逻辑是直接调用 authorizeResource。
|
||||||
|
handleAuthorizeAgent(selectedMember.child_user_id, selectedAgentIds, 'read');
|
||||||
|
setAuthModalVisible(false);
|
||||||
|
setSelectedAgentIds([]);
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setAuthModalVisible(false);
|
||||||
|
setSelectedAgentIds([]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||||
|
<span style={{ fontWeight: 500 }}>选择智能体</span>
|
||||||
|
<Checkbox
|
||||||
|
indeterminate={selectedAgentIds.length > 0 && selectedAgentIds.length < myAgents.length}
|
||||||
|
checked={selectedAgentIds.length === myAgents.length && myAgents.length > 0}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelectedAgentIds(e.target.checked ? myAgents.map(a => a.id) : []);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
全选
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
|
<Divider style={{ margin: '0 0 12px 0' }} />
|
||||||
|
<div style={{ maxHeight: 300, overflowY: 'auto', padding: '4px' }}>
|
||||||
|
<Checkbox.Group
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={selectedAgentIds}
|
||||||
|
onChange={(vals) => setSelectedAgentIds(vals as string[])}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
||||||
|
{myAgents.map(a => (
|
||||||
|
<div key={a.id} className="auth-checkbox-item">
|
||||||
|
<Checkbox value={a.id}>
|
||||||
|
<span title={a.name}>{a.name}</span>
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Checkbox.Group>
|
||||||
|
{myAgents.length === 0 && (
|
||||||
|
<div style={{ textAlign: 'center', padding: '20px 0', color: 'var(--color-text-tertiary)' }}>
|
||||||
|
暂无可选智能体
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,145 @@
|
||||||
|
.profile-page {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-header-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 32px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--gradient-brand);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-details h2 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-tier-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--color-brand-soft);
|
||||||
|
color: var(--color-brand);
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
display: block;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-section {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-list-table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-fill-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-display {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* H5 Styles */
|
||||||
|
.profile-page-h5 {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-page-h5 .profile-header-card {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-page-h5 .profile-stats {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useProfileLogic } from './Profile/ProfileLogic';
|
||||||
|
import ProfileWeb from './Profile/components/ProfileWeb';
|
||||||
|
import ProfileH5 from './Profile/components/ProfileH5';
|
||||||
|
|
||||||
|
const isMobileDevice = () => {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
return window.innerWidth < 768;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ProfilePage() {
|
||||||
|
const logic = useProfileLogic();
|
||||||
|
const [isMobile, setIsMobile] = useState(isMobileDevice());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => {
|
||||||
|
setIsMobile(isMobileDevice());
|
||||||
|
};
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return isMobile ? <ProfileH5 logic={logic} /> : <ProfileWeb logic={logic} />;
|
||||||
|
}
|
||||||
|
|
@ -12,7 +12,7 @@ export default function StatsTopAgentsCard({ logic }: { logic: StatsPageLogic })
|
||||||
{data.topAgents.length === 0 ? (
|
{data.topAgents.length === 0 ? (
|
||||||
<Empty description="暂无" />
|
<Empty description="暂无" />
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div className="stats-page-agent-list">
|
||||||
{data.topAgents.map((agent, index) => (
|
{data.topAgents.map((agent, index) => (
|
||||||
<div key={agent.id} className="stats-page-agent-item">
|
<div key={agent.id} className="stats-page-agent-item">
|
||||||
<div className="stats-page-agent-header">
|
<div className="stats-page-agent-header">
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useTeamsPageLogic } from './TeamsPage/TeamsPageLogic';
|
import { useTeamsPageLogic } from './TeamsPage/TeamsPageLogic';
|
||||||
import TeamsPageWeb from './TeamsPage/components/TeamsPageWeb';
|
import TeamsPageWebBase from './TeamsPage/components/TeamsPageWebBase';
|
||||||
import TeamsPageH5 from './TeamsPage/components/TeamsPageH5';
|
import TeamsPageH5 from './TeamsPage/components/TeamsPageH5';
|
||||||
|
import { useDesktopViewport } from '../hooks/useDesktopViewport';
|
||||||
|
|
||||||
const isMobileDevice = () => {
|
const isMobileDevice = () => {
|
||||||
if (typeof window === 'undefined') return false;
|
if (typeof window === 'undefined') return false;
|
||||||
|
|
@ -10,6 +11,7 @@ const isMobileDevice = () => {
|
||||||
|
|
||||||
export default function TeamsPage() {
|
export default function TeamsPage() {
|
||||||
const logic = useTeamsPageLogic();
|
const logic = useTeamsPageLogic();
|
||||||
|
const viewport = useDesktopViewport();
|
||||||
const [isMobile, setIsMobile] = useState(isMobileDevice());
|
const [isMobile, setIsMobile] = useState(isMobileDevice());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -20,5 +22,9 @@ export default function TeamsPage() {
|
||||||
return () => window.removeEventListener('resize', handleResize);
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return isMobile ? <TeamsPageH5 logic={logic} /> : <TeamsPageWeb logic={logic} />;
|
return isMobile ? (
|
||||||
|
<TeamsPageH5 logic={logic} />
|
||||||
|
) : (
|
||||||
|
<TeamsPageWebBase logic={logic} viewport={viewport} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ export function useTeamsPageLogic() {
|
||||||
const [list, setList] = useState<Team[]>([]);
|
const [list, setList] = useState<Team[]>([]);
|
||||||
const [active, setActive] = useState<Team | null>(null);
|
const [active, setActive] = useState<Team | null>(null);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [inviteOpen, setInviteOpen] = useState(false);
|
const [addMemberOpen, setAddMemberOpen] = useState(false);
|
||||||
const [lastInviteCode, setLastInviteCode] = useState<string | null>(null);
|
const [renameOpen, setRenameOpen] = useState(false);
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
const l = await TeamAPI.list();
|
const l = await TeamAPI.list();
|
||||||
|
|
@ -27,14 +27,22 @@ export function useTeamsPageLogic() {
|
||||||
setActive(await TeamAPI.detail(t.id));
|
setActive(await TeamAPI.detail(t.id));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInvite = async (v: any) => {
|
const handleRename = async (v: any) => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
const inv = await AuthAPI.createInvite({
|
await TeamAPI.rename(active.id, v.name);
|
||||||
teamId: active.id,
|
setRenameOpen(false);
|
||||||
phone: v.phone || undefined,
|
message.success('已更名');
|
||||||
ttlHours: Number(v.ttlHours) || 168,
|
await load();
|
||||||
});
|
setActive(await TeamAPI.detail(active.id));
|
||||||
setLastInviteCode(inv.code);
|
};
|
||||||
|
|
||||||
|
const handleAddMember = async (v: any) => {
|
||||||
|
if (!active) return;
|
||||||
|
await TeamAPI.addMember(active.id, v.phone);
|
||||||
|
setAddMemberOpen(false);
|
||||||
|
message.success('已添加');
|
||||||
|
await load();
|
||||||
|
setActive(await TeamAPI.detail(active.id));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
|
|
@ -44,10 +52,11 @@ export function useTeamsPageLogic() {
|
||||||
setActive(null);
|
setActive(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveMember = async (id: string) => {
|
const handleRemoveMember = async (userId: string) => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
await TeamAPI.removeMember(active.id, id);
|
await TeamAPI.removeMember(active.id, userId);
|
||||||
message.success('已移除');
|
message.success('已移除');
|
||||||
|
await load();
|
||||||
setActive(await TeamAPI.detail(active.id));
|
setActive(await TeamAPI.detail(active.id));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -55,17 +64,18 @@ export function useTeamsPageLogic() {
|
||||||
list,
|
list,
|
||||||
active,
|
active,
|
||||||
createOpen,
|
createOpen,
|
||||||
inviteOpen,
|
addMemberOpen,
|
||||||
lastInviteCode,
|
renameOpen,
|
||||||
load,
|
load,
|
||||||
handleCreate,
|
handleCreate,
|
||||||
handleInvite,
|
handleRename,
|
||||||
|
handleAddMember,
|
||||||
handleDelete,
|
handleDelete,
|
||||||
handleRemoveMember,
|
handleRemoveMember,
|
||||||
setActive,
|
setActive,
|
||||||
setCreateOpen,
|
setCreateOpen,
|
||||||
setInviteOpen,
|
setAddMemberOpen,
|
||||||
setLastInviteCode,
|
setRenameOpen,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
import {
|
import {
|
||||||
CopyOutlined,
|
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
MailOutlined,
|
EditOutlined,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
|
UserAddOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { Card, Button, List, Tag, Space, Popconfirm, App as AntApp, Modal, Form, Input, Empty } from 'antd';
|
import { Card, Button, List, Tag, Space, Popconfirm, App as AntApp, Modal, Form, Input, Empty } from 'antd';
|
||||||
import { TeamAPI, type Team } from '../../../api';
|
import { TeamAPI, type Team } from '../../../api';
|
||||||
import type { TeamsPageLogicOutput } from '../TeamsPageLogic';
|
import type { TeamsPageLogicOutput } from '../TeamsPageLogic';
|
||||||
|
import '../styles/teams-page-h5.css';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
logic: TeamsPageLogicOutput;
|
logic: TeamsPageLogicOutput;
|
||||||
|
|
@ -20,55 +21,26 @@ export default function TeamsPageH5({ logic }: Props) {
|
||||||
list,
|
list,
|
||||||
active,
|
active,
|
||||||
handleDelete,
|
handleDelete,
|
||||||
|
handleRename,
|
||||||
handleRemoveMember,
|
handleRemoveMember,
|
||||||
|
handleAddMember,
|
||||||
setCreateOpen,
|
setCreateOpen,
|
||||||
|
setRenameOpen,
|
||||||
|
setAddMemberOpen,
|
||||||
} = logic;
|
} = logic;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="feature-cover-container">
|
<div className="feature-cover-container">
|
||||||
<div className="page-container h5-page-container" style={{ padding: '0 8px' }}>
|
<div className="page-container h5-page-container">
|
||||||
<div
|
<div className="teams-page-h5-hero">
|
||||||
style={{
|
<div className="teams-page-h5-hero-header">
|
||||||
borderRadius: 16,
|
|
||||||
padding: '20px 16px 18px',
|
|
||||||
background:
|
|
||||||
'linear-gradient(135deg, rgba(255,255,255,0.98) 0%, rgba(236,253,245,0.92) 42%, rgba(239,246,255,0.96) 100%)',
|
|
||||||
border: '1px solid rgba(8, 145, 178, 0.12)',
|
|
||||||
boxShadow: '0 10px 24px rgba(15, 23, 42, 0.06)',
|
|
||||||
marginBottom: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
gap: 12,
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
marginBottom: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ width: '100%' }}>
|
<div style={{ width: '100%' }}>
|
||||||
<div
|
<div className="teams-page-h5-badge">
|
||||||
style={{
|
<TeamOutlined className="teams-page-h5-member-avatar-icon" />
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 6,
|
|
||||||
padding: '4px 10px',
|
|
||||||
borderRadius: 999,
|
|
||||||
background: 'rgba(255,255,255,0.78)',
|
|
||||||
border: '1px solid rgba(8, 145, 178, 0.10)',
|
|
||||||
color: 'var(--color-text-secondary)',
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 600,
|
|
||||||
marginBottom: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TeamOutlined style={{ color: 'var(--color-brand)', fontSize: 12 }} />
|
|
||||||
协作组织空间
|
协作组织空间
|
||||||
</div>
|
</div>
|
||||||
<h1 className="page-title h5-page-title" style={{ marginBottom: 8, fontSize: 22 }}>团队管理</h1>
|
<h1 className="page-title h5-page-title teams-page-h5-title">团队管理</h1>
|
||||||
<div className="page-subtitle h5-page-subtitle" style={{ marginTop: 0, fontSize: 13, lineHeight: 1.6 }}>
|
<div className="page-subtitle h5-page-subtitle teams-page-h5-subtitle">
|
||||||
团队不只是成员列表,更是共享智能体、协同运营和权限分工的组织单元。
|
团队不只是成员列表,更是共享智能体、协同运营和权限分工的组织单元。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -77,40 +49,23 @@ export default function TeamsPageH5({ logic }: Props) {
|
||||||
size="middle"
|
size="middle"
|
||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
onClick={() => setCreateOpen(true)}
|
onClick={() => setCreateOpen(true)}
|
||||||
style={{ borderRadius: 10, height: 40, padding: '0 14px', fontWeight: 600, width: '100%' }}
|
className="teams-page-h5-create-btn"
|
||||||
>
|
>
|
||||||
创建团队
|
创建团队
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(1, minmax(0, 1fr))', gap: 10 }}>
|
<div className="teams-page-h5-stats-grid">
|
||||||
{[
|
{[
|
||||||
{ label: '团队数量', value: list.length, tone: 'rgba(8, 145, 178, 0.10)', color: 'var(--color-brand)' },
|
{ label: '团队数量', value: list.length, type: 'brand' },
|
||||||
{ label: '当前成员数', value: active?.members?.length ?? 0, tone: 'rgba(14, 165, 233, 0.10)', color: 'var(--color-info)' },
|
{ label: '当前成员数', value: active?.members?.length ?? 0, type: 'info' },
|
||||||
{ label: '共享智能体', value: active?.agentCount ?? 0, tone: 'rgba(34, 197, 94, 0.10)', color: 'var(--color-success)' },
|
{ label: '共享智能体', value: active?.agentCount ?? 0, type: 'success' },
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
<div
|
<div key={item.label} className="teams-page-h5-stat-card">
|
||||||
key={item.label}
|
<div className="teams-page-h5-stat-label">{item.label}</div>
|
||||||
style={{
|
<div className="teams-page-h5-stat-row">
|
||||||
borderRadius: 12,
|
<span className="teams-page-h5-stat-value">{item.value}</span>
|
||||||
padding: '12px 14px',
|
<span className={`teams-page-h5-stat-chip teams-page-h5-stat-chip-${item.type}`}>
|
||||||
background: 'rgba(255,255,255,0.72)',
|
|
||||||
border: '1px solid rgba(255,255,255,0.7)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ fontSize: 11, color: 'var(--color-text-secondary)', marginBottom: 6 }}>{item.label}</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
|
|
||||||
<span style={{ fontSize: 22, fontWeight: 700, color: 'var(--color-text)' }}>{item.value}</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
borderRadius: 999,
|
|
||||||
padding: '3px 6px',
|
|
||||||
background: item.tone,
|
|
||||||
color: item.color,
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
当前选中
|
当前选中
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -120,38 +75,21 @@ export default function TeamsPageH5({ logic }: Props) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{list.length > 0 && (
|
{list.length > 0 && (
|
||||||
<div
|
<div className="teams-page-h5-list-panel">
|
||||||
style={{
|
<div className="teams-page-h5-list-head">
|
||||||
background: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(252,252,253,1) 100%)',
|
<div className="teams-page-h5-section-title">团队列表</div>
|
||||||
border: '1px solid var(--color-border)',
|
<div className="teams-page-h5-section-desc">点击选择一个团队</div>
|
||||||
borderRadius: 16,
|
|
||||||
padding: 12,
|
|
||||||
marginBottom: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ padding: '4px 6px 10px' }}>
|
|
||||||
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--color-text)', marginBottom: 4 }}>团队列表</div>
|
|
||||||
<div style={{ fontSize: 11, color: 'var(--color-text-secondary)' }}>点击选择一个团队</div>
|
|
||||||
</div>
|
</div>
|
||||||
{list.map((item) => (
|
{list.map((item) => (
|
||||||
<div
|
<div
|
||||||
className={`nav-item ${active?.id === item.id ? 'active' : ''}`}
|
key={item.id}
|
||||||
|
className={`teams-page-h5-nav-item ${active?.id === item.id ? 'active' : ''}`}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
logic.setActive(await TeamAPI.detail(item.id));
|
logic.setActive(await TeamAPI.detail(item.id));
|
||||||
}}
|
}}
|
||||||
style={{
|
|
||||||
padding: '10px 12px',
|
|
||||||
borderRadius: 10,
|
|
||||||
cursor: 'pointer',
|
|
||||||
marginBottom: 6,
|
|
||||||
background: active?.id === item.id ? 'rgba(8, 145, 178, 0.10)' : 'transparent',
|
|
||||||
color: active?.id === item.id ? 'var(--color-brand)' : 'var(--color-text-secondary)',
|
|
||||||
fontWeight: active?.id === item.id ? 600 : 500,
|
|
||||||
border: active?.id === item.id ? '1px solid rgba(8, 145, 178, 0.16)' : '1px solid transparent',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div style={{ fontSize: 13, marginBottom: 3 }}>{item.name}</div>
|
<div className="teams-page-h5-nav-item-name">{item.name}</div>
|
||||||
<div style={{ fontSize: 11, color: active?.id === item.id ? 'var(--color-brand)' : 'var(--color-text-tertiary)' }}>
|
<div className="teams-page-h5-nav-item-count">
|
||||||
{item.agentCount ?? 0} 个智能体
|
{item.agentCount ?? 0} 个智能体
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -160,27 +98,26 @@ export default function TeamsPageH5({ logic }: Props) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{active ? (
|
{active ? (
|
||||||
<Card
|
<Card className="teams-page-h5-detail-card">
|
||||||
style={{ borderRadius: 16, boxShadow: '0 6px 16px rgba(15, 23, 42, 0.045)' }}
|
<div className="teams-page-h5-detail-head">
|
||||||
bodyStyle={{ padding: 16 }}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginBottom: 16 }}>
|
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', marginBottom: 4 }}>
|
<div className="teams-page-h5-active-title-row">
|
||||||
<span style={{ fontSize: 18, fontWeight: 700, color: 'var(--color-text)' }}>{active.name}</span>
|
<span className="teams-page-h5-active-title">{active.name}</span>
|
||||||
<Tag bordered={false} style={{ background: 'var(--color-brand-soft)', color: 'var(--color-brand)', borderRadius: 999, margin: 0, fontSize: 11 }}>{active.myRole}</Tag>
|
<Button
|
||||||
<Tag bordered={false} style={{ background: 'var(--color-surface-2)', color: 'var(--color-text-secondary)', borderRadius: 999, margin: 0, fontSize: 11 }}>{active.agentCount ?? 0} 智能体</Tag>
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
onClick={() => setRenameOpen(true)}
|
||||||
|
className="teams-page-h5-edit-btn"
|
||||||
|
/>
|
||||||
|
<Tag bordered={false} className="teams-page-h5-tag teams-page-h5-tag-brand">{active.myRole}</Tag>
|
||||||
|
<Tag bordered={false} className="teams-page-h5-tag teams-page-h5-tag-neutral">{active.agentCount ?? 0} 智能体</Tag>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
|
<div className="teams-page-h5-section-desc">
|
||||||
管理成员权限、邀请新伙伴。
|
管理成员权限、邀请新伙伴。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
{(active.myRole === 'owner' || active.myRole === 'admin') && (
|
|
||||||
<Button icon={<MailOutlined />} size="small" onClick={() => logic.setInviteOpen(true)} style={{ borderRadius: 8 }}>
|
|
||||||
生成邀请码
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{active.myRole === 'owner' && (
|
{active.myRole === 'owner' && (
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title="确定删除该团队?团队内的智能体会变成 owner 私有"
|
title="确定删除该团队?团队内的智能体会变成 owner 私有"
|
||||||
|
|
@ -189,7 +126,7 @@ export default function TeamsPageH5({ logic }: Props) {
|
||||||
message.success('已删除');
|
message.success('已删除');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button danger size="small" icon={<DeleteOutlined />} style={{ borderRadius: 8 }}>
|
<Button danger size="small" icon={<DeleteOutlined />} className="teams-page-web-soft-btn">
|
||||||
删除团队
|
删除团队
|
||||||
</Button>
|
</Button>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
|
|
@ -197,29 +134,43 @@ export default function TeamsPageH5({ logic }: Props) {
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(1, minmax(0, 1fr))', gap: 10, marginBottom: 16 }}>
|
<div className="teams-page-h5-mini-grid">
|
||||||
<div style={{ borderRadius: 12, padding: '12px 14px', background: 'rgba(8, 145, 178, 0.06)', border: '1px solid rgba(8, 145, 178, 0.10)' }}>
|
<div className="teams-page-h5-mini-card teams-page-h5-mini-card-brand">
|
||||||
<div style={{ fontSize: 11, color: 'var(--color-text-secondary)', marginBottom: 6 }}>成员规模</div>
|
<div className="teams-page-h5-mini-label">成员规模</div>
|
||||||
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--color-text)' }}>{active.members?.length || 0}</div>
|
<div className="teams-page-h5-mini-value">{active.members?.length || 0}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ borderRadius: 12, padding: '12px 14px', background: 'rgba(34, 197, 94, 0.06)', border: '1px solid rgba(34, 197, 94, 0.10)' }}>
|
<div className="teams-page-h5-mini-card teams-page-h5-mini-card-success">
|
||||||
<div style={{ fontSize: 11, color: 'var(--color-text-secondary)', marginBottom: 6 }}>共享智能体</div>
|
<div className="teams-page-h5-mini-label">共享智能体</div>
|
||||||
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--color-text)' }}>{active.agentCount ?? 0}</div>
|
<div className="teams-page-h5-mini-value">{active.agentCount ?? 0}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ borderRadius: 12, padding: '12px 14px', background: 'rgba(249, 115, 22, 0.06)', border: '1px solid rgba(249, 115, 22, 0.10)' }}>
|
<div className="teams-page-h5-mini-card teams-page-h5-mini-card-warning">
|
||||||
<div style={{ fontSize: 11, color: 'var(--color-text-secondary)', marginBottom: 6 }}>当前身份</div>
|
<div className="teams-page-h5-mini-label">当前身份</div>
|
||||||
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--color-text)', textTransform: 'capitalize' }}>{active.myRole}</div>
|
<div className="teams-page-h5-mini-value" style={{ textTransform: 'capitalize' }}>{active.myRole}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--color-text)', marginBottom: 10 }}>
|
<div className="teams-page-h5-members-title-row">
|
||||||
|
<div className="teams-page-h5-members-title">
|
||||||
成员 ({active.members?.length || 0})
|
成员 ({active.members?.length || 0})
|
||||||
</div>
|
</div>
|
||||||
|
{(active.myRole === 'owner' || active.myRole === 'admin') && (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
ghost
|
||||||
|
icon={<UserAddOutlined />}
|
||||||
|
onClick={() => setAddMemberOpen(true)}
|
||||||
|
className="teams-page-web-soft-btn"
|
||||||
|
>
|
||||||
|
添加成员
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<List
|
<List
|
||||||
dataSource={active.members || []}
|
dataSource={active.members || []}
|
||||||
renderItem={(m) => (
|
renderItem={(m) => (
|
||||||
<List.Item
|
<List.Item
|
||||||
style={{ padding: '10px 0' }}
|
className="teams-page-h5-member-item"
|
||||||
actions={
|
actions={
|
||||||
(active.myRole === 'owner' || active.myRole === 'admin') && m.role !== 'owner'
|
(active.myRole === 'owner' || active.myRole === 'admin') && m.role !== 'owner'
|
||||||
? [
|
? [
|
||||||
|
|
@ -230,7 +181,7 @@ export default function TeamsPageH5({ logic }: Props) {
|
||||||
await handleRemoveMember(m.id);
|
await handleRemoveMember(m.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button size="small" danger style={{ borderRadius: 6 }}>
|
<Button size="small" danger className="teams-page-web-soft-btn">
|
||||||
移除
|
移除
|
||||||
</Button>
|
</Button>
|
||||||
</Popconfirm>,
|
</Popconfirm>,
|
||||||
|
|
@ -240,52 +191,25 @@ export default function TeamsPageH5({ logic }: Props) {
|
||||||
>
|
>
|
||||||
<List.Item.Meta
|
<List.Item.Meta
|
||||||
avatar={
|
avatar={
|
||||||
<div
|
<div className="teams-page-h5-member-avatar">
|
||||||
style={{
|
|
||||||
width: 36,
|
|
||||||
height: 36,
|
|
||||||
borderRadius: 999,
|
|
||||||
background: 'rgba(8, 145, 178, 0.10)',
|
|
||||||
color: 'var(--color-brand)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<UserOutlined />
|
<UserOutlined />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
title={
|
title={
|
||||||
<Space>
|
<Space>
|
||||||
<span style={{ fontWeight: 600, fontSize: 14 }}>{m.name}</span>
|
<span className="teams-page-h5-member-name">{m.name}</span>
|
||||||
<Tag
|
<Tag
|
||||||
bordered={false}
|
bordered={false}
|
||||||
style={{
|
className={`teams-page-h5-tag teams-page-h5-tag-role-${m.role}`}
|
||||||
background:
|
|
||||||
m.role === 'owner'
|
|
||||||
? 'var(--color-warning-soft)'
|
|
||||||
: m.role === 'admin'
|
|
||||||
? 'var(--color-info-soft)'
|
|
||||||
: 'var(--color-surface-2)',
|
|
||||||
color:
|
|
||||||
m.role === 'owner'
|
|
||||||
? 'var(--color-warning)'
|
|
||||||
: m.role === 'admin'
|
|
||||||
? 'var(--color-info)'
|
|
||||||
: 'var(--color-text-secondary)',
|
|
||||||
borderRadius: 999,
|
|
||||||
margin: 0,
|
|
||||||
fontSize: 10,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{m.role}
|
{m.role}
|
||||||
</Tag>
|
</Tag>
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
description={
|
description={
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
<div className="teams-page-h5-member-desc">
|
||||||
<span style={{ fontSize: 12 }}>{m.email}</span>
|
<span className="teams-page-h5-member-email">{m.email}</span>
|
||||||
<span style={{ fontSize: 11, color: 'var(--color-text-tertiary)' }}>
|
<span className="teams-page-h5-member-time">
|
||||||
加入时间 {new Date(m.joinedAt).toLocaleDateString('zh-CN')}
|
加入时间 {new Date(m.joinedAt).toLocaleDateString('zh-CN')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -317,63 +241,27 @@ export default function TeamsPageH5({ logic }: Props) {
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal open={logic.renameOpen} title="重命名团队" onCancel={() => logic.setRenameOpen(false)} footer={null} width="95%" destroyOnHidden>
|
||||||
open={logic.inviteOpen}
|
<Form layout="vertical" onFinish={logic.handleRename} initialValues={{ name: active?.name }}>
|
||||||
title={`📨 邀请加入 ${active?.name}`}
|
<Form.Item name="name" label="新团队名称" rules={[{ required: true }]}>
|
||||||
onCancel={() => {
|
<Input placeholder="输入新的团队名称" autoFocus />
|
||||||
logic.setInviteOpen(false);
|
|
||||||
logic.setLastInviteCode(null);
|
|
||||||
}}
|
|
||||||
width="95%"
|
|
||||||
footer={null}
|
|
||||||
destroyOnHidden
|
|
||||||
>
|
|
||||||
{logic.lastInviteCode ? (
|
|
||||||
<div>
|
|
||||||
<div style={{ marginBottom: 12 }}>邀请码生成成功,请发给受邀者:</div>
|
|
||||||
<Input.TextArea
|
|
||||||
value={logic.lastInviteCode}
|
|
||||||
readOnly
|
|
||||||
autoSize
|
|
||||||
style={{ fontFamily: 'monospace', fontSize: 14 }}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="default"
|
|
||||||
icon={<CopyOutlined />}
|
|
||||||
style={{ marginTop: 12, borderRadius: 8 }}
|
|
||||||
onClick={() => {
|
|
||||||
navigator.clipboard?.writeText(logic.lastInviteCode || '').then(() => message.success('邀请码已复制'));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
复制邀请码
|
|
||||||
</Button>
|
|
||||||
<div style={{ color: 'var(--color-text-secondary)', fontSize: 11, marginTop: 8 }}>
|
|
||||||
受邀者在注册页填入此邀请码即可加入团队
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Form layout="vertical" onFinish={logic.handleInvite}>
|
|
||||||
<Form.Item
|
|
||||||
name="phone"
|
|
||||||
label="限定手机号(可选)"
|
|
||||||
rules={[
|
|
||||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号格式' }
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Input placeholder="只允许该手机号使用此邀请码" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="ttlHours" label="有效期(小时)" initialValue={168}>
|
|
||||||
<Input type="number" placeholder="168 = 7 天" />
|
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Button type="primary" htmlType="submit" block>
|
<Button type="primary" htmlType="submit" block>
|
||||||
生成邀请码
|
确定
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal open={logic.addMemberOpen} title={`向 ${active?.name} 添加成员`} onCancel={() => logic.setAddMemberOpen(false)} footer={null} width="95%" destroyOnHidden>
|
||||||
|
<Form layout="vertical" onFinish={logic.handleAddMember}>
|
||||||
|
<Form.Item name="phone" label="成员手机号" rules={[{ required: true, message: '请输入手机号' }]}>
|
||||||
|
<Input placeholder="输入成员绑定的手机号" autoFocus />
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" block>
|
||||||
|
添加
|
||||||
</Button>
|
</Button>
|
||||||
</Form>
|
</Form>
|
||||||
)}
|
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
|
||||||
<div className="feature-cover">
|
|
||||||
<Empty description="功能规划中,本期不支持" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,382 +0,0 @@
|
||||||
import {
|
|
||||||
CopyOutlined,
|
|
||||||
DeleteOutlined,
|
|
||||||
MailOutlined,
|
|
||||||
PlusOutlined,
|
|
||||||
TeamOutlined,
|
|
||||||
UserOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import { Card, Button, List, Tag, Space, Popconfirm, App as AntApp, Modal, Form, Input, Empty } from 'antd';
|
|
||||||
import { TeamAPI, type Team } from '../../../api';
|
|
||||||
import type { TeamsPageLogicOutput } from '../TeamsPageLogic';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
logic: TeamsPageLogicOutput;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TeamsPageWeb({ logic }: Props) {
|
|
||||||
const { message } = AntApp.useApp();
|
|
||||||
const {
|
|
||||||
list,
|
|
||||||
active,
|
|
||||||
handleDelete,
|
|
||||||
handleRemoveMember,
|
|
||||||
setCreateOpen,
|
|
||||||
} = logic;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="feature-cover-container">
|
|
||||||
<div className="page-container" style={{ maxWidth: 1080 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
borderRadius: 24,
|
|
||||||
padding: '30px 30px 26px',
|
|
||||||
background:
|
|
||||||
'linear-gradient(135deg, rgba(255,255,255,0.98) 0%, rgba(236,253,245,0.92) 42%, rgba(239,246,255,0.96) 100%)',
|
|
||||||
border: '1px solid rgba(8, 145, 178, 0.12)',
|
|
||||||
boxShadow: '0 20px 48px rgba(15, 23, 42, 0.06)',
|
|
||||||
marginBottom: 24,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
gap: 20,
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
marginBottom: 20,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ maxWidth: 620 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 8,
|
|
||||||
padding: '6px 12px',
|
|
||||||
borderRadius: 999,
|
|
||||||
background: 'rgba(255,255,255,0.78)',
|
|
||||||
border: '1px solid rgba(8, 145, 178, 0.10)',
|
|
||||||
color: 'var(--color-text-secondary)',
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 600,
|
|
||||||
marginBottom: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TeamOutlined style={{ color: 'var(--color-brand)' }} />
|
|
||||||
协作组织空间
|
|
||||||
</div>
|
|
||||||
<h1 className="page-title" style={{ marginBottom: 10 }}>团队管理</h1>
|
|
||||||
<div className="page-subtitle" style={{ marginTop: 0, fontSize: 15, lineHeight: 1.75 }}>
|
|
||||||
团队不只是成员列表,更是共享智能体、协同运营和权限分工的组织单元。这里统一查看团队、成员和邀请状态。
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button type="primary" size="large" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)} style={{ borderRadius: 14, height: 46, padding: '0 18px', fontWeight: 600 }}>
|
|
||||||
创建团队
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 14 }}>
|
|
||||||
{[
|
|
||||||
{ label: '团队数量', value: list.length, tone: 'rgba(8, 145, 178, 0.10)', color: 'var(--color-brand)' },
|
|
||||||
{ label: '当前成员数', value: active?.members?.length ?? 0, tone: 'rgba(14, 165, 233, 0.10)', color: 'var(--color-info)' },
|
|
||||||
{ label: '共享智能体', value: active?.agentCount ?? 0, tone: 'rgba(34, 197, 94, 0.10)', color: 'var(--color-success)' },
|
|
||||||
].map((item) => (
|
|
||||||
<div
|
|
||||||
key={item.label}
|
|
||||||
style={{
|
|
||||||
borderRadius: 18,
|
|
||||||
padding: '16px 18px',
|
|
||||||
background: 'rgba(255,255,255,0.72)',
|
|
||||||
border: '1px solid rgba(255,255,255,0.7)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', marginBottom: 10 }}>{item.label}</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
|
|
||||||
<span style={{ fontSize: 30, fontWeight: 700, color: 'var(--color-text)' }}>{item.value}</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
borderRadius: 999,
|
|
||||||
padding: '4px 8px',
|
|
||||||
background: item.tone,
|
|
||||||
color: item.color,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
当前选中
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 22, alignItems: 'stretch' }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 260,
|
|
||||||
flexShrink: 0,
|
|
||||||
background: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(252,252,253,1) 100%)',
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
borderRadius: 22,
|
|
||||||
padding: 14,
|
|
||||||
boxShadow: '0 12px 28px rgba(15, 23, 42, 0.045)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ padding: '8px 10px 14px' }}>
|
|
||||||
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--color-text)', marginBottom: 4 }}>团队列表</div>
|
|
||||||
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', marginBottom: 4 }}>选择一个团队查看成员与邀请</div>
|
|
||||||
</div>
|
|
||||||
{list.length === 0 ? (
|
|
||||||
<Empty description="还没有团队" />
|
|
||||||
) : (
|
|
||||||
<List
|
|
||||||
dataSource={list}
|
|
||||||
renderItem={(item) => (
|
|
||||||
<div
|
|
||||||
className={`nav-item ${active?.id === item.id ? 'active' : ''}`}
|
|
||||||
onClick={async () => {
|
|
||||||
logic.setActive(await TeamAPI.detail(item.id));
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
padding: '10px 12px',
|
|
||||||
borderRadius: 14,
|
|
||||||
cursor: 'pointer',
|
|
||||||
marginBottom: 6,
|
|
||||||
background: active?.id === item.id ? 'rgba(8, 145, 178, 0.10)' : 'transparent',
|
|
||||||
color: active?.id === item.id ? 'var(--color-brand)' : 'var(--color-text-secondary)',
|
|
||||||
fontWeight: active?.id === item.id ? 600 : 500,
|
|
||||||
border: active?.id === item.id ? '1px solid rgba(8, 145, 178, 0.16)' : '1px solid transparent',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ fontSize: 14, marginBottom: 4 }}>{item.name}</div>
|
|
||||||
<div style={{ fontSize: 12, color: active?.id === item.id ? 'var(--color-brand)' : 'var(--color-text-tertiary)' }}>
|
|
||||||
{item.agentCount ?? 0} 个智能体
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
{active ? (
|
|
||||||
<Card
|
|
||||||
style={{ borderRadius: 22, boxShadow: '0 12px 28px rgba(15, 23, 42, 0.045)' }}
|
|
||||||
bodyStyle={{ padding: 22 }}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, flexWrap: 'wrap', marginBottom: 20 }}>
|
|
||||||
<div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', marginBottom: 8 }}>
|
|
||||||
<span style={{ fontSize: 22, fontWeight: 700, color: 'var(--color-text)' }}>{active.name}</span>
|
|
||||||
<Tag bordered={false} style={{ background: 'var(--color-brand-soft)', color: 'var(--color-brand)', borderRadius: 999, margin: 0 }}>{active.myRole}</Tag>
|
|
||||||
<Tag bordered={false} style={{ background: 'var(--color-surface-2)', color: 'var(--color-text-secondary)', borderRadius: 999, margin: 0 }}>{active.agentCount ?? 0} 智能体</Tag>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 13.5, color: 'var(--color-text-secondary)' }}>
|
|
||||||
管理成员权限、邀请新伙伴,并协同维护团队共享的智能体资产。
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Space>
|
|
||||||
{(active.myRole === 'owner' || active.myRole === 'admin') && (
|
|
||||||
<Button icon={<MailOutlined />} onClick={() => logic.setInviteOpen(true)} style={{ borderRadius: 12 }}>
|
|
||||||
生成邀请码
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{active.myRole === 'owner' && (
|
|
||||||
<Popconfirm
|
|
||||||
title="确定删除该团队?团队内的智能体会变成 owner 私有"
|
|
||||||
onConfirm={async () => {
|
|
||||||
await handleDelete(active.id);
|
|
||||||
message.success('已删除');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button danger icon={<DeleteOutlined />} style={{ borderRadius: 12 }}>
|
|
||||||
删除团队
|
|
||||||
</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 12, marginBottom: 20 }}>
|
|
||||||
<div style={{ borderRadius: 16, padding: '14px 16px', background: 'rgba(8, 145, 178, 0.06)', border: '1px solid rgba(8, 145, 178, 0.10)' }}>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 8 }}>成员规模</div>
|
|
||||||
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--color-text)' }}>{active.members?.length || 0}</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ borderRadius: 16, padding: '14px 16px', background: 'rgba(34, 197, 94, 0.06)', border: '1px solid rgba(34, 197, 94, 0.10)' }}>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 8 }}>共享智能体</div>
|
|
||||||
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--color-text)' }}>{active.agentCount ?? 0}</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ borderRadius: 16, padding: '14px 16px', background: 'rgba(249, 115, 22, 0.06)', border: '1px solid rgba(249, 115, 22, 0.10)' }}>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 8 }}>当前身份</div>
|
|
||||||
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--color-text)', textTransform: 'capitalize' }}>{active.myRole}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ fontSize: 16, fontWeight: 700, color: 'var(--color-text)', marginBottom: 14 }}>
|
|
||||||
成员 ({active.members?.length || 0})
|
|
||||||
</div>
|
|
||||||
<List
|
|
||||||
dataSource={active.members || []}
|
|
||||||
renderItem={(m) => (
|
|
||||||
<List.Item
|
|
||||||
style={{ padding: '14px 0' }}
|
|
||||||
actions={
|
|
||||||
(active.myRole === 'owner' || active.myRole === 'admin') && m.role !== 'owner'
|
|
||||||
? [
|
|
||||||
<Popconfirm
|
|
||||||
key="kick"
|
|
||||||
title="移除该成员?"
|
|
||||||
onConfirm={async () => {
|
|
||||||
await handleRemoveMember(m.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button size="small" danger style={{ borderRadius: 10 }}>
|
|
||||||
移除
|
|
||||||
</Button>
|
|
||||||
</Popconfirm>,
|
|
||||||
]
|
|
||||||
: []
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<List.Item.Meta
|
|
||||||
avatar={
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 42,
|
|
||||||
height: 42,
|
|
||||||
borderRadius: 999,
|
|
||||||
background: 'rgba(8, 145, 178, 0.10)',
|
|
||||||
color: 'var(--color-brand)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<UserOutlined />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
title={
|
|
||||||
<Space>
|
|
||||||
<span style={{ fontWeight: 600 }}>{m.name}</span>
|
|
||||||
<Tag
|
|
||||||
bordered={false}
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
m.role === 'owner'
|
|
||||||
? 'var(--color-warning-soft)'
|
|
||||||
: m.role === 'admin'
|
|
||||||
? 'var(--color-info-soft)'
|
|
||||||
: 'var(--color-surface-2)',
|
|
||||||
color:
|
|
||||||
m.role === 'owner'
|
|
||||||
? 'var(--color-warning)'
|
|
||||||
: m.role === 'admin'
|
|
||||||
? 'var(--color-info)'
|
|
||||||
: 'var(--color-text-secondary)',
|
|
||||||
borderRadius: 999,
|
|
||||||
margin: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{m.role}
|
|
||||||
</Tag>
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
description={
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
||||||
<span>{m.email}</span>
|
|
||||||
<span style={{ fontSize: 12, color: 'var(--color-text-tertiary)' }}>
|
|
||||||
加入时间 {new Date(m.joinedAt).toLocaleDateString('zh-CN')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</List.Item>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<Empty description="选择或创建一个团队" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
open={logic.createOpen}
|
|
||||||
title="新建团队"
|
|
||||||
onCancel={() => logic.setCreateOpen(false)}
|
|
||||||
footer={null}
|
|
||||||
destroyOnHidden
|
|
||||||
>
|
|
||||||
<Form layout="vertical" onFinish={logic.handleCreate}>
|
|
||||||
<Form.Item name="name" label="团队名称" rules={[{ required: true }]}>
|
|
||||||
<Input placeholder="如:AI 实验小组" autoFocus />
|
|
||||||
</Form.Item>
|
|
||||||
<Button type="primary" htmlType="submit" block>
|
|
||||||
创建
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
open={logic.inviteOpen}
|
|
||||||
title={`📨 邀请加入 ${active?.name}`}
|
|
||||||
onCancel={() => {
|
|
||||||
logic.setInviteOpen(false);
|
|
||||||
logic.setLastInviteCode(null);
|
|
||||||
}}
|
|
||||||
footer={null}
|
|
||||||
destroyOnHidden
|
|
||||||
>
|
|
||||||
{logic.lastInviteCode ? (
|
|
||||||
<div>
|
|
||||||
<div style={{ marginBottom: 12 }}>邀请码生成成功,请发给受邀者:</div>
|
|
||||||
<Input.TextArea
|
|
||||||
value={logic.lastInviteCode}
|
|
||||||
readOnly
|
|
||||||
autoSize
|
|
||||||
style={{ fontFamily: 'monospace', fontSize: 16 }}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="default"
|
|
||||||
icon={<CopyOutlined />}
|
|
||||||
style={{ marginTop: 12, borderRadius: 10 }}
|
|
||||||
onClick={() => {
|
|
||||||
navigator.clipboard?.writeText(logic.lastInviteCode || '').then(() => message.success('邀请码已复制'));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
复制邀请码
|
|
||||||
</Button>
|
|
||||||
<div style={{ color: 'var(--color-text-secondary)', fontSize: 12, marginTop: 8 }}>
|
|
||||||
受邀者在注册页填入此邀请码即可加入团队
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Form layout="vertical" onFinish={logic.handleInvite}>
|
|
||||||
<Form.Item
|
|
||||||
name="phone"
|
|
||||||
label="限定手机号(可选)"
|
|
||||||
rules={[
|
|
||||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号格式' }
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Input placeholder="只允许该手机号使用此邀请码" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="ttlHours" label="有效期(小时)" initialValue={168}>
|
|
||||||
<Input type="number" placeholder="168 = 7 天" />
|
|
||||||
</Form.Item>
|
|
||||||
<Button type="primary" htmlType="submit" block>
|
|
||||||
生成邀请码
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
</div>
|
|
||||||
<div className="feature-cover">
|
|
||||||
<Empty description="功能规划中,本期不支持" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -6,6 +6,7 @@ import TeamsPageWebDetail from './TeamsPageWebDetail';
|
||||||
import TeamsPageWebHero from './TeamsPageWebHero';
|
import TeamsPageWebHero from './TeamsPageWebHero';
|
||||||
import TeamsPageWebList from './TeamsPageWebList';
|
import TeamsPageWebList from './TeamsPageWebList';
|
||||||
import TeamsPageWebModals from './TeamsPageWebModals';
|
import TeamsPageWebModals from './TeamsPageWebModals';
|
||||||
|
import '../styles/teams-page-web.css';
|
||||||
|
|
||||||
export interface TeamsPageWebVariantProps {
|
export interface TeamsPageWebVariantProps {
|
||||||
logic: TeamsPageLogicOutput;
|
logic: TeamsPageLogicOutput;
|
||||||
|
|
@ -23,9 +24,6 @@ export default function TeamsPageWebBase({ logic, viewport }: TeamsPageWebVarian
|
||||||
</div>
|
</div>
|
||||||
<TeamsPageWebModals logic={logic} />
|
<TeamsPageWebModals logic={logic} />
|
||||||
</div>
|
</div>
|
||||||
<div className="feature-cover">
|
|
||||||
<Empty description="功能规划中,本期不支持" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { DeleteOutlined, MailOutlined, UserOutlined } from '@ant-design/icons';
|
import { DeleteOutlined, EditOutlined, UserAddOutlined, UserOutlined } from '@ant-design/icons';
|
||||||
import { Button, Card, Empty, List, Popconfirm, Space, Tag } from 'antd';
|
import { Button, Card, Empty, List, Popconfirm, Space, Tag } from 'antd';
|
||||||
import type { Team } from '../../../api';
|
import type { Team } from '../../../api';
|
||||||
import type { TeamsPageLogicOutput } from '../TeamsPageLogic';
|
import type { TeamsPageLogicOutput } from '../TeamsPageLogic';
|
||||||
|
|
@ -34,22 +34,26 @@ export default function TeamsPageWebDetail({ logic }: { logic: TeamsPageLogicOut
|
||||||
const canManage = active.myRole === 'owner' || active.myRole === 'admin';
|
const canManage = active.myRole === 'owner' || active.myRole === 'admin';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="teams-page-web-detail-card" styles={{ body: { padding: 22 } }}>
|
<Card className="teams-page-web-detail-card">
|
||||||
<div className="teams-page-web-detail-head">
|
<div className="teams-page-web-detail-head">
|
||||||
<div>
|
<div>
|
||||||
<div className="teams-page-web-active-title-row">
|
<div className="teams-page-web-active-title-row">
|
||||||
<span className="teams-page-web-active-title">{active.name}</span>
|
<span className="teams-page-web-active-title">{active.name}</span>
|
||||||
|
{canManage && (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
onClick={() => logic.setRenameOpen(true)}
|
||||||
|
className="teams-page-web-edit-btn"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Tag bordered={false} className="teams-page-web-tag-brand">{active.myRole}</Tag>
|
<Tag bordered={false} className="teams-page-web-tag-brand">{active.myRole}</Tag>
|
||||||
<Tag bordered={false} className="teams-page-web-tag-neutral">{active.agentCount ?? 0} 智能体</Tag>
|
<Tag bordered={false} className="teams-page-web-tag-neutral">{active.agentCount ?? 0} 智能体</Tag>
|
||||||
</div>
|
</div>
|
||||||
<div className="teams-page-web-section-desc">管理成员权限、邀请新伙伴,并协同维护团队共享的智能体资产。</div>
|
<div className="teams-page-web-section-desc">管理成员权限、邀请新伙伴,并协同维护团队共享的智能体资产。</div>
|
||||||
</div>
|
</div>
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
{canManage && (
|
|
||||||
<Button icon={<MailOutlined />} onClick={() => logic.setInviteOpen(true)} className="teams-page-web-soft-btn">
|
|
||||||
生成邀请码
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{active.myRole === 'owner' && (
|
{active.myRole === 'owner' && (
|
||||||
<Popconfirm title="确定删除该团队?团队内的智能体会变成 owner 私有" onConfirm={() => handleDelete(active.id)}>
|
<Popconfirm title="确定删除该团队?团队内的智能体会变成 owner 私有" onConfirm={() => handleDelete(active.id)}>
|
||||||
<Button danger icon={<DeleteOutlined />} className="teams-page-web-soft-btn">
|
<Button danger icon={<DeleteOutlined />} className="teams-page-web-soft-btn">
|
||||||
|
|
@ -62,26 +66,45 @@ export default function TeamsPageWebDetail({ logic }: { logic: TeamsPageLogicOut
|
||||||
|
|
||||||
<TeamSummary active={active} />
|
<TeamSummary active={active} />
|
||||||
|
|
||||||
|
<div className="teams-page-web-members-title-row">
|
||||||
<div className="teams-page-web-members-title">成员 ({active.members?.length || 0})</div>
|
<div className="teams-page-web-members-title">成员 ({active.members?.length || 0})</div>
|
||||||
<List
|
{canManage && (
|
||||||
dataSource={active.members || []}
|
<Button
|
||||||
renderItem={(member) => (
|
type="primary"
|
||||||
<List.Item
|
size="small"
|
||||||
className="teams-page-web-member-item"
|
ghost
|
||||||
actions={canManage && member.role !== 'owner' ? [
|
icon={<UserAddOutlined />}
|
||||||
<Popconfirm key="kick" title="移除该成员?" onConfirm={() => handleRemoveMember(member.id)}>
|
onClick={() => logic.setAddMemberOpen(true)}
|
||||||
<Button size="small" danger className="teams-page-web-soft-btn">移除</Button>
|
|
||||||
</Popconfirm>,
|
|
||||||
] : []}
|
|
||||||
>
|
>
|
||||||
<List.Item.Meta
|
添加成员
|
||||||
avatar={<div className="teams-page-web-member-avatar"><UserOutlined /></div>}
|
</Button>
|
||||||
title={<Space wrap><span className="teams-page-web-member-name">{member.name}</span><Tag bordered={false} className={roleClass(member.role)}>{member.role}</Tag></Space>}
|
|
||||||
description={<div className="teams-page-web-member-desc"><span>{member.email}</span><span>加入时间 {new Date(member.joinedAt).toLocaleDateString('zh-CN')}</span></div>}
|
|
||||||
/>
|
|
||||||
</List.Item>
|
|
||||||
)}
|
)}
|
||||||
/>
|
</div>
|
||||||
|
|
||||||
|
<div className="teams-page-web-member-list">
|
||||||
|
{(active.members || []).map((member) => (
|
||||||
|
<div className="teams-page-web-member-item" key={member.id}>
|
||||||
|
<div className="teams-page-web-member-info">
|
||||||
|
<div className="teams-page-web-member-avatar">
|
||||||
|
<UserOutlined />
|
||||||
|
</div>
|
||||||
|
<div className="teams-page-web-member-meta">
|
||||||
|
<div className="teams-page-web-member-name-row">
|
||||||
|
<span className="teams-page-web-member-name">{member.name}</span>
|
||||||
|
<Tag bordered={false} className={roleClass(member.role)}>{member.role}</Tag>
|
||||||
|
</div>
|
||||||
|
<div className="teams-page-web-member-email">{member.email}</div>
|
||||||
|
<div className="teams-page-web-member-time">加入时间 {new Date(member.joinedAt).toLocaleDateString('zh-CN')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{canManage && member.role !== 'owner' && (
|
||||||
|
<Popconfirm title="移除该成员?" onConfirm={() => handleRemoveMember(member.id)}>
|
||||||
|
<Button size="small" danger className="teams-page-web-soft-btn">移除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,9 @@ import type { TeamsPageLogicOutput } from '../TeamsPageLogic';
|
||||||
export default function TeamsPageWebHero({ logic }: { logic: TeamsPageLogicOutput }) {
|
export default function TeamsPageWebHero({ logic }: { logic: TeamsPageLogicOutput }) {
|
||||||
const { list, active, setCreateOpen } = logic;
|
const { list, active, setCreateOpen } = logic;
|
||||||
const stats = [
|
const stats = [
|
||||||
{ label: '团队数量', value: list.length, tone: 'rgba(8, 145, 178, 0.10)', color: 'var(--color-brand)' },
|
{ label: '团队数量', value: list.length, type: 'brand' },
|
||||||
{ label: '当前成员数', value: active?.members?.length ?? 0, tone: 'rgba(14, 165, 233, 0.10)', color: 'var(--color-info)' },
|
{ label: '当前成员数', value: active?.members?.length ?? 0, type: 'info' },
|
||||||
{ label: '共享智能体', value: active?.agentCount ?? 0, tone: 'rgba(34, 197, 94, 0.10)', color: 'var(--color-success)' },
|
{ label: '共享智能体', value: active?.agentCount ?? 0, type: 'success' },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -34,7 +34,7 @@ export default function TeamsPageWebHero({ logic }: { logic: TeamsPageLogicOutpu
|
||||||
<div className="teams-page-web-stat-label">{item.label}</div>
|
<div className="teams-page-web-stat-label">{item.label}</div>
|
||||||
<div className="teams-page-web-stat-row">
|
<div className="teams-page-web-stat-row">
|
||||||
<span className="teams-page-web-stat-value">{item.value}</span>
|
<span className="teams-page-web-stat-value">{item.value}</span>
|
||||||
<span className="teams-page-web-stat-chip" style={{ background: item.tone, color: item.color }}>
|
<span className={`teams-page-web-stat-chip teams-page-web-stat-chip-${item.type}`}>
|
||||||
当前选中
|
当前选中
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -14,19 +14,21 @@ export default function TeamsPageWebList({ logic }: { logic: TeamsPageLogicOutpu
|
||||||
{list.length === 0 ? (
|
{list.length === 0 ? (
|
||||||
<Empty description="还没有团队" />
|
<Empty description="还没有团队" />
|
||||||
) : (
|
) : (
|
||||||
<List
|
<div className="teams-page-web-list-items">
|
||||||
dataSource={list}
|
{list.map((item) => (
|
||||||
renderItem={(item) => (
|
|
||||||
<button
|
<button
|
||||||
|
key={item.id}
|
||||||
className={`teams-page-web-nav-item ${active?.id === item.id ? 'active' : ''}`}
|
className={`teams-page-web-nav-item ${active?.id === item.id ? 'active' : ''}`}
|
||||||
onClick={async () => setActive(await TeamAPI.detail(item.id))}
|
onClick={async () => setActive(await TeamAPI.detail(item.id))}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<span>{item.name}</span>
|
<div className="teams-page-web-nav-item-content">
|
||||||
<small>{item.agentCount ?? 0} 个智能体</small>
|
<span className="teams-page-web-nav-item-name">{item.name}</span>
|
||||||
|
<small className="teams-page-web-nav-item-count">{item.agentCount ?? 0} 个智能体</small>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)}
|
))}
|
||||||
/>
|
</div>
|
||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { CopyOutlined } from '@ant-design/icons';
|
|
||||||
import { App as AntApp, Button, Form, Input, Modal } from 'antd';
|
import { App as AntApp, Button, Form, Input, Modal } from 'antd';
|
||||||
import type { TeamsPageLogicOutput } from '../TeamsPageLogic';
|
import type { TeamsPageLogicOutput } from '../TeamsPageLogic';
|
||||||
|
|
||||||
|
|
@ -19,42 +18,26 @@ export default function TeamsPageWebModals({ logic }: { logic: TeamsPageLogicOut
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal open={logic.renameOpen} title="重命名团队" onCancel={() => logic.setRenameOpen(false)} footer={null} destroyOnHidden>
|
||||||
open={logic.inviteOpen}
|
<Form layout="vertical" onFinish={logic.handleRename} initialValues={{ name: active?.name }}>
|
||||||
title={`邀请加入 ${active?.name}`}
|
<Form.Item name="name" label="新团队名称" rules={[{ required: true }]}>
|
||||||
onCancel={() => {
|
<Input placeholder="输入新的团队名称" autoFocus />
|
||||||
logic.setInviteOpen(false);
|
|
||||||
logic.setLastInviteCode(null);
|
|
||||||
}}
|
|
||||||
footer={null}
|
|
||||||
destroyOnHidden
|
|
||||||
>
|
|
||||||
{logic.lastInviteCode ? (
|
|
||||||
<div>
|
|
||||||
<div className="teams-page-web-invite-label">邀请码生成成功,请发给受邀者:</div>
|
|
||||||
<Input.TextArea value={logic.lastInviteCode} readOnly autoSize className="teams-page-web-invite-code" />
|
|
||||||
<Button
|
|
||||||
icon={<CopyOutlined />}
|
|
||||||
className="teams-page-web-copy-btn"
|
|
||||||
onClick={() => navigator.clipboard?.writeText(logic.lastInviteCode || '').then(() => message.success('邀请码已复制'))}
|
|
||||||
>
|
|
||||||
复制邀请码
|
|
||||||
</Button>
|
|
||||||
<div className="teams-page-web-invite-tip">受邀者在注册页填入此邀请码即可加入团队</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Form layout="vertical" onFinish={logic.handleInvite}>
|
|
||||||
<Form.Item name="email" label="限定邮箱(可选)">
|
|
||||||
<Input placeholder="只允许该邮箱使用此邀请码" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="ttlHours" label="有效期(小时)" initialValue={168}>
|
|
||||||
<Input type="number" placeholder="168 = 7 天" />
|
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Button type="primary" htmlType="submit" block>
|
<Button type="primary" htmlType="submit" block>
|
||||||
生成邀请码
|
确定
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal open={logic.addMemberOpen} title={`向 ${active?.name} 添加成员`} onCancel={() => logic.setAddMemberOpen(false)} footer={null} destroyOnHidden>
|
||||||
|
<Form layout="vertical" onFinish={logic.handleAddMember}>
|
||||||
|
<Form.Item name="phone" label="成员手机号" rules={[{ required: true, message: '请输入手机号' }]}>
|
||||||
|
<Input placeholder="输入成员绑定的手机号" autoFocus />
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" block>
|
||||||
|
添加
|
||||||
</Button>
|
</Button>
|
||||||
</Form>
|
</Form>
|
||||||
)}
|
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,326 @@
|
||||||
|
.h5-page-container {
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-hero {
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 20px 16px 18px;
|
||||||
|
background: linear-gradient(135deg, rgba(255,255,255,0.98) 0%, rgba(236,253,245,0.92) 42%, rgba(239,246,255,0.96) 100%);
|
||||||
|
border: 1px solid rgba(8, 145, 178, 0.12);
|
||||||
|
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-hero-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255,255,255,0.78);
|
||||||
|
border: 1px solid rgba(8, 145, 178, 0.10);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-title {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-subtitle {
|
||||||
|
margin-top: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-create-btn {
|
||||||
|
border-radius: 10px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-stat-card {
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: rgba(255,255,255,0.72);
|
||||||
|
border: 1px solid rgba(255,255,255,0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-stat-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-stat-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-stat-value {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-stat-chip {
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-stat-chip-brand {
|
||||||
|
background: var(--color-brand-soft);
|
||||||
|
color: var(--color-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-stat-chip-info {
|
||||||
|
background: var(--color-info-soft);
|
||||||
|
color: var(--color-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-stat-chip-success {
|
||||||
|
background: var(--color-success-soft);
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-list-panel {
|
||||||
|
background: linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(252,252,253,1) 100%);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-list-head {
|
||||||
|
padding: 4px 6px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-section-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-section-desc {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-nav-item {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-nav-item.active {
|
||||||
|
background: rgba(8, 145, 178, 0.10);
|
||||||
|
color: var(--color-brand);
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid rgba(8, 145, 178, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-nav-item-name {
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-nav-item-count {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-nav-item.active .teams-page-h5-nav-item-count {
|
||||||
|
color: var(--color-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-detail-card {
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.045);
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-web-soft-btn {
|
||||||
|
border-radius: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-detail-card .ant-card-body {
|
||||||
|
padding: 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-detail-head {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-active-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-active-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-edit-btn {
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-tag {
|
||||||
|
border-radius: 999px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-tag-brand {
|
||||||
|
background: var(--color-brand-soft);
|
||||||
|
color: var(--color-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-tag-neutral {
|
||||||
|
background: var(--color-surface-2);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-mini-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-mini-card {
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-mini-card-brand {
|
||||||
|
background: rgba(8, 145, 178, 0.06);
|
||||||
|
border: 1px solid rgba(8, 145, 178, 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-mini-card-success {
|
||||||
|
background: rgba(34, 197, 94, 0.06);
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-mini-card-warning {
|
||||||
|
background: rgba(249, 115, 22, 0.06);
|
||||||
|
border: 1px solid rgba(249, 115, 22, 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-mini-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-mini-value {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-members-title-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-members-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-member-item {
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-member-avatar {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(8, 145, 178, 0.10);
|
||||||
|
color: var(--color-brand);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-member-avatar-icon {
|
||||||
|
color: var(--color-brand);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-member-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-member-desc {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-member-email {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-member-time {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-tag-role-owner {
|
||||||
|
background: var(--color-warning-soft);
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-tag-role-admin {
|
||||||
|
background: var(--color-info-soft);
|
||||||
|
color: var(--color-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-h5-tag-role-member {
|
||||||
|
background: var(--color-surface-2);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
@ -33,6 +33,8 @@
|
||||||
|
|
||||||
.teams-page-web-mini-card {
|
.teams-page-web-mini-card {
|
||||||
border: 1px solid rgba(8, 145, 178, 0.1);
|
border: 1px solid rgba(8, 145, 178, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.teams-page-web-mini-brand {
|
.teams-page-web-mini-brand {
|
||||||
|
|
@ -55,51 +57,83 @@
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.teams-page-web-members-title {
|
.teams-page-web-detail-card .ant-card-body {
|
||||||
margin-bottom: 14px;
|
padding: 24px !important;
|
||||||
font-size: 16px;
|
}
|
||||||
|
|
||||||
|
.teams-page-web-members-title-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 24px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid var(--color-border-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-web-member-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.teams-page-web-member-item {
|
.teams-page-web-member-item {
|
||||||
padding: 14px 0;
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 0;
|
||||||
|
border-bottom: 1px solid var(--color-border-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-web-member-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-web-member-info {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.teams-page-web-member-avatar {
|
.teams-page-web-member-avatar {
|
||||||
width: 42px;
|
width: 44px;
|
||||||
height: 42px;
|
height: 44px;
|
||||||
display: grid;
|
display: flex;
|
||||||
place-items: center;
|
align-items: center;
|
||||||
border-radius: 999px;
|
justify-content: center;
|
||||||
background: rgba(8, 145, 178, 0.1);
|
border-radius: 12px;
|
||||||
|
background: var(--color-brand-soft);
|
||||||
color: var(--color-brand);
|
color: var(--color-brand);
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-web-member-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-web-member-name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.teams-page-web-member-name {
|
.teams-page-web-member-name {
|
||||||
|
font-size: 15px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.teams-page-web-member-desc {
|
.teams-page-web-member-email {
|
||||||
display: grid;
|
font-size: 13px;
|
||||||
gap: 4px;
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-web-member-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.teams-page-web-empty-detail {
|
.teams-page-web-empty-detail {
|
||||||
align-self: center;
|
align-self: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.teams-page-web-invite-label {
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.teams-page-web-invite-code {
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.teams-page-web-copy-btn {
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.teams-page-web-invite-tip {
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -44,20 +44,35 @@
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.teams-page-web-badge .anticon {
|
||||||
|
color: var(--color-brand);
|
||||||
|
}
|
||||||
|
|
||||||
.teams-page-web-title {
|
.teams-page-web-title {
|
||||||
margin-bottom: 10px;
|
margin-bottom: 8px;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 850;
|
||||||
|
color: var(--color-text);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.teams-page-web-subtitle {
|
.teams-page-web-subtitle {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
font-size: 15px;
|
font-size: 14.5px;
|
||||||
line-height: 1.75;
|
line-height: 1.6;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
max-width: 580px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.teams-page-web-create-btn,
|
.teams-page-web-create-btn {
|
||||||
.teams-page-web-soft-btn,
|
border-radius: 14px !important;
|
||||||
.teams-page-web-copy-btn {
|
height: 46px !important;
|
||||||
border-radius: 12px;
|
padding: 0 18px !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-web-soft-btn {
|
||||||
|
border-radius: 12px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.teams-page-web-stats-grid,
|
.teams-page-web-stats-grid,
|
||||||
|
|
@ -69,25 +84,19 @@
|
||||||
|
|
||||||
.teams-page-web-stats-grid {
|
.teams-page-web-stats-grid {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
margin-top: 20px;
|
margin-top: 24px;
|
||||||
}
|
gap: 16px; /* Optimized gap for stats grid */
|
||||||
|
|
||||||
.teams-page-web-stat-card,
|
|
||||||
.teams-page-web-mini-card {
|
|
||||||
border-radius: 18px;
|
|
||||||
padding: 16px 18px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.teams-page-web-stat-card {
|
.teams-page-web-stat-card {
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 16px 18px;
|
||||||
background: rgba(255, 255, 255, 0.72);
|
background: rgba(255, 255, 255, 0.72);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.7);
|
border: 1px solid rgba(255, 255, 255, 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
.teams-page-web-stat-label,
|
.teams-page-web-stat-label,
|
||||||
.teams-page-web-section-desc,
|
.teams-page-web-section-desc {
|
||||||
.teams-page-web-mini-label,
|
|
||||||
.teams-page-web-member-desc,
|
|
||||||
.teams-page-web-invite-tip {
|
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
}
|
}
|
||||||
|
|
@ -102,14 +111,30 @@
|
||||||
.teams-page-web-stat-value {
|
.teams-page-web-stat-value {
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
font-size: 30px;
|
font-size: 30px;
|
||||||
font-weight: 800;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.teams-page-web-stat-chip {
|
.teams-page-web-stat-chip {
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 4px 8px;
|
padding: 4px 10px;
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-web-stat-chip-brand {
|
||||||
|
background: var(--color-brand-soft);
|
||||||
|
color: var(--color-brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-web-stat-chip-info {
|
||||||
|
background: var(--color-info-soft);
|
||||||
|
color: var(--color-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-web-stat-chip-success {
|
||||||
|
background: var(--color-success-soft);
|
||||||
|
color: var(--color-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.teams-page-web-main-grid {
|
.teams-page-web-main-grid {
|
||||||
|
|
@ -144,29 +169,56 @@
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.teams-page-web-list-items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.teams-page-web-nav-item {
|
.teams-page-web-nav-item {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: grid;
|
|
||||||
gap: 4px;
|
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 14px;
|
border-radius: 12px;
|
||||||
padding: 10px 12px;
|
padding: 10px 14px;
|
||||||
margin-bottom: 6px;
|
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-web-nav-item:hover {
|
||||||
|
background: var(--color-surface-2);
|
||||||
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.teams-page-web-nav-item.active {
|
.teams-page-web-nav-item.active {
|
||||||
background: rgba(8, 145, 178, 0.1);
|
background: var(--color-brand-soft);
|
||||||
border-color: rgba(8, 145, 178, 0.16);
|
border-color: rgba(8, 145, 178, 0.12);
|
||||||
color: var(--color-brand);
|
color: var(--color-brand);
|
||||||
font-weight: 700;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.teams-page-web-nav-item small {
|
.teams-page-web-nav-item-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-web-nav-item-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-web-nav-item-count {
|
||||||
|
font-size: 12px;
|
||||||
color: var(--color-text-tertiary);
|
color: var(--color-text-tertiary);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-page-web-nav-item.active .teams-page-web-nav-item-count {
|
||||||
|
color: var(--color-brand);
|
||||||
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.teams-page-web-detail-head {
|
.teams-page-web-detail-head {
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ export function useChatPageLogic() {
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [id, roomId]);
|
}, [id, roomId]);
|
||||||
|
|
||||||
const { agent, agentList, messages, setMessages, branches, setBranches, loadMessages } = useChatData({
|
const { agent, agentList, messages, setMessages, branches, setBranches, loadMessages, roomInvalidTick } = useChatData({
|
||||||
agentId: id,
|
agentId: id,
|
||||||
roomId,
|
roomId,
|
||||||
highlightId,
|
highlightId,
|
||||||
|
|
@ -116,6 +116,21 @@ export function useChatPageLogic() {
|
||||||
abort: () => abortRef.current?.abort(),
|
abort: () => abortRef.current?.abort(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 监听会话失效状态,自动创建新会话
|
||||||
|
useEffect(() => {
|
||||||
|
if (roomInvalidTick > 0 && id) {
|
||||||
|
SessionAPI.create(id).then((created) => {
|
||||||
|
setRoomId(created.id);
|
||||||
|
setHighlightId(null);
|
||||||
|
setMessages(() => []);
|
||||||
|
setBranches({});
|
||||||
|
message.info('当前会话不可访问,已为您创建新会话');
|
||||||
|
}).catch((e) => {
|
||||||
|
message.error('创建房间失败:' + (e?.message ?? e));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [roomInvalidTick, id]);
|
||||||
|
|
||||||
const sender = useChatSender({
|
const sender = useChatSender({
|
||||||
agentId: id,
|
agentId: id,
|
||||||
agent,
|
agent,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,193 @@
|
||||||
|
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
|
import { App as AntApp, Empty, Modal } from 'antd';
|
||||||
|
import type { ChatPageLogicOutput } from './ChatPageLogic';
|
||||||
|
import { markdownToPlainText } from './utils/copy';
|
||||||
|
import { useChatPageLogic } from './ChatPageLogic';
|
||||||
|
import ChatBody from './components/ChatBody';
|
||||||
|
import ChatDrawers from './components/ChatDrawers';
|
||||||
|
import ChatHeader from './components/ChatHeader';
|
||||||
|
import ChatInput from './components/ChatInput';
|
||||||
|
import ChatOutline from './components/ChatOutline';
|
||||||
|
import type { ModelOverrides } from '../../api';
|
||||||
|
import { useAuth } from '../../store/auth';
|
||||||
|
import './styles/chat-page-pure.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 纯会话页面组件
|
||||||
|
* 仅保留聊天主区域(section.chat-main),去掉全局 Sidebar 和 AgentSidebar(chat-side)
|
||||||
|
* 会话功能与原聊天页面完全一致
|
||||||
|
*/
|
||||||
|
export default function ChatPagePure() {
|
||||||
|
const logic = useChatPageLogic();
|
||||||
|
const { message } = AntApp.useApp();
|
||||||
|
const { logout } = useAuth();
|
||||||
|
const [outlineCollapsed, setOutlineCollapsed] = useState(true);
|
||||||
|
const isAutoScrolling = useRef(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
roomId,
|
||||||
|
highlightId,
|
||||||
|
historyDrawerOpen,
|
||||||
|
mcpDrawerOpen,
|
||||||
|
paramsDrawerOpen,
|
||||||
|
tplDrawerOpen,
|
||||||
|
agent,
|
||||||
|
agentList,
|
||||||
|
messages,
|
||||||
|
branches,
|
||||||
|
bodyRef,
|
||||||
|
sender,
|
||||||
|
navigate,
|
||||||
|
overrides,
|
||||||
|
setOverrides,
|
||||||
|
setRoomId,
|
||||||
|
setHighlightId,
|
||||||
|
setHistoryDrawerOpen,
|
||||||
|
setMcpDrawerOpen,
|
||||||
|
setParamsDrawerOpen,
|
||||||
|
setTplDrawerOpen,
|
||||||
|
handleNewSession,
|
||||||
|
} = logic;
|
||||||
|
|
||||||
|
// 初始化时,如果有历史消息,激活最后一条 Agent 消息
|
||||||
|
useEffect(() => {
|
||||||
|
if (messages.length > 0 && !highlightId) {
|
||||||
|
const agentMsgs = messages.filter(m => m.role === 'assistant' || m.role === 'agent' || m.speaker?.type === 'agent');
|
||||||
|
if (agentMsgs.length > 0) {
|
||||||
|
setHighlightId(agentMsgs[agentMsgs.length - 1].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [messages.length]);
|
||||||
|
|
||||||
|
const handleJump = useCallback((msgId: string) => {
|
||||||
|
isAutoScrolling.current = true;
|
||||||
|
setHighlightId(msgId);
|
||||||
|
const el = document.getElementById('msg-' + msgId);
|
||||||
|
if (el) {
|
||||||
|
el.scrollIntoView({ block: 'start', behavior: 'smooth' });
|
||||||
|
setTimeout(() => {
|
||||||
|
isAutoScrolling.current = false;
|
||||||
|
}, 800);
|
||||||
|
}
|
||||||
|
}, [setHighlightId]);
|
||||||
|
|
||||||
|
// 退出登录
|
||||||
|
const handleLogout = () => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确定要退出登录吗?',
|
||||||
|
onOk: async () => {
|
||||||
|
await logout();
|
||||||
|
window.location.href = '/login';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chat-pure-shell">
|
||||||
|
<section className="chat-main">
|
||||||
|
{!agent ? (
|
||||||
|
<div className="chat-empty-state">
|
||||||
|
<Empty description="请选择一个智能体开始对话" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="chat-content-layout">
|
||||||
|
<div className="chat-conversation-panel">
|
||||||
|
<ChatHeader
|
||||||
|
agent={agent}
|
||||||
|
useStream={sender.useStream}
|
||||||
|
setUseStream={sender.setUseStream}
|
||||||
|
onOpenHistory={() => setHistoryDrawerOpen(true)}
|
||||||
|
onOpenParams={() => setParamsDrawerOpen(true)}
|
||||||
|
onOpenMcp={() => setMcpDrawerOpen(true)}
|
||||||
|
onManageAgent={() => navigate(`/agents/${id}`)}
|
||||||
|
onClear={sender.handleClear}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ChatBody
|
||||||
|
bodyRef={bodyRef}
|
||||||
|
agent={agent}
|
||||||
|
agentList={agentList}
|
||||||
|
currentAgentId={id!}
|
||||||
|
messages={messages}
|
||||||
|
branches={branches}
|
||||||
|
highlightId={highlightId}
|
||||||
|
sending={sender.sending}
|
||||||
|
streaming={sender.streaming}
|
||||||
|
onRegenerate={sender.handleRegenerate}
|
||||||
|
onSwitchBranch={sender.handleSwitchBranch}
|
||||||
|
scrollBottom={logic.scrollBottom}
|
||||||
|
initialScrollDoneRef={logic.initialScrollDoneRef}
|
||||||
|
onCopy={(text, mode) => {
|
||||||
|
const content = mode === 'markdown' ? text : markdownToPlainText(text);
|
||||||
|
navigator.clipboard?.writeText(content).then(() => {
|
||||||
|
message.success(mode === 'markdown' ? '已复制(Markdown)' : '已复制(纯文本)');
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ChatInput
|
||||||
|
input={sender.input}
|
||||||
|
setInput={sender.setInput}
|
||||||
|
sending={sender.sending}
|
||||||
|
attachments={sender.attachments}
|
||||||
|
setAttachments={sender.setAttachments}
|
||||||
|
imageUrls={sender.imageUrls}
|
||||||
|
setImageUrls={sender.setImageUrls}
|
||||||
|
onSend={sender.handleSend}
|
||||||
|
onStop={sender.handleStop}
|
||||||
|
onAttach={sender.handleAttach}
|
||||||
|
onOpenTpl={() => setTplDrawerOpen(true)}
|
||||||
|
modelOptions={sender.modelOptions}
|
||||||
|
activeModelValue={sender.activeModelValue}
|
||||||
|
onChangeModel={(modelId) => {
|
||||||
|
const picked = sender.modelOptions.find((o) => o.value === modelId);
|
||||||
|
setOverrides((o: ModelOverrides) => ({
|
||||||
|
...o,
|
||||||
|
model_id: modelId,
|
||||||
|
model: picked?.label ?? o.model
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
agentList={agentList}
|
||||||
|
onInsertMention={() => {}}
|
||||||
|
onOpenHistory={() => setHistoryDrawerOpen(true)}
|
||||||
|
onNewSession={handleNewSession}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ChatOutline
|
||||||
|
messages={messages}
|
||||||
|
activeId={highlightId}
|
||||||
|
collapsed={outlineCollapsed}
|
||||||
|
onToggleCollapse={() => setOutlineCollapsed(!outlineCollapsed)}
|
||||||
|
onJump={handleJump}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<ChatDrawers
|
||||||
|
agentId={id}
|
||||||
|
agent={agent}
|
||||||
|
input={sender.input}
|
||||||
|
setInput={(updater) => sender.setInput(updater)}
|
||||||
|
overrides={overrides}
|
||||||
|
setOverrides={setOverrides}
|
||||||
|
roomId={roomId}
|
||||||
|
setRoomId={setRoomId}
|
||||||
|
setHighlightId={setHighlightId}
|
||||||
|
sessionRefresh={sender.sessionRefresh}
|
||||||
|
mcpDrawerOpen={mcpDrawerOpen}
|
||||||
|
setMcpDrawerOpen={setMcpDrawerOpen}
|
||||||
|
tplDrawerOpen={tplDrawerOpen}
|
||||||
|
setTplDrawerOpen={setTplDrawerOpen}
|
||||||
|
paramsDrawerOpen={paramsDrawerOpen}
|
||||||
|
setParamsDrawerOpen={setParamsDrawerOpen}
|
||||||
|
historyDrawerOpen={historyDrawerOpen}
|
||||||
|
setHistoryDrawerOpen={setHistoryDrawerOpen}
|
||||||
|
notify={{ success: (t) => message.success(t) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,28 +1,159 @@
|
||||||
|
.chat-side {
|
||||||
|
width: 300px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-right: 1px solid var(--color-border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-side.is-collapsed {
|
||||||
|
width: 68px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-agent-sidebar-toggle {
|
||||||
|
position: absolute;
|
||||||
|
right: -12px;
|
||||||
|
top: 24px;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 1001;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-agent-sidebar-toggle:hover {
|
||||||
|
color: var(--color-brand);
|
||||||
|
border-color: var(--color-brand);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-side.is-collapsed .chat-agent-sidebar-header {
|
||||||
|
padding: 16px 8px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-side.is-collapsed .chat-agent-create-btn {
|
||||||
|
padding: 0 !important;
|
||||||
|
width: 40px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-side.is-collapsed .chat-agent-sidebar-list {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-side.is-collapsed .chat-agent-item {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-side.is-collapsed .chat-agent-item.active {
|
||||||
|
background: var(--color-surface-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-side.is-collapsed .chat-agent-item.active::after {
|
||||||
|
right: 4px;
|
||||||
|
bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-side.is-full {
|
.chat-side.is-full {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-agent-sidebar-header {
|
.chat-agent-sidebar-header {
|
||||||
padding: 16px;
|
padding: 16px 16px 8px;
|
||||||
border-bottom: 1px solid var(--color-border);
|
}
|
||||||
|
|
||||||
|
.chat-agent-create-btn {
|
||||||
|
height: 40px !important;
|
||||||
|
border-radius: 12px !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
background: var(--color-brand) !important;
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: 0 4px 12px rgba(79, 209, 197, 0.2) !important;
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
gap: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-agent-create-btn:hover {
|
||||||
|
background: var(--color-brand-hover) !important;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-agent-tabs {
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-agent-tabs .ant-tabs-nav {
|
||||||
|
margin-bottom: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-agent-tabs .ant-tabs-tab {
|
||||||
|
padding: 8px 4px !important;
|
||||||
|
font-size: 13px !important;
|
||||||
|
color: var(--color-text-secondary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-agent-tabs .ant-tabs-tab-active .ant-tabs-tab-btn {
|
||||||
|
color: var(--color-text) !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-agent-tabs .ant-tabs-ink-bar {
|
||||||
|
background: var(--color-brand) !important;
|
||||||
|
height: 3px !important;
|
||||||
|
border-radius: 3px 3px 0 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-agent-sidebar-list {
|
.chat-agent-sidebar-list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 8px 0;
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-agent-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-agent-group-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
padding: 0 8px 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-agent-group-count {
|
||||||
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-agent-item {
|
.chat-agent-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 10px 16px;
|
padding: 8px 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: transparent;
|
border-radius: 12px;
|
||||||
border-left: 3px solid transparent;
|
margin-bottom: 2px;
|
||||||
transition: background 0.2s;
|
transition: all 0.2s;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-agent-item:hover {
|
.chat-agent-item:hover {
|
||||||
|
|
@ -30,22 +161,33 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-agent-item.active {
|
.chat-agent-item.active {
|
||||||
background: var(--color-surface-2);
|
background: var(--color-surface-3);
|
||||||
border-left: 3px solid var(--color-brand);
|
border-left: 3px solid #5CCFC4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-agent-item.active::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-agent-avatar-wrap {
|
.chat-agent-avatar-wrap {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
border-radius: 50%;
|
border-radius: 10px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-agent-avatar-img {
|
.chat-agent-avatar-img {
|
||||||
|
|
@ -60,7 +202,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-agent-name {
|
.chat-agent-name {
|
||||||
font-size: 14px;
|
font-size: 13.5px;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
|
@ -77,8 +219,7 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: 200px;
|
padding: 40px 20px;
|
||||||
padding: 20px;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -90,5 +231,5 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-agent-loading-text {
|
.chat-agent-loading-text {
|
||||||
margin-top: 8px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Button, Spin, Empty } from 'antd';
|
import { Button, Spin, Empty, Tabs, Tooltip } from 'antd';
|
||||||
import { MessageOutlined } from '@ant-design/icons';
|
import { PlusOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
import type { Agent } from '../../../api';
|
import type { Agent } from '../../../api';
|
||||||
import './AgentSidebar.css';
|
import './AgentSidebar.css';
|
||||||
|
|
||||||
|
|
@ -12,9 +12,20 @@ export default function AgentSidebar(props: {
|
||||||
onCreate: () => void;
|
onCreate: () => void;
|
||||||
onSelect: (agentId: string) => void;
|
onSelect: (agentId: string) => void;
|
||||||
isSidebar?: boolean;
|
isSidebar?: boolean;
|
||||||
|
collapsed?: boolean;
|
||||||
|
onToggleCollapse?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { agentList, activeAgentId, onCreate, onSelect, isSidebar = true } = props;
|
const {
|
||||||
|
agentList,
|
||||||
|
activeAgentId,
|
||||||
|
onCreate,
|
||||||
|
onSelect,
|
||||||
|
isSidebar = true,
|
||||||
|
collapsed = false,
|
||||||
|
onToggleCollapse
|
||||||
|
} = props;
|
||||||
const [loadingTimeout, setLoadingTimeout] = useState(false);
|
const [loadingTimeout, setLoadingTimeout] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState('mine');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (agentList.length > 0) return;
|
if (agentList.length > 0) return;
|
||||||
|
|
@ -26,18 +37,77 @@ export default function AgentSidebar(props: {
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [agentList.length]);
|
}, [agentList.length]);
|
||||||
|
|
||||||
|
const filteredList = useMemo(() => {
|
||||||
|
if (activeTab === 'public') return agentList.filter(a => a.visibility === 'public');
|
||||||
|
if (activeTab === 'private') return agentList.filter(a => a.visibility === 'private');
|
||||||
|
return agentList; // 'mine' tab shows all for now or filter by owner_id if available
|
||||||
|
}, [agentList, activeTab]);
|
||||||
|
|
||||||
|
// 模拟分组逻辑
|
||||||
|
const groupedAgents = useMemo(() => {
|
||||||
|
const groups: Record<string, Agent[]> = {
|
||||||
|
'我的智能体': [],
|
||||||
|
'推荐': []
|
||||||
|
};
|
||||||
|
|
||||||
|
filteredList.forEach(a => {
|
||||||
|
if (a.visibility === 'public') {
|
||||||
|
groups['推荐'].push(a);
|
||||||
|
} else {
|
||||||
|
groups['我的智能体'].push(a);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.entries(groups).filter(([_, list]) => list.length > 0);
|
||||||
|
}, [filteredList]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={`chat-side${isSidebar ? '' : ' is-full'}`}>
|
<aside className={`chat-side${isSidebar ? '' : ' is-full'}${collapsed ? ' is-collapsed' : ''}`}>
|
||||||
|
{onToggleCollapse && (
|
||||||
|
<div className="chat-agent-sidebar-toggle" onClick={onToggleCollapse}>
|
||||||
|
{collapsed ? <RightOutlined /> : <LeftOutlined />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="chat-agent-sidebar-header">
|
<div className="chat-agent-sidebar-header">
|
||||||
<Button block type="dashed" onClick={onCreate}>
|
<Button
|
||||||
+ 创建智能体
|
block
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={onCreate}
|
||||||
|
className="chat-agent-create-btn"
|
||||||
|
>
|
||||||
|
{!collapsed && '创建智能体'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="chat-agent-tabs">
|
||||||
|
<Tabs
|
||||||
|
activeKey={activeTab}
|
||||||
|
onChange={setActiveTab}
|
||||||
|
items={[
|
||||||
|
{ key: 'mine', label: '我的' },
|
||||||
|
{ key: 'public', label: '公开' },
|
||||||
|
{ key: 'private', label: '私有' }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="chat-agent-sidebar-list">
|
<div className="chat-agent-sidebar-list">
|
||||||
{agentList.length > 0 ? (
|
{agentList.length > 0 ? (
|
||||||
agentList.map((a) => {
|
groupedAgents.map(([groupName, list]) => (
|
||||||
|
<div key={groupName} className="chat-agent-group">
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="chat-agent-group-label">
|
||||||
|
<span>{groupName}</span>
|
||||||
|
<span className="chat-agent-group-count">{list.length}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{list.map((a) => {
|
||||||
const isActive = a.id === activeAgentId;
|
const isActive = a.id === activeAgentId;
|
||||||
return (
|
const content = (
|
||||||
<div
|
<div
|
||||||
key={a.id}
|
key={a.id}
|
||||||
onClick={() => onSelect(a.id)}
|
onClick={() => onSelect(a.id)}
|
||||||
|
|
@ -53,29 +123,41 @@ export default function AgentSidebar(props: {
|
||||||
(a.name?.charAt(0) || '?').toUpperCase()
|
(a.name?.charAt(0) || '?').toUpperCase()
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{!collapsed && (
|
||||||
<div className="chat-agent-info">
|
<div className="chat-agent-info">
|
||||||
<div className="chat-agent-name">
|
<div className="chat-agent-name">{a.name}</div>
|
||||||
{a.name}
|
|
||||||
{!isSidebar && <MessageOutlined style={{marginLeft: 12}} />}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
|
||||||
|
if (collapsed) {
|
||||||
|
return (
|
||||||
|
<Tooltip key={a.id} title={a.name} placement="right">
|
||||||
|
{content}
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
) : !loadingTimeout ? (
|
) : !loadingTimeout ? (
|
||||||
<div className="chat-agent-sidebar-status">
|
<div className="chat-agent-sidebar-status">
|
||||||
<Spin />
|
<Spin />
|
||||||
<div className="chat-agent-loading-text">正在加载智能体...</div>
|
{!collapsed && <div className="chat-agent-loading-text">正在加载智能体...</div>}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="chat-agent-sidebar-status">
|
<div className="chat-agent-sidebar-status">
|
||||||
<Empty
|
<Empty
|
||||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
description={
|
description={
|
||||||
|
!collapsed && (
|
||||||
<span className="chat-agent-empty-text">
|
<span className="chat-agent-empty-text">
|
||||||
暂无智能体<br />
|
暂无智能体<br />
|
||||||
点击上方按钮开始创建
|
点击上方按钮开始创建
|
||||||
</span>
|
</span>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
.chat-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--color-bg);
|
||||||
|
padding: 24px;
|
||||||
|
/* 恢复正向布局 */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
/* 解决移动端滚动流畅度 */
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-container {
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
/* 核心:当消息较少时,通过 margin-top: auto 将内容推到底部 */
|
||||||
|
margin-top: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-empty-welcome {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 100px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-welcome-avatar {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-brand-soft);
|
||||||
|
color: var(--color-brand);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 32px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-welcome-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-welcome-desc {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
line-height: 1.7;
|
||||||
|
max-width: 480px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streaming-message {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streaming-retry-card {
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--color-info-soft);
|
||||||
|
border: 1px solid rgba(49, 130, 206, 0.1);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streaming-retry-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streaming-retry-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.streaming-section-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import { Divider, Tag, Avatar } from 'antd';
|
import { Divider, Tag, Avatar } from 'antd';
|
||||||
|
import { useState, useEffect, useRef, useLayoutEffect } from 'react';
|
||||||
import type { Agent, BranchInfo, ChatMessage } from '../../../api';
|
import type { Agent, BranchInfo, ChatMessage } from '../../../api';
|
||||||
import Markdown from '../../../components/Markdown';
|
import Markdown from '../../../components/Markdown';
|
||||||
import type { StreamingState } from '../hooks/useChatSender';
|
import type { StreamingState } from '../hooks/useChatSender';
|
||||||
import type { CopyMode } from '../utils/copy';
|
import type { CopyMode } from '../utils/copy';
|
||||||
import MessageItem from './messages/MessageItem';
|
import MessageItem from './messages/MessageItem';
|
||||||
import { RetrievedView, ToolCallView } from './messages/MetaViews';
|
import { ReasoningView, RetrievedView, ToolCallView } from './messages/MetaViews';
|
||||||
|
import './ChatBody.css';
|
||||||
|
|
||||||
const isImageUrl = (url: string) => url?.startsWith('http') || url?.startsWith('/');
|
const isImageUrl = (url: string) => url?.startsWith('http') || url?.startsWith('/');
|
||||||
|
|
||||||
|
|
@ -21,36 +23,82 @@ export default function ChatBody(props: {
|
||||||
onRegenerate: (assistantId: string) => void;
|
onRegenerate: (assistantId: string) => void;
|
||||||
onSwitchBranch: (userMsgId: string, branchId: string) => void;
|
onSwitchBranch: (userMsgId: string, branchId: string) => void;
|
||||||
onCopy: (text: string, mode: CopyMode) => void;
|
onCopy: (text: string, mode: CopyMode) => void;
|
||||||
|
scrollBottom: (force?: boolean) => void;
|
||||||
|
initialScrollDoneRef: { current: boolean };
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { bodyRef, agent, agentList, currentAgentId, messages, branches, highlightId, sending, streaming, onRegenerate, onSwitchBranch, onCopy, isMobile } = props;
|
const { bodyRef, agent, agentList, currentAgentId, messages, branches, highlightId, sending, streaming, onRegenerate, onSwitchBranch, onCopy, scrollBottom, initialScrollDoneRef, isMobile } = props;
|
||||||
|
|
||||||
|
const [streamingReasoningExpanded, setStreamingReasoningExpanded] = useState(true);
|
||||||
|
const bottomAnchorRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 当开始新的回答时,默认展开推理过程;当正式回答开始时,自动折叠推理过程
|
||||||
|
useEffect(() => {
|
||||||
|
if (streaming.active) {
|
||||||
|
if (!streaming.answerText) {
|
||||||
|
setStreamingReasoningExpanded(true);
|
||||||
|
} else {
|
||||||
|
setStreamingReasoningExpanded(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [streaming.active, !!streaming.answerText]);
|
||||||
|
|
||||||
|
// 1. 初始加载:使用 useLayoutEffect 在浏览器绘图前尝试触底,消除跳转感
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (messages.length > 0 && !initialScrollDoneRef.current) {
|
||||||
|
const el = bodyRef.current;
|
||||||
|
if (el) {
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
// 注意:这里不立即设置 initialScrollDoneRef.current = true
|
||||||
|
// 留给下面的 useEffect 或 highlight 逻辑处理,确保后续第一次滚动也是 instant
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [messages.length, bodyRef]);
|
||||||
|
|
||||||
|
// 2. 新消息到达或流式输出时的自动跟进
|
||||||
|
useEffect(() => {
|
||||||
|
if (messages.length > 0 || streaming.active) {
|
||||||
|
const isInitial = !initialScrollDoneRef.current;
|
||||||
|
scrollBottom(isInitial);
|
||||||
|
if (isInitial) {
|
||||||
|
initialScrollDoneRef.current = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [messages.length, streaming.answerText, streaming.reasoningText, streaming.active, scrollBottom, initialScrollDoneRef]);
|
||||||
|
|
||||||
|
// 3. 监听内容高度变化(如图片加载、Markdown 渲染)
|
||||||
|
useEffect(() => {
|
||||||
|
const el = bodyRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
scrollBottom();
|
||||||
|
});
|
||||||
|
|
||||||
|
const container = el.querySelector('.messages-container');
|
||||||
|
if (container) {
|
||||||
|
observer.observe(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [scrollBottom, bodyRef]);
|
||||||
|
|
||||||
|
const isEmpty = messages.length === 0 && !streaming.active;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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 ? (
|
{isEmpty ? (
|
||||||
<div style={{ textAlign: 'center', marginTop: 120 }}>
|
<div className="chat-empty-welcome">
|
||||||
<div
|
<div className="chat-welcome-avatar">
|
||||||
style={{
|
{isImageUrl(agent.avatar) ? (
|
||||||
width: 68,
|
<img src={agent.avatar} className="w-full h-full object-cover" alt="avatar" />
|
||||||
height: 68,
|
) : (
|
||||||
borderRadius: '50%',
|
(agent.name?.charAt(0) || '?').toUpperCase()
|
||||||
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) ? <img src={agent.avatar} className="w-full h-full object-cover" alt="avatar" /> : (agent.name?.charAt(0) || '?').toUpperCase()}
|
|
||||||
</div>
|
</div>
|
||||||
<h2 style={{ fontSize: 28, fontWeight: 700, color: 'var(--color-text)', marginBottom: 8, letterSpacing: '-0.02em' }}>你好,今天想一起完成什么?</h2>
|
<h2 className="chat-welcome-title">你好,今天想一起完成什么?</h2>
|
||||||
<p style={{ color: 'var(--color-text-secondary)', fontSize: 15, lineHeight: 1.7 }}>{agent.description || '我是你的专属 AI 助手,随时准备为你服务。'}</p>
|
<p className="chat-welcome-desc">{agent.description || '我是你的专属 AI 助手,随时准备为你服务。'}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|
@ -71,50 +119,31 @@ export default function ChatBody(props: {
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{streaming.active && (
|
{streaming.active && (
|
||||||
<div style={{ marginBottom: 24 }}>
|
<div className="streaming-message">
|
||||||
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
|
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
|
||||||
{(() => {
|
{(() => {
|
||||||
const streamingAgentId = streaming.targetAgentId || currentAgentId;
|
const streamingAgentId = streaming.targetAgentId || currentAgentId;
|
||||||
const streamingAgent = agentList.find(a => a.id === streamingAgentId);
|
const streamingAgent = agentList.find(a => a.id === streamingAgentId);
|
||||||
if (streamingAgent) {
|
|
||||||
return (
|
return (
|
||||||
<Avatar src={streamingAgent.avatar} size={36} style={{ flexShrink: 0, marginTop: 2, backgroundColor: '#52c41a' }}>
|
<Avatar src={streamingAgent?.avatar} size={36} className="message-item-avatar">
|
||||||
{streamingAgent.name?.charAt(0)?.toUpperCase() || 'A'}
|
{streamingAgent?.name?.charAt(0)?.toUpperCase() || 'A'}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})()}
|
})()}
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div className="message-item-content">
|
||||||
{(() => {
|
<div className="message-item-header">
|
||||||
const streamingAgentId = streaming.targetAgentId || currentAgentId;
|
<span className="message-item-name">
|
||||||
const streamingAgent = agentList.find(a => a.id === streamingAgentId);
|
{agentList.find(a => a.id === (streaming.targetAgentId || currentAgentId))?.name || 'AI'}
|
||||||
if (streamingAgent) {
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 8,
|
|
||||||
marginBottom: 6
|
|
||||||
}}>
|
|
||||||
<span style={{
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 400,
|
|
||||||
color: 'var(--color-text-secondary)'
|
|
||||||
}}>
|
|
||||||
{streamingAgent.name}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})()}
|
|
||||||
<div className="bubble assistant">
|
<div className="bubble assistant">
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
{!!streaming.retryInfo?.message && (
|
{!!streaming.retryInfo?.message && (
|
||||||
<div style={{ padding: '8px 10px', borderRadius: 10, background: 'rgba(59, 130, 246, 0.08)', border: '1px solid rgba(59, 130, 246, 0.18)' }}>
|
<div className="streaming-retry-card">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
<div className="streaming-retry-header">
|
||||||
<span style={{ fontSize: 12.5, color: 'var(--color-text)', fontWeight: 600 }}>{streaming.retryInfo.stage === 'fallback_model' ? '自动切换模型' : '自动重试'}</span>
|
<span className="streaming-retry-title">
|
||||||
|
{streaming.retryInfo.stage === 'fallback_model' ? '自动切换模型' : '自动重试'}
|
||||||
|
</span>
|
||||||
{streaming.retryInfo.stage === 'fallback_model' ? (
|
{streaming.retryInfo.stage === 'fallback_model' ? (
|
||||||
<Tag color="processing" style={{ marginInlineEnd: 0 }}>
|
<Tag color="processing" style={{ marginInlineEnd: 0 }}>
|
||||||
{String(streaming.retryInfo.fromModel || '')} → {String(streaming.retryInfo.toModel || '')}
|
{String(streaming.retryInfo.fromModel || '')} → {String(streaming.retryInfo.toModel || '')}
|
||||||
|
|
@ -126,25 +155,29 @@ export default function ChatBody(props: {
|
||||||
</Tag>
|
</Tag>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: 4, fontSize: 12.5, color: 'var(--color-text-secondary)', lineHeight: 1.55 }}>{String(streaming.retryInfo.message)}</div>
|
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', lineHeight: 1.55 }}>{String(streaming.retryInfo.message)}</div>
|
||||||
{!!streaming.retryInfo.reason && (
|
|
||||||
<div style={{ marginTop: 4, fontSize: 12, color: 'var(--color-text-tertiary)', lineHeight: 1.5 }}>{String(streaming.retryInfo.reason)}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div className="streaming-section">
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginBottom: 4 }}>推理过程</div>
|
<ReasoningView
|
||||||
{streaming.reasoningText ? <Markdown>{streaming.reasoningText + '▍'}</Markdown> : <span style={{ color: 'var(--color-text-tertiary)' }}>等待推理…</span>}
|
reasoning={streaming.reasoningText || '等待推理…'}
|
||||||
|
expanded={streamingReasoningExpanded}
|
||||||
|
onToggle={() => setStreamingReasoningExpanded(!streamingReasoningExpanded)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Divider style={{ margin: '6px 0' }} />
|
{streaming.answerText && (
|
||||||
<div>
|
<>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginBottom: 4 }}>正式回答</div>
|
<Divider style={{ margin: '8px 0' }} />
|
||||||
{streaming.answerText ? <Markdown>{streaming.answerText + '▍'}</Markdown> : <span style={{ color: 'var(--color-text-tertiary)' }}>等待输出…</span>}
|
<div className="streaming-section">
|
||||||
|
<div className="streaming-section-label">正式回答</div>
|
||||||
|
<Markdown>{streaming.answerText + '▍'}</Markdown>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{(streaming.retrieved.length > 0 || streaming.toolCalls.length > 0) && (
|
{(streaming.retrieved.length > 0 || streaming.toolCalls.length > 0) && (
|
||||||
<div>
|
<div style={{ marginTop: 8 }}>
|
||||||
{streaming.retrieved.length > 0 && <RetrievedView retrieved={streaming.retrieved} />}
|
{streaming.retrieved.length > 0 && <RetrievedView retrieved={streaming.retrieved} />}
|
||||||
{streaming.toolCalls.length > 0 && <ToolCallView calls={streaming.toolCalls} liveStyle />}
|
{streaming.toolCalls.length > 0 && <ToolCallView calls={streaming.toolCalls} liveStyle />}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -153,6 +186,8 @@ export default function ChatBody(props: {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* 底部锚点:用于辅助滚动定位 */}
|
||||||
|
<div ref={bottomAnchorRef} style={{ height: 1, marginTop: -1 }} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
.chat-header {
|
||||||
|
height: 72px;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header-avatar {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-brand-soft);
|
||||||
|
color: var(--color-brand);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header-name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header-name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header-model {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-brand);
|
||||||
|
background: var(--color-brand-soft);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header-stream-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: medium;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
background-color: #F7FCFA;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header-btn {
|
||||||
|
font-size: 12px !important;
|
||||||
|
color: var(--color-text-secondary) !important;
|
||||||
|
height: 32px !important;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--color-border) !important;
|
||||||
|
background-color: #F7FCFA;
|
||||||
|
border: 0 !important;
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
gap: 6px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header-btn:hover {
|
||||||
|
color: var(--color-text) !important;
|
||||||
|
border-color: var(--color-border-strong) !important;
|
||||||
|
background: var(--color-surface-2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header-more-btn {
|
||||||
|
width: 32px !important;
|
||||||
|
height: 32px !important;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
@ -1,28 +1,31 @@
|
||||||
import { ApiOutlined, DeleteOutlined, DownOutlined, EditOutlined, SettingOutlined, EllipsisOutlined } from '@ant-design/icons';
|
import { useState, useEffect } from 'react';
|
||||||
import { Button, Dropdown, Modal, Space, Switch } from 'antd';
|
import { ApiOutlined, DeleteOutlined, EditOutlined, SettingOutlined, EllipsisOutlined, HistoryOutlined, LogoutOutlined } from '@ant-design/icons';
|
||||||
import type { Agent } from '../../../api';
|
import { Button, Dropdown, Modal, Switch } from 'antd';
|
||||||
import { useIsMobile } from '../../../hooks/useIsMobile';
|
import { Agent, ModelAPI, AiModel } from '../../../api';
|
||||||
|
import { parseAgentModels } from '../utils/agentModels';
|
||||||
|
import './ChatHeader.css';
|
||||||
|
|
||||||
function formatAgentModel(raw: string | null | undefined) {
|
function formatAgentModel(raw: unknown, models: AiModel[]) {
|
||||||
const s = String(raw ?? '').trim();
|
const findModelName = (id: string) => {
|
||||||
if (!s) return '默认模型';
|
const model = models.find(m => m.id === id);
|
||||||
if (s.startsWith('[') || s.startsWith('{')) {
|
return model ? model.model_name : id;
|
||||||
try {
|
};
|
||||||
const parsed = JSON.parse(s);
|
|
||||||
if (Array.isArray(parsed)) {
|
const parsedModels = parseAgentModels(raw)
|
||||||
const names = parsed
|
.map((item) => findModelName(item.id) || item.name)
|
||||||
.map((x: any) => String(x?.name || x?.model || x?.id || '').trim())
|
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
if (names.length) return names.join(', ');
|
if (parsedModels.length > 2) {
|
||||||
} else if (parsed && typeof parsed === 'object') {
|
return parsedModels.slice(0, 2).join(', ') + '...';
|
||||||
const name = String((parsed as any).name || (parsed as any).model || (parsed as any).id || '').trim();
|
|
||||||
if (name) return name;
|
|
||||||
}
|
}
|
||||||
} catch {}
|
if (parsedModels.length > 0) {
|
||||||
|
return parsedModels.join(', ');
|
||||||
}
|
}
|
||||||
return s;
|
const s = String(raw ?? '').trim();
|
||||||
|
return s ? findModelName(s) : '默认模型';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isImageUrl = (url: string) => url?.startsWith('http') || url?.startsWith('/');
|
||||||
|
|
||||||
export default function ChatHeader(props: {
|
export default function ChatHeader(props: {
|
||||||
agent: Agent;
|
agent: Agent;
|
||||||
useStream: boolean;
|
useStream: boolean;
|
||||||
|
|
@ -32,32 +35,47 @@ export default function ChatHeader(props: {
|
||||||
onOpenMcp: () => void;
|
onOpenMcp: () => void;
|
||||||
onManageAgent: () => void;
|
onManageAgent: () => void;
|
||||||
onClear: () => void;
|
onClear: () => void;
|
||||||
|
onLogout?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { agent, useStream, setUseStream, onOpenHistory, onOpenParams, onOpenMcp, onManageAgent, onClear } = props;
|
const { agent, useStream, setUseStream, onOpenHistory, onOpenParams, onOpenMcp, onManageAgent, onClear, onLogout } = props;
|
||||||
const modelText = formatAgentModel(agent.model);
|
const [models, setModels] = useState<AiModel[]>([]);
|
||||||
const isMobile = useIsMobile();
|
|
||||||
|
useEffect(() => {
|
||||||
|
ModelAPI.list().then(setModels).catch(console.error);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const modelText = formatAgentModel(agent.models ?? agent.model, models);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chat-header" style={isMobile ? { position: 'sticky', top: 0, zIndex: 10, background: 'var(--color-bg)' } : undefined}>
|
<div className="chat-header">
|
||||||
<div className="chat-header-agent">
|
<div className="chat-header-left">
|
||||||
<div className="chat-header-agent-title">
|
<div className="chat-header-avatar">
|
||||||
<span className="chat-header-agent-name">{agent.name}</span>
|
{isImageUrl(agent.avatar) ? (
|
||||||
{!!agent.description && <span className="chat-header-agent-desc">{agent.description}</span>}
|
<img src={agent.avatar} alt="avatar" style={{ width: '100%', height: '100%', borderRadius: '50%' }} />
|
||||||
|
) : (
|
||||||
|
(agent.name?.charAt(0) || '?').toUpperCase()
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="chat-header-agent-meta">
|
<div className="chat-header-info">
|
||||||
{modelText}
|
<div className="chat-header-name-row">
|
||||||
|
<span className="chat-header-name">{agent.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="chat-header-desc">
|
||||||
|
<span className="chat-header-model">{modelText}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Space>
|
</div>
|
||||||
|
|
||||||
|
<div className="chat-header-right">
|
||||||
<div className="chat-header-stream-toggle">
|
<div className="chat-header-stream-toggle">
|
||||||
<span className="chat-header-stream-label">流式输出</span>
|
<span>流式输出</span>
|
||||||
<Switch size="small" checked={useStream} onChange={setUseStream} />
|
<Switch size="small" checked={useStream} onChange={setUseStream} />
|
||||||
</div>
|
</div>
|
||||||
{!isMobile && (
|
|
||||||
<Button size="small" onClick={onOpenHistory}>
|
<Button className="chat-header-btn" onClick={onOpenHistory}>
|
||||||
历史对话
|
历史对话
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
<Dropdown
|
<Dropdown
|
||||||
menu={{
|
menu={{
|
||||||
items: [
|
items: [
|
||||||
|
|
@ -73,15 +91,27 @@ export default function ChatHeader(props: {
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
Modal.confirm({ title: '清空当前会话所有消息?', onOk: onClear });
|
Modal.confirm({ title: '清空当前会话所有消息?', onOk: onClear });
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
...(onLogout
|
||||||
|
? [
|
||||||
|
{ type: 'divider' as const },
|
||||||
|
{
|
||||||
|
key: 'logout',
|
||||||
|
label: '退出登录',
|
||||||
|
icon: <LogoutOutlined />,
|
||||||
|
onClick: onLogout,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
: [])
|
||||||
|
]
|
||||||
}}
|
}}
|
||||||
|
placement="bottomRight"
|
||||||
>
|
>
|
||||||
<Button size="small" type={isMobile ? 'text' : 'primary'}>
|
<Button className="chat-header-more-btn">
|
||||||
{isMobile ? <EllipsisOutlined /> : <>更多 <DownOutlined /></>}
|
<EllipsisOutlined />
|
||||||
</Button>
|
</Button>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</Space>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,159 @@
|
||||||
|
.chat-input-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
padding: 16px;
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-card-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-wrapper .chat-input-toolbar-top,
|
||||||
|
.chat-input-wrapper .chat-input-card {
|
||||||
|
max-width: 1080px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-toolbar-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin: 0 auto 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-actions-left {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-action-btn {
|
||||||
|
font-size: 13px !important;
|
||||||
|
color: var(--color-text-secondary) !important;
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
gap: 6px !important;
|
||||||
|
padding: 0 10px !important;
|
||||||
|
height: 32px !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
transition: all 0.2s !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-action-btn:hover {
|
||||||
|
background: var(--color-surface-2) !important;
|
||||||
|
color: var(--color-text) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-action-btn-primary {
|
||||||
|
background: var(--color-brand-soft) !important;
|
||||||
|
color: var(--color-brand) !important;
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-action-btn-primary:hover {
|
||||||
|
background: var(--color-brand-soft-2) !important;
|
||||||
|
color: var(--color-brand-hover) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-token-display {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
background: var(--color-surface-2);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-card {
|
||||||
|
background: #FAFCFC;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-card:focus-within {
|
||||||
|
/* border-color: var(--color-brand);
|
||||||
|
box-shadow: var(--shadow-focus); */
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-textarea {
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
font-size: 15px !important;
|
||||||
|
line-height: 1.6 !important;
|
||||||
|
resize: none !important;
|
||||||
|
background: transparent !important;
|
||||||
|
color: var(--color-text) !important;
|
||||||
|
margin-bottom: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-bottom-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-tools {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tool-btn {
|
||||||
|
color: var(--color-text-tertiary) !important;
|
||||||
|
font-size: 18px !important;
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
width: 32px !important;
|
||||||
|
height: 32px !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tool-btn:hover {
|
||||||
|
background: var(--color-surface-2) !important;
|
||||||
|
color: var(--color-text-secondary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-send-btn {
|
||||||
|
width: 36px !important;
|
||||||
|
height: 36px !important;
|
||||||
|
border-radius: 50% !important;
|
||||||
|
background: var(--color-brand) !important;
|
||||||
|
border: none !important;
|
||||||
|
color: #fff !important;
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
font-size: 18px !important;
|
||||||
|
transition: all 0.2s !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-send-btn:hover:not(:disabled) {
|
||||||
|
background: var(--color-brand-hover) !important;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-send-btn:disabled {
|
||||||
|
background: var(--color-border) !important;
|
||||||
|
color: var(--color-text-tertiary) !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-stop-btn {
|
||||||
|
background: var(--color-danger) !important;
|
||||||
|
box-shadow: 0 4px 10px rgba(229, 62, 62, 0.3) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-attachment-tag {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background: var(--color-brand-soft);
|
||||||
|
border: 1px solid var(--color-brand-soft-2);
|
||||||
|
color: var(--color-brand);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { ArrowUpOutlined, BookOutlined, CloseOutlined, DownOutlined, PaperClipOutlined } from '@ant-design/icons';
|
import { ArrowUpOutlined, BookOutlined, CloseOutlined, PaperClipOutlined, HistoryOutlined, PlusOutlined } from '@ant-design/icons';
|
||||||
import { Button, Image as AntImage, Input, Select, Tag, Tooltip, Upload, Popover } from 'antd';
|
import { Button, Image as AntImage, Input, Tag, Tooltip, Upload } from 'antd';
|
||||||
import type { TextAreaRef } from 'antd/es/input/TextArea';
|
import type { TextAreaRef } from 'antd/es/input/TextArea';
|
||||||
import type { ChatAttachment } from '../../../api';
|
import type { ChatAttachment } from '../../../api';
|
||||||
import type { Agent } from '../../../api/agents';
|
import type { Agent } from '../../../api/agents';
|
||||||
import { HistoryIcon, NewChatIcon } from '../../../components/icons';
|
import { useState, useRef } from 'react';
|
||||||
import { useState, useRef, useEffect } from 'react';
|
import './ChatInput.css';
|
||||||
|
import { IconAttachment, IconPrompt } from '../../../components/Icon';
|
||||||
|
|
||||||
export default function ChatInput(props: {
|
export default function ChatInput(props: {
|
||||||
input: string;
|
input: string;
|
||||||
|
|
@ -39,13 +40,9 @@ export default function ChatInput(props: {
|
||||||
onStop,
|
onStop,
|
||||||
onAttach,
|
onAttach,
|
||||||
onOpenTpl,
|
onOpenTpl,
|
||||||
modelOptions,
|
|
||||||
activeModelValue,
|
|
||||||
onChangeModel,
|
|
||||||
onOpenHistory,
|
onOpenHistory,
|
||||||
onNewSession,
|
onNewSession,
|
||||||
agentList,
|
agentList,
|
||||||
onInsertMention,
|
|
||||||
showActions = true
|
showActions = true
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
|
@ -98,7 +95,6 @@ export default function ChatInput(props: {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// 检测 @ 触发提及选择
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
setInput(value);
|
setInput(value);
|
||||||
|
|
@ -107,32 +103,17 @@ export default function ChatInput(props: {
|
||||||
const textBeforeCursor = value.slice(0, cursorPos);
|
const textBeforeCursor = value.slice(0, cursorPos);
|
||||||
const atIndex = textBeforeCursor.lastIndexOf('@');
|
const atIndex = textBeforeCursor.lastIndexOf('@');
|
||||||
|
|
||||||
console.log('[@mention] input change:', {
|
|
||||||
value,
|
|
||||||
cursorPos,
|
|
||||||
textBeforeCursor,
|
|
||||||
atIndex,
|
|
||||||
agentListLength: agentList.length,
|
|
||||||
agentList: agentList.map(a => ({ id: a.id, name: a.name }))
|
|
||||||
});
|
|
||||||
|
|
||||||
if (atIndex !== -1 && (atIndex === 0 || /\s$/.test(textBeforeCursor.slice(0, atIndex)))) {
|
if (atIndex !== -1 && (atIndex === 0 || /\s$/.test(textBeforeCursor.slice(0, atIndex)))) {
|
||||||
const query = textBeforeCursor.slice(atIndex + 1);
|
const query = textBeforeCursor.slice(atIndex + 1);
|
||||||
console.log('[@mention] matched @ at position:', { atIndex, query });
|
|
||||||
if (!query.includes(' ')) {
|
if (!query.includes(' ')) {
|
||||||
setMentionQuery(query);
|
setMentionQuery(query);
|
||||||
// 计算位置 - Ant Design Input.TextArea 需要从 resizableTextArea 获取实际 DOM
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const textarea = inputRef.current?.resizableTextArea?.textArea;
|
const textarea = inputRef.current?.resizableTextArea?.textArea;
|
||||||
if (!textarea) {
|
if (!textarea) return;
|
||||||
console.log('[@mention] cannot get textarea DOM from inputRef:', inputRef.current);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const caret = getCaretPos(textarea, cursorPos);
|
const caret = getCaretPos(textarea, cursorPos);
|
||||||
const top = Math.min(window.innerHeight - 8, caret.top + caret.lineHeight + 8);
|
const top = Math.min(window.innerHeight - 8, caret.top + caret.lineHeight + 8);
|
||||||
const left = Math.min(window.innerWidth - 160, Math.max(8, caret.left));
|
const left = Math.min(window.innerWidth - 160, Math.max(8, caret.left));
|
||||||
setMentionPos({ top, left });
|
setMentionPos({ top, left });
|
||||||
console.log('[@mention] show popover:', { top, left, query });
|
|
||||||
setShowMentionPopover(true);
|
setShowMentionPopover(true);
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|
@ -172,14 +153,45 @@ export default function ChatInput(props: {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chat-input-wrapper">
|
<div className="chat-input-wrapper">
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 }}>
|
{showActions && (
|
||||||
|
<div className="chat-input-toolbar-top">
|
||||||
|
<div className="chat-input-actions-left">
|
||||||
|
<Button
|
||||||
|
icon={<HistoryOutlined />}
|
||||||
|
onClick={onOpenHistory}
|
||||||
|
className="chat-input-action-btn"
|
||||||
|
>
|
||||||
|
历史记录
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={onNewSession}
|
||||||
|
className="chat-input-action-btn chat-input-action-btn-primary"
|
||||||
|
>
|
||||||
|
新建会话
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/* <div className="chat-input-token-display">
|
||||||
|
12,480 tokens
|
||||||
|
</div> */}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="chat-input-card">
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||||
{attachments.map((a, i) => (
|
{attachments.map((a, i) => (
|
||||||
<Tag key={i} color="blue" closable style={{ borderRadius: 6, padding: '4px 8px' }} onClose={() => setAttachments((arr) => arr.filter((_, j) => j !== i))}>
|
<Tag
|
||||||
|
key={i}
|
||||||
|
closable
|
||||||
|
className="chat-attachment-tag"
|
||||||
|
onClose={() => setAttachments((arr) => arr.filter((_, j) => j !== i))}
|
||||||
|
>
|
||||||
📎 {a.name}
|
📎 {a.name}
|
||||||
</Tag>
|
</Tag>
|
||||||
))}
|
))}
|
||||||
{imageUrls.map((u, i) => (
|
{imageUrls.map((u, i) => (
|
||||||
<div key={i} style={{ position: 'relative' }}>
|
<div key={i} style={{ position: 'relative', marginBottom: 8 }}>
|
||||||
<AntImage src={u} width={48} height={48} style={{ objectFit: 'cover', borderRadius: 8, border: '1px solid var(--color-border)' }} />
|
<AntImage src={u} width={48} height={48} style={{ objectFit: 'cover', borderRadius: 8, border: '1px solid var(--color-border)' }} />
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
|
|
@ -193,31 +205,15 @@ export default function ChatInput(props: {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="chat-input-card-wrap">
|
|
||||||
{showActions && <div className="chat-input-actions">
|
|
||||||
<Tooltip title="历史记录">
|
|
||||||
<Button size="small" type="text" className="chat-input-action-btn" icon={<HistoryIcon />} onClick={onOpenHistory}>
|
|
||||||
历史记录
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title="新增会话">
|
|
||||||
<Button size="small" type="text" className="chat-input-action-btn chat-input-action-btn-primary" icon={<NewChatIcon />} onClick={onNewSession}>
|
|
||||||
新建会话
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>}
|
|
||||||
<div className="chat-input-card">
|
|
||||||
<div className="chat-input-stack">
|
|
||||||
<Input.TextArea
|
<Input.TextArea
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={input}
|
value={input}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
placeholder="问我任何问题... 输入 @ 可 @其他智能体"
|
placeholder="问我任何问题... 输入 @ 可 @其他智能体"
|
||||||
autoSize={{ minRows: 3, maxRows: 10 }}
|
autoSize={{ minRows: 2, maxRows: 10 }}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key !== 'Enter') return;
|
if (e.key !== 'Enter') return;
|
||||||
if ((e as any).isComposing) return;
|
if ((e as any).isComposing) return;
|
||||||
|
|
||||||
if (e.metaKey || e.ctrlKey) {
|
if (e.metaKey || e.ctrlKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const el = e.currentTarget;
|
const el = e.currentTarget;
|
||||||
|
|
@ -230,7 +226,6 @@ export default function ChatInput(props: {
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!e.shiftKey && !e.altKey) {
|
if (!e.shiftKey && !e.altKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onSend();
|
onSend();
|
||||||
|
|
@ -239,6 +234,44 @@ export default function ChatInput(props: {
|
||||||
className="chat-input-textarea"
|
className="chat-input-textarea"
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="chat-input-bottom-bar">
|
||||||
|
<div className="chat-input-tools">
|
||||||
|
<Upload
|
||||||
|
multiple
|
||||||
|
beforeUpload={(_f, files) => {
|
||||||
|
onAttach(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" className="chat-tool-btn" icon={<IconAttachment />} />
|
||||||
|
</Upload>
|
||||||
|
<Button type="text" className="chat-tool-btn" icon={<IconPrompt />} onClick={onOpenTpl} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sending ? (
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
shape="circle"
|
||||||
|
onClick={onStop}
|
||||||
|
icon={<div style={{ width: 10, height: 10, background: '#fff', borderRadius: 2 }} />}
|
||||||
|
className="chat-send-btn chat-stop-btn"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
shape="circle"
|
||||||
|
onClick={onSend}
|
||||||
|
icon={<ArrowUpOutlined />}
|
||||||
|
disabled={!input.trim()}
|
||||||
|
className="chat-send-btn"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{showMentionPopover && mentionPos && (
|
{showMentionPopover && mentionPos && (
|
||||||
<div
|
<div
|
||||||
className="mention-popover"
|
className="mention-popover"
|
||||||
|
|
@ -249,15 +282,15 @@ export default function ChatInput(props: {
|
||||||
zIndex: 10000,
|
zIndex: 10000,
|
||||||
background: 'var(--color-surface)',
|
background: 'var(--color-surface)',
|
||||||
border: '1px solid var(--color-border)',
|
border: '1px solid var(--color-border)',
|
||||||
borderRadius: 6,
|
borderRadius: 8,
|
||||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
boxShadow: 'var(--shadow-lg)',
|
||||||
maxHeight: 200,
|
maxHeight: 200,
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
minWidth: 150
|
minWidth: 160
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{filteredAgents.length === 0 ? (
|
{filteredAgents.length === 0 ? (
|
||||||
<div style={{ padding: 8, color: 'var(--color-text-tertiary)' }}>
|
<div style={{ padding: 8, color: 'var(--color-text-tertiary)', fontSize: 12 }}>
|
||||||
未找到匹配的智能体
|
未找到匹配的智能体
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -267,72 +300,19 @@ export default function ChatInput(props: {
|
||||||
className="mention-item"
|
className="mention-item"
|
||||||
onClick={() => handleSelectAgent(agent)}
|
onClick={() => handleSelectAgent(agent)}
|
||||||
style={{
|
style={{
|
||||||
padding: '6px 10px',
|
padding: '8px 12px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
borderBottom: '1px solid var(--color-border)'
|
borderBottom: '1px solid var(--color-border)'
|
||||||
}}
|
}}
|
||||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--color-fill-hover)'}
|
|
||||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
|
||||||
>
|
>
|
||||||
<div style={{ fontSize: 14, fontWeight: 500, color: 'var(--color-text)' }}>
|
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text)' }}>
|
||||||
{agent.name}
|
{agent.name}
|
||||||
</div>
|
</div>
|
||||||
{agent.description && (
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)' }}>
|
|
||||||
{agent.description.slice(0, 30)}
|
|
||||||
{agent.description.length > 30 ? '...' : ''}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="chat-input-toolbar">
|
|
||||||
<div className="chat-input-toolbar-left">
|
|
||||||
{/* <Select
|
|
||||||
value={activeModelValue || undefined}
|
|
||||||
className="chat-model-select"
|
|
||||||
popupMatchSelectWidth={false}
|
|
||||||
options={modelOptions}
|
|
||||||
suffixIcon={<DownOutlined className="chat-model-select-arrow" />}
|
|
||||||
placeholder="选择模型"
|
|
||||||
onChange={onChangeModel}
|
|
||||||
/> */}
|
|
||||||
|
|
||||||
<Upload
|
|
||||||
className="chat-upload"
|
|
||||||
multiple
|
|
||||||
beforeUpload={(_f, files) => {
|
|
||||||
onAttach(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" className="chat-tool-button" icon={<PaperClipOutlined style={{ fontSize: 18 }} />} />
|
|
||||||
</Upload>
|
|
||||||
<Button type="text" className="chat-tool-button" icon={<BookOutlined style={{ fontSize: 18 }} />} onClick={onOpenTpl} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{sending ? (
|
|
||||||
<Button danger shape="circle" onClick={onStop} icon={<span className="chat-stop-icon" />} className="chat-send-button" />
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
shape="circle"
|
|
||||||
onClick={onSend}
|
|
||||||
icon={<ArrowUpOutlined />}
|
|
||||||
disabled={!input.trim()}
|
|
||||||
className="chat-send-button chat-send-button-primary"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,179 @@
|
||||||
|
.chat-outline {
|
||||||
|
width: 300px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 100%;
|
||||||
|
border-left: 1px solid var(--color-border);
|
||||||
|
background: var(--color-surface);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 24px 16px;
|
||||||
|
gap: 24px;
|
||||||
|
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
position: relative;
|
||||||
|
overflow: visible;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-outline.is-collapsed {
|
||||||
|
width: 52px !important;
|
||||||
|
min-width: 52px !important;
|
||||||
|
padding: 30px 8px;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-outline-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
transition: all 0.2s;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
position: absolute;
|
||||||
|
left: -12px;
|
||||||
|
top: 24px;
|
||||||
|
z-index: 1000;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-outline-toggle:hover {
|
||||||
|
background: var(--color-surface-2);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-outline-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-collapsed .chat-outline-section {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-outline-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
padding: 0 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-outline-title::before {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
width: 3px;
|
||||||
|
height: 14px;
|
||||||
|
background: var(--color-brand);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-outline-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-outline-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-collapsed .chat-outline-item {
|
||||||
|
padding: 8px 0;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-outline-item:hover {
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-outline-item.active {
|
||||||
|
background: var(--color-brand-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-collapsed .chat-outline-item.active {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-outline-index {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--color-bg);
|
||||||
|
border-radius: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-outline-item.active .chat-outline-index {
|
||||||
|
background: var(--color-brand);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-outline-text {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-outline-item.active .chat-outline-text {
|
||||||
|
color: var(--color-text);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 推荐话题部分 */
|
||||||
|
.chat-outline-recommend {
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
padding-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-recommend-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-recommend-item {
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-recommend-item:hover {
|
||||||
|
border-color: var(--color-brand);
|
||||||
|
background: var(--color-brand-soft);
|
||||||
|
color: var(--color-brand);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
|
import { LeftOutlined, OrderedListOutlined, RightOutlined } from '@ant-design/icons';
|
||||||
import type { ChatMessage } from '../../../api';
|
import type { ChatMessage } from '../../../api';
|
||||||
import { markdownToPlainText } from '../utils/copy';
|
import { markdownToPlainText } from '../utils/copy';
|
||||||
|
import './ChatOutline.css';
|
||||||
|
|
||||||
function summarize(content: string) {
|
function summarize(content: string) {
|
||||||
const plain = markdownToPlainText(content);
|
const plain = markdownToPlainText(content);
|
||||||
|
|
@ -12,22 +14,34 @@ function summarize(content: string) {
|
||||||
return text.slice(0, 44) + '…';
|
return text.slice(0, 44) + '…';
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ChatOutline(props: { messages: ChatMessage[]; onJump: (id: string) => void; activeId?: string | null }) {
|
interface ChatOutlineProps {
|
||||||
const { messages, onJump, activeId } = props;
|
messages: ChatMessage[];
|
||||||
const items = messages.filter((m) => m.speaker?.type === 'agent' || m.role === 'assistant' || m.role === 'agent');
|
onJump: (id: string) => void;
|
||||||
|
activeId?: string | null;
|
||||||
if (items.length === 0) {
|
collapsed?: boolean;
|
||||||
return (
|
onToggleCollapse?: () => void;
|
||||||
<aside className="chat-outline">
|
|
||||||
<div className="chat-outline-title">对话大纲</div>
|
|
||||||
<div style={{ padding: 12, color: 'var(--color-text-tertiary)', fontSize: 12 }}>暂无</div>
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function ChatOutline(props: ChatOutlineProps) {
|
||||||
|
const { messages, onJump, activeId, collapsed, onToggleCollapse } = props;
|
||||||
|
const items = messages.filter((m) => m.speaker?.type === 'agent' || m.role === 'assistant' || m.role === 'agent');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="chat-outline">
|
<aside className={`chat-outline ${collapsed ? 'is-collapsed' : ''}`}>
|
||||||
<div className="chat-outline-title">对话大纲</div>
|
<div className="chat-outline-toggle" onClick={onToggleCollapse}>
|
||||||
|
{collapsed ? <LeftOutlined style={{ fontSize: 12 }} /> : <RightOutlined style={{ fontSize: 12 }} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="chat-outline-section">
|
||||||
|
<div className="chat-outline-title">{collapsed ? <OrderedListOutlined /> : '对话大纲'}</div>
|
||||||
|
<div className="chat-outline-list"></div>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
!collapsed && (
|
||||||
|
<div style={{ padding: '12px 4px', color: 'var(--color-text-tertiary)', fontSize: 13 }}>
|
||||||
|
暂无对话记录
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
<div className="chat-outline-list">
|
<div className="chat-outline-list">
|
||||||
{items.map((m, idx) => (
|
{items.map((m, idx) => (
|
||||||
<button
|
<button
|
||||||
|
|
@ -38,10 +52,12 @@ export default function ChatOutline(props: { messages: ChatMessage[]; onJump: (i
|
||||||
title={summarize(m.content)}
|
title={summarize(m.content)}
|
||||||
>
|
>
|
||||||
<span className="chat-outline-index">{idx + 1}</span>
|
<span className="chat-outline-index">{idx + 1}</span>
|
||||||
<span className="chat-outline-text">{summarize(m.content)}</span>
|
{!collapsed && <span className="chat-outline-text">{summarize(m.content)}</span>}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,35 @@
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.h5-chat-shell {
|
.h5-chat-shell {
|
||||||
/* 移除固定高度,允许跟随全局滚动容器 */
|
height: 100vh;
|
||||||
|
height: 100svh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.h5-chat-main {
|
.h5-chat-main {
|
||||||
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.h5-chat-content-row {
|
.h5-chat-content-row {
|
||||||
/* 移除内部滚动,改为由 App 的 main-content 统一处理 */
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h5-chat-content-row .chat-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.h5-chat-content-row .chat-body .messages-container {
|
.h5-chat-content-row .chat-body .messages-container {
|
||||||
padding-bottom: 2rem;
|
padding: 12px 16px 32px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,8 @@ export default function ChatPageH5({ logic }: { logic: ChatPageLogicOutput }) {
|
||||||
streaming={sender.streaming}
|
streaming={sender.streaming}
|
||||||
onRegenerate={sender.handleRegenerate}
|
onRegenerate={sender.handleRegenerate}
|
||||||
onSwitchBranch={sender.handleSwitchBranch}
|
onSwitchBranch={sender.handleSwitchBranch}
|
||||||
|
scrollBottom={logic.scrollBottom}
|
||||||
|
initialScrollDoneRef={logic.initialScrollDoneRef}
|
||||||
onCopy={(text, mode) => {
|
onCopy={(text, mode) => {
|
||||||
const content = mode === 'markdown' ? text : markdownToPlainText(text);
|
const content = mode === 'markdown' ? text : markdownToPlainText(text);
|
||||||
navigator.clipboard?.writeText(content).then(() => {
|
navigator.clipboard?.writeText(content).then(() => {
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,23 @@
|
||||||
|
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
import { App as AntApp, Empty } from 'antd';
|
import { App as AntApp, Empty } from 'antd';
|
||||||
import type { ChatPageLogicOutput } from '../ChatPageLogic';
|
import type { ChatPageLogicOutput } from '../ChatPageLogic';
|
||||||
import { markdownToPlainText } from '../utils/copy';
|
import { markdownToPlainText } from '../utils/copy';
|
||||||
import type { ModelOverrides } from '../../../api';
|
import type { ModelOverrides } from '../../../api';
|
||||||
|
import { useDesktopViewport, desktopViewportClass } from '../../../hooks/useDesktopViewport';
|
||||||
import AgentSidebar from './AgentSidebar';
|
import AgentSidebar from './AgentSidebar';
|
||||||
import ChatBody from './ChatBody';
|
import ChatBody from './ChatBody';
|
||||||
import ChatDrawers from './ChatDrawers';
|
import ChatDrawers from './ChatDrawers';
|
||||||
import ChatHeader from './ChatHeader';
|
import ChatHeader from './ChatHeader';
|
||||||
import ChatInput from './ChatInput';
|
import ChatInput from './ChatInput';
|
||||||
import ChatOutline from './ChatOutline';
|
import ChatOutline from './ChatOutline';
|
||||||
|
import '../styles/chat-page-web.css';
|
||||||
|
|
||||||
export default function ChatPageWeb({ logic }: { logic: ChatPageLogicOutput }) {
|
export default function ChatPageWeb({ logic }: { logic: ChatPageLogicOutput }) {
|
||||||
const { message } = AntApp.useApp();
|
const { message } = AntApp.useApp();
|
||||||
|
const viewport = useDesktopViewport();
|
||||||
|
const [outlineCollapsed, setOutlineCollapsed] = useState(true);
|
||||||
|
const [agentSidebarCollapsed, setAgentSidebarCollapsed] = useState(false);
|
||||||
|
const isAutoScrolling = useRef(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
id,
|
id,
|
||||||
|
|
@ -38,22 +45,51 @@ export default function ChatPageWeb({ logic }: { logic: ChatPageLogicOutput }) {
|
||||||
handleNewSession,
|
handleNewSession,
|
||||||
} = logic;
|
} = logic;
|
||||||
|
|
||||||
|
// 1. 初始化时,如果有历史消息,激活最后一条 Agent 消息
|
||||||
|
useEffect(() => {
|
||||||
|
if (messages.length > 0 && !highlightId) {
|
||||||
|
const agentMsgs = messages.filter(m => m.role === 'assistant' || m.role === 'agent' || m.speaker?.type === 'agent');
|
||||||
|
if (agentMsgs.length > 0) {
|
||||||
|
setHighlightId(agentMsgs[agentMsgs.length - 1].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [messages.length]);
|
||||||
|
|
||||||
|
// 2. 移除滚动监听逻辑,避免干扰正常滚动
|
||||||
|
// 后续若需恢复,需重新设计非阻塞的判定算法
|
||||||
|
|
||||||
|
const handleJump = useCallback((msgId: string) => {
|
||||||
|
isAutoScrolling.current = true;
|
||||||
|
setHighlightId(msgId);
|
||||||
|
const el = document.getElementById('msg-' + msgId);
|
||||||
|
if (el) {
|
||||||
|
el.scrollIntoView({ block: 'start', behavior: 'smooth' });
|
||||||
|
// 滚动结束后重置标志位,避免触发 handleScroll
|
||||||
|
setTimeout(() => {
|
||||||
|
isAutoScrolling.current = false;
|
||||||
|
}, 800);
|
||||||
|
}
|
||||||
|
}, [setHighlightId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chat-shell">
|
<div className={`chat-shell ${desktopViewportClass(viewport)}`}>
|
||||||
<AgentSidebar
|
<AgentSidebar
|
||||||
agentList={agentList}
|
agentList={agentList}
|
||||||
activeAgentId={id}
|
activeAgentId={id}
|
||||||
|
collapsed={agentSidebarCollapsed}
|
||||||
|
onToggleCollapse={() => setAgentSidebarCollapsed(!agentSidebarCollapsed)}
|
||||||
onCreate={() => navigate('/agents/new')}
|
onCreate={() => navigate('/agents/new')}
|
||||||
onSelect={(aid) => navigate(`/chat/${aid}`)}
|
onSelect={(aid) => navigate(`/chat/${aid}`)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section className="chat-main">
|
<section className="chat-main">
|
||||||
{!agent ? (
|
{!agent ? (
|
||||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<div className="chat-empty-state">
|
||||||
<Empty description="请在左侧选择一个智能体开始对话" />
|
<Empty description="请在左侧选择一个智能体开始对话" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="chat-content-layout">
|
||||||
|
<div className="chat-conversation-panel">
|
||||||
<ChatHeader
|
<ChatHeader
|
||||||
agent={agent}
|
agent={agent}
|
||||||
useStream={sender.useStream}
|
useStream={sender.useStream}
|
||||||
|
|
@ -65,7 +101,6 @@ export default function ChatPageWeb({ logic }: { logic: ChatPageLogicOutput }) {
|
||||||
onClear={sender.handleClear}
|
onClear={sender.handleClear}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="chat-content-row">
|
|
||||||
<ChatBody
|
<ChatBody
|
||||||
bodyRef={bodyRef}
|
bodyRef={bodyRef}
|
||||||
agent={agent}
|
agent={agent}
|
||||||
|
|
@ -78,6 +113,8 @@ export default function ChatPageWeb({ logic }: { logic: ChatPageLogicOutput }) {
|
||||||
streaming={sender.streaming}
|
streaming={sender.streaming}
|
||||||
onRegenerate={sender.handleRegenerate}
|
onRegenerate={sender.handleRegenerate}
|
||||||
onSwitchBranch={sender.handleSwitchBranch}
|
onSwitchBranch={sender.handleSwitchBranch}
|
||||||
|
scrollBottom={logic.scrollBottom}
|
||||||
|
initialScrollDoneRef={logic.initialScrollDoneRef}
|
||||||
onCopy={(text, mode) => {
|
onCopy={(text, mode) => {
|
||||||
const content = mode === 'markdown' ? text : markdownToPlainText(text);
|
const content = mode === 'markdown' ? text : markdownToPlainText(text);
|
||||||
navigator.clipboard?.writeText(content).then(() => {
|
navigator.clipboard?.writeText(content).then(() => {
|
||||||
|
|
@ -86,17 +123,6 @@ export default function ChatPageWeb({ logic }: { logic: ChatPageLogicOutput }) {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ChatOutline
|
|
||||||
messages={messages}
|
|
||||||
activeId={highlightId}
|
|
||||||
onJump={(msgId) => {
|
|
||||||
setHighlightId(msgId);
|
|
||||||
const el = document.getElementById('msg-' + msgId);
|
|
||||||
if (el) el.scrollIntoView({ block: 'start', behavior: 'smooth' });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ChatInput
|
<ChatInput
|
||||||
input={sender.input}
|
input={sender.input}
|
||||||
setInput={sender.setInput}
|
setInput={sender.setInput}
|
||||||
|
|
@ -124,7 +150,16 @@ export default function ChatPageWeb({ logic }: { logic: ChatPageLogicOutput }) {
|
||||||
onOpenHistory={() => setHistoryDrawerOpen(true)}
|
onOpenHistory={() => setHistoryDrawerOpen(true)}
|
||||||
onNewSession={handleNewSession}
|
onNewSession={handleNewSession}
|
||||||
/>
|
/>
|
||||||
</>
|
</div>
|
||||||
|
|
||||||
|
<ChatOutline
|
||||||
|
messages={messages}
|
||||||
|
activeId={highlightId}
|
||||||
|
collapsed={outlineCollapsed}
|
||||||
|
onToggleCollapse={() => setOutlineCollapsed(!outlineCollapsed)}
|
||||||
|
onJump={handleJump}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -57,9 +57,11 @@ export default function ChatPageWebBase({ logic, viewport }: ChatPageWebVariantP
|
||||||
streaming={sender.streaming}
|
streaming={sender.streaming}
|
||||||
onRegenerate={sender.handleRegenerate}
|
onRegenerate={sender.handleRegenerate}
|
||||||
onSwitchBranch={sender.handleSwitchBranch}
|
onSwitchBranch={sender.handleSwitchBranch}
|
||||||
|
scrollBottom={logic.scrollBottom}
|
||||||
|
initialScrollDoneRef={logic.initialScrollDoneRef}
|
||||||
onCopy={(text, mode) => {
|
onCopy={(text, mode) => {
|
||||||
const content = mode === 'markdown' ? text : markdownToPlainText(text);
|
const content = mode === 'markdown' ? text : markdownToPlainText(text);
|
||||||
navigator.clipboard?.writeText(content).then(() => message.success(mode === 'markdown' ? '已复制(Markdown)' : '已复制(纯文本)'));
|
navigator.clipboard?.writeText(content).then(() => message.success(mode === 'markdown' ? '已复制(Markdown)' : '已复制'));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<ChatOutline messages={messages} activeId={highlightId} onJump={(msgId) => {
|
<ChatOutline messages={messages} activeId={highlightId} onJump={(msgId) => {
|
||||||
|
|
|
||||||
|
|
@ -1,78 +1,155 @@
|
||||||
.message-item-container {
|
.message-item-container {
|
||||||
margin-bottom: 20px;
|
transition: background 0.3s;
|
||||||
transition: background 0.4s, padding 0.4s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-item-container.highlighted {
|
.message-item-container.highlighted {
|
||||||
padding: 8px;
|
/* background: var(--color-surface-2); */
|
||||||
border-radius: 10px;
|
|
||||||
background: rgba(254, 243, 199, 0.6);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Assistant Message Styles */
|
.message-item-assistant,
|
||||||
.message-item-assistant {
|
.message-item-user {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: flex-start;
|
max-width: 90%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-item-assistant-avatar {
|
.message-item-user {
|
||||||
|
margin-left: auto;
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-item-avatar {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin-top: 2px;
|
width: 36px;
|
||||||
background-color: #52c41a;
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-item-assistant-content {
|
.message-item-content {
|
||||||
flex: 1;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-item-assistant-header {
|
.message-item-user .message-item-content {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-item-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-item-assistant-name {
|
.message-item-name {
|
||||||
font-size: 12px;
|
font-weight: 600;
|
||||||
font-weight: 400;
|
color: var(--color-text);
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* User Message Styles */
|
.message-item-time {
|
||||||
.message-item-user {
|
color: var(--color-text-tertiary);
|
||||||
display: flex;
|
font-size: 11px;
|
||||||
gap: 12px;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-item-user-content-wrapper {
|
.bubble {
|
||||||
flex: 1;
|
padding: 12px 16px;
|
||||||
min-width: 0;
|
border-radius: 16px;
|
||||||
max-width: 78%;
|
font-size: 14.5px;
|
||||||
display: flex;
|
line-height: 1.6;
|
||||||
flex-direction: column;
|
position: relative;
|
||||||
align-items: flex-end;
|
word-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-item-user-avatar {
|
.bubble.assistant {
|
||||||
flex-shrink: 0;
|
background: #ffffff;
|
||||||
margin-top: 2px;
|
padding: 16px;
|
||||||
background-color: #1890ff;
|
color: var(--color-text);
|
||||||
|
border-radius: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mention {
|
.bubble.user {
|
||||||
color: var(--color-brand);
|
background: var(--color-primary);
|
||||||
font-weight: 500;
|
color: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble.user span.mention {
|
||||||
|
background: rgba(255, 159, 10, 0.22);
|
||||||
|
border: 1px solid rgba(255, 159, 10, 0.38);
|
||||||
|
color: #ff9f0a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-item-actions {
|
.message-item-actions {
|
||||||
padding-top: 4px;
|
display: flex;
|
||||||
border-top: 1px solid var(--color-border);
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 8px;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-item-container:hover .message-item-actions {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions-btn {
|
.actions-btn {
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-tertiary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-btn:hover {
|
||||||
|
color: var(--color-text-secondary) !important;
|
||||||
|
background: var(--color-surface-3) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble.user .markdown p {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble.assistant .markdown p {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-reasoning-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reasoning-container {
|
||||||
|
margin: 4px 0;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reasoning-header {
|
||||||
|
padding: 4px 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reasoning-header:hover {
|
||||||
|
background-color: var(--color-surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reasoning-content {
|
||||||
|
margin-top: 8px;
|
||||||
|
animation: reasoningFadeIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes reasoningFadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-section-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
import { Button, Dropdown, Space, Tag, Tooltip, Avatar } from 'antd';
|
import { Button, Dropdown, Space, Tag, Tooltip, Avatar, Divider } from 'antd';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
import { CopyOutlined, SyncOutlined } from '@ant-design/icons';
|
import { CopyOutlined, SyncOutlined } from '@ant-design/icons';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
import type { BranchInfo, ChatMessage } from '../../../../api';
|
import type { BranchInfo, ChatMessage } from '../../../../api';
|
||||||
import type { Agent } from '../../../../api/agents';
|
import type { Agent } from '../../../../api/agents';
|
||||||
import Markdown from '../../../../components/Markdown';
|
import Markdown from '../../../../components/Markdown';
|
||||||
|
import { formatMessageContent } from '../../utils/format';
|
||||||
import type { CopyMode } from '../../utils/copy';
|
import type { CopyMode } from '../../utils/copy';
|
||||||
import { ReasoningView, RetrievedView, ToolCallView } from './MetaViews';
|
import { ReasoningView, RetrievedView, ToolCallView } from './MetaViews';
|
||||||
import './MessageItem.css';
|
import './MessageItem.css';
|
||||||
|
|
@ -21,6 +24,10 @@ export default function MessageItem(props: {
|
||||||
}) {
|
}) {
|
||||||
const { message, agentList, currentAgentId, highlighted, branch, busy, onRegenerate, onSwitchBranch, onCopy, isMobile } = props;
|
const { message, agentList, currentAgentId, highlighted, branch, busy, onRegenerate, onSwitchBranch, onCopy, isMobile } = props;
|
||||||
|
|
||||||
|
const [reasoningExpanded, setReasoningExpanded] = useState(false);
|
||||||
|
|
||||||
|
const formattedContent = useMemo(() => formatMessageContent(message.content), [message.content]);
|
||||||
|
|
||||||
const speakerType = (message as any)?.speaker?.type as ('user' | 'agent' | undefined);
|
const speakerType = (message as any)?.speaker?.type as ('user' | 'agent' | undefined);
|
||||||
const speakerId = (message as any)?.speaker?.id as string | undefined;
|
const speakerId = (message as any)?.speaker?.id as string | undefined;
|
||||||
const isUser = speakerType ? speakerType === 'user' : message.role === 'user';
|
const isUser = speakerType ? speakerType === 'user' : message.role === 'user';
|
||||||
|
|
@ -33,6 +40,11 @@ export default function MessageItem(props: {
|
||||||
const activeIdx = branch?.activeIndex ?? 0;
|
const activeIdx = branch?.activeIndex ?? 0;
|
||||||
const total = branch?.total ?? 1;
|
const total = branch?.total ?? 1;
|
||||||
|
|
||||||
|
const timeStr = useMemo(() => {
|
||||||
|
if (!message.createdAt) return '';
|
||||||
|
return dayjs(message.createdAt * 1000).format('HH:mm');
|
||||||
|
}, [message.createdAt]);
|
||||||
|
|
||||||
const goPrev = () => {
|
const goPrev = () => {
|
||||||
if (!branch || !message.parentId) return;
|
if (!branch || !message.parentId) return;
|
||||||
const i = Math.max(0, activeIdx - 1);
|
const i = Math.max(0, activeIdx - 1);
|
||||||
|
|
@ -48,25 +60,50 @@ export default function MessageItem(props: {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id={'msg-' + message.id}
|
id={'msg-' + message.id}
|
||||||
className={`message-item-container ${highlighted ? 'highlighted' : ''}`}
|
className={`message-item-container ${highlighted ? 'highlighted' : ''} ${bubbleRole}`}
|
||||||
|
data-msg-id={message.id}
|
||||||
|
data-is-agent={!isUser}
|
||||||
>
|
>
|
||||||
{!isUser ? (
|
<div className={isUser ? 'message-item-user' : 'message-item-assistant'}>
|
||||||
<div className="message-item-assistant">
|
|
||||||
<Avatar
|
<Avatar
|
||||||
src={answerAgent?.avatar}
|
src={isUser ? undefined : answerAgent?.avatar}
|
||||||
size={36}
|
size={36}
|
||||||
className="message-item-assistant-avatar"
|
className="message-item-avatar"
|
||||||
>
|
>
|
||||||
{answerAgent?.name?.charAt(0)?.toUpperCase() || 'A'}
|
{isUser ? '我' : (answerAgent?.name?.charAt(0)?.toUpperCase() || 'A')}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="message-item-assistant-content">
|
|
||||||
<div className="message-item-assistant-header">
|
<div className="message-item-content">
|
||||||
<span className="message-item-assistant-name">
|
<div className="message-item-header">
|
||||||
{answerAgent?.name || 'AI'}
|
<span className="message-item-name">
|
||||||
|
{isUser ? '我' : (answerAgent?.name || 'AI')}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="message-item-time">{timeStr}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`bubble ${bubbleRole}`}>
|
<div className={`bubble ${bubbleRole}`}>
|
||||||
<Markdown>{message.content}</Markdown>
|
{isUser && !formattedContent.includes(' ? (
|
||||||
|
<span dangerouslySetInnerHTML={{
|
||||||
|
__html: formattedContent.replace(/@([^\s]+)/g, '<span class="mention">@$1</span>')
|
||||||
|
}} />
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{/* 如果有推理过程且不是用户消息,展示推理部分 */}
|
||||||
|
{!isUser && message.meta?.reasoning && (
|
||||||
|
<div className="message-reasoning-section">
|
||||||
|
<ReasoningView
|
||||||
|
reasoning={message.meta.reasoning}
|
||||||
|
expanded={reasoningExpanded}
|
||||||
|
onToggle={() => setReasoningExpanded(!reasoningExpanded)}
|
||||||
|
/>
|
||||||
|
{reasoningExpanded && <Divider style={{ margin: '8px 0' }} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Markdown>{formattedContent}</Markdown>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="message-item-actions">
|
<div className="message-item-actions">
|
||||||
{hasBranches && (
|
{hasBranches && (
|
||||||
<Space size={2}>
|
<Space size={2}>
|
||||||
|
|
@ -82,12 +119,23 @@ export default function MessageItem(props: {
|
||||||
</Space>
|
</Space>
|
||||||
)}
|
)}
|
||||||
{message.meta?.aborted && <Tag color="orange">已停止</Tag>}
|
{message.meta?.aborted && <Tag color="orange">已停止</Tag>}
|
||||||
|
{isUser ? (
|
||||||
|
<Tooltip title="复制">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
className='actions-btn'
|
||||||
|
type="text"
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
onClick={() => onCopy?.(formattedContent, 'plain')}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
trigger={['click']}
|
trigger={['click']}
|
||||||
menu={{
|
menu={{
|
||||||
items: [
|
items: [
|
||||||
{ key: 'plain', label: '复制纯文本', onClick: () => onCopy?.(message.content, 'plain') },
|
{ key: 'plain', label: '复制纯文本', onClick: () => onCopy?.(formattedContent, 'plain') },
|
||||||
{ key: 'markdown', label: '复制 Markdown', onClick: () => onCopy?.(message.content, 'markdown') }
|
{ key: 'markdown', label: '复制 Markdown', onClick: () => onCopy?.(formattedContent, 'markdown') }
|
||||||
]
|
]
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -95,36 +143,27 @@ export default function MessageItem(props: {
|
||||||
<Button size="small" className='actions-btn' type="text" icon={<CopyOutlined />} />
|
<Button size="small" className='actions-btn' type="text" icon={<CopyOutlined />} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
<Tooltip title="重新生成(开新分支)">
|
)}
|
||||||
|
{!isUser && (
|
||||||
|
<Tooltip title="重新生成">
|
||||||
<Button size="small" className='actions-btn' type="text" icon={<SyncOutlined />} disabled={busy} onClick={() => onRegenerate?.(message.id)} />
|
<Button size="small" className='actions-btn' type="text" icon={<SyncOutlined />} disabled={busy} onClick={() => onRegenerate?.(message.id)} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{!isMobile && message.meta && (
|
{/* 底部 Meta 信息展示 (RAG / Tool) */}
|
||||||
<div>
|
{!isMobile && message.meta && !isUser && (
|
||||||
{!!message.meta.reasoning && <ReasoningView reasoning={message.meta.reasoning} />}
|
<div className="message-meta-section">
|
||||||
{!!message.meta.retrieved?.length && <RetrievedView retrieved={message.meta.retrieved} />}
|
{message.meta.retrieved && message.meta.retrieved.length > 0 && (
|
||||||
{!!message.meta.toolCalls?.length && <ToolCallView calls={message.meta.toolCalls} />}
|
<RetrievedView retrieved={message.meta.retrieved} />
|
||||||
|
)}
|
||||||
|
{message.meta.toolCalls && message.meta.toolCalls.length > 0 && (
|
||||||
|
<ToolCallView calls={message.meta.toolCalls} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="message-item-user">
|
|
||||||
<div className="message-item-user-content-wrapper">
|
|
||||||
<div className={`bubble ${bubbleRole}`}>
|
|
||||||
{message.content.includes(' ? (
|
|
||||||
<Markdown>{message.content}</Markdown>
|
|
||||||
) : (
|
|
||||||
<span dangerouslySetInnerHTML={{
|
|
||||||
__html: message.content.replace(/@([^\s]+)/g, '<span class="mention">@$1</span>')
|
|
||||||
}} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Avatar className="message-item-user-avatar" size={36}>我</Avatar>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
.reasoning-container {
|
||||||
|
margin: 4px 0;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reasoning-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
padding: 4px 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reasoning-header:hover {
|
||||||
|
background-color: var(--color-surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reasoning-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reasoning-toggle-icon {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reasoning-container.is-expanded .reasoning-toggle-icon {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reasoning-content {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
overflow: auto;
|
||||||
|
border-left: 2px solid var(--color-border-light);
|
||||||
|
padding-left: 12px;
|
||||||
|
margin-left: 4px;
|
||||||
|
margin-top: 8px;
|
||||||
|
animation: reasoningFadeIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes reasoningFadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* RAG View Styles */
|
||||||
|
.retrieved-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retrieved-list {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retrieved-item {
|
||||||
|
padding: 8px;
|
||||||
|
background: #f6f8ff;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
border-left: 3px solid #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retrieved-item-header {
|
||||||
|
color: #6366f1;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retrieved-item-preview {
|
||||||
|
color: #374151;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tool Call View Styles */
|
||||||
|
.tool-call-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #f97316;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-list {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-item {
|
||||||
|
padding: 8px;
|
||||||
|
background: #fff7ed;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
border-left: 3px solid #f97316;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-item-header {
|
||||||
|
color: #c2410c;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-item-args {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-item-result {
|
||||||
|
color: #374151;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-item-result code {
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
@ -1,24 +1,30 @@
|
||||||
import { Collapse, Tag } from 'antd';
|
import { Collapse, Tag } from 'antd';
|
||||||
|
import { RightOutlined } from '@ant-design/icons';
|
||||||
import type { RetrievedSnippet, ToolCallTrace } from '../../../../api';
|
import type { RetrievedSnippet, ToolCallTrace } from '../../../../api';
|
||||||
import Markdown from '../../../../components/Markdown';
|
import Markdown from '../../../../components/Markdown';
|
||||||
|
import './MetaViews.css';
|
||||||
|
|
||||||
export function ReasoningView({ reasoning }: { reasoning: string }) {
|
export function ReasoningView({
|
||||||
|
reasoning,
|
||||||
|
expanded,
|
||||||
|
onToggle
|
||||||
|
}: {
|
||||||
|
reasoning: string;
|
||||||
|
expanded?: boolean;
|
||||||
|
onToggle?: () => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<Collapse
|
<div className={`reasoning-container ${expanded ? 'is-expanded' : ''}`}>
|
||||||
size="small"
|
<div className="reasoning-header" onClick={onToggle}>
|
||||||
ghost
|
<span className="reasoning-label">已深度思考</span>
|
||||||
items={[
|
<RightOutlined className="reasoning-toggle-icon" />
|
||||||
{
|
</div>
|
||||||
key: 'reasoning',
|
{expanded && (
|
||||||
label: <span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>🧠 推理过程</span>,
|
<div className="reasoning-content">
|
||||||
children: (
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.6, maxHeight: 240, overflow: 'auto' }}>
|
|
||||||
<Markdown>{reasoning}</Markdown>
|
<Markdown>{reasoning}</Markdown>
|
||||||
</div>
|
</div>
|
||||||
)
|
)}
|
||||||
}
|
</div>
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -30,24 +36,15 @@ export function RetrievedView({ retrieved }: { retrieved: RetrievedSnippet[] })
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
key: 'rag',
|
key: 'rag',
|
||||||
label: <span style={{ fontSize: 12, color: '#6366f1' }}>🔍 RAG 命中片段 ({retrieved.length})</span>,
|
label: <span className="retrieved-label">RAG 命中片段 ({retrieved.length})</span>,
|
||||||
children: (
|
children: (
|
||||||
<div style={{ fontSize: 12 }}>
|
<div className="retrieved-list">
|
||||||
{retrieved.map((r, i) => (
|
{retrieved.map((r, i) => (
|
||||||
<div
|
<div key={i} className="retrieved-item">
|
||||||
key={i}
|
<div className="retrieved-item-header">
|
||||||
style={{
|
|
||||||
padding: 8,
|
|
||||||
background: '#f6f8ff',
|
|
||||||
borderRadius: 6,
|
|
||||||
marginBottom: 6,
|
|
||||||
borderLeft: '3px solid #6366f1'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ color: '#6366f1', fontWeight: 600, marginBottom: 4 }}>
|
|
||||||
📄 {r.fileName} · 切片#{r.chunkIndex} · score {r.score.toFixed(3)}
|
📄 {r.fileName} · 切片#{r.chunkIndex} · score {r.score.toFixed(3)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ color: '#374151', whiteSpace: 'pre-wrap' }}>
|
<div className="retrieved-item-preview">
|
||||||
{r.preview}
|
{r.preview}
|
||||||
{r.preview.length >= 200 ? '…' : ''}
|
{r.preview.length >= 200 ? '…' : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -70,35 +67,26 @@ export function ToolCallView({ calls, liveStyle }: { calls: ToolCallTrace[]; liv
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
key: 'tc',
|
key: 'tc',
|
||||||
label: <span style={{ fontSize: 12, color: '#f97316' }}>🛠 工具调用 ({calls.length})</span>,
|
label: <span className="tool-call-label">🛠 工具调用 ({calls.length})<RightOutlined /></span>,
|
||||||
children: (
|
children: (
|
||||||
<div style={{ fontSize: 12 }}>
|
<div className="tool-call-list">
|
||||||
{calls.map((t, i) => {
|
{calls.map((t, i) => {
|
||||||
const isPending = (t.result as any)?.pending;
|
const isPending = (t.result as any)?.pending;
|
||||||
const isFailed = t.result?.ok === false;
|
const isFailed = t.result?.ok === false;
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={i} className="tool-call-item">
|
||||||
key={i}
|
<div className="tool-call-item-header">
|
||||||
style={{
|
|
||||||
padding: 8,
|
|
||||||
background: '#fff7ed',
|
|
||||||
borderRadius: 6,
|
|
||||||
marginBottom: 6,
|
|
||||||
borderLeft: '3px solid #f97316'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ color: '#c2410c', fontWeight: 600, marginBottom: 4 }}>
|
|
||||||
⚙️ {t.name} {isPending && <Tag color="processing">运行中…</Tag>}
|
⚙️ {t.name} {isPending && <Tag color="processing">运行中…</Tag>}
|
||||||
{!isPending && t.result?.durationMs != null && <Tag>{t.result.durationMs}ms</Tag>}
|
{!isPending && t.result?.durationMs != null && <Tag>{t.result.durationMs}ms</Tag>}
|
||||||
{isFailed && <Tag color="error">失败</Tag>}
|
{isFailed && <Tag color="error">失败</Tag>}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ color: '#6b7280' }}>
|
<div className="tool-call-item-args">
|
||||||
<b>args:</b> {JSON.stringify(t.args)}
|
<b>args:</b> {JSON.stringify(t.args)}
|
||||||
</div>
|
</div>
|
||||||
{!isPending && (
|
{!isPending && (
|
||||||
<div style={{ color: '#374151', marginTop: 4 }}>
|
<div className="tool-call-item-result">
|
||||||
<b>result:</b>{' '}
|
<b>result:</b>{' '}
|
||||||
<code style={{ wordBreak: 'break-all' }}>
|
<code>
|
||||||
{JSON.stringify(t.result?.result ?? t.result?.error ?? t.result).slice(0, 500)}
|
{JSON.stringify(t.result?.result ?? t.result?.error ?? t.result).slice(0, 500)}
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,13 @@ export function useChatData(args: {
|
||||||
const [agentList, setAgentList] = useState<Agent[]>([]);
|
const [agentList, setAgentList] = useState<Agent[]>([]);
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
const [branches, setBranches] = useState<Record<string, BranchInfo>>({});
|
const [branches, setBranches] = useState<Record<string, BranchInfo>>({});
|
||||||
|
const [roomInvalidTick, setRoomInvalidTick] = useState(0);
|
||||||
const loadSeqRef = useRef(0);
|
const loadSeqRef = useRef(0);
|
||||||
|
const initialHighlightDoneRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initialHighlightDoneRef.current = false;
|
||||||
|
}, [agentId, roomId]);
|
||||||
|
|
||||||
const loadAgent = async () => {
|
const loadAgent = async () => {
|
||||||
if (!agentId) {
|
if (!agentId) {
|
||||||
|
|
@ -30,7 +36,7 @@ export function useChatData(args: {
|
||||||
}
|
}
|
||||||
const a = await AgentAPI.detail(agentId);
|
const a = await AgentAPI.detail(agentId);
|
||||||
setAgent(a);
|
setAgent(a);
|
||||||
const models = parseAgentModels(a.model);
|
const models = parseAgentModels(a.models ?? a.model);
|
||||||
const firstModel = models[0];
|
const firstModel = models[0];
|
||||||
if (firstModel) {
|
if (firstModel) {
|
||||||
setOverrides((o) => ({
|
setOverrides((o) => ({
|
||||||
|
|
@ -55,19 +61,25 @@ export function useChatData(args: {
|
||||||
if (!roomId) return;
|
if (!roomId) return;
|
||||||
const seq = ++loadSeqRef.current;
|
const seq = ++loadSeqRef.current;
|
||||||
const rid = roomId;
|
const rid = roomId;
|
||||||
|
try {
|
||||||
const his = await ChatAPI.history(rid);
|
const his = await ChatAPI.history(rid);
|
||||||
if (seq !== loadSeqRef.current) return;
|
if (seq !== loadSeqRef.current) return;
|
||||||
if (rid !== roomId) return;
|
if (rid !== roomId) return;
|
||||||
setMessages(Array.isArray(his.messages) ? his.messages : []);
|
setMessages(Array.isArray(his.messages) ? his.messages : []);
|
||||||
setBranches(his.branches || {});
|
setBranches(his.branches || {});
|
||||||
requestAnimationFrame(() => {
|
} catch (e: any) {
|
||||||
if (!initialScrollDoneRef.current) {
|
// 会话不存在或无权访问时,通知上层重新创建会话
|
||||||
scrollBottom(true);
|
if (seq === loadSeqRef.current && rid === roomId) {
|
||||||
initialScrollDoneRef.current = true;
|
const code = e?.response?.data?.code;
|
||||||
} else {
|
const msg = e?.response?.data?.error ?? e?.message ?? '';
|
||||||
scrollBottom();
|
if (code === '500000' || /无权访问|不存在/.test(msg)) {
|
||||||
|
setRoomInvalidTick((t) => t + 1);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 历史消息加载完成后的处理
|
||||||
|
// 注意:这里不再手动触发滚动,全部交给 ChatBody 的 useLayoutEffect 和 useEffect 处理
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -104,10 +116,26 @@ export function useChatData(args: {
|
||||||
if (!highlightId) return;
|
if (!highlightId) return;
|
||||||
const el = document.getElementById('msg-' + highlightId);
|
const el = document.getElementById('msg-' + highlightId);
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
const t = setTimeout(() => setHighlightId(null), 3000);
|
// 如果是房间加载后的第一次高亮(通常是系统自动选中的最后一条消息)
|
||||||
return () => clearTimeout(t);
|
const isFirstHighlight = !initialHighlightDoneRef.current;
|
||||||
}, [highlightId, setHighlightId]);
|
|
||||||
|
// 逻辑:如果是初始加载,且该消息是最后一条消息,则不触发 scrollIntoView(start)
|
||||||
|
// 这样可以保留 ChatBody 的 scrollBottom 效果(看到消息的尾部)
|
||||||
|
const isLastMessage = messages.length > 0 && messages[messages.length - 1].id === highlightId;
|
||||||
|
|
||||||
|
if (isFirstHighlight && isLastMessage) {
|
||||||
|
initialHighlightDoneRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const behavior = isFirstHighlight ? 'instant' : 'smooth';
|
||||||
|
el.scrollIntoView({ behavior, block: 'start' });
|
||||||
|
|
||||||
|
if (isFirstHighlight) {
|
||||||
|
initialHighlightDoneRef.current = true;
|
||||||
|
}
|
||||||
|
}, [highlightId, messages]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
agent,
|
agent,
|
||||||
|
|
@ -116,6 +144,7 @@ export function useChatData(args: {
|
||||||
setMessages,
|
setMessages,
|
||||||
branches,
|
branches,
|
||||||
setBranches,
|
setBranches,
|
||||||
loadMessages
|
loadMessages,
|
||||||
|
roomInvalidTick
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef, useCallback } from 'react';
|
||||||
|
|
||||||
export function useChatScroll() {
|
export function useChatScroll() {
|
||||||
const bodyRef = useRef<HTMLDivElement>(null);
|
const bodyRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -6,30 +6,40 @@ export function useChatScroll() {
|
||||||
const initialScrollDoneRef = useRef(false);
|
const initialScrollDoneRef = useRef(false);
|
||||||
const scrollRafRef = useRef<number | null>(null);
|
const scrollRafRef = useRef<number | null>(null);
|
||||||
const scrollProgrammaticAtRef = useRef(0);
|
const scrollProgrammaticAtRef = useRef(0);
|
||||||
|
const forceScrollPendingRef = useRef(false);
|
||||||
const lastScrollTopRef = useRef(0);
|
const lastScrollTopRef = useRef(0);
|
||||||
const userScrollLockRef = useRef(false);
|
const userScrollLockRef = useRef(false);
|
||||||
const lastUserScrollAtRef = useRef(0);
|
const lastUserScrollAtRef = useRef(0);
|
||||||
const lastUserScrollTypeRef = useRef<string>('');
|
const lastUserScrollTypeRef = useRef<string>('');
|
||||||
const attachRetryTimerRef = useRef<number | null>(null);
|
const attachRetryTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const scrollBottom = (force = false) => {
|
const scrollBottom = useCallback((force = false) => {
|
||||||
if (force) {
|
if (force) {
|
||||||
userScrollLockRef.current = false;
|
userScrollLockRef.current = false;
|
||||||
autoScrollRef.current = true;
|
autoScrollRef.current = true;
|
||||||
|
forceScrollPendingRef.current = true;
|
||||||
|
}
|
||||||
|
if (!force && (!autoScrollRef.current || userScrollLockRef.current)) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (!force && (!autoScrollRef.current || userScrollLockRef.current)) return;
|
|
||||||
if (scrollRafRef.current) {
|
if (scrollRafRef.current) {
|
||||||
cancelAnimationFrame(scrollRafRef.current);
|
cancelAnimationFrame(scrollRafRef.current);
|
||||||
scrollRafRef.current = null;
|
scrollRafRef.current = null;
|
||||||
}
|
}
|
||||||
scrollRafRef.current = requestAnimationFrame(() => {
|
scrollRafRef.current = requestAnimationFrame(() => {
|
||||||
scrollRafRef.current = null;
|
scrollRafRef.current = null;
|
||||||
|
forceScrollPendingRef.current = false;
|
||||||
const el = bodyRef.current;
|
const el = bodyRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
scrollProgrammaticAtRef.current = Date.now();
|
scrollProgrammaticAtRef.current = Date.now();
|
||||||
el.scrollTop = el.scrollHeight;
|
|
||||||
|
// 恢复正向滚动:scrollTop = scrollHeight
|
||||||
|
el.scrollTo({
|
||||||
|
top: el.scrollHeight,
|
||||||
|
behavior: force ? 'instant' : 'smooth'
|
||||||
});
|
});
|
||||||
};
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const cancelAutoScroll = (type: string) => {
|
const cancelAutoScroll = (type: string) => {
|
||||||
userScrollLockRef.current = true;
|
userScrollLockRef.current = true;
|
||||||
|
|
@ -52,10 +62,13 @@ export function useChatScroll() {
|
||||||
const isProgrammatic = now - scrollProgrammaticAtRef.current < 50;
|
const isProgrammatic = now - scrollProgrammaticAtRef.current < 50;
|
||||||
const nextTop = el.scrollTop;
|
const nextTop = el.scrollTop;
|
||||||
const lastTop = lastScrollTopRef.current;
|
const lastTop = lastScrollTopRef.current;
|
||||||
|
|
||||||
|
// 恢复正向距离计算:距离底部 = 总高度 - 当前滚动高度 - 容器可见高度
|
||||||
const distance = el.scrollHeight - nextTop - el.clientHeight;
|
const distance = el.scrollHeight - nextTop - el.clientHeight;
|
||||||
|
|
||||||
const scrollDelta = nextTop - lastTop;
|
const scrollDelta = nextTop - lastTop;
|
||||||
if (scrollDelta < 0) {
|
// scrollDelta < 0 表示向上滚动
|
||||||
|
if (scrollDelta < 0 && !forceScrollPendingRef.current) {
|
||||||
cancelAutoScroll(isProgrammatic ? 'scroll(up)+programmatic' : 'scroll(up)');
|
cancelAutoScroll(isProgrammatic ? 'scroll(up)+programmatic' : 'scroll(up)');
|
||||||
}
|
}
|
||||||
lastScrollTopRef.current = nextTop;
|
lastScrollTopRef.current = nextTop;
|
||||||
|
|
@ -67,11 +80,19 @@ export function useChatScroll() {
|
||||||
userScrollLockRef.current = false;
|
userScrollLockRef.current = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 只有当不是程序化滚动,且没有强制滚动在等待时,才根据距离更新 autoScroll 状态
|
||||||
|
if (!isProgrammatic && !forceScrollPendingRef.current) {
|
||||||
autoScrollRef.current = distance < 32 && !userScrollLockRef.current;
|
autoScrollRef.current = distance < 32 && !userScrollLockRef.current;
|
||||||
if ((!autoScrollRef.current || userScrollLockRef.current) && scrollRafRef.current) {
|
}
|
||||||
|
|
||||||
|
// 如果 autoScroll 被关闭或者是用户锁定状态,且当前有一个待执行的 RAF
|
||||||
|
if ((!autoScrollRef.current || userScrollLockRef.current) && scrollRafRef.current && !isProgrammatic && !forceScrollPendingRef.current) {
|
||||||
|
// 只有当明确是用户向上滚动了,才取消待执行的程序化滚动
|
||||||
|
if (scrollDelta < 0) {
|
||||||
cancelAnimationFrame(scrollRafRef.current);
|
cancelAnimationFrame(scrollRafRef.current);
|
||||||
scrollRafRef.current = null;
|
scrollRafRef.current = null;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onWindowWheelCapture = (e: WheelEvent) => {
|
const onWindowWheelCapture = (e: WheelEvent) => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import type { Agent, BranchInfo, ChatAttachment, ChatMessage, ModelOverrides, RetrievedSnippet, ToolCallTrace } from '../../../api';
|
import type { Agent, BranchInfo, ChatAttachment, ChatMessage, ModelOverrides, RetrievedSnippet, ToolCallTrace } from '../../../api';
|
||||||
import { ChatAPI, ChatAttachmentsAPI, ImageAPI, regenerateMessage, streamChat } from '../../../api';
|
import { ChatAPI, ChatAttachmentsAPI, ImageAPI, streamChat } from '../../../api';
|
||||||
import { buildAttachmentsText } from '../utils/attachments';
|
import { buildAttachmentsText } from '../utils/attachments';
|
||||||
import { parseAgentModels } from '../utils/agentModels';
|
import { parseAgentModels } from '../utils/agentModels';
|
||||||
|
|
||||||
|
|
@ -114,7 +114,7 @@ export function useChatSender(args: {
|
||||||
});
|
});
|
||||||
const [sessionRefresh, setSessionRefresh] = useState(0);
|
const [sessionRefresh, setSessionRefresh] = useState(0);
|
||||||
|
|
||||||
const agentModels = useMemo(() => parseAgentModels(agent?.model), [agent?.model]);
|
const agentModels = useMemo(() => parseAgentModels(agent?.models ?? agent?.model), [agent?.model, agent?.models]);
|
||||||
const modelOptions = useMemo(() => agentModels.map((model) => ({ value: model.id, label: model.name })), [agentModels]);
|
const modelOptions = useMemo(() => agentModels.map((model) => ({ value: model.id, label: model.name })), [agentModels]);
|
||||||
const activeModelValue = overrides.model_id || '';
|
const activeModelValue = overrides.model_id || '';
|
||||||
|
|
||||||
|
|
@ -141,7 +141,7 @@ export function useChatSender(args: {
|
||||||
let targetModel: string;
|
let targetModel: string;
|
||||||
let targetModelId: string;
|
let targetModelId: string;
|
||||||
if (targetAgent && targetAgent.id !== agentId) {
|
if (targetAgent && targetAgent.id !== agentId) {
|
||||||
const models = parseAgentModels(targetAgent.model);
|
const models = parseAgentModels(targetAgent.models ?? targetAgent.model);
|
||||||
targetModel = models[0]?.name || '';
|
targetModel = models[0]?.name || '';
|
||||||
targetModelId = models[0]?.id || '';
|
targetModelId = models[0]?.id || '';
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -166,7 +166,10 @@ export function useChatSender(args: {
|
||||||
onRetry: (data) => {
|
onRetry: (data) => {
|
||||||
setStreaming((s) => ({ ...s, retryInfo: data }));
|
setStreaming((s) => ({ ...s, retryInfo: data }));
|
||||||
if (data?.stage === 'fallback_model' && data?.toModel) {
|
if (data?.stage === 'fallback_model' && data?.toModel) {
|
||||||
setOverrides((o) => ({ ...o, model: String(data.toModel) }));
|
const nextModel = parseAgentModels(data.toModel)[0];
|
||||||
|
if (nextModel) {
|
||||||
|
setOverrides((o) => ({ ...o, model: nextModel.name, model_id: nextModel.id }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onReasoningDelta: (chunk) =>
|
onReasoningDelta: (chunk) =>
|
||||||
|
|
@ -235,8 +238,7 @@ export function useChatSender(args: {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
ctrl.signal,
|
ctrl.signal,
|
||||||
targetModel,
|
{ ...overrides, model: targetModel, model_id: targetModelId },
|
||||||
targetModelId,
|
|
||||||
imageUrls
|
imageUrls
|
||||||
);
|
);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|
@ -273,7 +275,7 @@ export function useChatSender(args: {
|
||||||
let targetModel: string;
|
let targetModel: string;
|
||||||
let targetModelId: string;
|
let targetModelId: string;
|
||||||
if (targetAgent && targetAgent.id !== agentId) {
|
if (targetAgent && targetAgent.id !== agentId) {
|
||||||
const models = parseAgentModels(targetAgent.model);
|
const models = parseAgentModels(targetAgent.models ?? targetAgent.model);
|
||||||
targetModel = models[0]?.name || '';
|
targetModel = models[0]?.name || '';
|
||||||
targetModelId = models[0]?.id || '';
|
targetModelId = models[0]?.id || '';
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -284,7 +286,7 @@ export function useChatSender(args: {
|
||||||
const attText = buildAttachmentsText(attachments);
|
const attText = buildAttachmentsText(attachments);
|
||||||
const content = attText ? `${text}\n\n${attText}` : text;
|
const content = attText ? `${text}\n\n${attText}` : text;
|
||||||
try {
|
try {
|
||||||
const res = await ChatAPI.send(roomId, content, targetAgentId, targetModel, targetModelId, imageUrls);
|
const res = await ChatAPI.send(roomId, content, targetAgentId, { ...overrides, model: targetModel, model_id: targetModelId }, imageUrls);
|
||||||
args.setMessages((m) => [...(m || []).filter((x) => x.id !== tempUser.id), res.user, res.assistant]);
|
args.setMessages((m) => [...(m || []).filter((x) => x.id !== tempUser.id), res.user, res.assistant]);
|
||||||
setSessionRefresh((t) => t + 1);
|
setSessionRefresh((t) => t + 1);
|
||||||
setAttachments([]);
|
setAttachments([]);
|
||||||
|
|
@ -327,65 +329,25 @@ export function useChatSender(args: {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRegenerate = async (assistantId: string) => {
|
const handleRegenerate = async (assistantId: string) => {
|
||||||
if (!agentId || sending) return;
|
if (!agentId || sending || !roomId) return;
|
||||||
|
|
||||||
|
// 找到当前要重新生成的助手消息,并回溯找到它的父消息(用户消息)
|
||||||
|
const assistantMsg = args.messages.find((m) => m.id === assistantId);
|
||||||
|
const userMsgId = assistantMsg?.parentId;
|
||||||
|
const userMsg = args.messages.find((m) => m.id === userMsgId);
|
||||||
|
|
||||||
|
// 如果找不到父消息,则尝试找最后一条用户消息
|
||||||
|
const lastUserMsg = [...args.messages].reverse().find((m) => m.role === 'user');
|
||||||
|
const targetUserMsg = userMsg || lastUserMsg;
|
||||||
|
|
||||||
|
if (!targetUserMsg) {
|
||||||
|
notify.error('找不到上一条用户消息');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setSending(true);
|
setSending(true);
|
||||||
setStreaming({ active: true, reasoningText: '', answerText: '', errorMessage: null, retryInfo: null, retrieved: [], toolCalls: [] });
|
|
||||||
abortRef.current?.abort();
|
|
||||||
const ctrl = new AbortController();
|
|
||||||
abortRef.current = ctrl;
|
|
||||||
try {
|
try {
|
||||||
await regenerateMessage(
|
await handleSendStream(targetUserMsg.content);
|
||||||
agentId,
|
|
||||||
assistantId,
|
|
||||||
{
|
|
||||||
onMeta: (m) => setStreaming((s) => ({ ...s, retrieved: m.retrieved || [] })),
|
|
||||||
onRetry: (data) => {
|
|
||||||
setStreaming((s) => ({ ...s, retryInfo: data }));
|
|
||||||
if (data?.stage === 'fallback_model' && data?.toModel) {
|
|
||||||
setOverrides((o) => ({ ...o, model: String(data.toModel) }));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onReasoningDelta: (chunk) =>
|
|
||||||
setStreaming((s) => {
|
|
||||||
const next = { ...s, reasoningText: s.reasoningText + chunk };
|
|
||||||
scrollBottom();
|
|
||||||
return next;
|
|
||||||
}),
|
|
||||||
onDelta: (chunk) =>
|
|
||||||
setStreaming((s) => {
|
|
||||||
const next = { ...s, answerText: s.answerText + chunk };
|
|
||||||
scrollBottom();
|
|
||||||
return next;
|
|
||||||
}),
|
|
||||||
onToolCall: (data) => setStreaming((s) => ({ ...s, toolCalls: [...s.toolCalls, { name: data.name, args: data.args, result: { pending: true } }] })),
|
|
||||||
onToolResult: (data) =>
|
|
||||||
setStreaming((s) => {
|
|
||||||
const list = [...s.toolCalls];
|
|
||||||
for (let i = list.length - 1; i >= 0; i--) {
|
|
||||||
if (list[i].name === data.name && (list[i].result as any)?.pending) {
|
|
||||||
list[i] = { ...list[i], result: data.result };
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { ...s, toolCalls: list };
|
|
||||||
}),
|
|
||||||
onDone: () => {
|
|
||||||
setStreaming({ active: false, reasoningText: '', answerText: '', errorMessage: null, retryInfo: null, retrieved: [], toolCalls: [] });
|
|
||||||
loadMessages();
|
|
||||||
},
|
|
||||||
onAborted: () => {
|
|
||||||
setStreaming({ active: false, reasoningText: '', answerText: '', errorMessage: null, retryInfo: null, retrieved: [], toolCalls: [] });
|
|
||||||
loadMessages();
|
|
||||||
},
|
|
||||||
onError: (errMsg) => {
|
|
||||||
notify.error('重新生成失败:' + errMsg);
|
|
||||||
setStreaming({ active: false, reasoningText: '', answerText: '', errorMessage: errMsg, retryInfo: null, retrieved: [], toolCalls: [] });
|
|
||||||
loadMessages();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
ctrl.signal,
|
|
||||||
overrides
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
setSending(false);
|
setSending(false);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
/* 纯会话页面样式 - 无侧边栏,仅保留聊天主区域 */
|
||||||
|
|
||||||
|
.chat-pure-shell {
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-pure-shell .chat-main {
|
||||||
|
flex: 1;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
.chat-shell.desktop-standardPc {
|
.chat-shell.desktop-standardPc {
|
||||||
grid-template-columns: 17.5rem minmax(0, 1fr);
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-shell.desktop-standardPc .chat-content-row {
|
.chat-shell.desktop-standardPc .chat-side {
|
||||||
grid-template-columns: minmax(0, 1fr) 260px;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-shell.desktop-standardPc .chat-outline {
|
.chat-shell.desktop-standardPc .chat-outline {
|
||||||
width: 100%;
|
flex-shrink: 0;
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,15 +11,28 @@
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-shell.desktop-tablet,
|
.chat-content-layout {
|
||||||
.chat-shell.desktop-smallPc,
|
display: flex;
|
||||||
.chat-shell.desktop-standardPc,
|
height: 100%;
|
||||||
.chat-shell.desktop-large2k,
|
width: 100%;
|
||||||
.chat-shell.desktop-ultra4k,
|
overflow: hidden;
|
||||||
.chat-shell.desktop-tablet .chat-content-row,
|
|
||||||
.chat-shell.desktop-smallPc .chat-content-row,
|
|
||||||
.chat-shell.desktop-standardPc .chat-content-row,
|
|
||||||
.chat-shell.desktop-large2k .chat-content-row,
|
|
||||||
.chat-shell.desktop-ultra4k .chat-content-row {
|
|
||||||
display: grid;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-conversation-panel {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-shell.desktop-standardPc {
|
||||||
|
grid-template-columns: 280px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-shell.desktop-standardPc .chat-main {
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,46 @@ export interface AgentModelOption {
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const parseAgentModels = (value?: string): AgentModelOption[] => {
|
// parseAgentModelItem 将后端多种模型结构统一转换为聊天侧使用的模型选项。
|
||||||
|
const parseAgentModelItem = (item: any): AgentModelOption | null => {
|
||||||
|
if (!item) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof item === 'string') {
|
||||||
|
return { id: item, name: item };
|
||||||
|
}
|
||||||
|
if (typeof item === 'object' && item.model?.id) {
|
||||||
|
return {
|
||||||
|
id: String(item.model.id),
|
||||||
|
name: String(item.model.name || item.model.id)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (typeof item === 'object' && item.id) {
|
||||||
|
return {
|
||||||
|
id: String(item.id),
|
||||||
|
name: String(item.name || item.model || item.id)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: String(item),
|
||||||
|
name: String(item)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// parseAgentModels 负责兼容旧 model 字符串和新 models 数组结构。
|
||||||
|
export const parseAgentModels = (value?: unknown): AgentModelOption[] => {
|
||||||
if (!value) return [];
|
if (!value) return [];
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(parseAgentModelItem).filter(Boolean) as AgentModelOption[];
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(value);
|
const parsed = JSON.parse(String(value));
|
||||||
if (Array.isArray(parsed)) {
|
if (Array.isArray(parsed)) {
|
||||||
return parsed.map((item: any) => ({
|
return parsed.map(parseAgentModelItem).filter(Boolean) as AgentModelOption[];
|
||||||
id: typeof item === 'object' ? item.id : String(item),
|
}
|
||||||
name: typeof item === 'object' ? item.name : String(item)
|
if (parsed && typeof parsed === 'object') {
|
||||||
}));
|
const single = parseAgentModelItem(parsed);
|
||||||
|
return single ? [single] : [];
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
|
|
@ -22,4 +53,3 @@ export const parseAgentModels = (value?: string): AgentModelOption[] => {
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map((item) => ({ id: item, name: item }));
|
.map((item) => ({ id: item, name: item }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
/**
|
||||||
|
* 清理消息内容中的冗余换行
|
||||||
|
* 1. 将 3 个及以上的换行替换为 2 个换行
|
||||||
|
* 2. 将特定的 Markdown 块元素前后的双换行替换为单换行,以减少间距
|
||||||
|
*/
|
||||||
|
export function formatMessageContent(content: string): string {
|
||||||
|
if (!content) return '';
|
||||||
|
|
||||||
|
return content
|
||||||
|
// 将 3 个及以上的换行替换为 2 个
|
||||||
|
.replace(/\n{3,}/g, '\n\n');
|
||||||
|
}
|
||||||
425
src/styles.css
425
src/styles.css
|
|
@ -280,111 +280,6 @@ body {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-content {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
min-height: 0;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-chat {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
|
||||||
width: 248px;
|
|
||||||
background: var(--color-surface);
|
|
||||||
border-right: 1px solid var(--color-border);
|
|
||||||
color: var(--color-text);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 14px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar .brand {
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--color-text);
|
|
||||||
padding: 6px 10px 18px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar .brand .brand-logo {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
object-fit: contain;
|
|
||||||
filter: drop-shadow(0 8px 16px rgba(17, 103, 255, 0.16));
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar .nav-section-label {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--color-text-tertiary);
|
|
||||||
padding: 14px 12px 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar .nav-item {
|
|
||||||
padding: 7px 12px;
|
|
||||||
border-radius: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
margin-bottom: 1px;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 13.5px;
|
|
||||||
font-weight: 500;
|
|
||||||
transition: background 0.15s ease, color 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar .nav-item:hover {
|
|
||||||
background: var(--color-surface-2);
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar .nav-item.active {
|
|
||||||
background: var(--color-brand-soft);
|
|
||||||
color: var(--color-brand);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar .nav-item .nav-icon {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar .kbd {
|
|
||||||
font-size: 10.5px;
|
|
||||||
color: var(--color-text-tertiary);
|
|
||||||
background: var(--color-surface-2);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
padding: 1px 5px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-user {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 8px 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: var(--color-surface-2);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-card {
|
.agent-card {
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
|
|
@ -468,17 +363,6 @@ body {
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-side {
|
|
||||||
width: 260px;
|
|
||||||
border-right: 1px solid var(--color-border);
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0;
|
|
||||||
height: 100%;
|
|
||||||
background: var(--color-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-main {
|
.chat-main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -494,135 +378,12 @@ body {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-header {
|
|
||||||
height: 60px;
|
|
||||||
padding: 0 24px;
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
background: var(--color-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-header-agent {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-header-agent-title {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 10px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-header-agent-name {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 16px;
|
|
||||||
color: var(--color-text);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
max-width: 320px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-header-agent-desc {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--color-text-tertiary);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
max-width: 380px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-header-agent-meta {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--color-text-tertiary);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
max-width: 720px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-header-stream-toggle {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
margin-right: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-h5 .chat-header-stream-toggle {
|
|
||||||
margin-right: 4px;;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-header-stream-label {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-body {
|
.chat-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-outline {
|
|
||||||
width: 260px;
|
|
||||||
border-left: 1px solid var(--color-border);
|
|
||||||
background: var(--color-surface);
|
|
||||||
padding: 14px 12px;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-outline-title {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-outline-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-outline-item {
|
|
||||||
border: 0;
|
|
||||||
background: var(--color-surface);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 4px 10px;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: flex-start;
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-outline-item.active {
|
|
||||||
background-color: #eee;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-outline-index {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--color-text-tertiary);
|
|
||||||
line-height: 1.4;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-outline-text {
|
|
||||||
font-size: 12.5px;
|
|
||||||
line-height: 1.4;
|
|
||||||
color: var(--color-text);
|
|
||||||
overflow: hidden;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 1;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.chat-content-row > .chat-outline {
|
.chat-content-row > .chat-outline {
|
||||||
display: none;
|
display: none;
|
||||||
|
|
@ -703,11 +464,6 @@ body {
|
||||||
padding: 1.125rem 0.75rem 6rem;
|
padding: 1.125rem 0.75rem 6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-input-wrapper {
|
|
||||||
max-width: 100%;
|
|
||||||
padding: 0 0.75rem 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-input-actions {
|
.chat-input-actions {
|
||||||
top: -22px;
|
top: -22px;
|
||||||
right: 0;
|
right: 0;
|
||||||
|
|
@ -747,98 +503,70 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-content {
|
.main-content {
|
||||||
min-height: 0;
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-body .messages-container {
|
.chat-body .messages-container {
|
||||||
max-width: 780px;
|
max-width: 1080px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 16px 12px 80px;
|
padding: 16px 12px 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble {
|
|
||||||
max-width: 78%;
|
|
||||||
display: inline-block;
|
|
||||||
padding: 8px;
|
|
||||||
border-radius: 14px;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-wrap: break-word;
|
|
||||||
line-height: 1.5;
|
|
||||||
font-size: 14.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-h5 .bubble {
|
.is-h5 .bubble {
|
||||||
max-width: 94%;
|
/* max-width: 94%; */
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble.user {
|
.bubble.assistant p,
|
||||||
background: #0a84ff;
|
.bubble.assistant h1,
|
||||||
color: #ffffff;
|
.bubble.assistant h2,
|
||||||
margin-left: auto;
|
.bubble.assistant h3,
|
||||||
border-bottom-right-radius: 5px;
|
.bubble.assistant h4,
|
||||||
|
.bubble.assistant h5,
|
||||||
|
.bubble.assistant h6,
|
||||||
|
.bubble.assistant li {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble.assistant {
|
.bubble.assistant ol,
|
||||||
background: #edf1f6;
|
.bubble.assistant ul {
|
||||||
color: #111827;
|
margin: 0;
|
||||||
border: 0;
|
padding-left: 1.5em;
|
||||||
border-bottom-left-radius: 5px;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble.assistant p {
|
.bubble.assistant p {
|
||||||
margin: 0 0 5px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble.assistant p:last-child {
|
.bubble.assistant p:last-child {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble.assistant h1,
|
|
||||||
.bubble.assistant h2,
|
|
||||||
.bubble.assistant h3,
|
|
||||||
.bubble.assistant h4,
|
|
||||||
.bubble.assistant h5,
|
|
||||||
.bubble.assistant h6 {
|
|
||||||
margin: 0.2em 0 0.1em;
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bubble.assistant ol,
|
|
||||||
.bubble.assistant ul {
|
|
||||||
margin: 0.15em 0;
|
|
||||||
padding-left: 1.25em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bubble.assistant li {
|
|
||||||
margin: 0.05em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bubble.assistant hr {
|
.bubble.assistant hr {
|
||||||
margin: 0.4em 0;
|
margin: 12px 0;
|
||||||
}
|
border: 0;
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
.bubble.assistant li > p {
|
|
||||||
margin: 0.15em 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble.assistant blockquote {
|
.bubble.assistant blockquote {
|
||||||
margin: 0.2em 0;
|
margin: 8px 0;
|
||||||
padding: 0.2em 0.6em;
|
padding: 4px 12px;
|
||||||
border-left: 3px solid var(--color-border);
|
border-left: 3px solid var(--color-border);
|
||||||
background: rgba(15, 23, 42, 0.03);
|
background: rgba(15, 23, 42, 0.03);
|
||||||
border-radius: 8px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble.assistant blockquote p {
|
.bubble.assistant blockquote p {
|
||||||
margin: 0.2em 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble.assistant table {
|
.bubble.assistant table {
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0.35em 0;
|
margin: 12px 0;
|
||||||
font-size: 13.5px;
|
font-size: 13.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -855,27 +583,6 @@ body {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-input-wrapper {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 820px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 0 24px 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-input-card {
|
|
||||||
width: 100%;
|
|
||||||
background: var(--color-surface);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: 20px;
|
|
||||||
padding: 14px 16px 12px;
|
|
||||||
min-height: 110px;
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-input-card-wrap {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-input-actions {
|
.chat-input-actions {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -24px;
|
top: -24px;
|
||||||
|
|
@ -1239,14 +946,16 @@ body {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.agent-model-checkbox-group {
|
.agent-model-checkbox-group,
|
||||||
|
.agent-model-radio-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.agent-model-checkbox-item {
|
.agent-model-checkbox-item,
|
||||||
|
.agent-model-radio-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
margin-inline-start: 0;
|
margin-inline-start: 0;
|
||||||
|
|
@ -1256,15 +965,52 @@ body {
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.agent-model-checkbox-item .ant-checkbox {
|
.agent-model-checkbox-item .ant-checkbox,
|
||||||
|
.agent-model-radio-item .ant-radio {
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.agent-model-checkbox-item .ant-checkbox + span {
|
.agent-model-checkbox-item .ant-checkbox + span,
|
||||||
|
.agent-model-radio-item .ant-radio + span {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding-inline-start: 10px;
|
padding-inline-start: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agent-model-radio-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-model-radio-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-model-radio-name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-model-radio-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);
|
||||||
|
}
|
||||||
|
|
||||||
.agent-model-checkbox-content {
|
.agent-model-checkbox-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -1281,7 +1027,7 @@ body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.agent-model-checkbox-icon {
|
.agent-model-radio-icon {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
|
|
@ -1675,10 +1421,6 @@ body {
|
||||||
.border-l { border-left: 1px solid var(--color-border); }
|
.border-l { border-left: 1px solid var(--color-border); }
|
||||||
.shadow-lg { box-shadow: var(--shadow-lg); }
|
.shadow-lg { box-shadow: var(--shadow-lg); }
|
||||||
|
|
||||||
.ant-btn-primary {
|
|
||||||
box-shadow: 0 1px 2px rgba(194, 84, 31, 0.18) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-input,
|
.ant-input,
|
||||||
.ant-input-affix-wrapper,
|
.ant-input-affix-wrapper,
|
||||||
.ant-input-number,
|
.ant-input-number,
|
||||||
|
|
@ -1700,14 +1442,6 @@ body {
|
||||||
border-color: var(--color-border-strong) !important;
|
border-color: var(--color-border-strong) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-input-affix-wrapper-focused,
|
|
||||||
.ant-input:focus,
|
|
||||||
.ant-select-focused .ant-select-selector,
|
|
||||||
.ant-picker-focused {
|
|
||||||
border-color: var(--color-brand) !important;
|
|
||||||
box-shadow: var(--shadow-focus) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-card {
|
.ant-card {
|
||||||
background: var(--color-surface) !important;
|
background: var(--color-surface) !important;
|
||||||
border-color: var(--color-border) !important;
|
border-color: var(--color-border) !important;
|
||||||
|
|
@ -1747,12 +1481,6 @@ span.mention {
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble.user span.mention {
|
|
||||||
background: rgba(255, 159, 10, 0.22);
|
|
||||||
border: 1px solid rgba(255, 159, 10, 0.38);
|
|
||||||
color: #ff9f0a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-collapse-content {
|
.ant-collapse-content {
|
||||||
background: var(--color-surface) !important;
|
background: var(--color-surface) !important;
|
||||||
color: var(--color-text) !important;
|
color: var(--color-text) !important;
|
||||||
|
|
@ -1894,7 +1622,8 @@ span.mention {
|
||||||
}
|
}
|
||||||
|
|
||||||
.points-mall-banner-section {
|
.points-mall-banner-section {
|
||||||
display: grid;
|
/* display: grid; */
|
||||||
|
display: none;
|
||||||
grid-template-columns: minmax(0, 1.55fr) minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1.55fr) minmax(0, 1fr);
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
|
|
@ -2366,6 +2095,12 @@ span.mention {
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stats-page-agent-list {
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.stats-page-agent-item {
|
.stats-page-agent-item {
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
|
|
|
||||||
|
|
@ -1,58 +1,59 @@
|
||||||
:root,
|
:root,
|
||||||
[data-theme='light'] {
|
[data-theme='light'] {
|
||||||
--color-bg: #f5f9ff;
|
--color-bg: #FAFCFC;
|
||||||
--color-surface: #ffffff;
|
--color-surface: #ffffff;
|
||||||
--color-surface-2: #eef5ff;
|
--color-surface-2: #edfdfd;
|
||||||
--color-surface-3: #dceaff;
|
--color-surface-3: #e6fffa;
|
||||||
--color-border: #d6e5fb;
|
--color-border: #E0EBEB;
|
||||||
--color-border-strong: #a9c8f6;
|
--color-border-strong: #cbd5e0;
|
||||||
--color-border-focus: #1167ff;
|
--color-border-focus: #4fd1c5;
|
||||||
--color-text: #06143f;
|
--color-text: #0F1F2E;
|
||||||
--color-text-secondary: #405784;
|
--color-text-secondary: #0F1F2E;
|
||||||
--color-text-tertiary: #7c8caf;
|
--color-text-tertiary: #718096;
|
||||||
--color-brand: #1167ff;
|
--color-primary: #5CCFC4;
|
||||||
--color-brand-hover: #0754df;
|
--color-brand: #4fd1c5;
|
||||||
--color-brand-soft: #eaf3ff;
|
--color-brand-hover: #38b2ac;
|
||||||
--color-brand-soft-2: #d7e8ff;
|
--color-brand-soft: #e6fffa;
|
||||||
--color-success: #0d9f6e;
|
--color-brand-soft-2: #b2f5ea;
|
||||||
--color-success-soft: #e7f8f1;
|
--color-success: #38a169;
|
||||||
--color-warning: #b7791f;
|
--color-success-soft: #f0fff4;
|
||||||
--color-warning-soft: #fff5dc;
|
--color-warning: #d69e2e;
|
||||||
--color-danger: #cf3434;
|
--color-warning-soft: #fffff0;
|
||||||
--color-danger-soft: #ffeaea;
|
--color-danger: #e53e3e;
|
||||||
--color-info: #1e86ff;
|
--color-danger-soft: #fff5f5;
|
||||||
--color-info-soft: #e8f3ff;
|
--color-info: #3182ce;
|
||||||
--shadow-xs: 0 1px 2px rgba(6, 20, 63, 0.04);
|
--color-info-soft: #ebf8ff;
|
||||||
--shadow-sm: 0 2px 8px rgba(17, 103, 255, 0.06);
|
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
--shadow-md: 0 10px 26px rgba(17, 103, 255, 0.1);
|
--shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||||
--shadow-lg: 0 18px 46px rgba(17, 103, 255, 0.14);
|
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
--shadow-xl: 0 24px 70px rgba(6, 20, 63, 0.16);
|
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||||
--shadow-focus: 0 0 0 3px rgba(17, 103, 255, 0.18);
|
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||||
--gradient-brand: linear-gradient(135deg, #0b39a8 0%, #1167ff 48%, #22a6ff 100%);
|
--shadow-focus: 0 0 0 3px rgba(79, 209, 197, 0.2);
|
||||||
--gradient-hero: radial-gradient(900px 420px at 4% 0%, rgba(34, 166, 255, 0.18), transparent 62%),
|
--gradient-brand: linear-gradient(135deg, #4fd1c5 0%, #38b2ac 100%);
|
||||||
radial-gradient(760px 420px at 100% 8%, rgba(17, 103, 255, 0.14), transparent 58%),
|
--gradient-hero: radial-gradient(900px 420px at 4% 0%, rgba(79, 209, 197, 0.15), transparent 60%),
|
||||||
linear-gradient(180deg, #f8fbff 0%, #eef6ff 100%);
|
radial-gradient(760px 420px at 100% 8%, rgba(56, 178, 172, 0.1), transparent 50%),
|
||||||
|
linear-gradient(180deg, #ffffff 0%, #f7fafc 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme='dark'] {
|
[data-theme='dark'] {
|
||||||
--color-bg: #071126;
|
--color-bg: #0f172a;
|
||||||
--color-surface: #0c1730;
|
--color-surface: #1e293b;
|
||||||
--color-surface-2: #111f3c;
|
--color-surface-2: #1e293b;
|
||||||
--color-surface-3: #17294c;
|
--color-surface-3: #334155;
|
||||||
--color-border: #1f3764;
|
--color-border: #334155;
|
||||||
--color-border-strong: #31558f;
|
--color-border-strong: #475569;
|
||||||
--color-border-focus: #55a5ff;
|
--color-border-focus: #4fd1c5;
|
||||||
--color-text: #edf5ff;
|
--color-text: #f8fafc;
|
||||||
--color-text-secondary: #b6c8e8;
|
--color-text-secondary: #94a3b8;
|
||||||
--color-text-tertiary: #7f93ba;
|
--color-text-tertiary: #64748b;
|
||||||
--color-brand: #55a5ff;
|
--color-brand: #4fd1c5;
|
||||||
--color-brand-hover: #7bbaff;
|
--color-brand-hover: #38b2ac;
|
||||||
--color-brand-soft: #10284f;
|
--color-brand-soft: #134e4a;
|
||||||
--color-brand-soft-2: #17396c;
|
--color-brand-soft-2: #115e59;
|
||||||
--color-info: #72b7ff;
|
--color-info: #60a5fa;
|
||||||
--color-info-soft: #10284f;
|
--color-info-soft: #1e3a8a;
|
||||||
--gradient-brand: linear-gradient(135deg, #0b39a8 0%, #1167ff 54%, #55c2ff 100%);
|
--gradient-brand: linear-gradient(135deg, #4fd1c5 0%, #38b2ac 100%);
|
||||||
--gradient-hero: radial-gradient(900px 420px at 4% 0%, rgba(85, 165, 255, 0.18), transparent 62%),
|
--gradient-hero: radial-gradient(900px 420px at 4% 0%, rgba(79, 209, 197, 0.18), transparent 62%),
|
||||||
radial-gradient(760px 420px at 100% 8%, rgba(17, 103, 255, 0.16), transparent 58%),
|
radial-gradient(760px 420px at 100% 8%, rgba(56, 178, 172, 0.16), transparent 58%),
|
||||||
linear-gradient(180deg, #071126 0%, #0c1730 100%);
|
linear-gradient(180deg, #0f172a 0%, #1e293b 100%);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,30 @@
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig, loadEnv } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
export default defineConfig(({ command }) => ({
|
// 默认走 Go 后端 :4001;要回退 Node 后端就 set VITE_API_TARGET=http://localhost:4000
|
||||||
|
export default defineConfig(({ command, mode }) => {
|
||||||
|
const env = loadEnv(mode, process.cwd(), '');
|
||||||
|
const target = env.VITE_API_TARGET || 'https://tianchaoai.cc';
|
||||||
|
|
||||||
|
return {
|
||||||
// 本地开发走根路径,生产构建部署到 /aura 子路径
|
// 本地开发走根路径,生产构建部署到 /aura 子路径
|
||||||
base: command === 'serve' ? '/' : '/aura/',
|
base: command === 'serve' ? '/' : '/aura/',
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
port: 3001
|
port: 3001,
|
||||||
|
proxy: {
|
||||||
|
'/api/v1': {
|
||||||
|
target,
|
||||||
|
changeOrigin: true,
|
||||||
|
// SSE 不要被压缩;保持长连接
|
||||||
|
configure: (proxy) => {
|
||||||
|
proxy.on('proxyReq', (proxyReq) => {
|
||||||
|
proxyReq.setHeader('Accept-Encoding', 'identity');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}));
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue