Compare commits

..

43 Commits

Author SHA1 Message Date
yannyang 08b310e1eb fix: change pay api params 2026-08-04 15:11:44 +08:00
sp mac bookpro 2605 409bbcc297 fix: 对齐智能体模型新接口并放宽登录手机号校验 2026-08-04 00:51:44 +08:00
yannyang 46ddf0652a fix: fix show model name 2026-08-03 21:24:12 +08:00
yannyang cabea3f83a fix: change agent model to single 2026-08-03 18:35:28 +08:00
yannyang b0de11aff6 feat: finish wechat pay 2026-08-03 18:28:02 +08:00
sp mac bookpro 2605 bb4c974b74 修复访问他人会话时提示无权访问并卡死的问题
- 在 useChatData 中捕获加载历史消息(ChatAPI.history)时的权限和不存在错误
- 发生 500000 无权访问等错误时,触发 roomInvalidTick 状态更新
- ChatPageLogic 监听到失效状态后,自动调用 SessionAPI.create 创建新的会话房间并重定向,同时给予提示
2026-08-02 15:39:46 +08:00
sp mac bookpro 2605 ecbd687983 修复未登录访问页面后登录不跳回原页面的问题
- 未登录访问非 /login 路径时,重定向到 /login?next=原路径
- 登录成功后 LoginPage 会读取 next 参数跳回原页面
- 修复 pure-chat 等独立路由登录后跳错页的问题
2026-07-31 16:47:04 +08:00
sp mac bookpro 2605 7cf5e1406f 纯会话页面添加退出登录功能
- ChatHeader 新增可选 onLogout 回调,传入时在更多菜单中显示退出登录项
- ChatPagePure 中集成 useAuth 的 logout 方法,点击后确认并跳转登录页
- 不影响原有聊天页(未传 onLogout 时不显示)
2026-07-31 16:37:49 +08:00
sp mac bookpro 2605 1b4812acae 新增纯会话页面,独立路由 /pure-chat
- 新增 ChatPagePure 组件:仅保留聊天主区域(section.chat-main),去掉全局 Sidebar 和 AgentSidebar
- 复用原有的 ChatHeader、ChatBody、ChatInput、ChatOutline、ChatDrawers 组件,会话功能完全一致
- 独立路由 /pure-chat 和 /pure-chat/:id,绕过 layout-shell 全局布局,不显示侧边栏
- 新增样式文件 chat-page-pure.css,适配纯会话页面全屏布局
2026-07-31 16:14:07 +08:00
yannyang b9cf3bc78a fix: fix chat scroll style 2026-07-30 22:13:27 +08:00
yannyang 7faf2cddcf feat: add pricing pay page 2026-07-30 17:16:23 +08:00
yannyang aabc979fec feat: add knowledge page 2026-07-30 16:18:58 +08:00
yannyang 23d1df700c feat: add team feature 2026-07-29 21:26:46 +08:00
sp mac bookpro 2605 c7c580a388 fix: 修复 JSON 编辑页首次打开内容不完整的问题 2026-07-29 21:23:00 +08:00
sp mac bookpro 2605 67e69d4d3b fix: 修复 JSON 编辑器同步时对象字段类型错误,并拆分模块控制文件行数 2026-07-29 13:42:11 +08:00
sp mac bookpro 2605 49b48df182 feat: ExternalToolEditor 支持 JSON 全量编辑与双向同步 2026-07-27 23:28:06 +08:00
yannyang f1553be178 fix: remove reason in message bottom 2026-07-27 22:32:28 +08:00
sp mac bookpro 2605 67585d17ce feat: enhance external tool routing with domains, intents, slots and examples 2026-07-27 22:28:18 +08:00
yannyang a4f5859c81 fix: fix knowledge upload white screen 2026-07-27 22:03:40 +08:00
yannyang eea224d759 fix: fix chat scroll 2026-07-27 21:32:46 +08:00
sp mac bookpro 2605 1482ef9cf3 feat: upgrade global API prefix from /api to /api/v1 2026-07-27 19:41:32 +08:00
sp mac bookpro 2605 39349bfb8c feat: 为外部工具集增加全局统一 headers 配置 2026-07-27 17:45:16 +08:00
sp mac bookpro 2605 5eed97a79c 优化外部工具编辑器的 API 卡片标题显示:优先显示工具名,无工具名时显示默认编号 2026-07-27 17:33:41 +08:00
yannyang aaf3db611e fix: fix agent sidebar style 2026-07-27 12:34:01 +08:00
yannyang 9da58f0cb1 fix: fix chat outline active 2026-07-27 12:21:37 +08:00
yannyang 2a4dc72059 feat: support membership and sub account 2026-07-27 00:42:15 +08:00
sp mac bookpro 2605 e33a1c3741 优化外部工具 API 卡片折叠交互 2026-07-23 21:22:36 +08:00
sp mac bookpro 2605 be0dae0719 统一外部工具编辑器输入框宽度 2026-07-22 22:11:23 +08:00
sp mac bookpro 2605 866a1d6141 调整外部工具编辑器输入框宽度 2026-07-22 22:03:08 +08:00
sp mac bookpro 2605 9c41dbbada 放宽插件 routing 摘要校验 2026-07-22 21:55:08 +08:00
sp mac bookpro 2605 a03cc82dd0 支持插件 API routing 结构化配置 2026-07-22 21:37:36 +08:00
sp mac bookpro 2605 31eba7234a 修复消息内容中单换行的显示问题,通过组合 white-space 策略消除额外空行 2026-07-22 17:55:49 +08:00
sp mac bookpro 2605 fe3eb1c697 优化消息间距:使用 pre-line 并重置块元素边距,平衡紧凑度与可读性 2026-07-22 17:48:53 +08:00
sp mac bookpro 2605 9c208cd8e1 优化消息内容的换行显示,解决间距过大的问题 2026-07-22 17:44:20 +08:00
sp mac bookpro 2605 5f947b4d64 feat: 移除复制工具集功能,新增 API 级别复制功能 2026-07-22 14:23:30 +08:00
sp mac bookpro 2605 c02c83083d fix: 复制工具集后若取消编辑则移除临时副本 2026-07-22 14:13:26 +08:00
sp mac bookpro 2605 7f5b7f0537 fix: 修复 KnowledgeSettingsPanel 崩溃并优化工具集复制为纯前端操作 2026-07-22 14:01:40 +08:00
sp mac bookpro 2605 16051c1f7a feat: 支持外部工具集快速复制并自动进入编辑状态 2026-07-22 13:56:56 +08:00
sp mac bookpro 2605 b5d40e876b 修复重新生成功能:改用重新发送消息链路,并确保 model_id 在请求顶层 2026-07-22 12:43:51 +08:00
sp mac bookpro 2605 44dba476c4 优化 ExternalToolEditor 的 JSON 解析逻辑,支持更宽松的对象格式(如无引号键值对) 2026-07-22 12:13:34 +08:00
sp mac bookpro 2605 bcb226c98b feat: 支持外部工具配置使用对象格式 (JS object literal) 2026-07-19 23:24:57 +08:00
sp mac bookpro 2605 86c923cc40 feat: 开放并完善外部工具配置管理功能 2026-07-19 23:01:42 +08:00
sp mac bookpro 2605 b7c4159f1a chore: 更新代码提交规则
明确默认允许直接提交并推送当前分支,包括 main 分支。
2026-07-18 12:31:25 +08:00
99 changed files with 8685 additions and 2106 deletions

View File

@ -4,10 +4,12 @@ alwaysApply: true
## 核心规则
1. **提交每次修改** — 每次任务执行后默认提交修改代码到远程仓库或者用户说“提交代码”执行git commit 并且要把commit 信息展示出来。默认是全部本地修改都提交,包括不是本次修改的也提交, 不需要提问用户确认。commit 信息你需要总结本次修改内容,不能是代码。例如:“修复登录页面的错误提示”.最后一定要执行 git push 推送到远程仓库。
2. **方案性内容** - 当用户需求,如果是方案性内容,你首先要评估,有没有更好的方案,而不是执行。更好的方案,你可以搜索类似阿里、字节等大厂的方案来参考并给出建议。
1. **提交每次修改** — 每次任务执行后默认提交修改代码到远程仓库或者用户说“提交代码”执行git commit 并且要把commit 信息展示出来。默认是全部本地修改都提交,包括不是本次修改的也提交, 不需要提问用户确认。commit 信息你需要总结本次修改内容,不能是代码。例如:“修复登录页面的错误提示”.最后一定要执行 git push 推送到远程仓库。默认直接提交当前分支包括main分支。
2. **方案性内容** - 当用户需求,如果是方案性内容,你首先要评估,有没有更好的方案,而不是执行。更好的方案,你可以搜索类似anthropic、openai、google、meta、阿里、字节等大厂的方案来参考并给出建议。优先参考anthropic、openai、google的方案。如果当前项目方案或者设计明确不合理应该强烈建议修正方案。如果是复杂的修改可以给出短期和中期修正方案。尽可能都是短期内直接修正避免问题遗留。
3. **代码修改** - 任何时候当存在字段格式不对变量名不对表使用不对等禁止做兼容修改必须按唯一性修改。比如约定字段是string正确只能传string错误可以传int。 比如约定字段名是data正确只能传data错误可以传sourceData或者data。
4. **语言** - 永远使用中文跟用户沟通,专有名词可以用英文。
5. **文档** - 在用户没有明确要求先提供文档时,禁止创建文档。避免生产垃圾文件。用户明确要求提供文档时,才创建文档。
6. **注释** - 你生成的代码尽可能完善中文注释。注释的格式需要按照Go语言的注释规范。要描述清楚代码的功能参数返回值异常等。
7. **代码格式** - .go文件.ts, .tsx 文件单文件代码不超过300行当超过300行时需要做拆分按功能模块拆分同模块在同一个文件夹下文件夹名要语义化。

View File

@ -15,7 +15,10 @@ npm run dev # http://localhost:5173
如需切换:
```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
```
## 构建

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
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 CommandPalette from './components/CommandPalette';
import AgentList from './pages/AgentList';
@ -13,8 +13,12 @@ import PointsMallPage from './pages/PointsMallPage';
import TeamsPage from './pages/TeamsPage';
import PromptLibraryPage from './pages/PromptLibraryPage';
import StatsPage from './pages/StatsPage';
import SharedSessionPage from './pages/SharedSessionPage';
import ProfilePage from './pages/ProfilePage';
import PricingPage from './pages/PricingPage';
import SharedSessionPage from './pages/SharedSessionPage';
import ChatPagePure from './pages/chat/ChatPagePure';
import WorkflowsPage from './pages/WorkflowsPage';
import KnowledgeBasePage from './pages/KnowledgeBase';
import { useAuth } from './store/auth';
import { AgentAPI } from './api';
import { useIsMobile } from './hooks/useIsMobile';
@ -29,6 +33,7 @@ export default function App() {
const location = useLocation();
const [paletteOpen, setPaletteOpen] = useState(false);
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const isMobile = useIsMobile();
// 全局快捷键 Ctrl/⌘ + K
@ -58,7 +63,11 @@ export default function App() {
<Route path="/teams" element={<TeamsPage />} />
<Route path="/prompts" element={<PromptLibraryPage />} />
<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="/knowledge" element={<KnowledgeBasePage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
@ -73,13 +82,33 @@ export default function App() {
) : !user ? (
<Routes>
<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>
) : (
<div className="layout-shell">
{/* 只有编辑器全屏显示,其他页面均保留侧边栏 */}
{!isMobile && (!location.pathname.startsWith('/agents/') || location.pathname.includes('/chat')) ? (
<Sidebar onOpenPalette={() => setPaletteOpen(true)} />
<Sidebar
onOpenPalette={() => setPaletteOpen(true)}
collapsed={sidebarCollapsed}
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
/>
) : null}
<main className={`main${isMobile ? ' is-h5' : ''}`}>
{isMobile && (

View File

@ -33,13 +33,64 @@ export interface SkillDetail extends SkillBrief {
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 {
id: string;
name: string;
description: string;
avatar: string;
prompt: string;
model: string;
model?: string;
models?: AgentModelConfig[];
temperature: number;
owner_id?: string | null;
team_id?: string | null;
@ -50,6 +101,7 @@ export interface Agent {
updated_at: number;
knowledge?: KnowledgeFile[];
skills?: SkillBrief[];
plugins?: ExternalToolPlugin[];
_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),
updateSkill: (agentId: string, skillId: string, payload: { content?: string; enabled?: boolean }) =>
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)
};

View File

@ -13,6 +13,14 @@ export interface ToolCallTrace {
result: any;
}
export interface ModelOverrides {
model?: string;
model_id?: string;
temperature?: number;
topP?: number;
maxTokens?: number;
}
export interface ChatMessage {
id: string;
role: 'user' | 'assistant' | 'agent' | 'system';
@ -48,13 +56,12 @@ export interface ChatHistoryResp {
export const ChatAPI = {
history: (roomId: string) =>
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
.post<{ user: ChatMessage; assistant: ChatMessage }>(`/rooms/${roomId}/messages`, {
content,
targetAgentId,
model,
model_id,
...overrides,
imageUrls
})
.then((r) => r.data),

View File

@ -1,7 +1,7 @@
import axios from 'axios';
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(/\/$/, '');
export const withAppBase = (path: string) => `${APP_BASE}${path.startsWith('/') ? path : `/${path}`}`;
export const withApiBase = (path: string) => `${API_BASE_URL}${path.replace(/^\//, '')}`;

View File

@ -15,4 +15,6 @@ export * from './stats';
export * from './llmProviders';
export * from './streamChat';
export * from './workflows';
export * from './membership';
export * from './knowledgeBase';

55
src/api/knowledgeBase.ts Normal file
View File

@ -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),
};

156
src/api/membership.ts Normal file
View File

@ -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),
};

View File

@ -1,4 +1,4 @@
import type { ChatMessage } from './chat';
import type { ChatMessage, ModelOverrides } from './chat';
import { API_BASE_URL } from './http';
export interface StreamEvents {
@ -13,22 +13,13 @@ export interface StreamEvents {
onError?: (msg: string) => void;
}
export interface ModelOverrides {
model?: string;
model_id?: string;
temperature?: number;
topP?: number;
maxTokens?: number;
}
export async function streamChat(
roomId: string,
targetAgentId: string,
content: string,
handlers: StreamEvents,
signal?: AbortSignal,
model?: string,
modelId?: string,
overrides?: ModelOverrides,
imageUrls?: string[]
) {
const resp = await fetch(`${API_BASE_URL}rooms/${roomId}/messages/stream`, {
@ -37,8 +28,7 @@ export async function streamChat(
body: JSON.stringify({
content,
targetAgentId,
model,
model_id: modelId,
...overrides,
imageUrls: imageUrls ?? []
}),
signal,
@ -47,24 +37,6 @@ export async function streamChat(
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) {
if (!resp.ok || !resp.body) {
const txt = await resp.text().catch(() => '');
@ -150,4 +122,3 @@ async function consumeSSE(resp: Response, h: StreamEvents, signal?: AbortSignal)
reader.cancel().catch(() => {});
}
}

View File

@ -15,6 +15,7 @@ export const TeamAPI = {
detail: (id: string) => api.get<Team>(`/teams/${id}`).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),
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),
removeMember: (id: string, userId: string) => api.delete(`/teams/${id}/members/${userId}`).then((r) => r.data)
};

View File

@ -9,6 +9,7 @@ import {
ToolCallTrace
} from '../api';
import Markdown from './Markdown';
import { parseAgentModels } from '../pages/chat/utils/agentModels';
interface Props {
agent: Agent;
@ -74,8 +75,9 @@ export default function ChatPreview({ agent, agentId }: Props) {
sid = created.id;
setSessionId(sid);
}
const model = String(agent?.model || '').split(',')[0]?.trim() || undefined;
const modelId = model ? undefined : undefined;
const agentModels = parseAgentModels(agent?.models ?? agent?.model);
const model = agentModels[0]?.name || undefined;
const modelId = agentModels[0]?.id || undefined;
const targetAgentId = agentId;
await streamChat(
sid,
@ -107,7 +109,7 @@ export default function ChatPreview({ agent, agentId }: Props) {
}),
onDone: (data) => {
setMessages((m) => [...m.filter((x) => x.id !== tempUser.id), data.user, data.assistant]);
setStreaming({ active: false, text: '', retrieved: [], toolCalls: [] });
setStreaming({ active: false, text: '', retrieved: [], toolCalls: [] });
scrollBottom();
},
onError: (errMsg) => {
@ -117,8 +119,7 @@ export default function ChatPreview({ agent, agentId }: Props) {
}
},
ctrl.signal,
model,
modelId
{ model, model_id: modelId }
);
} catch (e: any) {
if (e?.name !== 'AbortError') {

View File

@ -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>
);
}

View File

@ -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="pathAPI 地址)"
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>
</>
);
}

View File

@ -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 };

View File

@ -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: [],
},
};

View File

@ -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 || {},
})),
};
}

View File

@ -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>
);
}

252
src/components/Sidebar.css Normal file
View File

@ -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);
}

View File

@ -9,17 +9,23 @@ import {
ApartmentOutlined,
BarChartOutlined,
TeamOutlined,
SunOutlined,
MoonOutlined,
LogoutOutlined
LogoutOutlined,
LeftOutlined,
RightOutlined,
UserOutlined,
CreditCardOutlined,
ShoppingCartOutlined,
DatabaseOutlined
} from '@ant-design/icons';
import { useAuth } from '../store/auth';
import { useTheme } from '../main';
import kaiwuIcon from '../assets/brand/kaiwu-icon-gradient-transparent.png';
import './Sidebar.css';
interface Props {
onOpenPalette?: () => void;
onNavigate?: () => void;
collapsed?: boolean;
onToggleCollapse?: () => void;
}
const NAV_GROUPS: Array<{
@ -31,12 +37,13 @@ const NAV_GROUPS: Array<{
items: [
{ to: '/chat', icon: <MessageOutlined />, label: '聊天' },
{ to: '/agents', icon: <RobotOutlined />, label: '我的智能体' },
{ to: '/marketplace', icon: <CompassOutlined />, label: '智能体广场' }
// { to: '/marketplace', icon: <CompassOutlined />, label: '智能体广场' }
]
},
{
label: '资源',
items: [
{ to: '/knowledge', icon: <DatabaseOutlined />, label: '知识库' },
{ to: '/prompts', icon: <BookOutlined />, label: 'Prompt 模板库' },
{ to: '/workflows', icon: <ApartmentOutlined />, label: '工作流' }
]
@ -51,111 +58,112 @@ const NAV_GROUPS: Array<{
{
label: '商城',
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 navigate = useNavigate();
const { mode, toggle } = useTheme();
const isMac =
typeof navigator !== 'undefined' && /mac|iphone|ipad|ipod/i.test(navigator.platform || '');
const cmdKey = isMac ? '⌘' : 'Ctrl';
return (
<aside className="sidebar">
<div className="brand">
<img src={kaiwuIcon} alt="鲸域AI" className="brand-logo" />
<span>AI</span>
<div className="sidebar-brand-spacer" />
<Tooltip title={mode === 'dark' ? '切换到明亮模式' : '切换到深色模式'}>
<button className="theme-toggle" onClick={toggle} aria-label="切换主题">
{mode === 'dark' ? <SunOutlined /> : <MoonOutlined />}
</button>
</Tooltip>
<aside className={`sidebar ${collapsed ? 'is-collapsed' : ''}`}>
<div className="sidebar-brand">
<img src={kaiwuIcon} alt="鲸域AI" className="sidebar-brand-logo" />
{!collapsed && <span className="sidebar-brand-name">AI</span>}
<div className="sidebar-brand-toggle" onClick={onToggleCollapse}>
{collapsed ? <RightOutlined style={{ fontSize: 12 }} /> : <LeftOutlined style={{ fontSize: 12 }} />}
</div>
</div>
<div
onClick={() => {
onOpenPalette?.();
onNavigate?.();
}}
className="nav-item sidebar-search-action"
>
<span className="sidebar-search-label">
<SearchOutlined className="nav-icon" />
<span></span>
</span>
<span className="kbd">{cmdKey} K</span>
<div className="sidebar-search" onClick={onOpenPalette}>
<div className="sidebar-search-input">
<SearchOutlined className="sidebar-search-icon" />
{!collapsed && (
<>
<span className="sidebar-search-placeholder"></span>
<span className="sidebar-search-suffix">{cmdKey} K</span>
</>
)}
</div>
</div>
<div className="sidebar-scroll">
{NAV_GROUPS.map((group) => (
<div key={group.label}>
<div className="nav-section-label">{group.label}</div>
<div key={group.label} className="sidebar-nav-group">
{!collapsed && <div className="sidebar-nav-label">{group.label}</div>}
{group.items.map((it) => (
<NavLink
key={it.to}
to={it.to}
end={it.end}
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
onClick={onNavigate}
>
<span className="nav-icon">{it.icon}</span>
<span>{it.label}</span>
</NavLink>
<Tooltip key={it.to} title={collapsed ? it.label : ''} placement="right">
<NavLink
to={it.to}
end={it.end}
className={({ isActive }) => `sidebar-nav-item ${isActive ? 'active' : ''}`}
onClick={onNavigate}
>
<span className="sidebar-nav-icon">{it.icon}</span>
{!collapsed && <span>{it.label}</span>}
</NavLink>
</Tooltip>
))}
</div>
))}
</div>
{user && (
<Dropdown
menu={{
items: [
{
key: 'name',
label: <span className="sidebar-user-role">{user.phone}</span>,
disabled: true
},
{ type: 'divider' },
{
key: 'role',
label: `身份:${user.role === 'admin' ? '管理员' : '普通用户'}`,
disabled: true
},
{ type: 'divider' },
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出登录',
onClick: async () => {
await logout();
navigate('/login');
onNavigate?.();
<div className="sidebar-user">
<Dropdown
menu={{
items: [
{
key: 'name',
label: <span className="sidebar-user-role">{user.phone}</span>,
disabled: true
},
{
key: 'profile',
icon: <UserOutlined />,
label: '个人中心',
onClick: () => {
navigate('/profile');
onNavigate?.();
}
},
{ type: 'divider' },
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出登录',
onClick: async () => {
await logout();
navigate('/login');
onNavigate?.();
}
}
}
]
}}
placement="topLeft"
>
<div className="sidebar-user">
<Avatar size={32} className="sidebar-user-avatar">
{(user.name?.charAt(0) || '?').toUpperCase()}
</Avatar>
<div className="sidebar-user-main">
<div className="sidebar-user-name">
{user.name}
</div>
<div className="sidebar-user-role">
{user.role === 'admin' ? '管理员' : '成员'}
]
}}
placement="topLeft"
>
<div className="sidebar-user-card">
<div className="sidebar-user-avatar">
{(user.name?.charAt(0) || '?').toUpperCase()}
</div>
{!collapsed && (
<div className="sidebar-user-info">
<div className="sidebar-user-name">{user.name}</div>
<div className="sidebar-user-role">
{user.role === 'admin' ? '管理员' : '成员'}
</div>
</div>
)}
</div>
</div>
</Dropdown>
</Dropdown>
</div>
)}
</aside>
);

View File

@ -42,8 +42,8 @@ function ThemeProvider({ children }: { children: React.ReactNode }) {
() => ({
algorithm: isDark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
token: {
colorPrimary: isDark ? '#55a5ff' : '#1167ff',
colorInfo: isDark ? '#72b7ff' : '#1e86ff',
colorPrimary: isDark ? '#55a5ff' : '#5CCFC4',
colorInfo: isDark ? '#72b7ff' : '#5CCFC4',
colorBgBase: isDark ? '#071126' : '#f5f9ff',
colorBgContainer: isDark ? '#0c1730' : '#ffffff',
colorBgElevated: isDark ? '#111f3c' : '#ffffff',

View File

@ -17,6 +17,12 @@ interface CapabilitySettingsProps {
setAvatarSelectorOpen: (open: boolean) => void;
beforeUploadKnowledge: (file: any) => Promise<boolean>;
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;
isMobile?: boolean;
}
@ -32,6 +38,12 @@ export default function CapabilitySettings({
setAvatarSelectorOpen,
beforeUploadKnowledge,
onDeleteKnowledge,
onCreateSkill,
onEditSkill,
onDeleteSkill,
onCreateExternalTool,
onEditExternalTool,
onDeleteExternalTool,
markDirty,
isMobile = false,
}: CapabilitySettingsProps) {
@ -62,6 +74,12 @@ export default function CapabilitySettings({
agent={agent}
beforeUploadKnowledge={beforeUploadKnowledge}
onDeleteKnowledge={onDeleteKnowledge}
onCreateSkill={onCreateSkill}
onEditSkill={onEditSkill}
onDeleteSkill={onDeleteSkill}
onCreateExternalTool={onCreateExternalTool}
onEditExternalTool={onEditExternalTool}
onDeleteExternalTool={onDeleteExternalTool}
/>
<WebSearchCard />
</Form>

View File

@ -1,19 +1,21 @@
import { useState } from 'react';
import { Button, Checkbox, Dropdown } from 'antd';
import { Button, Radio, Dropdown } from 'antd';
import { DownOutlined } from '@ant-design/icons';
import { AiModel } from '../../../api';
import { DEFAULT_RH_40X40_GRAY } from '../../../constants';
interface ModelCheckboxDropdownProps {
value?: string[];
onChange?: (value: string[]) => void;
interface ModelSelectDropdownProps {
value?: string;
onChange?: (value: string) => void;
models: AiModel[];
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 summary = value.length ? `${value.length} 个已选` : '选择模型';
const selectedModel = models.find(m => m.id === value);
const summary = selectedModel ? selectedModel.model_name : '选择模型';
return (
<Dropdown
@ -22,41 +24,44 @@ export default function ModelCheckboxDropdown({ value = [], onChange, models, is
onOpenChange={setOpen}
popupRender={() => (
<div className={`agent-model-dropdown-panel${isMobile ? ' is-h5-panel' : ''}`} onClick={(e) => e.stopPropagation()}>
<Checkbox.Group
<Radio.Group
value={value}
onChange={(checked) => onChange?.(checked.map((item) => String(item)))}
className="agent-model-checkbox-group"
onChange={(e) => {
onChange?.(e.target.value);
setOpen(false); // 单选选中后自动关闭
}}
className="agent-model-radio-group"
>
{models.map((m) => {
const inputPrice = 2 * m.model_ratio;
const outputPrice = inputPrice * m.completion_ratio;
return (
<Checkbox key={m.id} value={m.id} className="agent-model-checkbox-item">
<div className="agent-model-checkbox-content">
<div className="agent-model-checkbox-meta">
<Radio key={m.id} value={m.id} className="agent-model-radio-item">
<div className="agent-model-radio-content">
<div className="agent-model-radio-meta">
<img
src={m.icon || DEFAULT_RH_40X40_GRAY}
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 className="agent-model-checkbox-price">
<div className="agent-model-radio-price">
<span>: ${inputPrice.toFixed(2)}/M</span>
<span>: ${outputPrice.toFixed(2)}/M</span>
</div>
</div>
</Checkbox>
</Radio>
);
})}
</Checkbox.Group>
</Radio.Group>
</div>
)}
>
<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">
{value.length ? value.join(', ') : '未选择'}
{summary}
</span>
<DownOutlined className="agent-model-dropdown-arrow" />
</Button>

View File

@ -1,5 +1,5 @@
import { Button, Collapse, Form, Input, List, Popconfirm, Tag } from 'antd';
import { DatabaseOutlined, ToolOutlined } from '@ant-design/icons';
import { ApiOutlined, DatabaseOutlined, DeleteOutlined, EditOutlined, PlusOutlined, ToolOutlined } from '@ant-design/icons';
import { Button, Card, Collapse, Input, List, Popconfirm, Space, Tag } from 'antd';
import { Agent } from '../../../../api';
import { STATUS_TAG } from '../../constants';
@ -7,13 +7,28 @@ interface KnowledgeSettingsPanelProps {
agent: Agent | null;
beforeUploadKnowledge: (file: any) => Promise<boolean>;
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({
agent,
beforeUploadKnowledge,
onDeleteKnowledge,
onCreateSkill,
onEditSkill,
onDeleteSkill,
onCreateExternalTool,
onEditExternalTool,
onDeleteExternalTool,
}: KnowledgeSettingsPanelProps) {
const skills = agent?.skills ?? [];
const plugins = agent?.plugins ?? [];
return (
<Collapse
ghost
@ -37,11 +52,11 @@ export default function KnowledgeSettingsPanel({
multiple
className="agent-editor-file-input"
id="knowledge-upload"
onChange={async (e) => {
const files = e.target.files;
onChange={async (event) => {
const files = event.target.files;
if (!files) return;
for (let i = 0; i < files.length; i++) {
await beforeUploadKnowledge(files[i]);
for (let index = 0; index < files.length; index++) {
await beforeUploadKnowledge(files[index]);
}
}}
/>
@ -79,8 +94,11 @@ export default function KnowledgeSettingsPanel({
<span className="agent-editor-indexing-label"></span>
</Tag>
) : (
<Tag color={STATUS_TAG[(item.status || 'ready')].color} className="m-0 text-[10px] px-1">
{STATUS_TAG[(item.status || 'ready')].text}
<Tag
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>
)}
</span>
@ -93,14 +111,248 @@ export default function KnowledgeSettingsPanel({
},
{
key: 'skills',
collapsible: 'disabled',
label: (
<div className="agent-editor-disabled-label" title="技能功能开发中">
<ToolOutlined />
& ()
<div className="agent-editor-collapse-label">
<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>
),
children: null,
},
]}
/>

View File

@ -1,7 +1,7 @@
import { Form, InputNumber } from 'antd';
import { SettingOutlined } from '@ant-design/icons';
import { parseModelSelections } from '../../constants';
import ModelCheckboxDropdown from '../ModelCheckboxDropdown';
import ModelSelectDropdown from '../ModelSelectDropdown';
interface ModelSettingsCardProps {
models: any[];
@ -22,17 +22,18 @@ export default function ModelSettingsCard({ models, isMobile = false }: ModelSet
required
rules={[
{
validator: async (_rule, value) => {
const selected = parseModelSelections(value);
if (selected.length > 0) return;
throw new Error('请选择至少一个模型');
},
required: true,
message: '请选择一个模型',
},
]}
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 name="temperature" label="Temperature" className="mb-0" hidden>
<div className="flex items-center gap-4">

View File

@ -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 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)) {
return value;
return value.map(parseModelSelectionItem).filter(Boolean);
}
// 尝试解析 JSON 格式
try {
const parsed = JSON.parse(String(value || '[]'));
if (Array.isArray(parsed)) {
return parsed.map((item: any) => {
if (typeof item === 'object' && item.id) {
return item.id;
}
return String(item);
}).filter(Boolean);
return parsed.map(parseModelSelectionItem).filter(Boolean);
}
} catch {
// 兼容旧格式:逗号分隔的字符串

View File

@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { FormInstance } from 'antd';
import { Agent, AgentAPI, Team, TeamAPI, AiModel, ModelAPI, ImageAPI } from '../../../api';
import { DEFAULT_AVATAR } from '../constants';
import { Agent, AgentAPI, AgentModelConfig, Team, TeamAPI, AiModel, ModelAPI, ImageAPI } from '../../../api';
import { DEFAULT_AVATAR, parseModelSelections } from '../constants';
interface UseAgentEditorOptions {
id?: string;
@ -11,6 +11,31 @@ interface UseAgentEditorOptions {
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) {
const [agent, setAgent] = useState<Agent | null>(null);
const [saving, setSaving] = useState(false);
@ -85,7 +110,7 @@ export function useAgentEditor({ id, isNew, form, message, navigate }: UseAgentE
setAgent(data);
if (force || autoSaveStatus !== 'dirty') {
hydratingRef.current = true;
form.setFieldsValue(data);
form.setFieldsValue(normalizeAgentFormValues(data));
window.setTimeout(() => {
hydratingRef.current = false;
}, 0);
@ -163,14 +188,15 @@ export function useAgentEditor({ id, isNew, form, message, navigate }: UseAgentE
Object.keys(values).forEach((key) => {
const formValue = (values as any)[key];
const originalValue = (agent as any)?.[key];
// 特殊处理 model 字段:将 id 和 name 组合成 JSON 数组字符串
if (key === 'model' && Array.isArray(formValue)) {
const modelObjects = formValue.map((modelId: string) => {
const model = models.find((m) => m.id === modelId);
return { id: modelId, name: model?.model_name || '' };
});
changedFields[key] = JSON.stringify(modelObjects);
} else if (formValue !== originalValue) {
// model 字段在表单内是单值,提交给后端时需要转换为新的 models 数组结构。
if (key === 'model') {
const nextModels = buildAgentModelsPayload(formValue, models);
if (JSON.stringify(nextModels) !== JSON.stringify(agent?.models || [])) {
changedFields.models = nextModels;
}
return;
}
if (formValue !== originalValue) {
changedFields[key] = formValue;
}
});
@ -181,7 +207,7 @@ export function useAgentEditor({ id, isNew, form, message, navigate }: UseAgentE
}
const updatedAgent = await AgentAPI.update(id!, changedFields);
setAgent(updatedAgent);
form.setFieldsValue(updatedAgent);
form.setFieldsValue(normalizeAgentFormValues(updatedAgent));
if (!silent) message.success('已保存');
setAutoSaveStatus('saved');
} 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) => {
if (!id) {
message.warning('请先保存智能体基础信息后再上传');
@ -328,6 +376,8 @@ export function useAgentEditor({ id, isNew, form, message, navigate }: UseAgentE
beforeUploadEditAvatar,
handleAvatarSelect,
handleDeleteKnowledge,
handleDeleteSkill,
handleDeletePlugin,
liveAgent,
currentName,
markDirty,

View File

@ -4,6 +4,7 @@ import { useNavigate, useParams } from 'react-router-dom';
import { App as AntApp } from 'antd';
import { FileTextOutlined, SaveOutlined } from '@ant-design/icons';
import SkillEditor from '../../components/SkillEditor';
import ExternalToolEditor from '../../components/ExternalToolEditor';
import { useAgentEditor } from './hooks/useAgentEditor';
import { useIsMobile } from '../../hooks/useIsMobile';
import Header from './components/Header';
@ -24,6 +25,8 @@ export default function AgentEditor() {
const navigate = useNavigate();
const { message } = AntApp.useApp();
const [form] = Form.useForm();
const [externalToolEditorOpen, setExternalToolEditorOpen] = useState(false);
const [editingPluginId, setEditingPluginId] = useState<string | null>(null);
const {
agent,
@ -52,6 +55,8 @@ export default function AgentEditor() {
beforeUploadEditAvatar,
handleAvatarSelect,
handleDeleteKnowledge,
handleDeleteSkill,
handleDeletePlugin,
liveAgent,
currentName,
markDirty,
@ -85,6 +90,24 @@ export default function AgentEditor() {
setAvatarSelectorOpen={setAvatarSelectorOpen}
beforeUploadKnowledge={beforeUploadKnowledge}
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}
isMobile={isMobile}
/>
@ -120,13 +143,28 @@ export default function AgentEditor() {
)}
{!isNew && (
<SkillEditor
open={skillEditorOpen}
agentId={id!}
skillId={editingSkillId}
onClose={() => setSkillEditorOpen(false)}
<>
<SkillEditor
open={skillEditorOpen}
agentId={id!}
skillId={editingSkillId}
onClose={() => {
setSkillEditorOpen(false);
setEditingSkillId(null);
}}
onSaved={refresh}
/>
<ExternalToolEditor
open={externalToolEditorOpen}
agentId={id!}
plugin={agent?.plugins?.find((plugin) => plugin.id === editingPluginId)}
onClose={() => {
setExternalToolEditorOpen(false);
setEditingPluginId(null);
}}
onSaved={refresh}
/>
/>
</>
)}
</div>

View File

@ -72,6 +72,138 @@
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-number-input {
height: 2.625rem;

View File

@ -1,17 +1,23 @@
import { useEffect, useMemo, useState } from 'react';
import { Agent, AgentAPI } from '../../api';
import { Agent, AgentAPI, ModelAPI, AiModel } from '../../api';
import { useAuth } from '../../store/auth';
export function useAgentListLogic() {
const { user } = useAuth();
const [list, setList] = useState<Agent[]>([]);
const [models, setModels] = useState<AiModel[]>([]);
const [loading, setLoading] = useState(false);
const load = async () => {
if (!user?.phone) return;
setLoading(true);
try {
setList(await AgentAPI.mine(user.phone));
const [agentList, modelList] = await Promise.all([
AgentAPI.mine(user.phone),
ModelAPI.list()
]);
setList(agentList);
setModels(modelList);
} finally {
setLoading(false);
}
@ -29,9 +35,17 @@ export function useAgentListLogic() {
const isImageUrl = (url?: string): boolean => !!(url?.startsWith('http') || url?.startsWith('/'));
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)) {
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())
.filter(Boolean);
return names.join('、');
@ -43,7 +57,10 @@ export function useAgentListLogic() {
const parsed = JSON.parse(raw);
if (Array.isArray(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())
.filter(Boolean);
return names.join('、');
@ -54,11 +71,11 @@ export function useAgentListLogic() {
if (raw.includes(',')) {
return raw
.split(',')
.map((s) => s.trim())
.map((s) => findModelName(s.trim()))
.filter(Boolean)
.join('、');
}
return raw;
return findModelName(raw);
};
const publicCount = useMemo(() => list.filter((a) => a.visibility === 'public').length, [list]);

View File

@ -38,10 +38,10 @@ export default function AgentListH5({ logic }: Props) {
type="primary"
size="middle"
icon={<CompassOutlined />}
onClick={() => navigate('/marketplace')}
onClick={() => navigate('/agents/new')}
className="h5-stats-marketplace-btn"
>
广
</Button>
</div>
@ -69,8 +69,8 @@ export default function AgentListH5({ logic }: Props) {
{!loading && list.length === 0 ? (
<div className="empty-state h5-empty-state">
<Empty description="你还没有任何智能体">
<Button type="primary" onClick={() => navigate('/marketplace')} style={{ borderRadius: 8 }}>
广
<Button type="primary" onClick={() => navigate('/agents/new')} style={{ borderRadius: 8 }}>
</Button>
</Empty>
</div>

View File

@ -126,14 +126,14 @@
.agent-card-desc-container {
margin-top: 16px;
padding: 16px 16px 18px;
padding: 16px;
border-radius: 16px;
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);
}
.agent-card-desc {
min-height: 66px;
min-height: 48px;
font-size: 13.5px;
line-height: 1.7;
display: -webkit-box;

View File

@ -42,10 +42,10 @@ export default function AgentListWeb({ logic }: Props) {
type="primary"
size="large"
icon={<CompassOutlined />}
onClick={() => navigate('/marketplace')}
onClick={() => navigate('/agents/new')}
style={{ borderRadius: 14, height: 46, padding: '0 18px', fontWeight: 600 }}
>
广
</Button>
</div>
@ -73,14 +73,16 @@ export default function AgentListWeb({ logic }: Props) {
{!loading && list.length === 0 ? (
<div className="empty-state">
<Empty description="你还没有任何智能体">
<Button type="primary" onClick={() => navigate('/marketplace')} style={{ borderRadius: 10 }}>
广
<Button type="primary" onClick={() => navigate('/agents/new')} style={{ borderRadius: 10 }}>
</Button>
</Empty>
</div>
) : (
<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}>
<div className="agent-card">
<div className="agent-card-header">
@ -124,14 +126,14 @@ export default function AgentListWeb({ logic }: Props) {
</Tag>
)}
{getModelLabel(a.model) && (
{modelLabel && (
<Tag
bordered={false}
className="agent-card-tag-model"
style={{ background: 'var(--color-brand-soft)', color: 'var(--color-brand)', borderRadius: 999, margin: 0, maxWidth: '100%' }}
>
<span className="agent-card-tag-model-text">
{getModelLabel(a.model)}
{modelLabel}
</span>
</Tag>
)}
@ -168,7 +170,7 @@ export default function AgentListWeb({ logic }: Props) {
</div>
</div>
</Col>
))}
)})}
</Row>
)}

View File

@ -30,8 +30,8 @@ export default function AgentListWebBase({ logic, viewport }: AgentListWebVarian
AI 广
</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>
</div>
@ -53,8 +53,8 @@ export default function AgentListWebBase({ logic, viewport }: AgentListWebVarian
{!loading && list.length === 0 ? (
<div className="empty-state">
<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>
</Empty>
</div>

View File

@ -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),
};
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
</>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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 />;
}

View File

@ -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);
}

View File

@ -42,7 +42,6 @@ export default function LoginFormCard({ logic }: { logic: LoginPageLogic }) {
label="手机号"
rules={[
{ required: true, message: '请填写手机号' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号格式' }
]}
>
<Input placeholder="13800138000" size="large" autoFocus />

View File

@ -2,6 +2,7 @@ import { PlusOutlined, SearchOutlined, CompassOutlined, FireOutlined } from '@an
import { Col, Row, Empty, Button, Tag, Space, Input, Spin } from 'antd';
import { useNavigate } from 'react-router-dom';
import type { MarketplacePageLogicOutput } from '../MarketplacePageLogic';
import '../styles/marketplace-page-web.css';
interface Props {
logic: MarketplacePageLogicOutput;
@ -12,35 +13,26 @@ export default function MarketplacePageWeb({ logic }: Props) {
const { loading, q, filtered, setQ, handleFork, isImageUrl } = logic;
return (
<div>
<div className="page-hero">
<div style={{ maxWidth: 1240, margin: '0 auto' }}>
<div
style={{
display: 'inline-flex',
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 className="page-container">
<div className="marketplace-header">
<div className="marketplace-header-content">
<div className="marketplace-intro">
<div className="marketplace-badge">
<CompassOutlined className="marketplace-badge-icon" />
</div>
<h2 className="page-title" style={{ marginBottom: 10 }}>
AI
</h2>
<div className="page-subtitle" style={{ marginTop: 0, fontSize: 15, lineHeight: 1.75 }}>
</div>
</div>
<h1 className="hero-title"> AI </h1>
<p className="hero-subtitle">
</p>
</div>
</div>
<div className="page-container" style={{ paddingTop: 28 }}>
<div style={{ paddingTop: 0 }}>
<div
style={{
display: 'flex',

View File

@ -4,28 +4,43 @@
@import './marketplace-page-web-large-2k.css';
@import './marketplace-page-web-ultra-4k.css';
.marketplace-web-hero-inner {
max-width: var(--marketplace-max-width, 1240px);
margin: 0 auto;
.marketplace-header {
border-radius: 24px;
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;
align-items: center;
gap: 8px;
padding: 6px 10px;
padding: 6px 12px;
border-radius: 999px;
margin-bottom: 18px;
background: var(--color-surface);
border: 1px solid var(--color-border);
background: rgba(255,255,255,0.78);
border: 1px solid rgba(8, 145, 178, 0.10);
color: var(--color-text-secondary);
font-size: 12px;
font-weight: 600;
margin-bottom: 16px;
}
.marketplace-web-container {
max-width: var(--marketplace-max-width, 1240px);
padding-top: 28px;
.marketplace-badge-icon {
color: var(--color-brand);
}
.marketplace-web-toolbar {

View File

@ -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>;

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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;
}
}

24
src/pages/PricingPage.tsx Normal file
View File

@ -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} />;
}

View File

@ -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>;

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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;
}

24
src/pages/ProfilePage.tsx Normal file
View File

@ -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} />;
}

View File

@ -12,7 +12,7 @@ export default function StatsTopAgentsCard({ logic }: { logic: StatsPageLogic })
{data.topAgents.length === 0 ? (
<Empty description="暂无" />
) : (
<div>
<div className="stats-page-agent-list">
{data.topAgents.map((agent, index) => (
<div key={agent.id} className="stats-page-agent-item">
<div className="stats-page-agent-header">

View File

@ -1,7 +1,8 @@
import { useEffect, useState } from 'react';
import { useTeamsPageLogic } from './TeamsPage/TeamsPageLogic';
import TeamsPageWeb from './TeamsPage/components/TeamsPageWeb';
import TeamsPageWebBase from './TeamsPage/components/TeamsPageWebBase';
import TeamsPageH5 from './TeamsPage/components/TeamsPageH5';
import { useDesktopViewport } from '../hooks/useDesktopViewport';
const isMobileDevice = () => {
if (typeof window === 'undefined') return false;
@ -10,6 +11,7 @@ const isMobileDevice = () => {
export default function TeamsPage() {
const logic = useTeamsPageLogic();
const viewport = useDesktopViewport();
const [isMobile, setIsMobile] = useState(isMobileDevice());
useEffect(() => {
@ -20,5 +22,9 @@ export default function TeamsPage() {
return () => window.removeEventListener('resize', handleResize);
}, []);
return isMobile ? <TeamsPageH5 logic={logic} /> : <TeamsPageWeb logic={logic} />;
return isMobile ? (
<TeamsPageH5 logic={logic} />
) : (
<TeamsPageWebBase logic={logic} viewport={viewport} />
);
}

View File

@ -6,8 +6,8 @@ export function useTeamsPageLogic() {
const [list, setList] = useState<Team[]>([]);
const [active, setActive] = useState<Team | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [inviteOpen, setInviteOpen] = useState(false);
const [lastInviteCode, setLastInviteCode] = useState<string | null>(null);
const [addMemberOpen, setAddMemberOpen] = useState(false);
const [renameOpen, setRenameOpen] = useState(false);
const load = async () => {
const l = await TeamAPI.list();
@ -27,14 +27,22 @@ export function useTeamsPageLogic() {
setActive(await TeamAPI.detail(t.id));
};
const handleInvite = async (v: any) => {
const handleRename = async (v: any) => {
if (!active) return;
const inv = await AuthAPI.createInvite({
teamId: active.id,
phone: v.phone || undefined,
ttlHours: Number(v.ttlHours) || 168,
});
setLastInviteCode(inv.code);
await TeamAPI.rename(active.id, v.name);
setRenameOpen(false);
message.success('已更名');
await load();
setActive(await TeamAPI.detail(active.id));
};
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) => {
@ -44,10 +52,11 @@ export function useTeamsPageLogic() {
setActive(null);
};
const handleRemoveMember = async (id: string) => {
const handleRemoveMember = async (userId: string) => {
if (!active) return;
await TeamAPI.removeMember(active.id, id);
await TeamAPI.removeMember(active.id, userId);
message.success('已移除');
await load();
setActive(await TeamAPI.detail(active.id));
};
@ -55,17 +64,18 @@ export function useTeamsPageLogic() {
list,
active,
createOpen,
inviteOpen,
lastInviteCode,
addMemberOpen,
renameOpen,
load,
handleCreate,
handleInvite,
handleRename,
handleAddMember,
handleDelete,
handleRemoveMember,
setActive,
setCreateOpen,
setInviteOpen,
setLastInviteCode,
setAddMemberOpen,
setRenameOpen,
};
}

View File

@ -1,14 +1,15 @@
import {
CopyOutlined,
DeleteOutlined,
MailOutlined,
EditOutlined,
PlusOutlined,
TeamOutlined,
UserAddOutlined,
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';
import '../styles/teams-page-h5.css';
interface Props {
logic: TeamsPageLogicOutput;
@ -20,55 +21,26 @@ export default function TeamsPageH5({ logic }: Props) {
list,
active,
handleDelete,
handleRename,
handleRemoveMember,
handleAddMember,
setCreateOpen,
setRenameOpen,
setAddMemberOpen,
} = logic;
return (
<div className="feature-cover-container">
<div className="page-container h5-page-container" style={{ padding: '0 8px' }}>
<div
style={{
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 className="page-container h5-page-container">
<div className="teams-page-h5-hero">
<div className="teams-page-h5-hero-header">
<div style={{ width: '100%' }}>
<div
style={{
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 className="teams-page-h5-badge">
<TeamOutlined className="teams-page-h5-member-avatar-icon" />
</div>
<h1 className="page-title h5-page-title" style={{ marginBottom: 8, fontSize: 22 }}></h1>
<div className="page-subtitle h5-page-subtitle" style={{ marginTop: 0, fontSize: 13, lineHeight: 1.6 }}>
<h1 className="page-title h5-page-title teams-page-h5-title"></h1>
<div className="page-subtitle h5-page-subtitle teams-page-h5-subtitle">
</div>
</div>
@ -77,40 +49,23 @@ export default function TeamsPageH5({ logic }: Props) {
size="middle"
icon={<PlusOutlined />}
onClick={() => setCreateOpen(true)}
style={{ borderRadius: 10, height: 40, padding: '0 14px', fontWeight: 600, width: '100%' }}
className="teams-page-h5-create-btn"
>
</Button>
</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: 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)' },
{ label: '团队数量', value: list.length, type: 'brand' },
{ label: '当前成员数', value: active?.members?.length ?? 0, type: 'info' },
{ label: '共享智能体', value: active?.agentCount ?? 0, type: 'success' },
].map((item) => (
<div
key={item.label}
style={{
borderRadius: 12,
padding: '12px 14px',
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,
}}
>
<div key={item.label} className="teams-page-h5-stat-card">
<div className="teams-page-h5-stat-label">{item.label}</div>
<div className="teams-page-h5-stat-row">
<span className="teams-page-h5-stat-value">{item.value}</span>
<span className={`teams-page-h5-stat-chip teams-page-h5-stat-chip-${item.type}`}>
</span>
</div>
@ -120,38 +75,21 @@ export default function TeamsPageH5({ logic }: Props) {
</div>
{list.length > 0 && (
<div
style={{
background: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(252,252,253,1) 100%)',
border: '1px solid var(--color-border)',
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 className="teams-page-h5-list-panel">
<div className="teams-page-h5-list-head">
<div className="teams-page-h5-section-title"></div>
<div className="teams-page-h5-section-desc"></div>
</div>
{list.map((item) => (
<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 () => {
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 style={{ fontSize: 11, color: active?.id === item.id ? 'var(--color-brand)' : 'var(--color-text-tertiary)' }}>
<div className="teams-page-h5-nav-item-name">{item.name}</div>
<div className="teams-page-h5-nav-item-count">
{item.agentCount ?? 0}
</div>
</div>
@ -160,27 +98,26 @@ export default function TeamsPageH5({ logic }: Props) {
)}
{active ? (
<Card
style={{ borderRadius: 16, boxShadow: '0 6px 16px rgba(15, 23, 42, 0.045)' }}
bodyStyle={{ padding: 16 }}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginBottom: 16 }}>
<Card className="teams-page-h5-detail-card">
<div className="teams-page-h5-detail-head">
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', marginBottom: 4 }}>
<span style={{ fontSize: 18, 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, fontSize: 11 }}>{active.myRole}</Tag>
<Tag bordered={false} style={{ background: 'var(--color-surface-2)', color: 'var(--color-text-secondary)', borderRadius: 999, margin: 0, fontSize: 11 }}>{active.agentCount ?? 0} </Tag>
<div className="teams-page-h5-active-title-row">
<span className="teams-page-h5-active-title">{active.name}</span>
<Button
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 style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
<div className="teams-page-h5-section-desc">
</div>
</div>
<Space wrap>
{(active.myRole === 'owner' || active.myRole === 'admin') && (
<Button icon={<MailOutlined />} size="small" onClick={() => logic.setInviteOpen(true)} style={{ borderRadius: 8 }}>
</Button>
)}
{active.myRole === 'owner' && (
<Popconfirm
title="确定删除该团队?团队内的智能体会变成 owner 私有"
@ -189,7 +126,7 @@ export default function TeamsPageH5({ logic }: Props) {
message.success('已删除');
}}
>
<Button danger size="small" icon={<DeleteOutlined />} style={{ borderRadius: 8 }}>
<Button danger size="small" icon={<DeleteOutlined />} className="teams-page-web-soft-btn">
</Button>
</Popconfirm>
@ -197,29 +134,43 @@ export default function TeamsPageH5({ logic }: Props) {
</Space>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(1, minmax(0, 1fr))', gap: 10, marginBottom: 16 }}>
<div style={{ borderRadius: 12, padding: '12px 14px', background: 'rgba(8, 145, 178, 0.06)', border: '1px solid rgba(8, 145, 178, 0.10)' }}>
<div style={{ fontSize: 11, color: 'var(--color-text-secondary)', marginBottom: 6 }}></div>
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--color-text)' }}>{active.members?.length || 0}</div>
<div className="teams-page-h5-mini-grid">
<div className="teams-page-h5-mini-card teams-page-h5-mini-card-brand">
<div className="teams-page-h5-mini-label"></div>
<div className="teams-page-h5-mini-value">{active.members?.length || 0}</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 style={{ fontSize: 11, color: 'var(--color-text-secondary)', marginBottom: 6 }}></div>
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--color-text)' }}>{active.agentCount ?? 0}</div>
<div className="teams-page-h5-mini-card teams-page-h5-mini-card-success">
<div className="teams-page-h5-mini-label"></div>
<div className="teams-page-h5-mini-value">{active.agentCount ?? 0}</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 style={{ fontSize: 11, color: 'var(--color-text-secondary)', marginBottom: 6 }}></div>
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--color-text)', textTransform: 'capitalize' }}>{active.myRole}</div>
<div className="teams-page-h5-mini-card teams-page-h5-mini-card-warning">
<div className="teams-page-h5-mini-label"></div>
<div className="teams-page-h5-mini-value" style={{ textTransform: 'capitalize' }}>{active.myRole}</div>
</div>
</div>
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--color-text)', marginBottom: 10 }}>
({active.members?.length || 0})
<div className="teams-page-h5-members-title-row">
<div className="teams-page-h5-members-title">
({active.members?.length || 0})
</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
dataSource={active.members || []}
renderItem={(m) => (
<List.Item
style={{ padding: '10px 0' }}
className="teams-page-h5-member-item"
actions={
(active.myRole === 'owner' || active.myRole === 'admin') && m.role !== 'owner'
? [
@ -230,7 +181,7 @@ export default function TeamsPageH5({ logic }: Props) {
await handleRemoveMember(m.id);
}}
>
<Button size="small" danger style={{ borderRadius: 6 }}>
<Button size="small" danger className="teams-page-web-soft-btn">
</Button>
</Popconfirm>,
@ -240,52 +191,25 @@ export default function TeamsPageH5({ logic }: Props) {
>
<List.Item.Meta
avatar={
<div
style={{
width: 36,
height: 36,
borderRadius: 999,
background: 'rgba(8, 145, 178, 0.10)',
color: 'var(--color-brand)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<div className="teams-page-h5-member-avatar">
<UserOutlined />
</div>
}
title={
<Space>
<span style={{ fontWeight: 600, fontSize: 14 }}>{m.name}</span>
<span className="teams-page-h5-member-name">{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,
fontSize: 10,
}}
className={`teams-page-h5-tag teams-page-h5-tag-role-${m.role}`}
>
{m.role}
</Tag>
</Space>
}
description={
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<span style={{ fontSize: 12 }}>{m.email}</span>
<span style={{ fontSize: 11, color: 'var(--color-text-tertiary)' }}>
<div className="teams-page-h5-member-desc">
<span className="teams-page-h5-member-email">{m.email}</span>
<span className="teams-page-h5-member-time">
{new Date(m.joinedAt).toLocaleDateString('zh-CN')}
</span>
</div>
@ -317,63 +241,27 @@ export default function TeamsPageH5({ logic }: Props) {
</Form>
</Modal>
<Modal
open={logic.inviteOpen}
title={`📨 邀请加入 ${active?.name}`}
onCancel={() => {
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>
<Button type="primary" htmlType="submit" block>
</Button>
</Form>
)}
<Modal open={logic.renameOpen} title="重命名团队" onCancel={() => logic.setRenameOpen(false)} footer={null} width="95%" destroyOnHidden>
<Form layout="vertical" onFinish={logic.handleRename} initialValues={{ name: active?.name }}>
<Form.Item name="name" label="新团队名称" rules={[{ required: true }]}>
<Input placeholder="输入新的团队名称" autoFocus />
</Form.Item>
<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>
</Form>
</Modal>
</div>
<div className="feature-cover">
<Empty description="功能规划中,本期不支持" />
</div>
</div>
);

View File

@ -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>
);
}

View File

@ -6,6 +6,7 @@ import TeamsPageWebDetail from './TeamsPageWebDetail';
import TeamsPageWebHero from './TeamsPageWebHero';
import TeamsPageWebList from './TeamsPageWebList';
import TeamsPageWebModals from './TeamsPageWebModals';
import '../styles/teams-page-web.css';
export interface TeamsPageWebVariantProps {
logic: TeamsPageLogicOutput;
@ -23,9 +24,6 @@ export default function TeamsPageWebBase({ logic, viewport }: TeamsPageWebVarian
</div>
<TeamsPageWebModals logic={logic} />
</div>
<div className="feature-cover">
<Empty description="功能规划中,本期不支持" />
</div>
</div>
);
}

View File

@ -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 type { Team } from '../../../api';
import type { TeamsPageLogicOutput } from '../TeamsPageLogic';
@ -34,22 +34,26 @@ export default function TeamsPageWebDetail({ logic }: { logic: TeamsPageLogicOut
const canManage = active.myRole === 'owner' || active.myRole === 'admin';
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>
<div className="teams-page-web-active-title-row">
<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-neutral">{active.agentCount ?? 0} </Tag>
</div>
<div className="teams-page-web-section-desc"></div>
</div>
<Space wrap>
{canManage && (
<Button icon={<MailOutlined />} onClick={() => logic.setInviteOpen(true)} className="teams-page-web-soft-btn">
</Button>
)}
{active.myRole === 'owner' && (
<Popconfirm title="确定删除该团队?团队内的智能体会变成 owner 私有" onConfirm={() => handleDelete(active.id)}>
<Button danger icon={<DeleteOutlined />} className="teams-page-web-soft-btn">
@ -62,26 +66,45 @@ export default function TeamsPageWebDetail({ logic }: { logic: TeamsPageLogicOut
<TeamSummary active={active} />
<div className="teams-page-web-members-title"> ({active.members?.length || 0})</div>
<List
dataSource={active.members || []}
renderItem={(member) => (
<List.Item
className="teams-page-web-member-item"
actions={canManage && member.role !== 'owner' ? [
<Popconfirm key="kick" title="移除该成员?" onConfirm={() => handleRemoveMember(member.id)}>
<Button size="small" danger className="teams-page-web-soft-btn"></Button>
</Popconfirm>,
] : []}
<div className="teams-page-web-members-title-row">
<div className="teams-page-web-members-title"> ({active.members?.length || 0})</div>
{canManage && (
<Button
type="primary"
size="small"
ghost
icon={<UserAddOutlined />}
onClick={() => logic.setAddMemberOpen(true)}
>
<List.Item.Meta
avatar={<div className="teams-page-web-member-avatar"><UserOutlined /></div>}
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>
</Button>
)}
/>
</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>
);
}

View File

@ -5,9 +5,9 @@ import type { TeamsPageLogicOutput } from '../TeamsPageLogic';
export default function TeamsPageWebHero({ logic }: { logic: TeamsPageLogicOutput }) {
const { list, active, setCreateOpen } = logic;
const stats = [
{ 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)' },
{ label: '团队数量', value: list.length, type: 'brand' },
{ label: '当前成员数', value: active?.members?.length ?? 0, type: 'info' },
{ label: '共享智能体', value: active?.agentCount ?? 0, type: 'success' },
];
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-row">
<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>
</div>

View File

@ -14,19 +14,21 @@ export default function TeamsPageWebList({ logic }: { logic: TeamsPageLogicOutpu
{list.length === 0 ? (
<Empty description="还没有团队" />
) : (
<List
dataSource={list}
renderItem={(item) => (
<div className="teams-page-web-list-items">
{list.map((item) => (
<button
key={item.id}
className={`teams-page-web-nav-item ${active?.id === item.id ? 'active' : ''}`}
onClick={async () => setActive(await TeamAPI.detail(item.id))}
type="button"
>
<span>{item.name}</span>
<small>{item.agentCount ?? 0} </small>
<div className="teams-page-web-nav-item-content">
<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>
)}
/>
))}
</div>
)}
</aside>
);

View File

@ -1,4 +1,3 @@
import { CopyOutlined } from '@ant-design/icons';
import { App as AntApp, Button, Form, Input, Modal } from 'antd';
import type { TeamsPageLogicOutput } from '../TeamsPageLogic';
@ -18,43 +17,27 @@ export default function TeamsPageWebModals({ logic }: { logic: TeamsPageLogicOut
</Button>
</Form>
</Modal>
<Modal open={logic.renameOpen} title="重命名团队" onCancel={() => logic.setRenameOpen(false)} footer={null} destroyOnHidden>
<Form layout="vertical" onFinish={logic.handleRename} initialValues={{ name: active?.name }}>
<Form.Item name="name" label="新团队名称" rules={[{ required: true }]}>
<Input placeholder="输入新的团队名称" 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 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>
<Button type="primary" htmlType="submit" block>
</Button>
</Form>
)}
<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>
</Form>
</Modal>
</>
);

View File

@ -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);
}

View File

@ -33,6 +33,8 @@
.teams-page-web-mini-card {
border: 1px solid rgba(8, 145, 178, 0.1);
border-radius: 8px;
padding: 12px;
}
.teams-page-web-mini-brand {
@ -55,51 +57,83 @@
text-transform: capitalize;
}
.teams-page-web-members-title {
margin-bottom: 14px;
font-size: 16px;
.teams-page-web-detail-card .ant-card-body {
padding: 24px !important;
}
.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 {
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 {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border-radius: 999px;
background: rgba(8, 145, 178, 0.1);
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12px;
background: var(--color-brand-soft);
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 {
font-size: 15px;
font-weight: 700;
color: var(--color-text);
}
.teams-page-web-member-desc {
display: grid;
gap: 4px;
.teams-page-web-member-email {
font-size: 13px;
color: var(--color-text-secondary);
}
.teams-page-web-member-time {
font-size: 12px;
color: var(--color-text-tertiary);
}
.teams-page-web-empty-detail {
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;
}

View File

@ -44,20 +44,35 @@
font-weight: 700;
}
.teams-page-web-badge .anticon {
color: var(--color-brand);
}
.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 {
margin-top: 0;
font-size: 15px;
line-height: 1.75;
font-size: 14.5px;
line-height: 1.6;
color: var(--color-text-secondary);
max-width: 580px;
}
.teams-page-web-create-btn,
.teams-page-web-soft-btn,
.teams-page-web-copy-btn {
border-radius: 12px;
.teams-page-web-create-btn {
border-radius: 14px !important;
height: 46px !important;
padding: 0 18px !important;
font-weight: 600 !important;
}
.teams-page-web-soft-btn {
border-radius: 12px !important;
}
.teams-page-web-stats-grid,
@ -69,25 +84,19 @@
.teams-page-web-stats-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-top: 20px;
}
.teams-page-web-stat-card,
.teams-page-web-mini-card {
border-radius: 18px;
padding: 16px 18px;
margin-top: 24px;
gap: 16px; /* Optimized gap for stats grid */
}
.teams-page-web-stat-card {
background: rgba(255,255,255,0.72);
border: 1px solid rgba(255,255,255,0.7);
border-radius: 18px;
padding: 16px 18px;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(255, 255, 255, 0.7);
}
.teams-page-web-stat-label,
.teams-page-web-section-desc,
.teams-page-web-mini-label,
.teams-page-web-member-desc,
.teams-page-web-invite-tip {
.teams-page-web-section-desc {
color: var(--color-text-secondary);
font-size: 12.5px;
}
@ -102,14 +111,30 @@
.teams-page-web-stat-value {
color: var(--color-text);
font-size: 30px;
font-weight: 800;
font-weight: 700;
}
.teams-page-web-stat-chip {
border-radius: 999px;
padding: 4px 8px;
font-size: 12px;
padding: 4px 10px;
font-size: 11px;
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 {
@ -144,29 +169,56 @@
font-size: 15px;
}
.teams-page-web-list-items {
display: flex;
flex-direction: column;
gap: 2px;
}
.teams-page-web-nav-item {
width: 100%;
display: grid;
gap: 4px;
border: 1px solid transparent;
border-radius: 14px;
padding: 10px 12px;
margin-bottom: 6px;
border-radius: 12px;
padding: 10px 14px;
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
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 {
background: rgba(8, 145, 178, 0.1);
border-color: rgba(8, 145, 178, 0.16);
background: var(--color-brand-soft);
border-color: rgba(8, 145, 178, 0.12);
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);
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 {

View File

@ -105,7 +105,7 @@ export function useChatPageLogic() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, roomId]);
const { agent, agentList, messages, setMessages, branches, setBranches, loadMessages } = useChatData({
const { agent, agentList, messages, setMessages, branches, setBranches, loadMessages, roomInvalidTick } = useChatData({
agentId: id,
roomId,
highlightId,
@ -116,6 +116,21 @@ export function useChatPageLogic() {
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({
agentId: id,
agent,

View File

@ -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 AgentSidebarchat-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>
);
}

View File

@ -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 {
width: 100%;
border: 0;
}
.chat-agent-sidebar-header {
padding: 16px;
border-bottom: 1px solid var(--color-border);
padding: 16px 16px 8px;
}
.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 {
flex: 1;
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 {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
padding: 8px 10px;
cursor: pointer;
background: transparent;
border-left: 3px solid transparent;
transition: background 0.2s;
border-radius: 12px;
margin-bottom: 2px;
transition: all 0.2s;
position: relative;
}
.chat-agent-item:hover {
@ -30,22 +161,33 @@
}
.chat-agent-item.active {
background: var(--color-surface-2);
border-left: 3px solid var(--color-brand);
background: var(--color-surface-3);
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 {
width: 32px;
height: 32px;
border-radius: 50%;
border-radius: 10px;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 14px;
font-size: 13px;
overflow: hidden;
flex-shrink: 0;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.chat-agent-avatar-img {
@ -60,7 +202,7 @@
}
.chat-agent-name {
font-size: 14px;
font-size: 13.5px;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
@ -77,8 +219,7 @@
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
padding: 20px;
padding: 40px 20px;
text-align: center;
}
@ -90,5 +231,5 @@
}
.chat-agent-loading-text {
margin-top: 8px;
margin-top: 12px;
}

View File

@ -1,6 +1,6 @@
import { Button, Spin, Empty } from 'antd';
import { MessageOutlined } from '@ant-design/icons';
import { useEffect, useState } from 'react';
import { Button, Spin, Empty, Tabs, Tooltip } from 'antd';
import { PlusOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons';
import { useEffect, useState, useMemo } from 'react';
import type { Agent } from '../../../api';
import './AgentSidebar.css';
@ -12,9 +12,20 @@ export default function AgentSidebar(props: {
onCreate: () => void;
onSelect: (agentId: string) => void;
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 [activeTab, setActiveTab] = useState('mine');
useEffect(() => {
if (agentList.length > 0) return;
@ -26,56 +37,127 @@ export default function AgentSidebar(props: {
return () => clearTimeout(timer);
}, [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 (
<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">
<Button block type="dashed" onClick={onCreate}>
+
<Button
block
type="primary"
icon={<PlusOutlined />}
onClick={onCreate}
className="chat-agent-create-btn"
>
{!collapsed && '创建智能体'}
</Button>
</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">
{agentList.length > 0 ? (
agentList.map((a) => {
const isActive = a.id === activeAgentId;
return (
<div
key={a.id}
onClick={() => onSelect(a.id)}
className={`chat-agent-item ${isActive ? 'active' : ''}`}
>
<div
className="chat-agent-avatar-wrap"
style={{ background: a.avatar || 'var(--gradient-brand)' }}
>
{isImageUrl(a.avatar) ? (
<img src={a.avatar} className="chat-agent-avatar-img" alt="avatar" />
) : (
(a.name?.charAt(0) || '?').toUpperCase()
)}
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>
<div className="chat-agent-info">
<div className="chat-agent-name">
{a.name}
{!isSidebar && <MessageOutlined style={{marginLeft: 12}} />}
)}
{list.map((a) => {
const isActive = a.id === activeAgentId;
const content = (
<div
key={a.id}
onClick={() => onSelect(a.id)}
className={`chat-agent-item ${isActive ? 'active' : ''}`}
>
<div
className="chat-agent-avatar-wrap"
style={{ background: a.avatar || 'var(--gradient-brand)' }}
>
{isImageUrl(a.avatar) ? (
<img src={a.avatar} className="chat-agent-avatar-img" alt="avatar" />
) : (
(a.name?.charAt(0) || '?').toUpperCase()
)}
</div>
{!collapsed && (
<div className="chat-agent-info">
<div className="chat-agent-name">{a.name}</div>
</div>
)}
</div>
</div>
</div>
);
})
);
if (collapsed) {
return (
<Tooltip key={a.id} title={a.name} placement="right">
{content}
</Tooltip>
);
}
return content;
})}
</div>
))
) : !loadingTimeout ? (
<div className="chat-agent-sidebar-status">
<Spin />
<div className="chat-agent-loading-text">...</div>
{!collapsed && <div className="chat-agent-loading-text">...</div>}
</div>
) : (
<div className="chat-agent-sidebar-status">
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
<span className="chat-agent-empty-text">
<br />
</span>
!collapsed && (
<span className="chat-agent-empty-text">
<br />
</span>
)
}
/>
</div>

View File

@ -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;
}

View File

@ -1,10 +1,12 @@
import { Divider, Tag, Avatar } from 'antd';
import { useState, useEffect, useRef, useLayoutEffect } from 'react';
import type { Agent, BranchInfo, ChatMessage } from '../../../api';
import Markdown from '../../../components/Markdown';
import type { StreamingState } from '../hooks/useChatSender';
import type { CopyMode } from '../utils/copy';
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('/');
@ -21,36 +23,82 @@ export default function ChatBody(props: {
onRegenerate: (assistantId: string) => void;
onSwitchBranch: (userMsgId: string, branchId: string) => void;
onCopy: (text: string, mode: CopyMode) => void;
scrollBottom: (force?: boolean) => void;
initialScrollDoneRef: { current: 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 (
<div ref={bodyRef} className="chat-body">
<div className="messages-container">
{messages.length === 0 && !streaming.active ? (
<div style={{ textAlign: 'center', marginTop: 120 }}>
<div
style={{
width: 68,
height: 68,
borderRadius: '50%',
background: agent.avatar || 'var(--gradient-brand)',
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 700,
fontSize: 32,
margin: '0 auto 20px',
boxShadow: 'var(--shadow-lg)',
overflow: 'hidden'
}}
>
{isImageUrl(agent.avatar) ? <img src={agent.avatar} className="w-full h-full object-cover" alt="avatar" /> : (agent.name?.charAt(0) || '?').toUpperCase()}
{isEmpty ? (
<div className="chat-empty-welcome">
<div className="chat-welcome-avatar">
{isImageUrl(agent.avatar) ? (
<img src={agent.avatar} className="w-full h-full object-cover" alt="avatar" />
) : (
(agent.name?.charAt(0) || '?').toUpperCase()
)}
</div>
<h2 style={{ fontSize: 28, fontWeight: 700, color: 'var(--color-text)', marginBottom: 8, letterSpacing: '-0.02em' }}></h2>
<p style={{ color: 'var(--color-text-secondary)', fontSize: 15, lineHeight: 1.7 }}>{agent.description || '我是你的专属 AI 助手,随时准备为你服务。'}</p>
<h2 className="chat-welcome-title"></h2>
<p className="chat-welcome-desc">{agent.description || '我是你的专属 AI 助手,随时准备为你服务。'}</p>
</div>
) : (
<>
@ -71,50 +119,31 @@ export default function ChatBody(props: {
))}
{streaming.active && (
<div style={{ marginBottom: 24 }}>
<div className="streaming-message">
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
{(() => {
const streamingAgentId = streaming.targetAgentId || currentAgentId;
const streamingAgent = agentList.find(a => a.id === streamingAgentId);
if (streamingAgent) {
return (
<Avatar src={streamingAgent.avatar} size={36} style={{ flexShrink: 0, marginTop: 2, backgroundColor: '#52c41a' }}>
{streamingAgent.name?.charAt(0)?.toUpperCase() || 'A'}
</Avatar>
);
}
return null;
return (
<Avatar src={streamingAgent?.avatar} size={36} className="message-item-avatar">
{streamingAgent?.name?.charAt(0)?.toUpperCase() || 'A'}
</Avatar>
);
})()}
<div style={{ flex: 1, minWidth: 0 }}>
{(() => {
const streamingAgentId = streaming.targetAgentId || currentAgentId;
const streamingAgent = agentList.find(a => a.id === streamingAgentId);
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>
</div>
);
}
return null;
})()}
<div className="message-item-content">
<div className="message-item-header">
<span className="message-item-name">
{agentList.find(a => a.id === (streaming.targetAgentId || currentAgentId))?.name || 'AI'}
</span>
</div>
<div className="bubble assistant">
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{!!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 style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<span style={{ fontSize: 12.5, color: 'var(--color-text)', fontWeight: 600 }}>{streaming.retryInfo.stage === 'fallback_model' ? '自动切换模型' : '自动重试'}</span>
<div className="streaming-retry-card">
<div className="streaming-retry-header">
<span className="streaming-retry-title">
{streaming.retryInfo.stage === 'fallback_model' ? '自动切换模型' : '自动重试'}
</span>
{streaming.retryInfo.stage === 'fallback_model' ? (
<Tag color="processing" style={{ marginInlineEnd: 0 }}>
{String(streaming.retryInfo.fromModel || '')} {String(streaming.retryInfo.toModel || '')}
@ -126,25 +155,29 @@ export default function ChatBody(props: {
</Tag>
)}
</div>
<div style={{ marginTop: 4, 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 style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', lineHeight: 1.55 }}>{String(streaming.retryInfo.message)}</div>
</div>
)}
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginBottom: 4 }}></div>
{streaming.reasoningText ? <Markdown>{streaming.reasoningText + '▍'}</Markdown> : <span style={{ color: 'var(--color-text-tertiary)' }}></span>}
</div>
<Divider style={{ margin: '6px 0' }} />
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginBottom: 4 }}></div>
{streaming.answerText ? <Markdown>{streaming.answerText + '▍'}</Markdown> : <span style={{ color: 'var(--color-text-tertiary)' }}></span>}
<div className="streaming-section">
<ReasoningView
reasoning={streaming.reasoningText || '等待推理…'}
expanded={streamingReasoningExpanded}
onToggle={() => setStreamingReasoningExpanded(!streamingReasoningExpanded)}
/>
</div>
{streaming.answerText && (
<>
<Divider style={{ margin: '8px 0' }} />
<div className="streaming-section">
<div className="streaming-section-label"></div>
<Markdown>{streaming.answerText + '▍'}</Markdown>
</div>
</>
)}
</div>
</div>
{(streaming.retrieved.length > 0 || streaming.toolCalls.length > 0) && (
<div>
<div style={{ marginTop: 8 }}>
{streaming.retrieved.length > 0 && <RetrievedView retrieved={streaming.retrieved} />}
{streaming.toolCalls.length > 0 && <ToolCallView calls={streaming.toolCalls} liveStyle />}
</div>
@ -153,6 +186,8 @@ export default function ChatBody(props: {
</div>
</div>
)}
{/* 底部锚点:用于辅助滚动定位 */}
<div ref={bottomAnchorRef} style={{ height: 1, marginTop: -1 }} />
</>
)}
</div>

View File

@ -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;
}

View File

@ -1,28 +1,31 @@
import { ApiOutlined, DeleteOutlined, DownOutlined, EditOutlined, SettingOutlined, EllipsisOutlined } from '@ant-design/icons';
import { Button, Dropdown, Modal, Space, Switch } from 'antd';
import type { Agent } from '../../../api';
import { useIsMobile } from '../../../hooks/useIsMobile';
import { useState, useEffect } from 'react';
import { ApiOutlined, DeleteOutlined, EditOutlined, SettingOutlined, EllipsisOutlined, HistoryOutlined, LogoutOutlined } from '@ant-design/icons';
import { Button, Dropdown, Modal, Switch } from 'antd';
import { Agent, ModelAPI, AiModel } from '../../../api';
import { parseAgentModels } from '../utils/agentModels';
import './ChatHeader.css';
function formatAgentModel(raw: string | null | undefined) {
const s = String(raw ?? '').trim();
if (!s) return '默认模型';
if (s.startsWith('[') || s.startsWith('{')) {
try {
const parsed = JSON.parse(s);
if (Array.isArray(parsed)) {
const names = parsed
.map((x: any) => String(x?.name || x?.model || x?.id || '').trim())
.filter(Boolean);
if (names.length) return names.join(', ');
} else if (parsed && typeof parsed === 'object') {
const name = String((parsed as any).name || (parsed as any).model || (parsed as any).id || '').trim();
if (name) return name;
}
} catch {}
function formatAgentModel(raw: unknown, models: AiModel[]) {
const findModelName = (id: string) => {
const model = models.find(m => m.id === id);
return model ? model.model_name : id;
};
const parsedModels = parseAgentModels(raw)
.map((item) => findModelName(item.id) || item.name)
.filter(Boolean);
if (parsedModels.length > 2) {
return parsedModels.slice(0, 2).join(', ') + '...';
}
return s;
if (parsedModels.length > 0) {
return parsedModels.join(', ');
}
const s = String(raw ?? '').trim();
return s ? findModelName(s) : '默认模型';
}
const isImageUrl = (url: string) => url?.startsWith('http') || url?.startsWith('/');
export default function ChatHeader(props: {
agent: Agent;
useStream: boolean;
@ -32,32 +35,47 @@ export default function ChatHeader(props: {
onOpenMcp: () => void;
onManageAgent: () => void;
onClear: () => void;
onLogout?: () => void;
}) {
const { agent, useStream, setUseStream, onOpenHistory, onOpenParams, onOpenMcp, onManageAgent, onClear } = props;
const modelText = formatAgentModel(agent.model);
const isMobile = useIsMobile();
const { agent, useStream, setUseStream, onOpenHistory, onOpenParams, onOpenMcp, onManageAgent, onClear, onLogout } = props;
const [models, setModels] = useState<AiModel[]>([]);
useEffect(() => {
ModelAPI.list().then(setModels).catch(console.error);
}, []);
const modelText = formatAgentModel(agent.models ?? agent.model, models);
return (
<div className="chat-header" style={isMobile ? { position: 'sticky', top: 0, zIndex: 10, background: 'var(--color-bg)' } : undefined}>
<div className="chat-header-agent">
<div className="chat-header-agent-title">
<span className="chat-header-agent-name">{agent.name}</span>
{!!agent.description && <span className="chat-header-agent-desc">{agent.description}</span>}
<div className="chat-header">
<div className="chat-header-left">
<div className="chat-header-avatar">
{isImageUrl(agent.avatar) ? (
<img src={agent.avatar} alt="avatar" style={{ width: '100%', height: '100%', borderRadius: '50%' }} />
) : (
(agent.name?.charAt(0) || '?').toUpperCase()
)}
</div>
<div className="chat-header-agent-meta">
{modelText}
<div className="chat-header-info">
<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>
<Space>
<div className="chat-header-right">
<div className="chat-header-stream-toggle">
<span className="chat-header-stream-label"></span>
<span></span>
<Switch size="small" checked={useStream} onChange={setUseStream} />
</div>
{!isMobile && (
<Button size="small" onClick={onOpenHistory}>
</Button>
)}
<Button className="chat-header-btn" onClick={onOpenHistory}>
</Button>
<Dropdown
menu={{
items: [
@ -73,15 +91,27 @@ export default function ChatHeader(props: {
onClick: () => {
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'}>
{isMobile ? <EllipsisOutlined /> : <> <DownOutlined /></>}
<Button className="chat-header-more-btn">
<EllipsisOutlined />
</Button>
</Dropdown>
</Space>
</div>
</div>
);
}

View File

@ -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;
}

View File

@ -1,10 +1,11 @@
import { ArrowUpOutlined, BookOutlined, CloseOutlined, DownOutlined, PaperClipOutlined } from '@ant-design/icons';
import { Button, Image as AntImage, Input, Select, Tag, Tooltip, Upload, Popover } from 'antd';
import { ArrowUpOutlined, BookOutlined, CloseOutlined, PaperClipOutlined, HistoryOutlined, PlusOutlined } from '@ant-design/icons';
import { Button, Image as AntImage, Input, Tag, Tooltip, Upload } from 'antd';
import type { TextAreaRef } from 'antd/es/input/TextArea';
import type { ChatAttachment } from '../../../api';
import type { Agent } from '../../../api/agents';
import { HistoryIcon, NewChatIcon } from '../../../components/icons';
import { useState, useRef, useEffect } from 'react';
import { useState, useRef } from 'react';
import './ChatInput.css';
import { IconAttachment, IconPrompt } from '../../../components/Icon';
export default function ChatInput(props: {
input: string;
@ -39,13 +40,9 @@ export default function ChatInput(props: {
onStop,
onAttach,
onOpenTpl,
modelOptions,
activeModelValue,
onChangeModel,
onOpenHistory,
onNewSession,
agentList,
onInsertMention,
showActions = true
} = props;
@ -98,7 +95,6 @@ export default function ChatInput(props: {
};
};
// 检测 @ 触发提及选择
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
setInput(value);
@ -107,32 +103,17 @@ export default function ChatInput(props: {
const textBeforeCursor = value.slice(0, cursorPos);
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)))) {
const query = textBeforeCursor.slice(atIndex + 1);
console.log('[@mention] matched @ at position:', { atIndex, query });
if (!query.includes(' ')) {
setMentionQuery(query);
// 计算位置 - Ant Design Input.TextArea 需要从 resizableTextArea 获取实际 DOM
requestAnimationFrame(() => {
const textarea = inputRef.current?.resizableTextArea?.textArea;
if (!textarea) {
console.log('[@mention] cannot get textarea DOM from inputRef:', inputRef.current);
return;
}
if (!textarea) return;
const caret = getCaretPos(textarea, cursorPos);
const top = Math.min(window.innerHeight - 8, caret.top + caret.lineHeight + 8);
const left = Math.min(window.innerWidth - 160, Math.max(8, caret.left));
setMentionPos({ top, left });
console.log('[@mention] show popover:', { top, left, query });
setShowMentionPopover(true);
});
return;
@ -172,167 +153,166 @@ export default function ChatInput(props: {
return (
<div className="chat-input-wrapper">
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 }}>
{attachments.map((a, i) => (
<Tag key={i} color="blue" closable style={{ borderRadius: 6, padding: '4px 8px' }} onClose={() => setAttachments((arr) => arr.filter((_, j) => j !== i))}>
📎 {a.name}
</Tag>
))}
{imageUrls.map((u, i) => (
<div key={i} style={{ position: 'relative' }}>
<AntImage src={u} width={48} height={48} style={{ objectFit: 'cover', borderRadius: 8, border: '1px solid var(--color-border)' }} />
<Button
size="small"
type="primary"
shape="circle"
icon={<CloseOutlined style={{ fontSize: 8 }} />}
style={{ position: 'absolute', top: -6, right: -6, width: 16, height: 16, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
onClick={() => setImageUrls((arr) => arr.filter((_, j) => j !== i))}
/>
</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}>
{showActions && (
<div className="chat-input-toolbar-top">
<div className="chat-input-actions-left">
<Button
icon={<HistoryOutlined />}
onClick={onOpenHistory}
className="chat-input-action-btn"
>
</Button>
</Tooltip>
<Tooltip title="新增会话">
<Button size="small" type="text" className="chat-input-action-btn chat-input-action-btn-primary" icon={<NewChatIcon />} onClick={onNewSession}>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={onNewSession}
className="chat-input-action-btn chat-input-action-btn-primary"
>
</Button>
</Tooltip>
</div>}
<div className="chat-input-card">
<div className="chat-input-stack">
<Input.TextArea
ref={inputRef}
value={input}
onChange={handleInputChange}
placeholder="问我任何问题... 输入 @ 可 @其他智能体"
autoSize={{ minRows: 3, maxRows: 10 }}
onKeyDown={(e) => {
if (e.key !== 'Enter') return;
if ((e as any).isComposing) return;
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
const el = e.currentTarget;
const start = el.selectionStart ?? input.length;
const end = el.selectionEnd ?? input.length;
const next = input.slice(0, start) + '\n' + input.slice(end);
setInput(next);
requestAnimationFrame(() => {
el.selectionStart = el.selectionEnd = start + 1;
});
return;
}
if (!e.shiftKey && !e.altKey) {
e.preventDefault();
onSend();
}
}}
className="chat-input-textarea"
disabled={sending}
/>
{showMentionPopover && mentionPos && (
<div
className="mention-popover"
style={{
position: 'fixed',
top: mentionPos.top,
left: mentionPos.left,
zIndex: 10000,
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 6,
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
maxHeight: 200,
overflowY: 'auto',
minWidth: 150
}}
>
{filteredAgents.length === 0 ? (
<div style={{ padding: 8, color: 'var(--color-text-tertiary)' }}>
</div>
) : (
filteredAgents.map(agent => (
<div
key={agent.id}
className="mention-item"
onClick={() => handleSelectAgent(agent)}
style={{
padding: '6px 10px',
cursor: 'pointer',
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)' }}>
{agent.name}
</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 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 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) => (
<Tag
key={i}
closable
className="chat-attachment-tag"
onClose={() => setAttachments((arr) => arr.filter((_, j) => j !== i))}
>
📎 {a.name}
</Tag>
))}
{imageUrls.map((u, i) => (
<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)' }} />
<Button
size="small"
type="primary"
shape="circle"
icon={<CloseOutlined style={{ fontSize: 8 }} />}
style={{ position: 'absolute', top: -6, right: -6, width: 16, height: 16, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
onClick={() => setImageUrls((arr) => arr.filter((_, j) => j !== i))}
/>
</div>
))}
</div>
<Input.TextArea
ref={inputRef}
value={input}
onChange={handleInputChange}
placeholder="问我任何问题... 输入 @ 可 @其他智能体"
autoSize={{ minRows: 2, maxRows: 10 }}
onKeyDown={(e) => {
if (e.key !== 'Enter') return;
if ((e as any).isComposing) return;
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
const el = e.currentTarget;
const start = el.selectionStart ?? input.length;
const end = el.selectionEnd ?? input.length;
const next = input.slice(0, start) + '\n' + input.slice(end);
setInput(next);
requestAnimationFrame(() => {
el.selectionStart = el.selectionEnd = start + 1;
});
return;
}
if (!e.shiftKey && !e.altKey) {
e.preventDefault();
onSend();
}
}}
className="chat-input-textarea"
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 && (
<div
className="mention-popover"
style={{
position: 'fixed',
top: mentionPos.top,
left: mentionPos.left,
zIndex: 10000,
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 8,
boxShadow: 'var(--shadow-lg)',
maxHeight: 200,
overflowY: 'auto',
minWidth: 160
}}
>
{filteredAgents.length === 0 ? (
<div style={{ padding: 8, color: 'var(--color-text-tertiary)', fontSize: 12 }}>
</div>
) : (
filteredAgents.map(agent => (
<div
key={agent.id}
className="mention-item"
onClick={() => handleSelectAgent(agent)}
style={{
padding: '8px 12px',
cursor: 'pointer',
borderBottom: '1px solid var(--color-border)'
}}
>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text)' }}>
{agent.name}
</div>
</div>
))
)}
</div>
)}
</div>
);
}

View File

@ -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);
}

View File

@ -1,5 +1,7 @@
import { LeftOutlined, OrderedListOutlined, RightOutlined } from '@ant-design/icons';
import type { ChatMessage } from '../../../api';
import { markdownToPlainText } from '../utils/copy';
import './ChatOutline.css';
function summarize(content: string) {
const plain = markdownToPlainText(content);
@ -12,35 +14,49 @@ function summarize(content: string) {
return text.slice(0, 44) + '…';
}
export default function ChatOutline(props: { messages: ChatMessage[]; onJump: (id: string) => void; activeId?: string | null }) {
const { messages, onJump, activeId } = props;
interface ChatOutlineProps {
messages: ChatMessage[];
onJump: (id: string) => void;
activeId?: string | null;
collapsed?: boolean;
onToggleCollapse?: () => void;
}
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');
if (items.length === 0) {
return (
<aside className="chat-outline">
<div className="chat-outline-title"></div>
<div style={{ padding: 12, color: 'var(--color-text-tertiary)', fontSize: 12 }}></div>
</aside>
);
}
return (
<aside className="chat-outline">
<div className="chat-outline-title"></div>
<div className="chat-outline-list">
{items.map((m, idx) => (
<button
key={m.id}
type="button"
className={`chat-outline-item${activeId === m.id ? ' active' : ''}`}
onClick={() => onJump(m.id)}
title={summarize(m.content)}
>
<span className="chat-outline-index">{idx + 1}</span>
<span className="chat-outline-text">{summarize(m.content)}</span>
</button>
))}
<aside className={`chat-outline ${collapsed ? 'is-collapsed' : ''}`}>
<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">
{items.map((m, idx) => (
<button
key={m.id}
type="button"
className={`chat-outline-item${activeId === m.id ? ' active' : ''}`}
onClick={() => onJump(m.id)}
title={summarize(m.content)}
>
<span className="chat-outline-index">{idx + 1}</span>
{!collapsed && <span className="chat-outline-text">{summarize(m.content)}</span>}
</button>
))}
</div>
)}
</div>
</aside>
);

View File

@ -1,21 +1,35 @@
@media (max-width: 768px) {
.h5-chat-shell {
/* 移除固定高度,允许跟随全局滚动容器 */
height: 100vh;
height: 100svh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.h5-chat-main {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.h5-chat-content-row {
/* 移除内部滚动,改为由 App 的 main-content 统一处理 */
flex: 1;
display: flex;
flex-direction: column;
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 {
padding-bottom: 2rem;
padding: 12px 16px 32px;
}
}

View File

@ -122,6 +122,8 @@ export default function ChatPageH5({ logic }: { logic: ChatPageLogicOutput }) {
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(() => {

View File

@ -1,16 +1,23 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { App as AntApp, Empty } from 'antd';
import type { ChatPageLogicOutput } from '../ChatPageLogic';
import { markdownToPlainText } from '../utils/copy';
import type { ModelOverrides } from '../../../api';
import { useDesktopViewport, desktopViewportClass } from '../../../hooks/useDesktopViewport';
import AgentSidebar from './AgentSidebar';
import ChatBody from './ChatBody';
import ChatDrawers from './ChatDrawers';
import ChatHeader from './ChatHeader';
import ChatInput from './ChatInput';
import ChatOutline from './ChatOutline';
import '../styles/chat-page-web.css';
export default function ChatPageWeb({ logic }: { logic: ChatPageLogicOutput }) {
const { message } = AntApp.useApp();
const viewport = useDesktopViewport();
const [outlineCollapsed, setOutlineCollapsed] = useState(true);
const [agentSidebarCollapsed, setAgentSidebarCollapsed] = useState(false);
const isAutoScrolling = useRef(false);
const {
id,
@ -38,34 +45,62 @@ export default function ChatPageWeb({ logic }: { logic: ChatPageLogicOutput }) {
handleNewSession,
} = 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 (
<div className="chat-shell">
<div className={`chat-shell ${desktopViewportClass(viewport)}`}>
<AgentSidebar
agentList={agentList}
activeAgentId={id}
collapsed={agentSidebarCollapsed}
onToggleCollapse={() => setAgentSidebarCollapsed(!agentSidebarCollapsed)}
onCreate={() => navigate('/agents/new')}
onSelect={(aid) => navigate(`/chat/${aid}`)}
/>
<section className="chat-main">
{!agent ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div className="chat-empty-state">
<Empty description="请在左侧选择一个智能体开始对话" />
</div>
) : (
<>
<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}
/>
<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}
/>
<div className="chat-content-row">
<ChatBody
bodyRef={bodyRef}
agent={agent}
@ -78,6 +113,8 @@ export default function ChatPageWeb({ logic }: { logic: ChatPageLogicOutput }) {
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(() => {
@ -86,45 +123,43 @@ 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' });
<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>
<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}
<ChatOutline
messages={messages}
activeId={highlightId}
collapsed={outlineCollapsed}
onToggleCollapse={() => setOutlineCollapsed(!outlineCollapsed)}
onJump={handleJump}
/>
</>
</div>
)}
</section>

View File

@ -57,9 +57,11 @@ export default function ChatPageWebBase({ logic, viewport }: ChatPageWebVariantP
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' : '已复制(纯文本)'));
navigator.clipboard?.writeText(content).then(() => message.success(mode === 'markdown' ? '已复制Markdown' : '已复制'));
}}
/>
<ChatOutline messages={messages} activeId={highlightId} onJump={(msgId) => {

View File

@ -1,78 +1,155 @@
.message-item-container {
margin-bottom: 20px;
transition: background 0.4s, padding 0.4s;
transition: background 0.3s;
}
.message-item-container.highlighted {
padding: 8px;
border-radius: 10px;
background: rgba(254, 243, 199, 0.6);
/* background: var(--color-surface-2); */
}
/* Assistant Message Styles */
.message-item-assistant {
.message-item-assistant,
.message-item-user {
display: flex;
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;
margin-top: 2px;
background-color: #52c41a;
width: 36px;
height: 36px;
border-radius: 50%;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.message-item-assistant-content {
flex: 1;
.message-item-content {
display: flex;
flex-direction: column;
min-width: 0;
}
.message-item-assistant-header {
.message-item-user .message-item-content {
align-items: flex-end;
}
.message-item-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
font-size: 13px;
}
.message-item-assistant-name {
font-size: 12px;
font-weight: 400;
color: var(--color-text-secondary);
.message-item-name {
font-weight: 600;
color: var(--color-text);
}
/* User Message Styles */
.message-item-user {
display: flex;
gap: 12px;
align-items: flex-start;
justify-content: flex-end;
.message-item-time {
color: var(--color-text-tertiary);
font-size: 11px;
}
.message-item-user-content-wrapper {
flex: 1;
min-width: 0;
max-width: 78%;
display: flex;
flex-direction: column;
align-items: flex-end;
.bubble {
padding: 12px 16px;
border-radius: 16px;
font-size: 14.5px;
line-height: 1.6;
position: relative;
word-wrap: break-word;
}
.message-item-user-avatar {
flex-shrink: 0;
margin-top: 2px;
background-color: #1890ff;
.bubble.assistant {
background: #ffffff;
padding: 16px;
color: var(--color-text);
border-radius: 16px;
}
.mention {
color: var(--color-brand);
font-weight: 500;
.bubble.user {
background: var(--color-primary);
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 {
padding-top: 4px;
border-top: 1px solid var(--color-border);
display: flex;
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 {
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;
}

View File

@ -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 dayjs from 'dayjs';
import type { BranchInfo, ChatMessage } from '../../../../api';
import type { Agent } from '../../../../api/agents';
import Markdown from '../../../../components/Markdown';
import { formatMessageContent } from '../../utils/format';
import type { CopyMode } from '../../utils/copy';
import { ReasoningView, RetrievedView, ToolCallView } from './MetaViews';
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 [reasoningExpanded, setReasoningExpanded] = useState(false);
const formattedContent = useMemo(() => formatMessageContent(message.content), [message.content]);
const speakerType = (message as any)?.speaker?.type as ('user' | 'agent' | undefined);
const speakerId = (message as any)?.speaker?.id as string | undefined;
const isUser = speakerType ? speakerType === 'user' : message.role === 'user';
@ -33,6 +40,11 @@ export default function MessageItem(props: {
const activeIdx = branch?.activeIndex ?? 0;
const total = branch?.total ?? 1;
const timeStr = useMemo(() => {
if (!message.createdAt) return '';
return dayjs(message.createdAt * 1000).format('HH:mm');
}, [message.createdAt]);
const goPrev = () => {
if (!branch || !message.parentId) return;
const i = Math.max(0, activeIdx - 1);
@ -48,46 +60,82 @@ export default function MessageItem(props: {
return (
<div
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="message-item-assistant">
<Avatar
src={answerAgent?.avatar}
size={36}
className="message-item-assistant-avatar"
>
{answerAgent?.name?.charAt(0)?.toUpperCase() || 'A'}
</Avatar>
<div className="message-item-assistant-content">
<div className="message-item-assistant-header">
<span className="message-item-assistant-name">
{answerAgent?.name || 'AI'}
</span>
</div>
<div className={`bubble ${bubbleRole}`}>
<Markdown>{message.content}</Markdown>
<div className="message-item-actions">
{hasBranches && (
<Space size={2}>
<Button size="small" type="text" disabled={activeIdx === 0} onClick={goPrev}>
</Button>
<span>
{activeIdx + 1} / {total}
</span>
<Button size="small" type="text" disabled={activeIdx === total - 1} onClick={goNext}>
</Button>
</Space>
<div className={isUser ? 'message-item-user' : 'message-item-assistant'}>
<Avatar
src={isUser ? undefined : answerAgent?.avatar}
size={36}
className="message-item-avatar"
>
{isUser ? '我' : (answerAgent?.name?.charAt(0)?.toUpperCase() || 'A')}
</Avatar>
<div className="message-item-content">
<div className="message-item-header">
<span className="message-item-name">
{isUser ? '我' : (answerAgent?.name || 'AI')}
</span>
<span className="message-item-time">{timeStr}</span>
</div>
<div className={`bubble ${bubbleRole}`}>
{isUser && !formattedContent.includes('![image](') ? (
<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>
)}
{message.meta?.aborted && <Tag color="orange"></Tag>}
<Markdown>{formattedContent}</Markdown>
</div>
)}
</div>
<div className="message-item-actions">
{hasBranches && (
<Space size={2}>
<Button size="small" type="text" disabled={activeIdx === 0} onClick={goPrev}>
</Button>
<span>
{activeIdx + 1} / {total}
</span>
<Button size="small" type="text" disabled={activeIdx === total - 1} onClick={goNext}>
</Button>
</Space>
)}
{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
trigger={['click']}
menu={{
items: [
{ key: 'plain', label: '复制纯文本', onClick: () => onCopy?.(message.content, 'plain') },
{ key: 'markdown', label: '复制 Markdown', onClick: () => onCopy?.(message.content, 'markdown') }
{ key: 'plain', label: '复制纯文本', onClick: () => onCopy?.(formattedContent, 'plain') },
{ 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 />} />
</Tooltip>
</Dropdown>
<Tooltip title="重新生成(开新分支)">
)}
{!isUser && (
<Tooltip title="重新生成">
<Button size="small" className='actions-btn' type="text" icon={<SyncOutlined />} disabled={busy} onClick={() => onRegenerate?.(message.id)} />
</Tooltip>
</div>
</div>
{!isMobile && message.meta && (
<div>
{!!message.meta.reasoning && <ReasoningView reasoning={message.meta.reasoning} />}
{!!message.meta.retrieved?.length && <RetrievedView retrieved={message.meta.retrieved} />}
{!!message.meta.toolCalls?.length && <ToolCallView calls={message.meta.toolCalls} />}
</div>
)}
</div>
</div>
) : (
<div className="message-item-user">
<div className="message-item-user-content-wrapper">
<div className={`bubble ${bubbleRole}`}>
{message.content.includes('![image](') ? (
<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>
{/* 底部 Meta 信息展示 (RAG / Tool) */}
{!isMobile && message.meta && !isUser && (
<div className="message-meta-section">
{message.meta.retrieved && message.meta.retrieved.length > 0 && (
<RetrievedView retrieved={message.meta.retrieved} />
)}
{message.meta.toolCalls && message.meta.toolCalls.length > 0 && (
<ToolCallView calls={message.meta.toolCalls} />
)}
</div>
)}
</div>
)}
</div>
</div>
);
}

View File

@ -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;
}

View File

@ -1,24 +1,30 @@
import { Collapse, Tag } from 'antd';
import { RightOutlined } from '@ant-design/icons';
import type { RetrievedSnippet, ToolCallTrace } from '../../../../api';
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 (
<Collapse
size="small"
ghost
items={[
{
key: 'reasoning',
label: <span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>🧠 </span>,
children: (
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.6, maxHeight: 240, overflow: 'auto' }}>
<Markdown>{reasoning}</Markdown>
</div>
)
}
]}
/>
<div className={`reasoning-container ${expanded ? 'is-expanded' : ''}`}>
<div className="reasoning-header" onClick={onToggle}>
<span className="reasoning-label"></span>
<RightOutlined className="reasoning-toggle-icon" />
</div>
{expanded && (
<div className="reasoning-content">
<Markdown>{reasoning}</Markdown>
</div>
)}
</div>
);
}
@ -30,24 +36,15 @@ export function RetrievedView({ retrieved }: { retrieved: RetrievedSnippet[] })
items={[
{
key: 'rag',
label: <span style={{ fontSize: 12, color: '#6366f1' }}>🔍 RAG ({retrieved.length})</span>,
label: <span className="retrieved-label">RAG ({retrieved.length})</span>,
children: (
<div style={{ fontSize: 12 }}>
<div className="retrieved-list">
{retrieved.map((r, i) => (
<div
key={i}
style={{
padding: 8,
background: '#f6f8ff',
borderRadius: 6,
marginBottom: 6,
borderLeft: '3px solid #6366f1'
}}
>
<div style={{ color: '#6366f1', fontWeight: 600, marginBottom: 4 }}>
<div key={i} className="retrieved-item">
<div className="retrieved-item-header">
📄 {r.fileName} · #{r.chunkIndex} · score {r.score.toFixed(3)}
</div>
<div style={{ color: '#374151', whiteSpace: 'pre-wrap' }}>
<div className="retrieved-item-preview">
{r.preview}
{r.preview.length >= 200 ? '…' : ''}
</div>
@ -70,35 +67,26 @@ export function ToolCallView({ calls, liveStyle }: { calls: ToolCallTrace[]; liv
items={[
{
key: 'tc',
label: <span style={{ fontSize: 12, color: '#f97316' }}>🛠 ({calls.length})</span>,
label: <span className="tool-call-label">🛠 ({calls.length})<RightOutlined /></span>,
children: (
<div style={{ fontSize: 12 }}>
<div className="tool-call-list">
{calls.map((t, i) => {
const isPending = (t.result as any)?.pending;
const isFailed = t.result?.ok === false;
return (
<div
key={i}
style={{
padding: 8,
background: '#fff7ed',
borderRadius: 6,
marginBottom: 6,
borderLeft: '3px solid #f97316'
}}
>
<div style={{ color: '#c2410c', fontWeight: 600, marginBottom: 4 }}>
<div key={i} className="tool-call-item">
<div className="tool-call-item-header">
{t.name} {isPending && <Tag color="processing"></Tag>}
{!isPending && t.result?.durationMs != null && <Tag>{t.result.durationMs}ms</Tag>}
{isFailed && <Tag color="error"></Tag>}
</div>
<div style={{ color: '#6b7280' }}>
<div className="tool-call-item-args">
<b>args:</b> {JSON.stringify(t.args)}
</div>
{!isPending && (
<div style={{ color: '#374151', marginTop: 4 }}>
<div className="tool-call-item-result">
<b>result:</b>{' '}
<code style={{ wordBreak: 'break-all' }}>
<code>
{JSON.stringify(t.result?.result ?? t.result?.error ?? t.result).slice(0, 500)}
</code>
</div>

View File

@ -20,7 +20,13 @@ export function useChatData(args: {
const [agentList, setAgentList] = useState<Agent[]>([]);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [branches, setBranches] = useState<Record<string, BranchInfo>>({});
const [roomInvalidTick, setRoomInvalidTick] = useState(0);
const loadSeqRef = useRef(0);
const initialHighlightDoneRef = useRef(false);
useEffect(() => {
initialHighlightDoneRef.current = false;
}, [agentId, roomId]);
const loadAgent = async () => {
if (!agentId) {
@ -30,7 +36,7 @@ export function useChatData(args: {
}
const a = await AgentAPI.detail(agentId);
setAgent(a);
const models = parseAgentModels(a.model);
const models = parseAgentModels(a.models ?? a.model);
const firstModel = models[0];
if (firstModel) {
setOverrides((o) => ({
@ -55,19 +61,25 @@ export function useChatData(args: {
if (!roomId) return;
const seq = ++loadSeqRef.current;
const rid = roomId;
const his = await ChatAPI.history(rid);
if (seq !== loadSeqRef.current) return;
if (rid !== roomId) return;
setMessages(Array.isArray(his.messages) ? his.messages : []);
setBranches(his.branches || {});
requestAnimationFrame(() => {
if (!initialScrollDoneRef.current) {
scrollBottom(true);
initialScrollDoneRef.current = true;
} else {
scrollBottom();
try {
const his = await ChatAPI.history(rid);
if (seq !== loadSeqRef.current) return;
if (rid !== roomId) return;
setMessages(Array.isArray(his.messages) ? his.messages : []);
setBranches(his.branches || {});
} catch (e: any) {
// 会话不存在或无权访问时,通知上层重新创建会话
if (seq === loadSeqRef.current && rid === roomId) {
const code = e?.response?.data?.code;
const msg = e?.response?.data?.error ?? e?.message ?? '';
if (code === '500000' || /无权访问|不存在/.test(msg)) {
setRoomInvalidTick((t) => t + 1);
}
}
});
}
// 历史消息加载完成后的处理
// 注意:这里不再手动触发滚动,全部交给 ChatBody 的 useLayoutEffect 和 useEffect 处理
};
useEffect(() => {
@ -104,10 +116,26 @@ export function useChatData(args: {
if (!highlightId) return;
const el = document.getElementById('msg-' + highlightId);
if (!el) return;
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
const t = setTimeout(() => setHighlightId(null), 3000);
return () => clearTimeout(t);
}, [highlightId, setHighlightId]);
// 如果是房间加载后的第一次高亮(通常是系统自动选中的最后一条消息)
const isFirstHighlight = !initialHighlightDoneRef.current;
// 逻辑:如果是初始加载,且该消息是最后一条消息,则不触发 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 {
agent,
@ -116,6 +144,7 @@ export function useChatData(args: {
setMessages,
branches,
setBranches,
loadMessages
loadMessages,
roomInvalidTick
};
}

View File

@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useCallback } from 'react';
export function useChatScroll() {
const bodyRef = useRef<HTMLDivElement>(null);
@ -6,30 +6,40 @@ export function useChatScroll() {
const initialScrollDoneRef = useRef(false);
const scrollRafRef = useRef<number | null>(null);
const scrollProgrammaticAtRef = useRef(0);
const forceScrollPendingRef = useRef(false);
const lastScrollTopRef = useRef(0);
const userScrollLockRef = useRef(false);
const lastUserScrollAtRef = useRef(0);
const lastUserScrollTypeRef = useRef<string>('');
const attachRetryTimerRef = useRef<number | null>(null);
const scrollBottom = (force = false) => {
const scrollBottom = useCallback((force = false) => {
if (force) {
userScrollLockRef.current = false;
autoScrollRef.current = true;
forceScrollPendingRef.current = true;
}
if (!force && (!autoScrollRef.current || userScrollLockRef.current)) {
return;
}
if (!force && (!autoScrollRef.current || userScrollLockRef.current)) return;
if (scrollRafRef.current) {
cancelAnimationFrame(scrollRafRef.current);
scrollRafRef.current = null;
}
scrollRafRef.current = requestAnimationFrame(() => {
scrollRafRef.current = null;
forceScrollPendingRef.current = false;
const el = bodyRef.current;
if (!el) return;
scrollProgrammaticAtRef.current = Date.now();
el.scrollTop = el.scrollHeight;
// 恢复正向滚动scrollTop = scrollHeight
el.scrollTo({
top: el.scrollHeight,
behavior: force ? 'instant' : 'smooth'
});
});
};
}, []);
const cancelAutoScroll = (type: string) => {
userScrollLockRef.current = true;
@ -52,10 +62,13 @@ export function useChatScroll() {
const isProgrammatic = now - scrollProgrammaticAtRef.current < 50;
const nextTop = el.scrollTop;
const lastTop = lastScrollTopRef.current;
// 恢复正向距离计算:距离底部 = 总高度 - 当前滚动高度 - 容器可见高度
const distance = el.scrollHeight - nextTop - el.clientHeight;
const scrollDelta = nextTop - lastTop;
if (scrollDelta < 0) {
// scrollDelta < 0 表示向上滚动
if (scrollDelta < 0 && !forceScrollPendingRef.current) {
cancelAutoScroll(isProgrammatic ? 'scroll(up)+programmatic' : 'scroll(up)');
}
lastScrollTopRef.current = nextTop;
@ -67,10 +80,18 @@ export function useChatScroll() {
userScrollLockRef.current = false;
}
}
autoScrollRef.current = distance < 32 && !userScrollLockRef.current;
if ((!autoScrollRef.current || userScrollLockRef.current) && scrollRafRef.current) {
cancelAnimationFrame(scrollRafRef.current);
scrollRafRef.current = null;
// 只有当不是程序化滚动,且没有强制滚动在等待时,才根据距离更新 autoScroll 状态
if (!isProgrammatic && !forceScrollPendingRef.current) {
autoScrollRef.current = distance < 32 && !userScrollLockRef.current;
}
// 如果 autoScroll 被关闭或者是用户锁定状态,且当前有一个待执行的 RAF
if ((!autoScrollRef.current || userScrollLockRef.current) && scrollRafRef.current && !isProgrammatic && !forceScrollPendingRef.current) {
// 只有当明确是用户向上滚动了,才取消待执行的程序化滚动
if (scrollDelta < 0) {
cancelAnimationFrame(scrollRafRef.current);
scrollRafRef.current = null;
}
}
};

View File

@ -1,6 +1,6 @@
import { useMemo, useState } from 'react';
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 { parseAgentModels } from '../utils/agentModels';
@ -114,7 +114,7 @@ export function useChatSender(args: {
});
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 activeModelValue = overrides.model_id || '';
@ -141,7 +141,7 @@ export function useChatSender(args: {
let targetModel: string;
let targetModelId: string;
if (targetAgent && targetAgent.id !== agentId) {
const models = parseAgentModels(targetAgent.model);
const models = parseAgentModels(targetAgent.models ?? targetAgent.model);
targetModel = models[0]?.name || '';
targetModelId = models[0]?.id || '';
} else {
@ -166,7 +166,10 @@ export function useChatSender(args: {
onRetry: (data) => {
setStreaming((s) => ({ ...s, retryInfo: data }));
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) =>
@ -235,8 +238,7 @@ export function useChatSender(args: {
}
},
ctrl.signal,
targetModel,
targetModelId,
{ ...overrides, model: targetModel, model_id: targetModelId },
imageUrls
);
} catch (e: any) {
@ -273,7 +275,7 @@ export function useChatSender(args: {
let targetModel: string;
let targetModelId: string;
if (targetAgent && targetAgent.id !== agentId) {
const models = parseAgentModels(targetAgent.model);
const models = parseAgentModels(targetAgent.models ?? targetAgent.model);
targetModel = models[0]?.name || '';
targetModelId = models[0]?.id || '';
} else {
@ -284,7 +286,7 @@ export function useChatSender(args: {
const attText = buildAttachmentsText(attachments);
const content = attText ? `${text}\n\n${attText}` : text;
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]);
setSessionRefresh((t) => t + 1);
setAttachments([]);
@ -327,65 +329,25 @@ export function useChatSender(args: {
};
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);
setStreaming({ active: true, reasoningText: '', answerText: '', errorMessage: null, retryInfo: null, retrieved: [], toolCalls: [] });
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
try {
await regenerateMessage(
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
);
await handleSendStream(targetUserMsg.content);
} finally {
setSending(false);
}

View File

@ -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;
}

View File

@ -1,12 +1,12 @@
.chat-shell.desktop-standardPc {
grid-template-columns: 17.5rem minmax(0, 1fr);
display: flex;
}
.chat-shell.desktop-standardPc .chat-content-row {
grid-template-columns: minmax(0, 1fr) 260px;
.chat-shell.desktop-standardPc .chat-side {
flex-shrink: 0;
}
.chat-shell.desktop-standardPc .chat-outline {
width: 100%;
min-width: 0;
flex-shrink: 0;
}

View File

@ -11,15 +11,28 @@
justify-content: center;
}
.chat-shell.desktop-tablet,
.chat-shell.desktop-smallPc,
.chat-shell.desktop-standardPc,
.chat-shell.desktop-large2k,
.chat-shell.desktop-ultra4k,
.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-content-layout {
display: flex;
height: 100%;
width: 100%;
overflow: hidden;
}
.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;
}

View File

@ -3,15 +3,46 @@ export interface AgentModelOption {
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 (Array.isArray(value)) {
return value.map(parseAgentModelItem).filter(Boolean) as AgentModelOption[];
}
try {
const parsed = JSON.parse(value);
const parsed = JSON.parse(String(value));
if (Array.isArray(parsed)) {
return parsed.map((item: any) => ({
id: typeof item === 'object' ? item.id : String(item),
name: typeof item === 'object' ? item.name : String(item)
}));
return parsed.map(parseAgentModelItem).filter(Boolean) as AgentModelOption[];
}
if (parsed && typeof parsed === 'object') {
const single = parseAgentModelItem(parsed);
return single ? [single] : [];
}
} catch {
// ignore
@ -22,4 +53,3 @@ export const parseAgentModels = (value?: string): AgentModelOption[] => {
.filter(Boolean)
.map((item) => ({ id: item, name: item }));
};

View File

@ -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');
}

View File

@ -280,111 +280,6 @@ body {
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 {
background: var(--color-surface);
border-radius: 14px;
@ -468,17 +363,6 @@ body {
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 {
flex: 1;
display: flex;
@ -494,135 +378,12 @@ body {
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 {
flex: 1;
overflow-y: auto;
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) {
.chat-content-row > .chat-outline {
display: none;
@ -703,11 +464,6 @@ body {
padding: 1.125rem 0.75rem 6rem;
}
.chat-input-wrapper {
max-width: 100%;
padding: 0 0.75rem 1rem;
}
.chat-input-actions {
top: -22px;
right: 0;
@ -747,98 +503,70 @@ body {
}
.main-content {
min-height: 0;
flex: 1;
overflow: auto;
position: relative;
}
.chat-body .messages-container {
max-width: 780px;
max-width: 1080px;
width: 100%;
margin: 0 auto;
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 {
max-width: 94%;
/* max-width: 94%; */
}
.bubble.user {
background: #0a84ff;
color: #ffffff;
margin-left: auto;
border-bottom-right-radius: 5px;
.bubble.assistant p,
.bubble.assistant h1,
.bubble.assistant h2,
.bubble.assistant h3,
.bubble.assistant h4,
.bubble.assistant h5,
.bubble.assistant h6,
.bubble.assistant li {
margin: 0;
white-space: pre-wrap;
}
.bubble.assistant {
background: #edf1f6;
color: #111827;
border: 0;
border-bottom-left-radius: 5px;
box-shadow: none;
.bubble.assistant ol,
.bubble.assistant ul {
margin: 0;
padding-left: 1.5em;
}
.bubble.assistant p {
margin: 0 0 5px;
margin-bottom: 4px;
}
.bubble.assistant p:last-child {
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 {
margin: 0.4em 0;
}
.bubble.assistant li > p {
margin: 0.15em 0;
margin: 12px 0;
border: 0;
border-top: 1px solid rgba(0, 0, 0, 0.1);
}
.bubble.assistant blockquote {
margin: 0.2em 0;
padding: 0.2em 0.6em;
margin: 8px 0;
padding: 4px 12px;
border-left: 3px solid var(--color-border);
background: rgba(15, 23, 42, 0.03);
border-radius: 8px;
border-radius: 4px;
}
.bubble.assistant blockquote p {
margin: 0.2em 0;
margin: 0;
}
.bubble.assistant table {
border-collapse: collapse;
width: 100%;
margin: 0.35em 0;
margin: 12px 0;
font-size: 13.5px;
}
@ -855,27 +583,6 @@ body {
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 {
position: absolute;
top: -24px;
@ -1239,14 +946,16 @@ body {
}
}
.agent-model-checkbox-group {
.agent-model-checkbox-group,
.agent-model-radio-group {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
}
.agent-model-checkbox-item {
.agent-model-checkbox-item,
.agent-model-radio-item {
display: flex;
align-items: flex-start;
margin-inline-start: 0;
@ -1256,15 +965,52 @@ body {
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;
}
.agent-model-checkbox-item .ant-checkbox + span {
.agent-model-checkbox-item .ant-checkbox + span,
.agent-model-radio-item .ant-radio + span {
width: 100%;
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 {
display: flex;
align-items: center;
@ -1281,7 +1027,7 @@ body {
flex: 1;
}
.agent-model-checkbox-icon {
.agent-model-radio-icon {
width: 20px;
height: 20px;
object-fit: contain;
@ -1675,10 +1421,6 @@ body {
.border-l { border-left: 1px solid var(--color-border); }
.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-affix-wrapper,
.ant-input-number,
@ -1700,14 +1442,6 @@ body {
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 {
background: var(--color-surface) !important;
border-color: var(--color-border) !important;
@ -1747,12 +1481,6 @@ span.mention {
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 {
background: var(--color-surface) !important;
color: var(--color-text) !important;
@ -1894,7 +1622,8 @@ span.mention {
}
.points-mall-banner-section {
display: grid;
/* display: grid; */
display: none;
grid-template-columns: minmax(0, 1.55fr) minmax(0, 1fr);
gap: 14px;
margin-bottom: 12px;
@ -2366,6 +2095,12 @@ span.mention {
border-radius: 999px;
}
.stats-page-agent-list {
max-height: 240px;
overflow-y: auto;
padding-right: 4px;
}
.stats-page-agent-item {
margin-bottom: 12px;
padding: 12px 14px;

View File

@ -1,58 +1,59 @@
:root,
[data-theme='light'] {
--color-bg: #f5f9ff;
--color-bg: #FAFCFC;
--color-surface: #ffffff;
--color-surface-2: #eef5ff;
--color-surface-3: #dceaff;
--color-border: #d6e5fb;
--color-border-strong: #a9c8f6;
--color-border-focus: #1167ff;
--color-text: #06143f;
--color-text-secondary: #405784;
--color-text-tertiary: #7c8caf;
--color-brand: #1167ff;
--color-brand-hover: #0754df;
--color-brand-soft: #eaf3ff;
--color-brand-soft-2: #d7e8ff;
--color-success: #0d9f6e;
--color-success-soft: #e7f8f1;
--color-warning: #b7791f;
--color-warning-soft: #fff5dc;
--color-danger: #cf3434;
--color-danger-soft: #ffeaea;
--color-info: #1e86ff;
--color-info-soft: #e8f3ff;
--shadow-xs: 0 1px 2px rgba(6, 20, 63, 0.04);
--shadow-sm: 0 2px 8px rgba(17, 103, 255, 0.06);
--shadow-md: 0 10px 26px rgba(17, 103, 255, 0.1);
--shadow-lg: 0 18px 46px rgba(17, 103, 255, 0.14);
--shadow-xl: 0 24px 70px rgba(6, 20, 63, 0.16);
--shadow-focus: 0 0 0 3px rgba(17, 103, 255, 0.18);
--gradient-brand: linear-gradient(135deg, #0b39a8 0%, #1167ff 48%, #22a6ff 100%);
--gradient-hero: radial-gradient(900px 420px at 4% 0%, rgba(34, 166, 255, 0.18), transparent 62%),
radial-gradient(760px 420px at 100% 8%, rgba(17, 103, 255, 0.14), transparent 58%),
linear-gradient(180deg, #f8fbff 0%, #eef6ff 100%);
--color-surface-2: #edfdfd;
--color-surface-3: #e6fffa;
--color-border: #E0EBEB;
--color-border-strong: #cbd5e0;
--color-border-focus: #4fd1c5;
--color-text: #0F1F2E;
--color-text-secondary: #0F1F2E;
--color-text-tertiary: #718096;
--color-primary: #5CCFC4;
--color-brand: #4fd1c5;
--color-brand-hover: #38b2ac;
--color-brand-soft: #e6fffa;
--color-brand-soft-2: #b2f5ea;
--color-success: #38a169;
--color-success-soft: #f0fff4;
--color-warning: #d69e2e;
--color-warning-soft: #fffff0;
--color-danger: #e53e3e;
--color-danger-soft: #fff5f5;
--color-info: #3182ce;
--color-info-soft: #ebf8ff;
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
--shadow-focus: 0 0 0 3px rgba(79, 209, 197, 0.2);
--gradient-brand: linear-gradient(135deg, #4fd1c5 0%, #38b2ac 100%);
--gradient-hero: radial-gradient(900px 420px at 4% 0%, rgba(79, 209, 197, 0.15), transparent 60%),
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'] {
--color-bg: #071126;
--color-surface: #0c1730;
--color-surface-2: #111f3c;
--color-surface-3: #17294c;
--color-border: #1f3764;
--color-border-strong: #31558f;
--color-border-focus: #55a5ff;
--color-text: #edf5ff;
--color-text-secondary: #b6c8e8;
--color-text-tertiary: #7f93ba;
--color-brand: #55a5ff;
--color-brand-hover: #7bbaff;
--color-brand-soft: #10284f;
--color-brand-soft-2: #17396c;
--color-info: #72b7ff;
--color-info-soft: #10284f;
--gradient-brand: linear-gradient(135deg, #0b39a8 0%, #1167ff 54%, #55c2ff 100%);
--gradient-hero: radial-gradient(900px 420px at 4% 0%, rgba(85, 165, 255, 0.18), transparent 62%),
radial-gradient(760px 420px at 100% 8%, rgba(17, 103, 255, 0.16), transparent 58%),
linear-gradient(180deg, #071126 0%, #0c1730 100%);
--color-bg: #0f172a;
--color-surface: #1e293b;
--color-surface-2: #1e293b;
--color-surface-3: #334155;
--color-border: #334155;
--color-border-strong: #475569;
--color-border-focus: #4fd1c5;
--color-text: #f8fafc;
--color-text-secondary: #94a3b8;
--color-text-tertiary: #64748b;
--color-brand: #4fd1c5;
--color-brand-hover: #38b2ac;
--color-brand-soft: #134e4a;
--color-brand-soft-2: #115e59;
--color-info: #60a5fa;
--color-info-soft: #1e3a8a;
--gradient-brand: linear-gradient(135deg, #4fd1c5 0%, #38b2ac 100%);
--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(56, 178, 172, 0.16), transparent 58%),
linear-gradient(180deg, #0f172a 0%, #1e293b 100%);
}

View File

@ -1,11 +1,30 @@
import { defineConfig } from 'vite';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(({ command }) => ({
// 本地开发走根路径,生产构建部署到 /aura 子路径
base: command === 'serve' ? '/' : '/aura/',
plugins: [react()],
server: {
port: 3001
}
}));
// 默认走 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 子路径
base: command === 'serve' ? '/' : '/aura/',
plugins: [react()],
server: {
port: 3001,
proxy: {
'/api/v1': {
target,
changeOrigin: true,
// SSE 不要被压缩;保持长连接
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq) => {
proxyReq.setHeader('Accept-Encoding', 'identity');
});
}
}
}
}
};
});