Compare commits

..

No commits in common. "main" and "feat/unify-api-and-responsive-pages" have entirely different histories.

99 changed files with 2111 additions and 8690 deletions

View File

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

View File

@ -15,10 +15,7 @@ npm run dev # http://localhost:5173
如需切换: 如需切换:
```bash ```bash
VITE_API_TARGET=http://localhost:4001 npm run dev VITE_API_TARGET=http://localhost:4000 npm run dev
# 或添加本地环境变量 .env.local
VITE_API_TARGET=http://localhost:4001
``` ```
## 构建 ## 构建

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom'; import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
import { Button, Drawer, Spin } from 'antd'; import { Button, Drawer, Spin } from 'antd';
import { MenuOutlined, SearchOutlined, MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons'; import { MenuOutlined, SearchOutlined } from '@ant-design/icons';
import Sidebar from './components/Sidebar'; import Sidebar from './components/Sidebar';
import CommandPalette from './components/CommandPalette'; import CommandPalette from './components/CommandPalette';
import AgentList from './pages/AgentList'; import AgentList from './pages/AgentList';
@ -13,12 +13,8 @@ import PointsMallPage from './pages/PointsMallPage';
import TeamsPage from './pages/TeamsPage'; import TeamsPage from './pages/TeamsPage';
import PromptLibraryPage from './pages/PromptLibraryPage'; import PromptLibraryPage from './pages/PromptLibraryPage';
import StatsPage from './pages/StatsPage'; import StatsPage from './pages/StatsPage';
import ProfilePage from './pages/ProfilePage'; import SharedSessionPage from './pages/SharedSessionPage';
import PricingPage from './pages/PricingPage';
import SharedSessionPage from './pages/SharedSessionPage';
import ChatPagePure from './pages/chat/ChatPagePure';
import WorkflowsPage from './pages/WorkflowsPage'; import WorkflowsPage from './pages/WorkflowsPage';
import KnowledgeBasePage from './pages/KnowledgeBase';
import { useAuth } from './store/auth'; import { useAuth } from './store/auth';
import { AgentAPI } from './api'; import { AgentAPI } from './api';
import { useIsMobile } from './hooks/useIsMobile'; import { useIsMobile } from './hooks/useIsMobile';
@ -33,7 +29,6 @@ export default function App() {
const location = useLocation(); const location = useLocation();
const [paletteOpen, setPaletteOpen] = useState(false); const [paletteOpen, setPaletteOpen] = useState(false);
const [mobileNavOpen, setMobileNavOpen] = useState(false); const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
// 全局快捷键 Ctrl/⌘ + K // 全局快捷键 Ctrl/⌘ + K
@ -63,11 +58,7 @@ export default function App() {
<Route path="/teams" element={<TeamsPage />} /> <Route path="/teams" element={<TeamsPage />} />
<Route path="/prompts" element={<PromptLibraryPage />} /> <Route path="/prompts" element={<PromptLibraryPage />} />
<Route path="/stats" element={<StatsPage />} /> <Route path="/stats" element={<StatsPage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/pricing" element={<PricingPage />} />
<Route path="/pricing/pay/:tierId" element={<PricingPage />} />
<Route path="/workflows" element={<WorkflowsPage />} /> <Route path="/workflows" element={<WorkflowsPage />} />
<Route path="/knowledge" element={<KnowledgeBasePage />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
); );
@ -82,33 +73,13 @@ export default function App() {
) : !user ? ( ) : !user ? (
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route <Route path="*" element={<LoginPage />} />
path="*"
element={
<Navigate
to={`/login?next=${encodeURIComponent(
location.pathname + location.search
)}`}
replace
/>
}
/>
</Routes>
) : location.pathname.startsWith('/pure-chat') ? (
<Routes>
{/* 纯会话页:无全局 Sidebar无 AgentSidebar仅保留聊天主区域 */}
<Route path="/pure-chat" element={<ChatPagePure />} />
<Route path="/pure-chat/:id" element={<ChatPagePure />} />
</Routes> </Routes>
) : ( ) : (
<div className="layout-shell"> <div className="layout-shell">
{/* 只有编辑器全屏显示,其他页面均保留侧边栏 */} {/* 只有编辑器全屏显示,其他页面均保留侧边栏 */}
{!isMobile && (!location.pathname.startsWith('/agents/') || location.pathname.includes('/chat')) ? ( {!isMobile && (!location.pathname.startsWith('/agents/') || location.pathname.includes('/chat')) ? (
<Sidebar <Sidebar onOpenPalette={() => setPaletteOpen(true)} />
onOpenPalette={() => setPaletteOpen(true)}
collapsed={sidebarCollapsed}
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
/>
) : null} ) : null}
<main className={`main${isMobile ? ' is-h5' : ''}`}> <main className={`main${isMobile ? ' is-h5' : ''}`}>
{isMobile && ( {isMobile && (

View File

@ -33,64 +33,13 @@ export interface SkillDetail extends SkillBrief {
config: string; config: string;
} }
export interface ExternalToolApiRouting {
summary: string;
useWhen: string[];
doNotUseWhen?: string[];
domains?: string[];
intents?: string[];
requiredSlots?: string[];
optionalSlots?: string[];
examples?: string[];
}
export interface ExternalToolApi {
id?: string;
name: string;
description: string;
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
path: string;
headers?: Record<string, string> | null;
parametersSchema: Record<string, unknown>;
routing: ExternalToolApiRouting;
createdAt?: number;
}
export interface ExternalToolPluginPayload {
name: string;
description?: string;
baseUrl: string;
authType: 'none' | 'bearer' | 'basic' | 'apiKey' | 'custom';
authConfig: Record<string, unknown>;
headers?: Record<string, string> | null;
apis: ExternalToolApi[];
}
export interface ExternalToolPlugin extends ExternalToolPluginPayload {
id: string;
enabled: number;
createdAt: number;
isTemp?: boolean;
}
export interface AgentModelConfig {
model: {
id: string;
name: string;
};
role: string;
priority: number;
enabled: boolean;
}
export interface Agent { export interface Agent {
id: string; id: string;
name: string; name: string;
description: string; description: string;
avatar: string; avatar: string;
prompt: string; prompt: string;
model?: string; model: string;
models?: AgentModelConfig[];
temperature: number; temperature: number;
owner_id?: string | null; owner_id?: string | null;
team_id?: string | null; team_id?: string | null;
@ -101,7 +50,6 @@ export interface Agent {
updated_at: number; updated_at: number;
knowledge?: KnowledgeFile[]; knowledge?: KnowledgeFile[];
skills?: SkillBrief[]; skills?: SkillBrief[];
plugins?: ExternalToolPlugin[];
_access?: 'owner' | 'team' | 'view' | 'none'; _access?: 'owner' | 'team' | 'view' | 'none';
} }
@ -135,12 +83,6 @@ export const AgentAPI = {
getSkill: (agentId: string, skillId: string) => api.get<SkillDetail>(`/agents/${agentId}/skills/${skillId}`).then((r) => r.data), getSkill: (agentId: string, skillId: string) => api.get<SkillDetail>(`/agents/${agentId}/skills/${skillId}`).then((r) => r.data),
updateSkill: (agentId: string, skillId: string, payload: { content?: string; enabled?: boolean }) => updateSkill: (agentId: string, skillId: string, payload: { content?: string; enabled?: boolean }) =>
api.put(`/agents/${agentId}/skills/${skillId}`, payload).then((r) => r.data), api.put(`/agents/${agentId}/skills/${skillId}`, payload).then((r) => r.data),
deleteSkill: (agentId: string, skillId: string) => api.delete(`/agents/${agentId}/skills/${skillId}`).then((r) => r.data), deleteSkill: (agentId: string, skillId: string) => api.delete(`/agents/${agentId}/skills/${skillId}`).then((r) => r.data)
bindPlugin: (agentId: string, payload: ExternalToolPluginPayload) =>
api.post(`/agents/${agentId}/plugins`, payload).then((r) => r.data),
updatePlugin: (agentId: string, pluginId: string, payload: ExternalToolPluginPayload) =>
api.put(`/agents/${agentId}/plugins/${pluginId}`, payload).then((r) => r.data),
deletePlugin: (agentId: string, pluginId: string) =>
api.delete(`/agents/${agentId}/plugins/${pluginId}`).then((r) => r.data)
}; };

View File

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

View File

@ -1,7 +1,7 @@
import axios from 'axios'; import axios from 'axios';
import { clearUserStorage } from '../utils/storage'; import { clearUserStorage } from '../utils/storage';
export const API_BASE_URL = import.meta.env.DEV ? '/api/v1/' : 'https://www.tianchaoai.cc/api/v1/'; export const API_BASE_URL = 'https://www.tianchaoai.cc/api/v1/';
const APP_BASE = (import.meta.env.BASE_URL || '/').replace(/\/$/, ''); const APP_BASE = (import.meta.env.BASE_URL || '/').replace(/\/$/, '');
export const withAppBase = (path: string) => `${APP_BASE}${path.startsWith('/') ? path : `/${path}`}`; export const withAppBase = (path: string) => `${APP_BASE}${path.startsWith('/') ? path : `/${path}`}`;
export const withApiBase = (path: string) => `${API_BASE_URL}${path.replace(/^\//, '')}`; export const withApiBase = (path: string) => `${API_BASE_URL}${path.replace(/^\//, '')}`;

View File

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

View File

@ -1,55 +0,0 @@
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),
};

View File

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

View File

@ -15,7 +15,6 @@ export const TeamAPI = {
detail: (id: string) => api.get<Team>(`/teams/${id}`).then((r) => r.data), detail: (id: string) => api.get<Team>(`/teams/${id}`).then((r) => r.data),
create: (name: string) => api.post<Team>('/teams', { name }).then((r) => r.data), create: (name: string) => api.post<Team>('/teams', { name }).then((r) => r.data),
rename: (id: string, name: string) => api.put(`/teams/${id}`, { name }).then((r) => r.data), rename: (id: string, name: string) => api.put(`/teams/${id}`, { name }).then((r) => r.data),
addMember: (id: string, phone: string) => api.post(`/teams/${id}/members`, { phone }).then((r) => r.data),
remove: (id: string) => api.delete(`/teams/${id}`).then((r) => r.data), remove: (id: string) => api.delete(`/teams/${id}`).then((r) => r.data),
removeMember: (id: string, userId: string) => api.delete(`/teams/${id}/members/${userId}`).then((r) => r.data) removeMember: (id: string, userId: string) => api.delete(`/teams/${id}/members/${userId}`).then((r) => r.data)
}; };

View File

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

View File

@ -1,31 +0,0 @@
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

@ -1,281 +0,0 @@
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

@ -1,235 +0,0 @@
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

@ -1,63 +0,0 @@
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

@ -1,231 +0,0 @@
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

@ -1,62 +0,0 @@
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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import { ApiOutlined, DatabaseOutlined, DeleteOutlined, EditOutlined, PlusOutlined, ToolOutlined } from '@ant-design/icons'; import { Button, Collapse, Form, Input, List, Popconfirm, Tag } from 'antd';
import { Button, Card, Collapse, Input, List, Popconfirm, Space, Tag } from 'antd'; import { DatabaseOutlined, ToolOutlined } from '@ant-design/icons';
import { Agent } from '../../../../api'; import { Agent } from '../../../../api';
import { STATUS_TAG } from '../../constants'; import { STATUS_TAG } from '../../constants';
@ -7,28 +7,13 @@ interface KnowledgeSettingsPanelProps {
agent: Agent | null; agent: Agent | null;
beforeUploadKnowledge: (file: any) => Promise<boolean>; beforeUploadKnowledge: (file: any) => Promise<boolean>;
onDeleteKnowledge: (fileId: string) => Promise<void>; onDeleteKnowledge: (fileId: string) => Promise<void>;
onCreateSkill: () => void;
onEditSkill: (skillId: string) => void;
onDeleteSkill: (skillId: string) => Promise<void>;
onCreateExternalTool: () => void;
onEditExternalTool: (pluginId: string) => void;
onDeleteExternalTool: (pluginId: string) => Promise<void>;
} }
export default function KnowledgeSettingsPanel({ export default function KnowledgeSettingsPanel({
agent, agent,
beforeUploadKnowledge, beforeUploadKnowledge,
onDeleteKnowledge, onDeleteKnowledge,
onCreateSkill,
onEditSkill,
onDeleteSkill,
onCreateExternalTool,
onEditExternalTool,
onDeleteExternalTool,
}: KnowledgeSettingsPanelProps) { }: KnowledgeSettingsPanelProps) {
const skills = agent?.skills ?? [];
const plugins = agent?.plugins ?? [];
return ( return (
<Collapse <Collapse
ghost ghost
@ -52,11 +37,11 @@ export default function KnowledgeSettingsPanel({
multiple multiple
className="agent-editor-file-input" className="agent-editor-file-input"
id="knowledge-upload" id="knowledge-upload"
onChange={async (event) => { onChange={async (e) => {
const files = event.target.files; const files = e.target.files;
if (!files) return; if (!files) return;
for (let index = 0; index < files.length; index++) { for (let i = 0; i < files.length; i++) {
await beforeUploadKnowledge(files[index]); await beforeUploadKnowledge(files[i]);
} }
}} }}
/> />
@ -94,11 +79,8 @@ export default function KnowledgeSettingsPanel({
<span className="agent-editor-indexing-label"></span> <span className="agent-editor-indexing-label"></span>
</Tag> </Tag>
) : ( ) : (
<Tag <Tag color={STATUS_TAG[(item.status || 'ready')].color} className="m-0 text-[10px] px-1">
color={STATUS_TAG[item.status as keyof typeof STATUS_TAG]?.color || 'default'} {STATUS_TAG[(item.status || 'ready')].text}
className="m-0 text-[10px] px-1"
>
{STATUS_TAG[item.status as keyof typeof STATUS_TAG]?.text || item.status || '未知'}
</Tag> </Tag>
)} )}
</span> </span>
@ -111,248 +93,14 @@ export default function KnowledgeSettingsPanel({
}, },
{ {
key: 'skills', key: 'skills',
collapsible: 'disabled',
label: ( label: (
<div className="agent-editor-collapse-label"> <div className="agent-editor-disabled-label" title="技能功能开发中">
<ToolOutlined className="agent-editor-label-icon" /> <ToolOutlined />
& ({skills.length + plugins.length}) & ()
</div>
),
children: (
<div className="px-1">
<div className="agent-editor-skill-actions">
<span className="text-xs text-gray-500">Markdown API </span>
<Space wrap>
<Button size="small" icon={<PlusOutlined />} onClick={onCreateSkill}>
Skill
</Button>
<Button type="primary" ghost size="small" icon={<ApiOutlined />} onClick={onCreateExternalTool}>
</Button>
</Space>
</div>
<div className="agent-editor-tool-section">
<div className="agent-editor-tool-section-title">
<span>Skills</span>
<Tag>{skills.length}</Tag>
</div>
<List
size="small"
locale={{ emptyText: '暂无 Markdown Skill' }}
dataSource={skills}
renderItem={(item) => (
<List.Item
className="agent-editor-skill-item"
actions={[
<Button key="edit" type="text" size="small" icon={<EditOutlined />} onClick={() => onEditSkill(item.id)}>
</Button>,
<Popconfirm key="delete" title="确认删除该 Skill" onConfirm={() => onDeleteSkill(item.id)}>
<Button type="text" danger size="small" icon={<DeleteOutlined />}>
</Button>
</Popconfirm>,
]}
>
<List.Item.Meta
title={<span className="agent-editor-skill-name">{item.filename || item.name}</span>}
description={
<div>
<div>{item.description || '暂无描述'}</div>
{item.filename && item.filename !== item.name && <div className="agent-editor-tool-help">{item.name}</div>}
</div>
}
/>
<Space size={6} wrap>
<Tag color="blue">{item.type}</Tag>
<Tag color={item.enabled ? 'success' : 'default'}>{item.enabled ? '已启用' : '未启用'}</Tag>
</Space>
</List.Item>
)}
/>
</div>
<div className="agent-editor-tool-section">
<div className="agent-editor-tool-section-title">
<span></span>
<Tag>{plugins.length}</Tag>
</div>
{plugins.length === 0 ? (
<div className="agent-editor-tool-empty"></div>
) : (
<Space direction="vertical" size={10} className="agent-editor-tool-list">
{plugins.map((plugin) => (
<Card
key={plugin.id}
size="small"
className="agent-editor-plugin-card"
title={
<Space wrap>
<ApiOutlined className="agent-editor-label-icon" />
<span>{plugin.name}</span>
<Tag color={plugin.enabled ? 'success' : 'default'}>{plugin.enabled ? '已启用' : '未启用'}</Tag>
</Space>
}
extra={
<Space>
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => onEditExternalTool(plugin.id)}>
</Button>
<Popconfirm title="确认删除该工具集及其全部 API" onConfirm={() => onDeleteExternalTool(plugin.id)}>
<Button type="text" danger size="small" icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Space>
}
>
<div className="agent-editor-plugin-description">{plugin.description || '暂无描述'}</div>
<Space size={6} wrap className="agent-editor-plugin-meta">
<Tag>{plugin.authType || 'none'}</Tag>
<span>{plugin.baseUrl}</span>
<span>{plugin.apis?.length ?? 0} API</span>
</Space>
<Collapse
ghost
size="small"
className="agent-editor-plugin-apis"
items={plugin.apis?.map((api) => ({
key: api.id || api.name,
label: (
<Space wrap>
<Tag color="geekblue">{api.method}</Tag>
<strong>{api.name}</strong>
<span className="agent-editor-tool-help">{api.path}</span>
</Space>
),
children: (
<div className="agent-editor-api-detail">
<div className="mb-2">{api.description || '暂无描述'}</div>
{api.routing?.requiredSlots?.length ? (
<div className="mb-3 p-2 bg-orange-50 border border-orange-100 rounded">
<div className="text-[10px] text-orange-400 mb-1 font-bold uppercase tracking-wider"></div>
<Space wrap size={[4, 4]}>
{api.routing.requiredSlots.map((slot) => (
<Tag key={slot} color="orange" className="m-0 border-none bg-orange-200 text-orange-700">
{slot}
</Tag>
))}
</Space>
</div>
) : null}
<div className="agent-editor-api-config-grid">
<div>
<strong>headers</strong>
<pre>{JSON.stringify(api.headers || {}, null, 2)}</pre>
</div>
<div>
<strong>parametersSchema</strong>
<pre>{JSON.stringify(api.parametersSchema || {}, null, 2)}</pre>
</div>
</div>
<div className="mt-3 pt-3 border-t border-gray-100">
<div className="text-[10px] text-gray-400 mb-2 font-bold uppercase tracking-wider">Routing ()</div>
<div className="space-y-3">
<div>
<div className="text-xs font-medium text-gray-600 mb-1">Summary</div>
<div className="text-xs text-gray-500">{api.routing?.summary || '-'}</div>
</div>
<div className="grid grid-cols-2 gap-4">
{api.routing?.domains?.length ? (
<div>
<div className="text-xs font-medium text-gray-600 mb-1">Domains</div>
<Space wrap size={[4, 4]}>
{api.routing.domains.map((d) => (
<Tag key={d} className="m-0 text-[10px] px-1 leading-4">
{d}
</Tag>
))}
</Space>
</div>
) : null}
{api.routing?.intents?.length ? (
<div>
<div className="text-xs font-medium text-gray-600 mb-1">Intents</div>
<Space wrap size={[4, 4]}>
{api.routing.intents.map((i) => (
<Tag key={i} className="m-0 text-[10px] px-1 leading-4">
{i}
</Tag>
))}
</Space>
</div>
) : null}
</div>
<div className="grid grid-cols-2 gap-4">
{api.routing?.useWhen?.length ? (
<div>
<div className="text-xs font-medium text-gray-600 mb-1">Use When</div>
<div className="text-[11px] text-gray-500 space-y-1">
{api.routing.useWhen.map((item, idx) => (
<div key={idx} className="flex gap-1">
<span>·</span>
<span>{item}</span>
</div>
))}
</div>
</div>
) : null}
{api.routing?.doNotUseWhen?.length ? (
<div>
<div className="text-xs font-medium text-gray-600 mb-1">Do Not Use When</div>
<div className="text-[11px] text-gray-500 space-y-1">
{api.routing.doNotUseWhen.map((item, idx) => (
<div key={idx} className="flex gap-1">
<span>·</span>
<span>{item}</span>
</div>
))}
</div>
</div>
) : null}
</div>
{api.routing?.optionalSlots?.length ? (
<div>
<div className="text-xs font-medium text-gray-600 mb-1">Optional Slots</div>
<Space wrap size={[4, 4]}>
{api.routing.optionalSlots.map((slot) => (
<Tag key={slot} className="m-0 text-[10px] px-1 leading-4">
{slot}
</Tag>
))}
</Space>
</div>
) : null}
{api.routing?.examples?.length ? (
<div>
<div className="text-xs font-medium text-gray-600 mb-1">Examples</div>
<div className="text-[11px] text-gray-500 italic space-y-1 bg-gray-50 p-2 rounded">
{api.routing.examples.map((ex, idx) => (
<div key={idx}>"{ex}"</div>
))}
</div>
</div>
) : null}
</div>
</div>
</div>
),
})) || []}
/>
</Card>
))}
</Space>
)}
</div>
</div> </div>
), ),
children: null,
}, },
]} ]}
/> />

View File

@ -1,7 +1,7 @@
import { Form, InputNumber } from 'antd'; import { Form, InputNumber } from 'antd';
import { SettingOutlined } from '@ant-design/icons'; import { SettingOutlined } from '@ant-design/icons';
import { parseModelSelections } from '../../constants'; import { parseModelSelections } from '../../constants';
import ModelSelectDropdown from '../ModelSelectDropdown'; import ModelCheckboxDropdown from '../ModelCheckboxDropdown';
interface ModelSettingsCardProps { interface ModelSettingsCardProps {
models: any[]; models: any[];
@ -22,18 +22,17 @@ export default function ModelSettingsCard({ models, isMobile = false }: ModelSet
required required
rules={[ rules={[
{ {
required: true, validator: async (_rule, value) => {
message: '请选择一个模型', const selected = parseModelSelections(value);
if (selected.length > 0) return;
throw new Error('请选择至少一个模型');
},
}, },
]} ]}
className="mb-0" className="mb-0"
getValueProps={(value) => { getValueProps={(value) => ({ value: parseModelSelections(value) })}
// 如果后端返回的是数组或 JSON 字符串,取第一个作为单选值
const selected = parseModelSelections(value);
return { value: selected[0] || '' };
}}
> >
<ModelSelectDropdown models={models} isMobile={isMobile} /> <ModelCheckboxDropdown models={models} isMobile={isMobile} />
</Form.Item> </Form.Item>
<Form.Item name="temperature" label="Temperature" className="mb-0" hidden> <Form.Item name="temperature" label="Temperature" className="mb-0" hidden>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">

View File

@ -51,33 +51,20 @@ export const TYPE_TAG: Record<SkillType, { color: string; icon: string; label: s
export const isImageUrl = (url: string | undefined) => url?.startsWith('http') || url?.startsWith('/'); export const isImageUrl = (url: string | undefined) => url?.startsWith('http') || url?.startsWith('/');
// parseModelSelectionItem 将多种模型结构统一提取为模型 ID。 export const parseModelSelections = (value?: string | string[]) => {
const parseModelSelectionItem = (item: any) => {
if (!item) {
return '';
}
if (typeof item === 'string') {
return item;
}
if (typeof item === 'object' && item.model?.id) {
return String(item.model.id);
}
if (typeof item === 'object' && item.id) {
return String(item.id);
}
return String(item);
};
// parseModelSelections 负责把接口返回的模型配置统一转换为模型 ID 列表。
export const parseModelSelections = (value?: unknown) => {
if (Array.isArray(value)) { if (Array.isArray(value)) {
return value.map(parseModelSelectionItem).filter(Boolean); return value;
} }
// 尝试解析 JSON 格式 // 尝试解析 JSON 格式
try { try {
const parsed = JSON.parse(String(value || '[]')); const parsed = JSON.parse(String(value || '[]'));
if (Array.isArray(parsed)) { if (Array.isArray(parsed)) {
return parsed.map(parseModelSelectionItem).filter(Boolean); return parsed.map((item: any) => {
if (typeof item === 'object' && item.id) {
return item.id;
}
return String(item);
}).filter(Boolean);
} }
} catch { } catch {
// 兼容旧格式:逗号分隔的字符串 // 兼容旧格式:逗号分隔的字符串

View File

@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { FormInstance } from 'antd'; import { FormInstance } from 'antd';
import { Agent, AgentAPI, AgentModelConfig, Team, TeamAPI, AiModel, ModelAPI, ImageAPI } from '../../../api'; import { Agent, AgentAPI, Team, TeamAPI, AiModel, ModelAPI, ImageAPI } from '../../../api';
import { DEFAULT_AVATAR, parseModelSelections } from '../constants'; import { DEFAULT_AVATAR } from '../constants';
interface UseAgentEditorOptions { interface UseAgentEditorOptions {
id?: string; id?: string;
@ -11,31 +11,6 @@ interface UseAgentEditorOptions {
navigate: any; navigate: any;
} }
// buildAgentModelsPayload 将表单里的单选模型值转换为后端需要的 models 数组结构。
const buildAgentModelsPayload = (modelId: string | undefined, models: AiModel[]): AgentModelConfig[] => {
if (!modelId) {
return [];
}
const selectedModel = models.find((item) => item.id === modelId);
return [
{
model: {
id: modelId,
name: selectedModel?.model_name || '',
},
role: 'primary',
priority: 1,
enabled: true,
},
];
};
// normalizeAgentFormValues 将接口返回的 models 字段映射为表单使用的 model 单值。
const normalizeAgentFormValues = (data: Agent) => ({
...data,
model: parseModelSelections(data.models)[0] || '',
});
export function useAgentEditor({ id, isNew, form, message, navigate }: UseAgentEditorOptions) { export function useAgentEditor({ id, isNew, form, message, navigate }: UseAgentEditorOptions) {
const [agent, setAgent] = useState<Agent | null>(null); const [agent, setAgent] = useState<Agent | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@ -110,7 +85,7 @@ export function useAgentEditor({ id, isNew, form, message, navigate }: UseAgentE
setAgent(data); setAgent(data);
if (force || autoSaveStatus !== 'dirty') { if (force || autoSaveStatus !== 'dirty') {
hydratingRef.current = true; hydratingRef.current = true;
form.setFieldsValue(normalizeAgentFormValues(data)); form.setFieldsValue(data);
window.setTimeout(() => { window.setTimeout(() => {
hydratingRef.current = false; hydratingRef.current = false;
}, 0); }, 0);
@ -188,15 +163,14 @@ export function useAgentEditor({ id, isNew, form, message, navigate }: UseAgentE
Object.keys(values).forEach((key) => { Object.keys(values).forEach((key) => {
const formValue = (values as any)[key]; const formValue = (values as any)[key];
const originalValue = (agent as any)?.[key]; const originalValue = (agent as any)?.[key];
// model 字段在表单内是单值,提交给后端时需要转换为新的 models 数组结构。 // 特殊处理 model 字段:将 id 和 name 组合成 JSON 数组字符串
if (key === 'model') { if (key === 'model' && Array.isArray(formValue)) {
const nextModels = buildAgentModelsPayload(formValue, models); const modelObjects = formValue.map((modelId: string) => {
if (JSON.stringify(nextModels) !== JSON.stringify(agent?.models || [])) { const model = models.find((m) => m.id === modelId);
changedFields.models = nextModels; return { id: modelId, name: model?.model_name || '' };
} });
return; changedFields[key] = JSON.stringify(modelObjects);
} } else if (formValue !== originalValue) {
if (formValue !== originalValue) {
changedFields[key] = formValue; changedFields[key] = formValue;
} }
}); });
@ -207,7 +181,7 @@ export function useAgentEditor({ id, isNew, form, message, navigate }: UseAgentE
} }
const updatedAgent = await AgentAPI.update(id!, changedFields); const updatedAgent = await AgentAPI.update(id!, changedFields);
setAgent(updatedAgent); setAgent(updatedAgent);
form.setFieldsValue(normalizeAgentFormValues(updatedAgent)); form.setFieldsValue(updatedAgent);
if (!silent) message.success('已保存'); if (!silent) message.success('已保存');
setAutoSaveStatus('saved'); setAutoSaveStatus('saved');
} catch (e) { } catch (e) {
@ -249,28 +223,6 @@ export function useAgentEditor({ id, isNew, form, message, navigate }: UseAgentE
} }
}; };
const handleDeleteSkill = async (skillId: string) => {
if (!id) return;
try {
await AgentAPI.deleteSkill(id, skillId);
setAgent((prev) => prev ? { ...prev, skills: (prev.skills || []).filter((skill) => skill.id !== skillId) } : prev);
message.success('Skill 已删除');
} catch (e: any) {
message.error('Skill 删除失败:' + (e?.message ?? e));
}
};
const handleDeletePlugin = async (pluginId: string) => {
if (!id) return;
try {
await AgentAPI.deletePlugin(id, pluginId);
setAgent((prev) => prev ? { ...prev, plugins: (prev.plugins || []).filter((plugin) => plugin.id !== pluginId) } : prev);
message.success('外部工具已删除');
} catch (e: any) {
message.error('外部工具删除失败:' + (e?.message ?? e));
}
};
const beforeUploadKnowledge = async (file: any) => { const beforeUploadKnowledge = async (file: any) => {
if (!id) { if (!id) {
message.warning('请先保存智能体基础信息后再上传'); message.warning('请先保存智能体基础信息后再上传');
@ -376,8 +328,6 @@ export function useAgentEditor({ id, isNew, form, message, navigate }: UseAgentE
beforeUploadEditAvatar, beforeUploadEditAvatar,
handleAvatarSelect, handleAvatarSelect,
handleDeleteKnowledge, handleDeleteKnowledge,
handleDeleteSkill,
handleDeletePlugin,
liveAgent, liveAgent,
currentName, currentName,
markDirty, markDirty,

View File

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

View File

@ -72,138 +72,6 @@
color: var(--color-brand); color: var(--color-brand);
} }
.agent-editor-skill-actions,
.agent-editor-tool-list-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.875rem;
}
.agent-editor-skill-item {
margin-bottom: 0.5rem;
padding: 0.625rem 0.75rem !important;
border: 1px solid #edf1f7;
border-radius: 0.75rem;
background: #fff;
}
.agent-editor-skill-name {
font-weight: 700;
}
.agent-editor-tool-section + .agent-editor-tool-section {
margin-top: 1.25rem;
}
.agent-editor-tool-section-title {
display: flex;
align-items: center;
gap: 0.375rem;
margin-bottom: 0.625rem;
color: var(--color-text);
font-size: 0.8125rem;
font-weight: 800;
}
.agent-editor-tool-empty {
padding: 1.25rem;
border: 1px dashed #d9e1ec;
border-radius: 0.75rem;
color: var(--color-text-secondary);
text-align: center;
}
.agent-editor-plugin-card {
border-color: #e7edf6;
border-radius: 0.875rem;
}
.agent-editor-plugin-description {
margin-bottom: 0.5rem;
color: var(--color-text-secondary);
font-size: 0.8125rem;
}
.agent-editor-plugin-meta {
color: var(--color-text-secondary);
font-size: 0.75rem;
}
.agent-editor-plugin-apis {
margin-top: 0.625rem;
border-top: 1px solid #edf1f7;
}
.agent-editor-api-detail {
color: var(--color-text-secondary);
font-size: 0.78125rem;
}
.agent-editor-api-config-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 0.75rem;
margin-top: 0.625rem;
}
.agent-editor-api-config-grid pre {
max-height: 12rem;
margin: 0.25rem 0 0;
padding: 0.625rem;
overflow: auto;
border-radius: 0.5rem;
background: #f6f8fb;
color: #334155;
font-size: 0.6875rem;
white-space: pre-wrap;
word-break: break-word;
}
.agent-editor-tool-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 0.875rem;
}
.agent-editor-tool-list {
width: 100%;
}
.agent-editor-tool-list-header {
margin: 0;
}
.agent-editor-tool-help {
margin-top: 0.125rem;
color: var(--color-text-secondary);
font-size: 0.75rem;
}
.agent-editor-tool-card {
border-radius: 0.875rem;
}
.agent-editor-code-input {
font-family: Consolas, Menlo, Monaco, monospace;
font-size: 0.75rem;
}
@media (max-width: 768px) {
.agent-editor-tool-grid,
.agent-editor-api-config-grid {
grid-template-columns: 1fr;
gap: 0;
}
.agent-editor-skill-actions,
.agent-editor-tool-list-header {
align-items: flex-start;
flex-direction: column;
}
}
.agent-editor-field-input, .agent-editor-field-input,
.agent-editor-number-input { .agent-editor-number-input {
height: 2.625rem; height: 2.625rem;

View File

@ -1,23 +1,17 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Agent, AgentAPI, ModelAPI, AiModel } from '../../api'; import { Agent, AgentAPI } from '../../api';
import { useAuth } from '../../store/auth'; import { useAuth } from '../../store/auth';
export function useAgentListLogic() { export function useAgentListLogic() {
const { user } = useAuth(); const { user } = useAuth();
const [list, setList] = useState<Agent[]>([]); const [list, setList] = useState<Agent[]>([]);
const [models, setModels] = useState<AiModel[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const load = async () => { const load = async () => {
if (!user?.phone) return; if (!user?.phone) return;
setLoading(true); setLoading(true);
try { try {
const [agentList, modelList] = await Promise.all([ setList(await AgentAPI.mine(user.phone));
AgentAPI.mine(user.phone),
ModelAPI.list()
]);
setList(agentList);
setModels(modelList);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -35,17 +29,9 @@ export function useAgentListLogic() {
const isImageUrl = (url?: string): boolean => !!(url?.startsWith('http') || url?.startsWith('/')); const isImageUrl = (url?: string): boolean => !!(url?.startsWith('http') || url?.startsWith('/'));
const getModelLabel = (value: unknown): string => { const getModelLabel = (value: unknown): string => {
const findModelName = (id: string) => {
const model = models.find(m => m.id === id);
return model ? model.model_name : id;
};
if (Array.isArray(value)) { if (Array.isArray(value)) {
const names = value const names = value
.map((item: any) => { .map((item: any) => (typeof item === 'string' ? item : item?.name))
if (typeof item === 'string') return findModelName(item);
return item?.name || findModelName(item?.id);
})
.map((v) => String(v || '').trim()) .map((v) => String(v || '').trim())
.filter(Boolean); .filter(Boolean);
return names.join('、'); return names.join('、');
@ -57,10 +43,7 @@ export function useAgentListLogic() {
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) { if (Array.isArray(parsed)) {
const names = parsed const names = parsed
.map((item: any) => { .map((item: any) => (typeof item === 'string' ? item : item?.name))
if (typeof item === 'string') return findModelName(item);
return item?.name || findModelName(item?.id);
})
.map((v) => String(v || '').trim()) .map((v) => String(v || '').trim())
.filter(Boolean); .filter(Boolean);
return names.join('、'); return names.join('、');
@ -71,11 +54,11 @@ export function useAgentListLogic() {
if (raw.includes(',')) { if (raw.includes(',')) {
return raw return raw
.split(',') .split(',')
.map((s) => findModelName(s.trim())) .map((s) => s.trim())
.filter(Boolean) .filter(Boolean)
.join('、'); .join('、');
} }
return findModelName(raw); return raw;
}; };
const publicCount = useMemo(() => list.filter((a) => a.visibility === 'public').length, [list]); 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" type="primary"
size="middle" size="middle"
icon={<CompassOutlined />} icon={<CompassOutlined />}
onClick={() => navigate('/agents/new')} onClick={() => navigate('/marketplace')}
className="h5-stats-marketplace-btn" className="h5-stats-marketplace-btn"
> >
广
</Button> </Button>
</div> </div>
@ -69,8 +69,8 @@ export default function AgentListH5({ logic }: Props) {
{!loading && list.length === 0 ? ( {!loading && list.length === 0 ? (
<div className="empty-state h5-empty-state"> <div className="empty-state h5-empty-state">
<Empty description="你还没有任何智能体"> <Empty description="你还没有任何智能体">
<Button type="primary" onClick={() => navigate('/agents/new')} style={{ borderRadius: 8 }}> <Button type="primary" onClick={() => navigate('/marketplace')} style={{ borderRadius: 8 }}>
广
</Button> </Button>
</Empty> </Empty>
</div> </div>

View File

@ -126,14 +126,14 @@
.agent-card-desc-container { .agent-card-desc-container {
margin-top: 16px; margin-top: 16px;
padding: 16px; padding: 16px 16px 18px;
border-radius: 16px; border-radius: 16px;
background: linear-gradient(180deg, rgba(248,250,252,0.9) 0%, rgba(255,255,255,0.95) 100%); background: linear-gradient(180deg, rgba(248,250,252,0.9) 0%, rgba(255,255,255,0.95) 100%);
border: 1px solid rgba(148, 163, 184, 0.14); border: 1px solid rgba(148, 163, 184, 0.14);
} }
.agent-card-desc { .agent-card-desc {
min-height: 48px; min-height: 66px;
font-size: 13.5px; font-size: 13.5px;
line-height: 1.7; line-height: 1.7;
display: -webkit-box; display: -webkit-box;

View File

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

View File

@ -30,8 +30,8 @@ export default function AgentListWebBase({ logic, viewport }: AgentListWebVarian
AI 广 AI 广
</div> </div>
</div> </div>
<Button type="primary" size="large" icon={<CompassOutlined />} onClick={() => navigate('/agents/new')} className="agent-list-web-market-btn"> <Button type="primary" size="large" icon={<CompassOutlined />} onClick={() => navigate('/marketplace')} className="agent-list-web-market-btn">
广
</Button> </Button>
</div> </div>
@ -53,8 +53,8 @@ export default function AgentListWebBase({ logic, viewport }: AgentListWebVarian
{!loading && list.length === 0 ? ( {!loading && list.length === 0 ? (
<div className="empty-state"> <div className="empty-state">
<Empty description="你还没有任何智能体"> <Empty description="你还没有任何智能体">
<Button type="primary" onClick={() => navigate('/agents/new')} className="agent-list-web-empty-btn"> <Button type="primary" onClick={() => navigate('/marketplace')} className="agent-list-web-empty-btn">
广
</Button> </Button>
</Empty> </Empty>
</div> </div>

View File

@ -1,150 +0,0 @@
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

@ -1,196 +0,0 @@
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

@ -1,61 +0,0 @@
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

@ -1,274 +0,0 @@
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

@ -1,131 +0,0 @@
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

@ -1,85 +0,0 @@
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

@ -1,8 +0,0 @@
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

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

View File

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

View File

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

View File

@ -1,326 +0,0 @@
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

@ -1,273 +0,0 @@
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

@ -1,275 +0,0 @@
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

@ -1,374 +0,0 @@
.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;
}
}

View File

@ -1,24 +0,0 @@
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

@ -1,148 +0,0 @@
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

@ -1,304 +0,0 @@
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

@ -1,396 +0,0 @@
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

@ -1,145 +0,0 @@
.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;
}

View File

@ -1,24 +0,0 @@
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 ? ( {data.topAgents.length === 0 ? (
<Empty description="暂无" /> <Empty description="暂无" />
) : ( ) : (
<div className="stats-page-agent-list"> <div>
{data.topAgents.map((agent, index) => ( {data.topAgents.map((agent, index) => (
<div key={agent.id} className="stats-page-agent-item"> <div key={agent.id} className="stats-page-agent-item">
<div className="stats-page-agent-header"> <div className="stats-page-agent-header">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,9 +5,9 @@ import type { TeamsPageLogicOutput } from '../TeamsPageLogic';
export default function TeamsPageWebHero({ logic }: { logic: TeamsPageLogicOutput }) { export default function TeamsPageWebHero({ logic }: { logic: TeamsPageLogicOutput }) {
const { list, active, setCreateOpen } = logic; const { list, active, setCreateOpen } = logic;
const stats = [ const stats = [
{ label: '团队数量', value: list.length, type: 'brand' }, { label: '团队数量', value: list.length, tone: 'rgba(8, 145, 178, 0.10)', color: 'var(--color-brand)' },
{ label: '当前成员数', value: active?.members?.length ?? 0, type: 'info' }, { label: '当前成员数', value: active?.members?.length ?? 0, tone: 'rgba(14, 165, 233, 0.10)', color: 'var(--color-info)' },
{ label: '共享智能体', value: active?.agentCount ?? 0, type: 'success' }, { label: '共享智能体', value: active?.agentCount ?? 0, tone: 'rgba(34, 197, 94, 0.10)', color: 'var(--color-success)' },
]; ];
return ( return (
@ -34,7 +34,7 @@ export default function TeamsPageWebHero({ logic }: { logic: TeamsPageLogicOutpu
<div className="teams-page-web-stat-label">{item.label}</div> <div className="teams-page-web-stat-label">{item.label}</div>
<div className="teams-page-web-stat-row"> <div className="teams-page-web-stat-row">
<span className="teams-page-web-stat-value">{item.value}</span> <span className="teams-page-web-stat-value">{item.value}</span>
<span className={`teams-page-web-stat-chip teams-page-web-stat-chip-${item.type}`}> <span className="teams-page-web-stat-chip" style={{ background: item.tone, color: item.color }}>
</span> </span>
</div> </div>

View File

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

View File

@ -1,3 +1,4 @@
import { CopyOutlined } from '@ant-design/icons';
import { App as AntApp, Button, Form, Input, Modal } from 'antd'; import { App as AntApp, Button, Form, Input, Modal } from 'antd';
import type { TeamsPageLogicOutput } from '../TeamsPageLogic'; import type { TeamsPageLogicOutput } from '../TeamsPageLogic';
@ -17,27 +18,43 @@ export default function TeamsPageWebModals({ logic }: { logic: TeamsPageLogicOut
</Button> </Button>
</Form> </Form>
</Modal> </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.addMemberOpen} title={`${active?.name} 添加成员`} onCancel={() => logic.setAddMemberOpen(false)} footer={null} destroyOnHidden> <Modal
<Form layout="vertical" onFinish={logic.handleAddMember}> open={logic.inviteOpen}
<Form.Item name="phone" label="成员手机号" rules={[{ required: true, message: '请输入手机号' }]}> title={`邀请加入 ${active?.name}`}
<Input placeholder="输入成员绑定的手机号" autoFocus /> onCancel={() => {
</Form.Item> logic.setInviteOpen(false);
<Button type="primary" htmlType="submit" block> logic.setLastInviteCode(null);
}}
</Button> footer={null}
</Form> 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> </Modal>
</> </>
); );

View File

@ -1,326 +0,0 @@
.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,8 +33,6 @@
.teams-page-web-mini-card { .teams-page-web-mini-card {
border: 1px solid rgba(8, 145, 178, 0.1); border: 1px solid rgba(8, 145, 178, 0.1);
border-radius: 8px;
padding: 12px;
} }
.teams-page-web-mini-brand { .teams-page-web-mini-brand {
@ -57,83 +55,51 @@
text-transform: capitalize; text-transform: capitalize;
} }
.teams-page-web-detail-card .ant-card-body { .teams-page-web-members-title {
padding: 24px !important; margin-bottom: 14px;
} font-size: 16px;
.teams-page-web-members-title-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 24px;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--color-border-soft);
}
.teams-page-web-member-list {
display: flex;
flex-direction: column;
} }
.teams-page-web-member-item { .teams-page-web-member-item {
display: flex; padding: 14px 0;
justify-content: space-between;
align-items: center;
padding: 16px 0;
border-bottom: 1px solid var(--color-border-soft);
}
.teams-page-web-member-item:last-child {
border-bottom: none;
}
.teams-page-web-member-info {
display: flex;
gap: 14px;
align-items: center;
} }
.teams-page-web-member-avatar { .teams-page-web-member-avatar {
width: 44px; width: 42px;
height: 44px; height: 42px;
display: flex; display: grid;
align-items: center; place-items: center;
justify-content: center; border-radius: 999px;
border-radius: 12px; background: rgba(8, 145, 178, 0.1);
background: var(--color-brand-soft);
color: var(--color-brand); color: var(--color-brand);
font-size: 20px;
}
.teams-page-web-member-meta {
display: flex;
flex-direction: column;
gap: 4px;
}
.teams-page-web-member-name-row {
display: flex;
align-items: center;
gap: 8px;
} }
.teams-page-web-member-name { .teams-page-web-member-name {
font-size: 15px;
font-weight: 700; font-weight: 700;
color: var(--color-text);
} }
.teams-page-web-member-email { .teams-page-web-member-desc {
font-size: 13px; display: grid;
color: var(--color-text-secondary); gap: 4px;
}
.teams-page-web-member-time {
font-size: 12px;
color: var(--color-text-tertiary);
} }
.teams-page-web-empty-detail { .teams-page-web-empty-detail {
align-self: center; align-self: center;
} }
.teams-page-web-invite-label {
margin-bottom: 12px;
}
.teams-page-web-invite-code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 16px;
}
.teams-page-web-copy-btn {
margin-top: 12px;
}
.teams-page-web-invite-tip {
margin-top: 8px;
}

View File

@ -44,35 +44,20 @@
font-weight: 700; font-weight: 700;
} }
.teams-page-web-badge .anticon {
color: var(--color-brand);
}
.teams-page-web-title { .teams-page-web-title {
margin-bottom: 8px; margin-bottom: 10px;
font-size: 28px;
font-weight: 850;
color: var(--color-text);
letter-spacing: -0.02em;
} }
.teams-page-web-subtitle { .teams-page-web-subtitle {
margin-top: 0; margin-top: 0;
font-size: 14.5px; font-size: 15px;
line-height: 1.6; line-height: 1.75;
color: var(--color-text-secondary);
max-width: 580px;
} }
.teams-page-web-create-btn { .teams-page-web-create-btn,
border-radius: 14px !important; .teams-page-web-soft-btn,
height: 46px !important; .teams-page-web-copy-btn {
padding: 0 18px !important; border-radius: 12px;
font-weight: 600 !important;
}
.teams-page-web-soft-btn {
border-radius: 12px !important;
} }
.teams-page-web-stats-grid, .teams-page-web-stats-grid,
@ -84,19 +69,25 @@
.teams-page-web-stats-grid { .teams-page-web-stats-grid {
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
margin-top: 24px; margin-top: 20px;
gap: 16px; /* Optimized gap for stats grid */ }
.teams-page-web-stat-card,
.teams-page-web-mini-card {
border-radius: 18px;
padding: 16px 18px;
} }
.teams-page-web-stat-card { .teams-page-web-stat-card {
border-radius: 18px; background: rgba(255,255,255,0.72);
padding: 16px 18px; border: 1px solid rgba(255,255,255,0.7);
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(255, 255, 255, 0.7);
} }
.teams-page-web-stat-label, .teams-page-web-stat-label,
.teams-page-web-section-desc { .teams-page-web-section-desc,
.teams-page-web-mini-label,
.teams-page-web-member-desc,
.teams-page-web-invite-tip {
color: var(--color-text-secondary); color: var(--color-text-secondary);
font-size: 12.5px; font-size: 12.5px;
} }
@ -111,30 +102,14 @@
.teams-page-web-stat-value { .teams-page-web-stat-value {
color: var(--color-text); color: var(--color-text);
font-size: 30px; font-size: 30px;
font-weight: 700; font-weight: 800;
} }
.teams-page-web-stat-chip { .teams-page-web-stat-chip {
border-radius: 999px; border-radius: 999px;
padding: 4px 10px; padding: 4px 8px;
font-size: 11px; font-size: 12px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.02em;
}
.teams-page-web-stat-chip-brand {
background: var(--color-brand-soft);
color: var(--color-brand);
}
.teams-page-web-stat-chip-info {
background: var(--color-info-soft);
color: var(--color-info);
}
.teams-page-web-stat-chip-success {
background: var(--color-success-soft);
color: var(--color-success);
} }
.teams-page-web-main-grid { .teams-page-web-main-grid {
@ -169,56 +144,29 @@
font-size: 15px; font-size: 15px;
} }
.teams-page-web-list-items {
display: flex;
flex-direction: column;
gap: 2px;
}
.teams-page-web-nav-item { .teams-page-web-nav-item {
width: 100%; width: 100%;
display: grid;
gap: 4px;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 12px; border-radius: 14px;
padding: 10px 14px; padding: 10px 12px;
margin-bottom: 6px;
background: transparent; background: transparent;
color: var(--color-text-secondary); color: var(--color-text-secondary);
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.teams-page-web-nav-item:hover {
background: var(--color-surface-2);
color: var(--color-text);
} }
.teams-page-web-nav-item.active { .teams-page-web-nav-item.active {
background: var(--color-brand-soft); background: rgba(8, 145, 178, 0.1);
border-color: rgba(8, 145, 178, 0.12); border-color: rgba(8, 145, 178, 0.16);
color: var(--color-brand); color: var(--color-brand);
font-weight: 700;
} }
.teams-page-web-nav-item-content { .teams-page-web-nav-item small {
display: flex;
flex-direction: column;
gap: 2px;
}
.teams-page-web-nav-item-name {
font-size: 14px;
font-weight: 600;
line-height: 1.4;
}
.teams-page-web-nav-item-count {
font-size: 12px;
color: var(--color-text-tertiary); color: var(--color-text-tertiary);
font-weight: 400;
}
.teams-page-web-nav-item.active .teams-page-web-nav-item-count {
color: var(--color-brand);
opacity: 0.8;
} }
.teams-page-web-detail-head { .teams-page-web-detail-head {

View File

@ -105,7 +105,7 @@ export function useChatPageLogic() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, roomId]); }, [id, roomId]);
const { agent, agentList, messages, setMessages, branches, setBranches, loadMessages, roomInvalidTick } = useChatData({ const { agent, agentList, messages, setMessages, branches, setBranches, loadMessages } = useChatData({
agentId: id, agentId: id,
roomId, roomId,
highlightId, highlightId,
@ -116,21 +116,6 @@ export function useChatPageLogic() {
abort: () => abortRef.current?.abort(), abort: () => abortRef.current?.abort(),
}); });
// 监听会话失效状态,自动创建新会话
useEffect(() => {
if (roomInvalidTick > 0 && id) {
SessionAPI.create(id).then((created) => {
setRoomId(created.id);
setHighlightId(null);
setMessages(() => []);
setBranches({});
message.info('当前会话不可访问,已为您创建新会话');
}).catch((e) => {
message.error('创建房间失败:' + (e?.message ?? e));
});
}
}, [roomInvalidTick, id]);
const sender = useChatSender({ const sender = useChatSender({
agentId: id, agentId: id,
agent, agent,

View File

@ -1,193 +0,0 @@
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,159 +1,28 @@
.chat-side {
width: 300px;
background: var(--color-surface);
border-right: 1px solid var(--color-border);
display: flex;
flex-direction: column;
height: 100%;
transition: width 0.3s ease;
position: relative;
}
.chat-side.is-collapsed {
width: 68px !important;
}
.chat-agent-sidebar-toggle {
position: absolute;
right: -12px;
top: 24px;
width: 24px;
height: 24px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 1001;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
color: var(--color-text-secondary);
font-size: 12px;
transition: all 0.2s;
}
.chat-agent-sidebar-toggle:hover {
color: var(--color-brand);
border-color: var(--color-brand);
transform: scale(1.1);
}
.chat-side.is-collapsed .chat-agent-sidebar-header {
padding: 16px 8px 8px;
}
.chat-side.is-collapsed .chat-agent-create-btn {
padding: 0 !important;
width: 40px;
margin: 0 auto;
}
.chat-side.is-collapsed .chat-agent-sidebar-list {
padding: 8px;
}
.chat-side.is-collapsed .chat-agent-item {
justify-content: center;
padding: 8px 0;
border-left: none;
}
.chat-side.is-collapsed .chat-agent-item.active {
background: var(--color-surface-3);
}
.chat-side.is-collapsed .chat-agent-item.active::after {
right: 4px;
bottom: 4px;
}
.chat-side.is-full { .chat-side.is-full {
width: 100%; width: 100%;
border: 0; border: 0;
} }
.chat-agent-sidebar-header { .chat-agent-sidebar-header {
padding: 16px 16px 8px; padding: 16px;
} border-bottom: 1px solid var(--color-border);
.chat-agent-create-btn {
height: 40px !important;
border-radius: 12px !important;
font-weight: 600 !important;
background: var(--color-brand) !important;
border: none !important;
box-shadow: 0 4px 12px rgba(79, 209, 197, 0.2) !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
gap: 8px !important;
}
.chat-agent-create-btn:hover {
background: var(--color-brand-hover) !important;
transform: translateY(-1px);
}
.chat-agent-tabs {
padding: 0 16px;
}
.chat-agent-tabs .ant-tabs-nav {
margin-bottom: 8px !important;
}
.chat-agent-tabs .ant-tabs-tab {
padding: 8px 4px !important;
font-size: 13px !important;
color: var(--color-text-secondary) !important;
}
.chat-agent-tabs .ant-tabs-tab-active .ant-tabs-tab-btn {
color: var(--color-text) !important;
font-weight: 600 !important;
}
.chat-agent-tabs .ant-tabs-ink-bar {
background: var(--color-brand) !important;
height: 3px !important;
border-radius: 3px 3px 0 0 !important;
} }
.chat-agent-sidebar-list { .chat-agent-sidebar-list {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 8px 12px; padding: 8px 0;
}
.chat-agent-group {
margin-bottom: 20px;
}
.chat-agent-group-label {
font-size: 11px;
font-weight: 600;
color: var(--color-text-tertiary);
padding: 0 8px 8px;
display: flex;
align-items: center;
justify-content: space-between;
}
.chat-agent-group-count {
font-weight: 400;
} }
.chat-agent-item { .chat-agent-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 8px 10px; padding: 10px 16px;
cursor: pointer; cursor: pointer;
border-radius: 12px; background: transparent;
margin-bottom: 2px; border-left: 3px solid transparent;
transition: all 0.2s; transition: background 0.2s;
position: relative;
} }
.chat-agent-item:hover { .chat-agent-item:hover {
@ -161,33 +30,22 @@
} }
.chat-agent-item.active { .chat-agent-item.active {
background: var(--color-surface-3); background: var(--color-surface-2);
border-left: 3px solid #5CCFC4; border-left: 3px solid var(--color-brand);
}
.chat-agent-item.active::after {
content: '';
position: absolute;
right: 12px;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--color-brand);
} }
.chat-agent-avatar-wrap { .chat-agent-avatar-wrap {
width: 32px; width: 32px;
height: 32px; height: 32px;
border-radius: 10px; border-radius: 50%;
color: #fff; color: #fff;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-weight: 700; font-weight: 700;
font-size: 13px; font-size: 14px;
overflow: hidden; overflow: hidden;
flex-shrink: 0; flex-shrink: 0;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
} }
.chat-agent-avatar-img { .chat-agent-avatar-img {
@ -202,7 +60,7 @@
} }
.chat-agent-name { .chat-agent-name {
font-size: 13.5px; font-size: 14px;
color: var(--color-text); color: var(--color-text);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@ -219,7 +77,8 @@
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 40px 20px; height: 200px;
padding: 20px;
text-align: center; text-align: center;
} }
@ -231,5 +90,5 @@
} }
.chat-agent-loading-text { .chat-agent-loading-text {
margin-top: 12px; margin-top: 8px;
} }

View File

@ -1,6 +1,6 @@
import { Button, Spin, Empty, Tabs, Tooltip } from 'antd'; import { Button, Spin, Empty } from 'antd';
import { PlusOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons'; import { MessageOutlined } from '@ant-design/icons';
import { useEffect, useState, useMemo } from 'react'; import { useEffect, useState } from 'react';
import type { Agent } from '../../../api'; import type { Agent } from '../../../api';
import './AgentSidebar.css'; import './AgentSidebar.css';
@ -12,20 +12,9 @@ export default function AgentSidebar(props: {
onCreate: () => void; onCreate: () => void;
onSelect: (agentId: string) => void; onSelect: (agentId: string) => void;
isSidebar?: boolean; isSidebar?: boolean;
collapsed?: boolean;
onToggleCollapse?: () => void;
}) { }) {
const { const { agentList, activeAgentId, onCreate, onSelect, isSidebar = true } = props;
agentList,
activeAgentId,
onCreate,
onSelect,
isSidebar = true,
collapsed = false,
onToggleCollapse
} = props;
const [loadingTimeout, setLoadingTimeout] = useState(false); const [loadingTimeout, setLoadingTimeout] = useState(false);
const [activeTab, setActiveTab] = useState('mine');
useEffect(() => { useEffect(() => {
if (agentList.length > 0) return; if (agentList.length > 0) return;
@ -37,127 +26,56 @@ export default function AgentSidebar(props: {
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [agentList.length]); }, [agentList.length]);
const filteredList = useMemo(() => {
if (activeTab === 'public') return agentList.filter(a => a.visibility === 'public');
if (activeTab === 'private') return agentList.filter(a => a.visibility === 'private');
return agentList; // 'mine' tab shows all for now or filter by owner_id if available
}, [agentList, activeTab]);
// 模拟分组逻辑
const groupedAgents = useMemo(() => {
const groups: Record<string, Agent[]> = {
'我的智能体': [],
'推荐': []
};
filteredList.forEach(a => {
if (a.visibility === 'public') {
groups['推荐'].push(a);
} else {
groups['我的智能体'].push(a);
}
});
return Object.entries(groups).filter(([_, list]) => list.length > 0);
}, [filteredList]);
return ( return (
<aside className={`chat-side${isSidebar ? '' : ' is-full'}${collapsed ? ' is-collapsed' : ''}`}> <aside className={`chat-side${isSidebar ? '' : ' is-full'}`}>
{onToggleCollapse && (
<div className="chat-agent-sidebar-toggle" onClick={onToggleCollapse}>
{collapsed ? <RightOutlined /> : <LeftOutlined />}
</div>
)}
<div className="chat-agent-sidebar-header"> <div className="chat-agent-sidebar-header">
<Button <Button block type="dashed" onClick={onCreate}>
block +
type="primary"
icon={<PlusOutlined />}
onClick={onCreate}
className="chat-agent-create-btn"
>
{!collapsed && '创建智能体'}
</Button> </Button>
</div> </div>
{!collapsed && (
<div className="chat-agent-tabs">
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={[
{ key: 'mine', label: '我的' },
{ key: 'public', label: '公开' },
{ key: 'private', label: '私有' }
]}
/>
</div>
)}
<div className="chat-agent-sidebar-list"> <div className="chat-agent-sidebar-list">
{agentList.length > 0 ? ( {agentList.length > 0 ? (
groupedAgents.map(([groupName, list]) => ( agentList.map((a) => {
<div key={groupName} className="chat-agent-group"> const isActive = a.id === activeAgentId;
{!collapsed && ( return (
<div className="chat-agent-group-label"> <div
<span>{groupName}</span> key={a.id}
<span className="chat-agent-group-count">{list.length}</span> 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> </div>
)} <div className="chat-agent-info">
{list.map((a) => { <div className="chat-agent-name">
const isActive = a.id === activeAgentId; {a.name}
const content = ( {!isSidebar && <MessageOutlined style={{marginLeft: 12}} />}
<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>
</div>
if (collapsed) { );
return ( })
<Tooltip key={a.id} title={a.name} placement="right">
{content}
</Tooltip>
);
}
return content;
})}
</div>
))
) : !loadingTimeout ? ( ) : !loadingTimeout ? (
<div className="chat-agent-sidebar-status"> <div className="chat-agent-sidebar-status">
<Spin /> <Spin />
{!collapsed && <div className="chat-agent-loading-text">...</div>} <div className="chat-agent-loading-text">...</div>
</div> </div>
) : ( ) : (
<div className="chat-agent-sidebar-status"> <div className="chat-agent-sidebar-status">
<Empty <Empty
image={Empty.PRESENTED_IMAGE_SIMPLE} image={Empty.PRESENTED_IMAGE_SIMPLE}
description={ description={
!collapsed && ( <span className="chat-agent-empty-text">
<span className="chat-agent-empty-text"> <br />
<br />
</span>
</span>
)
} }
/> />
</div> </div>

View File

@ -1,93 +0,0 @@
.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,12 +1,10 @@
import { Divider, Tag, Avatar } from 'antd'; import { Divider, Tag, Avatar } from 'antd';
import { useState, useEffect, useRef, useLayoutEffect } from 'react';
import type { Agent, BranchInfo, ChatMessage } from '../../../api'; import type { Agent, BranchInfo, ChatMessage } from '../../../api';
import Markdown from '../../../components/Markdown'; import Markdown from '../../../components/Markdown';
import type { StreamingState } from '../hooks/useChatSender'; import type { StreamingState } from '../hooks/useChatSender';
import type { CopyMode } from '../utils/copy'; import type { CopyMode } from '../utils/copy';
import MessageItem from './messages/MessageItem'; import MessageItem from './messages/MessageItem';
import { ReasoningView, RetrievedView, ToolCallView } from './messages/MetaViews'; import { RetrievedView, ToolCallView } from './messages/MetaViews';
import './ChatBody.css';
const isImageUrl = (url: string) => url?.startsWith('http') || url?.startsWith('/'); const isImageUrl = (url: string) => url?.startsWith('http') || url?.startsWith('/');
@ -23,82 +21,36 @@ export default function ChatBody(props: {
onRegenerate: (assistantId: string) => void; onRegenerate: (assistantId: string) => void;
onSwitchBranch: (userMsgId: string, branchId: string) => void; onSwitchBranch: (userMsgId: string, branchId: string) => void;
onCopy: (text: string, mode: CopyMode) => void; onCopy: (text: string, mode: CopyMode) => void;
scrollBottom: (force?: boolean) => void;
initialScrollDoneRef: { current: boolean };
isMobile?: boolean; isMobile?: boolean;
}) { }) {
const { bodyRef, agent, agentList, currentAgentId, messages, branches, highlightId, sending, streaming, onRegenerate, onSwitchBranch, onCopy, scrollBottom, initialScrollDoneRef, isMobile } = props; const { bodyRef, agent, agentList, currentAgentId, messages, branches, highlightId, sending, streaming, onRegenerate, onSwitchBranch, onCopy, isMobile } = props;
const [streamingReasoningExpanded, setStreamingReasoningExpanded] = useState(true);
const bottomAnchorRef = useRef<HTMLDivElement>(null);
// 当开始新的回答时,默认展开推理过程;当正式回答开始时,自动折叠推理过程
useEffect(() => {
if (streaming.active) {
if (!streaming.answerText) {
setStreamingReasoningExpanded(true);
} else {
setStreamingReasoningExpanded(false);
}
}
}, [streaming.active, !!streaming.answerText]);
// 1. 初始加载:使用 useLayoutEffect 在浏览器绘图前尝试触底,消除跳转感
useLayoutEffect(() => {
if (messages.length > 0 && !initialScrollDoneRef.current) {
const el = bodyRef.current;
if (el) {
el.scrollTop = el.scrollHeight;
// 注意:这里不立即设置 initialScrollDoneRef.current = true
// 留给下面的 useEffect 或 highlight 逻辑处理,确保后续第一次滚动也是 instant
}
}
}, [messages.length, bodyRef]);
// 2. 新消息到达或流式输出时的自动跟进
useEffect(() => {
if (messages.length > 0 || streaming.active) {
const isInitial = !initialScrollDoneRef.current;
scrollBottom(isInitial);
if (isInitial) {
initialScrollDoneRef.current = true;
}
}
}, [messages.length, streaming.answerText, streaming.reasoningText, streaming.active, scrollBottom, initialScrollDoneRef]);
// 3. 监听内容高度变化如图片加载、Markdown 渲染)
useEffect(() => {
const el = bodyRef.current;
if (!el) return;
const observer = new ResizeObserver(() => {
scrollBottom();
});
const container = el.querySelector('.messages-container');
if (container) {
observer.observe(container);
}
return () => observer.disconnect();
}, [scrollBottom, bodyRef]);
const isEmpty = messages.length === 0 && !streaming.active;
return ( return (
<div ref={bodyRef} className="chat-body"> <div ref={bodyRef} className="chat-body">
<div className="messages-container"> <div className="messages-container">
{isEmpty ? ( {messages.length === 0 && !streaming.active ? (
<div className="chat-empty-welcome"> <div style={{ textAlign: 'center', marginTop: 120 }}>
<div className="chat-welcome-avatar"> <div
{isImageUrl(agent.avatar) ? ( style={{
<img src={agent.avatar} className="w-full h-full object-cover" alt="avatar" /> width: 68,
) : ( height: 68,
(agent.name?.charAt(0) || '?').toUpperCase() 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()}
</div> </div>
<h2 className="chat-welcome-title"></h2> <h2 style={{ fontSize: 28, fontWeight: 700, color: 'var(--color-text)', marginBottom: 8, letterSpacing: '-0.02em' }}></h2>
<p className="chat-welcome-desc">{agent.description || '我是你的专属 AI 助手,随时准备为你服务。'}</p> <p style={{ color: 'var(--color-text-secondary)', fontSize: 15, lineHeight: 1.7 }}>{agent.description || '我是你的专属 AI 助手,随时准备为你服务。'}</p>
</div> </div>
) : ( ) : (
<> <>
@ -119,31 +71,50 @@ export default function ChatBody(props: {
))} ))}
{streaming.active && ( {streaming.active && (
<div className="streaming-message"> <div style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}> <div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
{(() => { {(() => {
const streamingAgentId = streaming.targetAgentId || currentAgentId; const streamingAgentId = streaming.targetAgentId || currentAgentId;
const streamingAgent = agentList.find(a => a.id === streamingAgentId); const streamingAgent = agentList.find(a => a.id === streamingAgentId);
return ( if (streamingAgent) {
<Avatar src={streamingAgent?.avatar} size={36} className="message-item-avatar"> return (
{streamingAgent?.name?.charAt(0)?.toUpperCase() || 'A'} <Avatar src={streamingAgent.avatar} size={36} style={{ flexShrink: 0, marginTop: 2, backgroundColor: '#52c41a' }}>
</Avatar> {streamingAgent.name?.charAt(0)?.toUpperCase() || 'A'}
); </Avatar>
);
}
return null;
})()} })()}
<div className="message-item-content"> <div style={{ flex: 1, minWidth: 0 }}>
<div className="message-item-header"> {(() => {
<span className="message-item-name"> const streamingAgentId = streaming.targetAgentId || currentAgentId;
{agentList.find(a => a.id === (streaming.targetAgentId || currentAgentId))?.name || 'AI'} const streamingAgent = agentList.find(a => a.id === streamingAgentId);
</span> if (streamingAgent) {
</div> 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="bubble assistant"> <div className="bubble assistant">
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{!!streaming.retryInfo?.message && ( {!!streaming.retryInfo?.message && (
<div className="streaming-retry-card"> <div style={{ padding: '8px 10px', borderRadius: 10, background: 'rgba(59, 130, 246, 0.08)', border: '1px solid rgba(59, 130, 246, 0.18)' }}>
<div className="streaming-retry-header"> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<span className="streaming-retry-title"> <span style={{ fontSize: 12.5, color: 'var(--color-text)', fontWeight: 600 }}>{streaming.retryInfo.stage === 'fallback_model' ? '自动切换模型' : '自动重试'}</span>
{streaming.retryInfo.stage === 'fallback_model' ? '自动切换模型' : '自动重试'}
</span>
{streaming.retryInfo.stage === 'fallback_model' ? ( {streaming.retryInfo.stage === 'fallback_model' ? (
<Tag color="processing" style={{ marginInlineEnd: 0 }}> <Tag color="processing" style={{ marginInlineEnd: 0 }}>
{String(streaming.retryInfo.fromModel || '')} {String(streaming.retryInfo.toModel || '')} {String(streaming.retryInfo.fromModel || '')} {String(streaming.retryInfo.toModel || '')}
@ -155,29 +126,25 @@ export default function ChatBody(props: {
</Tag> </Tag>
)} )}
</div> </div>
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', lineHeight: 1.55 }}>{String(streaming.retryInfo.message)}</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> </div>
)} )}
<div className="streaming-section"> <div>
<ReasoningView <div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginBottom: 4 }}></div>
reasoning={streaming.reasoningText || '等待推理…'} {streaming.reasoningText ? <Markdown>{streaming.reasoningText + '▍'}</Markdown> : <span style={{ color: 'var(--color-text-tertiary)' }}></span>}
expanded={streamingReasoningExpanded} </div>
onToggle={() => setStreamingReasoningExpanded(!streamingReasoningExpanded)} <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> </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>
</div> </div>
{(streaming.retrieved.length > 0 || streaming.toolCalls.length > 0) && ( {(streaming.retrieved.length > 0 || streaming.toolCalls.length > 0) && (
<div style={{ marginTop: 8 }}> <div>
{streaming.retrieved.length > 0 && <RetrievedView retrieved={streaming.retrieved} />} {streaming.retrieved.length > 0 && <RetrievedView retrieved={streaming.retrieved} />}
{streaming.toolCalls.length > 0 && <ToolCallView calls={streaming.toolCalls} liveStyle />} {streaming.toolCalls.length > 0 && <ToolCallView calls={streaming.toolCalls} liveStyle />}
</div> </div>
@ -186,8 +153,6 @@ export default function ChatBody(props: {
</div> </div>
</div> </div>
)} )}
{/* 底部锚点:用于辅助滚动定位 */}
<div ref={bottomAnchorRef} style={{ height: 1, marginTop: -1 }} />
</> </>
)} )}
</div> </div>

View File

@ -1,114 +0,0 @@
.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,31 +1,28 @@
import { useState, useEffect } from 'react'; import { ApiOutlined, DeleteOutlined, DownOutlined, EditOutlined, SettingOutlined, EllipsisOutlined } from '@ant-design/icons';
import { ApiOutlined, DeleteOutlined, EditOutlined, SettingOutlined, EllipsisOutlined, HistoryOutlined, LogoutOutlined } from '@ant-design/icons'; import { Button, Dropdown, Modal, Space, Switch } from 'antd';
import { Button, Dropdown, Modal, Switch } from 'antd'; import type { Agent } from '../../../api';
import { Agent, ModelAPI, AiModel } from '../../../api'; import { useIsMobile } from '../../../hooks/useIsMobile';
import { parseAgentModels } from '../utils/agentModels';
import './ChatHeader.css';
function formatAgentModel(raw: unknown, models: AiModel[]) { function formatAgentModel(raw: string | null | undefined) {
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(', ') + '...';
}
if (parsedModels.length > 0) {
return parsedModels.join(', ');
}
const s = String(raw ?? '').trim(); const s = String(raw ?? '').trim();
return s ? findModelName(s) : '默认模型'; 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 {}
}
return s;
} }
const isImageUrl = (url: string) => url?.startsWith('http') || url?.startsWith('/');
export default function ChatHeader(props: { export default function ChatHeader(props: {
agent: Agent; agent: Agent;
useStream: boolean; useStream: boolean;
@ -35,47 +32,32 @@ export default function ChatHeader(props: {
onOpenMcp: () => void; onOpenMcp: () => void;
onManageAgent: () => void; onManageAgent: () => void;
onClear: () => void; onClear: () => void;
onLogout?: () => void;
}) { }) {
const { agent, useStream, setUseStream, onOpenHistory, onOpenParams, onOpenMcp, onManageAgent, onClear, onLogout } = props; const { agent, useStream, setUseStream, onOpenHistory, onOpenParams, onOpenMcp, onManageAgent, onClear } = props;
const [models, setModels] = useState<AiModel[]>([]); const modelText = formatAgentModel(agent.model);
const isMobile = useIsMobile();
useEffect(() => {
ModelAPI.list().then(setModels).catch(console.error);
}, []);
const modelText = formatAgentModel(agent.models ?? agent.model, models);
return ( return (
<div className="chat-header"> <div className="chat-header" style={isMobile ? { position: 'sticky', top: 0, zIndex: 10, background: 'var(--color-bg)' } : undefined}>
<div className="chat-header-left"> <div className="chat-header-agent">
<div className="chat-header-avatar"> <div className="chat-header-agent-title">
{isImageUrl(agent.avatar) ? ( <span className="chat-header-agent-name">{agent.name}</span>
<img src={agent.avatar} alt="avatar" style={{ width: '100%', height: '100%', borderRadius: '50%' }} /> {!!agent.description && <span className="chat-header-agent-desc">{agent.description}</span>}
) : (
(agent.name?.charAt(0) || '?').toUpperCase()
)}
</div> </div>
<div className="chat-header-info"> <div className="chat-header-agent-meta">
<div className="chat-header-name-row"> {modelText}
<span className="chat-header-name">{agent.name}</span>
</div>
<div className="chat-header-desc">
<span className="chat-header-model">{modelText}</span>
</div>
</div> </div>
</div> </div>
<Space>
<div className="chat-header-right">
<div className="chat-header-stream-toggle"> <div className="chat-header-stream-toggle">
<span></span> <span className="chat-header-stream-label"></span>
<Switch size="small" checked={useStream} onChange={setUseStream} /> <Switch size="small" checked={useStream} onChange={setUseStream} />
</div> </div>
{!isMobile && (
<Button className="chat-header-btn" onClick={onOpenHistory}> <Button size="small" onClick={onOpenHistory}>
</Button> </Button>
)}
<Dropdown <Dropdown
menu={{ menu={{
items: [ items: [
@ -91,27 +73,15 @@ export default function ChatHeader(props: {
onClick: () => { onClick: () => {
Modal.confirm({ title: '清空当前会话所有消息?', onOk: onClear }); Modal.confirm({ title: '清空当前会话所有消息?', onOk: onClear });
} }
}, }
...(onLogout
? [
{ type: 'divider' as const },
{
key: 'logout',
label: '退出登录',
icon: <LogoutOutlined />,
onClick: onLogout,
}
]
: [])
] ]
}} }}
placement="bottomRight"
> >
<Button className="chat-header-more-btn"> <Button size="small" type={isMobile ? 'text' : 'primary'}>
<EllipsisOutlined /> {isMobile ? <EllipsisOutlined /> : <> <DownOutlined /></>}
</Button> </Button>
</Dropdown> </Dropdown>
</div> </Space>
</div> </div>
); );
} }

View File

@ -1,159 +0,0 @@
.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,11 +1,10 @@
import { ArrowUpOutlined, BookOutlined, CloseOutlined, PaperClipOutlined, HistoryOutlined, PlusOutlined } from '@ant-design/icons'; import { ArrowUpOutlined, BookOutlined, CloseOutlined, DownOutlined, PaperClipOutlined } from '@ant-design/icons';
import { Button, Image as AntImage, Input, Tag, Tooltip, Upload } from 'antd'; import { Button, Image as AntImage, Input, Select, Tag, Tooltip, Upload, Popover } from 'antd';
import type { TextAreaRef } from 'antd/es/input/TextArea'; import type { TextAreaRef } from 'antd/es/input/TextArea';
import type { ChatAttachment } from '../../../api'; import type { ChatAttachment } from '../../../api';
import type { Agent } from '../../../api/agents'; import type { Agent } from '../../../api/agents';
import { useState, useRef } from 'react'; import { HistoryIcon, NewChatIcon } from '../../../components/icons';
import './ChatInput.css'; import { useState, useRef, useEffect } from 'react';
import { IconAttachment, IconPrompt } from '../../../components/Icon';
export default function ChatInput(props: { export default function ChatInput(props: {
input: string; input: string;
@ -40,9 +39,13 @@ export default function ChatInput(props: {
onStop, onStop,
onAttach, onAttach,
onOpenTpl, onOpenTpl,
modelOptions,
activeModelValue,
onChangeModel,
onOpenHistory, onOpenHistory,
onNewSession, onNewSession,
agentList, agentList,
onInsertMention,
showActions = true showActions = true
} = props; } = props;
@ -95,6 +98,7 @@ export default function ChatInput(props: {
}; };
}; };
// 检测 @ 触发提及选择
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value; const value = e.target.value;
setInput(value); setInput(value);
@ -103,17 +107,32 @@ export default function ChatInput(props: {
const textBeforeCursor = value.slice(0, cursorPos); const textBeforeCursor = value.slice(0, cursorPos);
const atIndex = textBeforeCursor.lastIndexOf('@'); const atIndex = textBeforeCursor.lastIndexOf('@');
console.log('[@mention] input change:', {
value,
cursorPos,
textBeforeCursor,
atIndex,
agentListLength: agentList.length,
agentList: agentList.map(a => ({ id: a.id, name: a.name }))
});
if (atIndex !== -1 && (atIndex === 0 || /\s$/.test(textBeforeCursor.slice(0, atIndex)))) { if (atIndex !== -1 && (atIndex === 0 || /\s$/.test(textBeforeCursor.slice(0, atIndex)))) {
const query = textBeforeCursor.slice(atIndex + 1); const query = textBeforeCursor.slice(atIndex + 1);
console.log('[@mention] matched @ at position:', { atIndex, query });
if (!query.includes(' ')) { if (!query.includes(' ')) {
setMentionQuery(query); setMentionQuery(query);
// 计算位置 - Ant Design Input.TextArea 需要从 resizableTextArea 获取实际 DOM
requestAnimationFrame(() => { requestAnimationFrame(() => {
const textarea = inputRef.current?.resizableTextArea?.textArea; const textarea = inputRef.current?.resizableTextArea?.textArea;
if (!textarea) return; if (!textarea) {
console.log('[@mention] cannot get textarea DOM from inputRef:', inputRef.current);
return;
}
const caret = getCaretPos(textarea, cursorPos); const caret = getCaretPos(textarea, cursorPos);
const top = Math.min(window.innerHeight - 8, caret.top + caret.lineHeight + 8); const top = Math.min(window.innerHeight - 8, caret.top + caret.lineHeight + 8);
const left = Math.min(window.innerWidth - 160, Math.max(8, caret.left)); const left = Math.min(window.innerWidth - 160, Math.max(8, caret.left));
setMentionPos({ top, left }); setMentionPos({ top, left });
console.log('[@mention] show popover:', { top, left, query });
setShowMentionPopover(true); setShowMentionPopover(true);
}); });
return; return;
@ -153,166 +172,167 @@ export default function ChatInput(props: {
return ( return (
<div className="chat-input-wrapper"> <div className="chat-input-wrapper">
{showActions && ( <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 }}>
<div className="chat-input-toolbar-top"> {attachments.map((a, i) => (
<div className="chat-input-actions-left"> <Tag key={i} color="blue" closable style={{ borderRadius: 6, padding: '4px 8px' }} onClose={() => setAttachments((arr) => arr.filter((_, j) => j !== i))}>
<Button 📎 {a.name}
icon={<HistoryOutlined />} </Tag>
onClick={onOpenHistory} ))}
className="chat-input-action-btn" {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>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={onNewSession}
className="chat-input-action-btn chat-input-action-btn-primary"
>
</Button>
</div>
{/* <div className="chat-input-token-display">
12,480 tokens
</div> */}
</div>
)}
<div className="chat-input-card">
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{attachments.map((a, i) => (
<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 <Button
size="small"
type="primary" type="primary"
shape="circle" shape="circle"
onClick={onSend} icon={<CloseOutlined style={{ fontSize: 8 }} />}
icon={<ArrowUpOutlined />} style={{ position: 'absolute', top: -6, right: -6, width: 16, height: 16, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
disabled={!input.trim()} onClick={() => setImageUrls((arr) => arr.filter((_, j) => j !== i))}
className="chat-send-btn"
/> />
)} </div>
))}
</div>
<div className="chat-input-card-wrap">
{showActions && <div className="chat-input-actions">
<Tooltip title="历史记录">
<Button size="small" type="text" className="chat-input-action-btn" icon={<HistoryIcon />} onClick={onOpenHistory}>
</Button>
</Tooltip>
<Tooltip title="新增会话">
<Button size="small" type="text" className="chat-input-action-btn chat-input-action-btn-primary" icon={<NewChatIcon />} onClick={onNewSession}>
</Button>
</Tooltip>
</div>}
<div className="chat-input-card">
<div className="chat-input-stack">
<Input.TextArea
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> </div>
</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> </div>
); );
} }

View File

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

View File

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

View File

@ -122,8 +122,6 @@ export default function ChatPageH5({ logic }: { logic: ChatPageLogicOutput }) {
streaming={sender.streaming} streaming={sender.streaming}
onRegenerate={sender.handleRegenerate} onRegenerate={sender.handleRegenerate}
onSwitchBranch={sender.handleSwitchBranch} onSwitchBranch={sender.handleSwitchBranch}
scrollBottom={logic.scrollBottom}
initialScrollDoneRef={logic.initialScrollDoneRef}
onCopy={(text, mode) => { onCopy={(text, mode) => {
const content = mode === 'markdown' ? text : markdownToPlainText(text); const content = mode === 'markdown' ? text : markdownToPlainText(text);
navigator.clipboard?.writeText(content).then(() => { navigator.clipboard?.writeText(content).then(() => {

View File

@ -1,23 +1,16 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { App as AntApp, Empty } from 'antd'; import { App as AntApp, Empty } from 'antd';
import type { ChatPageLogicOutput } from '../ChatPageLogic'; import type { ChatPageLogicOutput } from '../ChatPageLogic';
import { markdownToPlainText } from '../utils/copy'; import { markdownToPlainText } from '../utils/copy';
import type { ModelOverrides } from '../../../api'; import type { ModelOverrides } from '../../../api';
import { useDesktopViewport, desktopViewportClass } from '../../../hooks/useDesktopViewport';
import AgentSidebar from './AgentSidebar'; import AgentSidebar from './AgentSidebar';
import ChatBody from './ChatBody'; import ChatBody from './ChatBody';
import ChatDrawers from './ChatDrawers'; import ChatDrawers from './ChatDrawers';
import ChatHeader from './ChatHeader'; import ChatHeader from './ChatHeader';
import ChatInput from './ChatInput'; import ChatInput from './ChatInput';
import ChatOutline from './ChatOutline'; import ChatOutline from './ChatOutline';
import '../styles/chat-page-web.css';
export default function ChatPageWeb({ logic }: { logic: ChatPageLogicOutput }) { export default function ChatPageWeb({ logic }: { logic: ChatPageLogicOutput }) {
const { message } = AntApp.useApp(); const { message } = AntApp.useApp();
const viewport = useDesktopViewport();
const [outlineCollapsed, setOutlineCollapsed] = useState(true);
const [agentSidebarCollapsed, setAgentSidebarCollapsed] = useState(false);
const isAutoScrolling = useRef(false);
const { const {
id, id,
@ -45,62 +38,34 @@ export default function ChatPageWeb({ logic }: { logic: ChatPageLogicOutput }) {
handleNewSession, handleNewSession,
} = logic; } = logic;
// 1. 初始化时,如果有历史消息,激活最后一条 Agent 消息
useEffect(() => {
if (messages.length > 0 && !highlightId) {
const agentMsgs = messages.filter(m => m.role === 'assistant' || m.role === 'agent' || m.speaker?.type === 'agent');
if (agentMsgs.length > 0) {
setHighlightId(agentMsgs[agentMsgs.length - 1].id);
}
}
}, [messages.length]);
// 2. 移除滚动监听逻辑,避免干扰正常滚动
// 后续若需恢复,需重新设计非阻塞的判定算法
const handleJump = useCallback((msgId: string) => {
isAutoScrolling.current = true;
setHighlightId(msgId);
const el = document.getElementById('msg-' + msgId);
if (el) {
el.scrollIntoView({ block: 'start', behavior: 'smooth' });
// 滚动结束后重置标志位,避免触发 handleScroll
setTimeout(() => {
isAutoScrolling.current = false;
}, 800);
}
}, [setHighlightId]);
return ( return (
<div className={`chat-shell ${desktopViewportClass(viewport)}`}> <div className="chat-shell">
<AgentSidebar <AgentSidebar
agentList={agentList} agentList={agentList}
activeAgentId={id} activeAgentId={id}
collapsed={agentSidebarCollapsed}
onToggleCollapse={() => setAgentSidebarCollapsed(!agentSidebarCollapsed)}
onCreate={() => navigate('/agents/new')} onCreate={() => navigate('/agents/new')}
onSelect={(aid) => navigate(`/chat/${aid}`)} onSelect={(aid) => navigate(`/chat/${aid}`)}
/> />
<section className="chat-main"> <section className="chat-main">
{!agent ? ( {!agent ? (
<div className="chat-empty-state"> <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Empty description="请在左侧选择一个智能体开始对话" /> <Empty description="请在左侧选择一个智能体开始对话" />
</div> </div>
) : ( ) : (
<div className="chat-content-layout"> <>
<div className="chat-conversation-panel"> <ChatHeader
<ChatHeader agent={agent}
agent={agent} useStream={sender.useStream}
useStream={sender.useStream} setUseStream={sender.setUseStream}
setUseStream={sender.setUseStream} onOpenHistory={() => setHistoryDrawerOpen(true)}
onOpenHistory={() => setHistoryDrawerOpen(true)} onOpenParams={() => setParamsDrawerOpen(true)}
onOpenParams={() => setParamsDrawerOpen(true)} onOpenMcp={() => setMcpDrawerOpen(true)}
onOpenMcp={() => setMcpDrawerOpen(true)} onManageAgent={() => navigate(`/agents/${id}`)}
onManageAgent={() => navigate(`/agents/${id}`)} onClear={sender.handleClear}
onClear={sender.handleClear} />
/>
<div className="chat-content-row">
<ChatBody <ChatBody
bodyRef={bodyRef} bodyRef={bodyRef}
agent={agent} agent={agent}
@ -113,8 +78,6 @@ export default function ChatPageWeb({ logic }: { logic: ChatPageLogicOutput }) {
streaming={sender.streaming} streaming={sender.streaming}
onRegenerate={sender.handleRegenerate} onRegenerate={sender.handleRegenerate}
onSwitchBranch={sender.handleSwitchBranch} onSwitchBranch={sender.handleSwitchBranch}
scrollBottom={logic.scrollBottom}
initialScrollDoneRef={logic.initialScrollDoneRef}
onCopy={(text, mode) => { onCopy={(text, mode) => {
const content = mode === 'markdown' ? text : markdownToPlainText(text); const content = mode === 'markdown' ? text : markdownToPlainText(text);
navigator.clipboard?.writeText(content).then(() => { navigator.clipboard?.writeText(content).then(() => {
@ -123,43 +86,45 @@ export default function ChatPageWeb({ logic }: { logic: ChatPageLogicOutput }) {
}} }}
/> />
<ChatInput <ChatOutline
input={sender.input} messages={messages}
setInput={sender.setInput} activeId={highlightId}
sending={sender.sending} onJump={(msgId) => {
attachments={sender.attachments} setHighlightId(msgId);
setAttachments={sender.setAttachments} const el = document.getElementById('msg-' + msgId);
imageUrls={sender.imageUrls} if (el) el.scrollIntoView({ block: 'start', behavior: 'smooth' });
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> </div>
<ChatOutline <ChatInput
messages={messages} input={sender.input}
activeId={highlightId} setInput={sender.setInput}
collapsed={outlineCollapsed} sending={sender.sending}
onToggleCollapse={() => setOutlineCollapsed(!outlineCollapsed)} attachments={sender.attachments}
onJump={handleJump} 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> </>
)} )}
</section> </section>

View File

@ -57,11 +57,9 @@ export default function ChatPageWebBase({ logic, viewport }: ChatPageWebVariantP
streaming={sender.streaming} streaming={sender.streaming}
onRegenerate={sender.handleRegenerate} onRegenerate={sender.handleRegenerate}
onSwitchBranch={sender.handleSwitchBranch} onSwitchBranch={sender.handleSwitchBranch}
scrollBottom={logic.scrollBottom}
initialScrollDoneRef={logic.initialScrollDoneRef}
onCopy={(text, mode) => { onCopy={(text, mode) => {
const content = mode === 'markdown' ? text : markdownToPlainText(text); const content = mode === 'markdown' ? text : markdownToPlainText(text);
navigator.clipboard?.writeText(content).then(() => message.success(mode === 'markdown' ? '已复制Markdown' : '已复制')); navigator.clipboard?.writeText(content).then(() => message.success(mode === 'markdown' ? '已复制Markdown' : '已复制(纯文本)'));
}} }}
/> />
<ChatOutline messages={messages} activeId={highlightId} onJump={(msgId) => { <ChatOutline messages={messages} activeId={highlightId} onJump={(msgId) => {

View File

@ -1,155 +1,78 @@
.message-item-container { .message-item-container {
transition: background 0.3s; margin-bottom: 20px;
transition: background 0.4s, padding 0.4s;
} }
.message-item-container.highlighted { .message-item-container.highlighted {
/* background: var(--color-surface-2); */ padding: 8px;
border-radius: 10px;
background: rgba(254, 243, 199, 0.6);
} }
.message-item-assistant, /* Assistant Message Styles */
.message-item-user { .message-item-assistant {
display: flex; display: flex;
gap: 12px; gap: 12px;
max-width: 90%; align-items: flex-start;
} }
.message-item-user { .message-item-assistant-avatar {
margin-left: auto;
flex-direction: row-reverse;
}
.message-item-avatar {
flex-shrink: 0; flex-shrink: 0;
width: 36px; margin-top: 2px;
height: 36px; background-color: #52c41a;
border-radius: 50%;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
} }
.message-item-content { .message-item-assistant-content {
display: flex; flex: 1;
flex-direction: column;
min-width: 0; min-width: 0;
} }
.message-item-user .message-item-content { .message-item-assistant-header {
align-items: flex-end;
}
.message-item-header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
margin-bottom: 6px; margin-bottom: 6px;
font-size: 13px;
} }
.message-item-name { .message-item-assistant-name {
font-weight: 600; font-size: 12px;
color: var(--color-text); font-weight: 400;
color: var(--color-text-secondary);
} }
.message-item-time { /* User Message Styles */
color: var(--color-text-tertiary); .message-item-user {
font-size: 11px; display: flex;
gap: 12px;
align-items: flex-start;
justify-content: flex-end;
} }
.bubble { .message-item-user-content-wrapper {
padding: 12px 16px; flex: 1;
border-radius: 16px; min-width: 0;
font-size: 14.5px; max-width: 78%;
line-height: 1.6; display: flex;
position: relative; flex-direction: column;
word-wrap: break-word; align-items: flex-end;
} }
.bubble.assistant { .message-item-user-avatar {
background: #ffffff; flex-shrink: 0;
padding: 16px; margin-top: 2px;
color: var(--color-text); background-color: #1890ff;
border-radius: 16px;
} }
.bubble.user { .mention {
background: var(--color-primary); color: var(--color-brand);
color: #fff; font-weight: 500;
border-radius: 16px;
}
.bubble.user span.mention {
background: rgba(255, 159, 10, 0.22);
border: 1px solid rgba(255, 159, 10, 0.38);
color: #ff9f0a;
} }
.message-item-actions { .message-item-actions {
display: flex; padding-top: 4px;
align-items: center; border-top: 1px solid var(--color-border);
gap: 4px;
margin-top: 8px;
transition: opacity 0.2s;
opacity: 0;
}
.message-item-container:hover .message-item-actions {
opacity: 1;
} }
.actions-btn { .actions-btn {
color: var(--color-text-tertiary) !important; color: var(--color-text-secondary);
}
.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,11 +1,8 @@
import { Button, Dropdown, Space, Tag, Tooltip, Avatar, Divider } from 'antd'; import { Button, Dropdown, Space, Tag, Tooltip, Avatar } from 'antd';
import { useMemo, useState } from 'react';
import { CopyOutlined, SyncOutlined } from '@ant-design/icons'; import { CopyOutlined, SyncOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import type { BranchInfo, ChatMessage } from '../../../../api'; import type { BranchInfo, ChatMessage } from '../../../../api';
import type { Agent } from '../../../../api/agents'; import type { Agent } from '../../../../api/agents';
import Markdown from '../../../../components/Markdown'; import Markdown from '../../../../components/Markdown';
import { formatMessageContent } from '../../utils/format';
import type { CopyMode } from '../../utils/copy'; import type { CopyMode } from '../../utils/copy';
import { ReasoningView, RetrievedView, ToolCallView } from './MetaViews'; import { ReasoningView, RetrievedView, ToolCallView } from './MetaViews';
import './MessageItem.css'; import './MessageItem.css';
@ -24,10 +21,6 @@ export default function MessageItem(props: {
}) { }) {
const { message, agentList, currentAgentId, highlighted, branch, busy, onRegenerate, onSwitchBranch, onCopy, isMobile } = props; const { message, agentList, currentAgentId, highlighted, branch, busy, onRegenerate, onSwitchBranch, onCopy, isMobile } = props;
const [reasoningExpanded, setReasoningExpanded] = useState(false);
const formattedContent = useMemo(() => formatMessageContent(message.content), [message.content]);
const speakerType = (message as any)?.speaker?.type as ('user' | 'agent' | undefined); const speakerType = (message as any)?.speaker?.type as ('user' | 'agent' | undefined);
const speakerId = (message as any)?.speaker?.id as string | undefined; const speakerId = (message as any)?.speaker?.id as string | undefined;
const isUser = speakerType ? speakerType === 'user' : message.role === 'user'; const isUser = speakerType ? speakerType === 'user' : message.role === 'user';
@ -40,11 +33,6 @@ export default function MessageItem(props: {
const activeIdx = branch?.activeIndex ?? 0; const activeIdx = branch?.activeIndex ?? 0;
const total = branch?.total ?? 1; const total = branch?.total ?? 1;
const timeStr = useMemo(() => {
if (!message.createdAt) return '';
return dayjs(message.createdAt * 1000).format('HH:mm');
}, [message.createdAt]);
const goPrev = () => { const goPrev = () => {
if (!branch || !message.parentId) return; if (!branch || !message.parentId) return;
const i = Math.max(0, activeIdx - 1); const i = Math.max(0, activeIdx - 1);
@ -60,82 +48,46 @@ export default function MessageItem(props: {
return ( return (
<div <div
id={'msg-' + message.id} id={'msg-' + message.id}
className={`message-item-container ${highlighted ? 'highlighted' : ''} ${bubbleRole}`} className={`message-item-container ${highlighted ? 'highlighted' : ''}`}
data-msg-id={message.id}
data-is-agent={!isUser}
> >
<div className={isUser ? 'message-item-user' : 'message-item-assistant'}> {!isUser ? (
<Avatar <div className="message-item-assistant">
src={isUser ? undefined : answerAgent?.avatar} <Avatar
size={36} src={answerAgent?.avatar}
className="message-item-avatar" size={36}
> className="message-item-assistant-avatar"
{isUser ? '我' : (answerAgent?.name?.charAt(0)?.toUpperCase() || 'A')} >
</Avatar> {answerAgent?.name?.charAt(0)?.toUpperCase() || 'A'}
</Avatar>
<div className="message-item-content"> <div className="message-item-assistant-content">
<div className="message-item-header"> <div className="message-item-assistant-header">
<span className="message-item-name"> <span className="message-item-assistant-name">
{isUser ? '我' : (answerAgent?.name || 'AI')} {answerAgent?.name || 'AI'}
</span> </span>
<span className="message-item-time">{timeStr}</span> </div>
</div> <div className={`bubble ${bubbleRole}`}>
<Markdown>{message.content}</Markdown>
<div className={`bubble ${bubbleRole}`}> <div className="message-item-actions">
{isUser && !formattedContent.includes('![image](') ? ( {hasBranches && (
<span dangerouslySetInnerHTML={{ <Space size={2}>
__html: formattedContent.replace(/@([^\s]+)/g, '<span class="mention">@$1</span>') <Button size="small" type="text" disabled={activeIdx === 0} onClick={goPrev}>
}} />
) : ( </Button>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}> <span>
{/* 如果有推理过程且不是用户消息,展示推理部分 */} {activeIdx + 1} / {total}
{!isUser && message.meta?.reasoning && ( </span>
<div className="message-reasoning-section"> <Button size="small" type="text" disabled={activeIdx === total - 1} onClick={goNext}>
<ReasoningView
reasoning={message.meta.reasoning} </Button>
expanded={reasoningExpanded} </Space>
onToggle={() => setReasoningExpanded(!reasoningExpanded)}
/>
{reasoningExpanded && <Divider style={{ margin: '8px 0' }} />}
</div>
)} )}
<Markdown>{formattedContent}</Markdown> {message.meta?.aborted && <Tag color="orange"></Tag>}
</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 <Dropdown
trigger={['click']} trigger={['click']}
menu={{ menu={{
items: [ items: [
{ key: 'plain', label: '复制纯文本', onClick: () => onCopy?.(formattedContent, 'plain') }, { key: 'plain', label: '复制纯文本', onClick: () => onCopy?.(message.content, 'plain') },
{ key: 'markdown', label: '复制 Markdown', onClick: () => onCopy?.(formattedContent, 'markdown') } { key: 'markdown', label: '复制 Markdown', onClick: () => onCopy?.(message.content, 'markdown') }
] ]
}} }}
> >
@ -143,27 +95,36 @@ export default function MessageItem(props: {
<Button size="small" className='actions-btn' type="text" icon={<CopyOutlined />} /> <Button size="small" className='actions-btn' type="text" icon={<CopyOutlined />} />
</Tooltip> </Tooltip>
</Dropdown> </Dropdown>
)} <Tooltip title="重新生成(开新分支)">
{!isUser && (
<Tooltip title="重新生成">
<Button size="small" className='actions-btn' type="text" icon={<SyncOutlined />} disabled={busy} onClick={() => onRegenerate?.(message.id)} /> <Button size="small" className='actions-btn' type="text" icon={<SyncOutlined />} disabled={busy} onClick={() => onRegenerate?.(message.id)} />
</Tooltip> </Tooltip>
)} </div>
</div> </div>
{!isMobile && message.meta && (
{/* 底部 Meta 信息展示 (RAG / Tool) */} <div>
{!isMobile && message.meta && !isUser && ( {!!message.meta.reasoning && <ReasoningView reasoning={message.meta.reasoning} />}
<div className="message-meta-section"> {!!message.meta.retrieved?.length && <RetrievedView retrieved={message.meta.retrieved} />}
{message.meta.retrieved && message.meta.retrieved.length > 0 && ( {!!message.meta.toolCalls?.length && <ToolCallView calls={message.meta.toolCalls} />}
<RetrievedView retrieved={message.meta.retrieved} /> </div>
)} )}
{message.meta.toolCalls && message.meta.toolCalls.length > 0 && ( </div>
<ToolCallView calls={message.meta.toolCalls} />
)}
</div>
)}
</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>
</div>
)}
</div> </div>
); );
} }

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import { useEffect, useRef, useCallback } from 'react'; import { useEffect, useRef } from 'react';
export function useChatScroll() { export function useChatScroll() {
const bodyRef = useRef<HTMLDivElement>(null); const bodyRef = useRef<HTMLDivElement>(null);
@ -6,40 +6,30 @@ export function useChatScroll() {
const initialScrollDoneRef = useRef(false); const initialScrollDoneRef = useRef(false);
const scrollRafRef = useRef<number | null>(null); const scrollRafRef = useRef<number | null>(null);
const scrollProgrammaticAtRef = useRef(0); const scrollProgrammaticAtRef = useRef(0);
const forceScrollPendingRef = useRef(false);
const lastScrollTopRef = useRef(0); const lastScrollTopRef = useRef(0);
const userScrollLockRef = useRef(false); const userScrollLockRef = useRef(false);
const lastUserScrollAtRef = useRef(0); const lastUserScrollAtRef = useRef(0);
const lastUserScrollTypeRef = useRef<string>(''); const lastUserScrollTypeRef = useRef<string>('');
const attachRetryTimerRef = useRef<number | null>(null); const attachRetryTimerRef = useRef<number | null>(null);
const scrollBottom = useCallback((force = false) => { const scrollBottom = (force = false) => {
if (force) { if (force) {
userScrollLockRef.current = false; userScrollLockRef.current = false;
autoScrollRef.current = true; autoScrollRef.current = true;
forceScrollPendingRef.current = true;
}
if (!force && (!autoScrollRef.current || userScrollLockRef.current)) {
return;
} }
if (!force && (!autoScrollRef.current || userScrollLockRef.current)) return;
if (scrollRafRef.current) { if (scrollRafRef.current) {
cancelAnimationFrame(scrollRafRef.current); cancelAnimationFrame(scrollRafRef.current);
scrollRafRef.current = null; scrollRafRef.current = null;
} }
scrollRafRef.current = requestAnimationFrame(() => { scrollRafRef.current = requestAnimationFrame(() => {
scrollRafRef.current = null; scrollRafRef.current = null;
forceScrollPendingRef.current = false;
const el = bodyRef.current; const el = bodyRef.current;
if (!el) return; if (!el) return;
scrollProgrammaticAtRef.current = Date.now(); scrollProgrammaticAtRef.current = Date.now();
el.scrollTop = el.scrollHeight;
// 恢复正向滚动scrollTop = scrollHeight
el.scrollTo({
top: el.scrollHeight,
behavior: force ? 'instant' : 'smooth'
});
}); });
}, []); };
const cancelAutoScroll = (type: string) => { const cancelAutoScroll = (type: string) => {
userScrollLockRef.current = true; userScrollLockRef.current = true;
@ -62,13 +52,10 @@ export function useChatScroll() {
const isProgrammatic = now - scrollProgrammaticAtRef.current < 50; const isProgrammatic = now - scrollProgrammaticAtRef.current < 50;
const nextTop = el.scrollTop; const nextTop = el.scrollTop;
const lastTop = lastScrollTopRef.current; const lastTop = lastScrollTopRef.current;
// 恢复正向距离计算:距离底部 = 总高度 - 当前滚动高度 - 容器可见高度
const distance = el.scrollHeight - nextTop - el.clientHeight; const distance = el.scrollHeight - nextTop - el.clientHeight;
const scrollDelta = nextTop - lastTop; const scrollDelta = nextTop - lastTop;
// scrollDelta < 0 表示向上滚动 if (scrollDelta < 0) {
if (scrollDelta < 0 && !forceScrollPendingRef.current) {
cancelAutoScroll(isProgrammatic ? 'scroll(up)+programmatic' : 'scroll(up)'); cancelAutoScroll(isProgrammatic ? 'scroll(up)+programmatic' : 'scroll(up)');
} }
lastScrollTopRef.current = nextTop; lastScrollTopRef.current = nextTop;
@ -80,18 +67,10 @@ export function useChatScroll() {
userScrollLockRef.current = false; userScrollLockRef.current = false;
} }
} }
// 只有当不是程序化滚动,且没有强制滚动在等待时,才根据距离更新 autoScroll 状态 autoScrollRef.current = distance < 32 && !userScrollLockRef.current;
if (!isProgrammatic && !forceScrollPendingRef.current) { if ((!autoScrollRef.current || userScrollLockRef.current) && scrollRafRef.current) {
autoScrollRef.current = distance < 32 && !userScrollLockRef.current; cancelAnimationFrame(scrollRafRef.current);
} scrollRafRef.current = null;
// 如果 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 { useMemo, useState } from 'react';
import type { Agent, BranchInfo, ChatAttachment, ChatMessage, ModelOverrides, RetrievedSnippet, ToolCallTrace } from '../../../api'; import type { Agent, BranchInfo, ChatAttachment, ChatMessage, ModelOverrides, RetrievedSnippet, ToolCallTrace } from '../../../api';
import { ChatAPI, ChatAttachmentsAPI, ImageAPI, streamChat } from '../../../api'; import { ChatAPI, ChatAttachmentsAPI, ImageAPI, regenerateMessage, streamChat } from '../../../api';
import { buildAttachmentsText } from '../utils/attachments'; import { buildAttachmentsText } from '../utils/attachments';
import { parseAgentModels } from '../utils/agentModels'; import { parseAgentModels } from '../utils/agentModels';
@ -114,7 +114,7 @@ export function useChatSender(args: {
}); });
const [sessionRefresh, setSessionRefresh] = useState(0); const [sessionRefresh, setSessionRefresh] = useState(0);
const agentModels = useMemo(() => parseAgentModels(agent?.models ?? agent?.model), [agent?.model, agent?.models]); const agentModels = useMemo(() => parseAgentModels(agent?.model), [agent?.model]);
const modelOptions = useMemo(() => agentModels.map((model) => ({ value: model.id, label: model.name })), [agentModels]); const modelOptions = useMemo(() => agentModels.map((model) => ({ value: model.id, label: model.name })), [agentModels]);
const activeModelValue = overrides.model_id || ''; const activeModelValue = overrides.model_id || '';
@ -141,7 +141,7 @@ export function useChatSender(args: {
let targetModel: string; let targetModel: string;
let targetModelId: string; let targetModelId: string;
if (targetAgent && targetAgent.id !== agentId) { if (targetAgent && targetAgent.id !== agentId) {
const models = parseAgentModels(targetAgent.models ?? targetAgent.model); const models = parseAgentModels(targetAgent.model);
targetModel = models[0]?.name || ''; targetModel = models[0]?.name || '';
targetModelId = models[0]?.id || ''; targetModelId = models[0]?.id || '';
} else { } else {
@ -166,10 +166,7 @@ export function useChatSender(args: {
onRetry: (data) => { onRetry: (data) => {
setStreaming((s) => ({ ...s, retryInfo: data })); setStreaming((s) => ({ ...s, retryInfo: data }));
if (data?.stage === 'fallback_model' && data?.toModel) { if (data?.stage === 'fallback_model' && data?.toModel) {
const nextModel = parseAgentModels(data.toModel)[0]; setOverrides((o) => ({ ...o, model: String(data.toModel) }));
if (nextModel) {
setOverrides((o) => ({ ...o, model: nextModel.name, model_id: nextModel.id }));
}
} }
}, },
onReasoningDelta: (chunk) => onReasoningDelta: (chunk) =>
@ -238,7 +235,8 @@ export function useChatSender(args: {
} }
}, },
ctrl.signal, ctrl.signal,
{ ...overrides, model: targetModel, model_id: targetModelId }, targetModel,
targetModelId,
imageUrls imageUrls
); );
} catch (e: any) { } catch (e: any) {
@ -275,7 +273,7 @@ export function useChatSender(args: {
let targetModel: string; let targetModel: string;
let targetModelId: string; let targetModelId: string;
if (targetAgent && targetAgent.id !== agentId) { if (targetAgent && targetAgent.id !== agentId) {
const models = parseAgentModels(targetAgent.models ?? targetAgent.model); const models = parseAgentModels(targetAgent.model);
targetModel = models[0]?.name || ''; targetModel = models[0]?.name || '';
targetModelId = models[0]?.id || ''; targetModelId = models[0]?.id || '';
} else { } else {
@ -286,7 +284,7 @@ export function useChatSender(args: {
const attText = buildAttachmentsText(attachments); const attText = buildAttachmentsText(attachments);
const content = attText ? `${text}\n\n${attText}` : text; const content = attText ? `${text}\n\n${attText}` : text;
try { try {
const res = await ChatAPI.send(roomId, content, targetAgentId, { ...overrides, model: targetModel, model_id: targetModelId }, imageUrls); const res = await ChatAPI.send(roomId, content, targetAgentId, targetModel, targetModelId, imageUrls);
args.setMessages((m) => [...(m || []).filter((x) => x.id !== tempUser.id), res.user, res.assistant]); args.setMessages((m) => [...(m || []).filter((x) => x.id !== tempUser.id), res.user, res.assistant]);
setSessionRefresh((t) => t + 1); setSessionRefresh((t) => t + 1);
setAttachments([]); setAttachments([]);
@ -329,25 +327,65 @@ export function useChatSender(args: {
}; };
const handleRegenerate = async (assistantId: string) => { const handleRegenerate = async (assistantId: string) => {
if (!agentId || sending || !roomId) return; if (!agentId || sending) return;
// 找到当前要重新生成的助手消息,并回溯找到它的父消息(用户消息)
const assistantMsg = args.messages.find((m) => m.id === assistantId);
const userMsgId = assistantMsg?.parentId;
const userMsg = args.messages.find((m) => m.id === userMsgId);
// 如果找不到父消息,则尝试找最后一条用户消息
const lastUserMsg = [...args.messages].reverse().find((m) => m.role === 'user');
const targetUserMsg = userMsg || lastUserMsg;
if (!targetUserMsg) {
notify.error('找不到上一条用户消息');
return;
}
setSending(true); setSending(true);
setStreaming({ active: true, reasoningText: '', answerText: '', errorMessage: null, retryInfo: null, retrieved: [], toolCalls: [] });
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
try { try {
await handleSendStream(targetUserMsg.content); 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
);
} finally { } finally {
setSending(false); setSending(false);
} }

View File

@ -1,16 +0,0 @@
/* 纯会话页面样式 - 无侧边栏,仅保留聊天主区域 */
.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 { .chat-shell.desktop-standardPc {
display: flex; grid-template-columns: 17.5rem minmax(0, 1fr);
} }
.chat-shell.desktop-standardPc .chat-side { .chat-shell.desktop-standardPc .chat-content-row {
flex-shrink: 0; grid-template-columns: minmax(0, 1fr) 260px;
} }
.chat-shell.desktop-standardPc .chat-outline { .chat-shell.desktop-standardPc .chat-outline {
flex-shrink: 0; width: 100%;
min-width: 0;
} }

View File

@ -11,28 +11,15 @@
justify-content: center; justify-content: center;
} }
.chat-content-layout { .chat-shell.desktop-tablet,
display: flex; .chat-shell.desktop-smallPc,
height: 100%; .chat-shell.desktop-standardPc,
width: 100%; .chat-shell.desktop-large2k,
overflow: hidden; .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-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,46 +3,15 @@ export interface AgentModelOption {
name: string; name: string;
} }
// parseAgentModelItem 将后端多种模型结构统一转换为聊天侧使用的模型选项。 export const parseAgentModels = (value?: string): AgentModelOption[] => {
const parseAgentModelItem = (item: any): AgentModelOption | null => {
if (!item) {
return null;
}
if (typeof item === 'string') {
return { id: item, name: item };
}
if (typeof item === 'object' && item.model?.id) {
return {
id: String(item.model.id),
name: String(item.model.name || item.model.id)
};
}
if (typeof item === 'object' && item.id) {
return {
id: String(item.id),
name: String(item.name || item.model || item.id)
};
}
return {
id: String(item),
name: String(item)
};
};
// parseAgentModels 负责兼容旧 model 字符串和新 models 数组结构。
export const parseAgentModels = (value?: unknown): AgentModelOption[] => {
if (!value) return []; if (!value) return [];
if (Array.isArray(value)) {
return value.map(parseAgentModelItem).filter(Boolean) as AgentModelOption[];
}
try { try {
const parsed = JSON.parse(String(value)); const parsed = JSON.parse(value);
if (Array.isArray(parsed)) { if (Array.isArray(parsed)) {
return parsed.map(parseAgentModelItem).filter(Boolean) as AgentModelOption[]; return parsed.map((item: any) => ({
} id: typeof item === 'object' ? item.id : String(item),
if (parsed && typeof parsed === 'object') { name: typeof item === 'object' ? item.name : String(item)
const single = parseAgentModelItem(parsed); }));
return single ? [single] : [];
} }
} catch { } catch {
// ignore // ignore
@ -53,3 +22,4 @@ export const parseAgentModels = (value?: unknown): AgentModelOption[] => {
.filter(Boolean) .filter(Boolean)
.map((item) => ({ id: item, name: item })); .map((item) => ({ id: item, name: item }));
}; };

View File

@ -1,12 +0,0 @@
/**
*
* 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,6 +280,111 @@ body {
overflow: hidden; overflow: hidden;
} }
.main-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
min-height: 0;
-webkit-overflow-scrolling: touch;
}
.main-chat {
overflow: hidden;
}
.sidebar {
width: 248px;
background: var(--color-surface);
border-right: 1px solid var(--color-border);
color: var(--color-text);
display: flex;
flex-direction: column;
padding: 14px 12px;
}
.sidebar .brand {
font-size: 15px;
font-weight: 700;
color: var(--color-text);
padding: 6px 10px 18px;
display: flex;
align-items: center;
gap: 10px;
}
.sidebar .brand .brand-logo {
width: 28px;
height: 28px;
flex: 0 0 auto;
object-fit: contain;
filter: drop-shadow(0 8px 16px rgba(17, 103, 255, 0.16));
}
.sidebar .nav-section-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-text-tertiary);
padding: 14px 12px 6px;
}
.sidebar .nav-item {
padding: 7px 12px;
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 1px;
color: var(--color-text-secondary);
text-decoration: none;
font-size: 13.5px;
font-weight: 500;
transition: background 0.15s ease, color 0.15s ease;
}
.sidebar .nav-item:hover {
background: var(--color-surface-2);
color: var(--color-text);
}
.sidebar .nav-item.active {
background: var(--color-brand-soft);
color: var(--color-brand);
font-weight: 600;
}
.sidebar .nav-item .nav-icon {
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
.sidebar .kbd {
font-size: 10.5px;
color: var(--color-text-tertiary);
background: var(--color-surface-2);
border: 1px solid var(--color-border);
padding: 1px 5px;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.sidebar-user {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
cursor: pointer;
border-radius: 10px;
background: var(--color-surface-2);
border: 1px solid var(--color-border);
}
.agent-card { .agent-card {
background: var(--color-surface); background: var(--color-surface);
border-radius: 14px; border-radius: 14px;
@ -363,6 +468,17 @@ body {
background: var(--color-bg); background: var(--color-bg);
} }
.chat-side {
width: 260px;
border-right: 1px solid var(--color-border);
overflow: hidden;
display: flex;
flex-direction: column;
gap: 0;
height: 100%;
background: var(--color-surface);
}
.chat-main { .chat-main {
flex: 1; flex: 1;
display: flex; display: flex;
@ -378,12 +494,135 @@ body {
display: flex; display: flex;
} }
.chat-header {
height: 60px;
padding: 0 24px;
border-bottom: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: space-between;
background: var(--color-surface);
}
.chat-header-agent {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.chat-header-agent-title {
display: flex;
align-items: baseline;
gap: 10px;
min-width: 0;
}
.chat-header-agent-name {
font-weight: 600;
font-size: 16px;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 320px;
}
.chat-header-agent-desc {
font-size: 12px;
color: var(--color-text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 380px;
}
.chat-header-agent-meta {
font-size: 12px;
color: var(--color-text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 720px;
}
.chat-header-stream-toggle {
display: flex;
align-items: center;
gap: 6px;
margin-right: 12px;
}
.is-h5 .chat-header-stream-toggle {
margin-right: 4px;;
}
.chat-header-stream-label {
font-size: 12px;
color: var(--color-text-secondary);
}
.chat-body { .chat-body {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
background: var(--color-bg); background: var(--color-bg);
} }
.chat-outline {
width: 260px;
border-left: 1px solid var(--color-border);
background: var(--color-surface);
padding: 14px 12px;
overflow: auto;
}
.chat-outline-title {
font-size: 12px;
font-weight: 600;
color: var(--color-text-secondary);
margin-bottom: 10px;
}
.chat-outline-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.chat-outline-item {
border: 0;
background: var(--color-surface);
border-radius: 10px;
padding: 4px 10px;
text-align: left;
cursor: pointer;
display: flex;
gap: 8px;
align-items: flex-start;
color: var(--color-text);
}
.chat-outline-item.active {
background-color: #eee;
}
.chat-outline-index {
font-size: 12px;
color: var(--color-text-tertiary);
line-height: 1.4;
flex: 0 0 auto;
}
.chat-outline-text {
font-size: 12.5px;
line-height: 1.4;
color: var(--color-text);
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
}
@media (max-width: 1100px) { @media (max-width: 1100px) {
.chat-content-row > .chat-outline { .chat-content-row > .chat-outline {
display: none; display: none;
@ -464,6 +703,11 @@ body {
padding: 1.125rem 0.75rem 6rem; padding: 1.125rem 0.75rem 6rem;
} }
.chat-input-wrapper {
max-width: 100%;
padding: 0 0.75rem 1rem;
}
.chat-input-actions { .chat-input-actions {
top: -22px; top: -22px;
right: 0; right: 0;
@ -503,70 +747,98 @@ body {
} }
.main-content { .main-content {
flex: 1; min-height: 0;
overflow: auto;
position: relative;
} }
.chat-body .messages-container { .chat-body .messages-container {
max-width: 1080px; max-width: 780px;
width: 100%; width: 100%;
margin: 0 auto; margin: 0 auto;
padding: 16px 12px 80px; padding: 16px 12px 80px;
} }
.is-h5 .bubble { .bubble {
/* max-width: 94%; */ max-width: 78%;
} display: inline-block;
padding: 8px;
.bubble.assistant p, border-radius: 14px;
.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; white-space: pre-wrap;
word-wrap: break-word;
line-height: 1.5;
font-size: 14.5px;
} }
.bubble.assistant ol, .is-h5 .bubble {
.bubble.assistant ul { max-width: 94%;
margin: 0; }
padding-left: 1.5em;
.bubble.user {
background: #0a84ff;
color: #ffffff;
margin-left: auto;
border-bottom-right-radius: 5px;
}
.bubble.assistant {
background: #edf1f6;
color: #111827;
border: 0;
border-bottom-left-radius: 5px;
box-shadow: none;
} }
.bubble.assistant p { .bubble.assistant p {
margin-bottom: 4px; margin: 0 0 5px;
} }
.bubble.assistant p:last-child { .bubble.assistant p:last-child {
margin-bottom: 0; margin-bottom: 0;
} }
.bubble.assistant h1,
.bubble.assistant h2,
.bubble.assistant h3,
.bubble.assistant h4,
.bubble.assistant h5,
.bubble.assistant h6 {
margin: 0.2em 0 0.1em;
line-height: 1.3;
}
.bubble.assistant ol,
.bubble.assistant ul {
margin: 0.15em 0;
padding-left: 1.25em;
}
.bubble.assistant li {
margin: 0.05em 0;
}
.bubble.assistant hr { .bubble.assistant hr {
margin: 12px 0; margin: 0.4em 0;
border: 0; }
border-top: 1px solid rgba(0, 0, 0, 0.1);
.bubble.assistant li > p {
margin: 0.15em 0;
} }
.bubble.assistant blockquote { .bubble.assistant blockquote {
margin: 8px 0; margin: 0.2em 0;
padding: 4px 12px; padding: 0.2em 0.6em;
border-left: 3px solid var(--color-border); border-left: 3px solid var(--color-border);
background: rgba(15, 23, 42, 0.03); background: rgba(15, 23, 42, 0.03);
border-radius: 4px; border-radius: 8px;
} }
.bubble.assistant blockquote p { .bubble.assistant blockquote p {
margin: 0; margin: 0.2em 0;
} }
.bubble.assistant table { .bubble.assistant table {
border-collapse: collapse; border-collapse: collapse;
width: 100%; width: 100%;
margin: 12px 0; margin: 0.35em 0;
font-size: 13.5px; font-size: 13.5px;
} }
@ -583,6 +855,27 @@ body {
font-weight: 600; font-weight: 600;
} }
.chat-input-wrapper {
width: 100%;
max-width: 820px;
margin: 0 auto;
padding: 0 24px 24px;
}
.chat-input-card {
width: 100%;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 20px;
padding: 14px 16px 12px;
min-height: 110px;
box-shadow: var(--shadow-sm);
}
.chat-input-card-wrap {
position: relative;
}
.chat-input-actions { .chat-input-actions {
position: absolute; position: absolute;
top: -24px; top: -24px;
@ -946,16 +1239,14 @@ body {
} }
} }
.agent-model-checkbox-group, .agent-model-checkbox-group {
.agent-model-radio-group {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
width: 100%; width: 100%;
} }
.agent-model-checkbox-item, .agent-model-checkbox-item {
.agent-model-radio-item {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
margin-inline-start: 0; margin-inline-start: 0;
@ -965,52 +1256,15 @@ body {
background: var(--color-surface); background: var(--color-surface);
} }
.agent-model-checkbox-item .ant-checkbox, .agent-model-checkbox-item .ant-checkbox {
.agent-model-radio-item .ant-radio {
margin-top: 3px; margin-top: 3px;
} }
.agent-model-checkbox-item .ant-checkbox + span, .agent-model-checkbox-item .ant-checkbox + span {
.agent-model-radio-item .ant-radio + span {
width: 100%; width: 100%;
padding-inline-start: 10px; padding-inline-start: 10px;
} }
.agent-model-radio-content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
}
.agent-model-radio-meta {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
}
.agent-model-radio-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
color: var(--color-text);
}
.agent-model-radio-price {
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: center;
flex-shrink: 0;
margin-left: 8px;
font-size: 10px;
color: var(--color-text-tertiary);
}
.agent-model-checkbox-content { .agent-model-checkbox-content {
display: flex; display: flex;
align-items: center; align-items: center;
@ -1027,7 +1281,7 @@ body {
flex: 1; flex: 1;
} }
.agent-model-radio-icon { .agent-model-checkbox-icon {
width: 20px; width: 20px;
height: 20px; height: 20px;
object-fit: contain; object-fit: contain;
@ -1421,6 +1675,10 @@ body {
.border-l { border-left: 1px solid var(--color-border); } .border-l { border-left: 1px solid var(--color-border); }
.shadow-lg { box-shadow: var(--shadow-lg); } .shadow-lg { box-shadow: var(--shadow-lg); }
.ant-btn-primary {
box-shadow: 0 1px 2px rgba(194, 84, 31, 0.18) !important;
}
.ant-input, .ant-input,
.ant-input-affix-wrapper, .ant-input-affix-wrapper,
.ant-input-number, .ant-input-number,
@ -1442,6 +1700,14 @@ body {
border-color: var(--color-border-strong) !important; border-color: var(--color-border-strong) !important;
} }
.ant-input-affix-wrapper-focused,
.ant-input:focus,
.ant-select-focused .ant-select-selector,
.ant-picker-focused {
border-color: var(--color-brand) !important;
box-shadow: var(--shadow-focus) !important;
}
.ant-card { .ant-card {
background: var(--color-surface) !important; background: var(--color-surface) !important;
border-color: var(--color-border) !important; border-color: var(--color-border) !important;
@ -1481,6 +1747,12 @@ span.mention {
line-height: 1.3; line-height: 1.3;
} }
.bubble.user span.mention {
background: rgba(255, 159, 10, 0.22);
border: 1px solid rgba(255, 159, 10, 0.38);
color: #ff9f0a;
}
.ant-collapse-content { .ant-collapse-content {
background: var(--color-surface) !important; background: var(--color-surface) !important;
color: var(--color-text) !important; color: var(--color-text) !important;
@ -1622,8 +1894,7 @@ span.mention {
} }
.points-mall-banner-section { .points-mall-banner-section {
/* display: grid; */ display: grid;
display: none;
grid-template-columns: minmax(0, 1.55fr) minmax(0, 1fr); grid-template-columns: minmax(0, 1.55fr) minmax(0, 1fr);
gap: 14px; gap: 14px;
margin-bottom: 12px; margin-bottom: 12px;
@ -2095,12 +2366,6 @@ span.mention {
border-radius: 999px; border-radius: 999px;
} }
.stats-page-agent-list {
max-height: 240px;
overflow-y: auto;
padding-right: 4px;
}
.stats-page-agent-item { .stats-page-agent-item {
margin-bottom: 12px; margin-bottom: 12px;
padding: 12px 14px; padding: 12px 14px;

View File

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

View File

@ -1,30 +1,11 @@
import { defineConfig, loadEnv } from 'vite'; import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
// 默认走 Go 后端 :4001要回退 Node 后端就 set VITE_API_TARGET=http://localhost:4000 export default defineConfig(({ command }) => ({
export default defineConfig(({ command, mode }) => { // 本地开发走根路径,生产构建部署到 /aura 子路径
const env = loadEnv(mode, process.cwd(), ''); base: command === 'serve' ? '/' : '/aura/',
const target = env.VITE_API_TARGET || 'https://tianchaoai.cc'; plugins: [react()],
server: {
return { port: 3001
// 本地开发走根路径,生产构建部署到 /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');
});
}
}
}
}
};
});