refactor: 拆分超400行文件并模块化API
parent
c23496a56c
commit
83aadde08e
970
src/api.ts
970
src/api.ts
|
|
@ -1,969 +1 @@
|
||||||
import axios from 'axios';
|
export * from './api/index';
|
||||||
|
|
||||||
const API_BASE_URL = 'https://api.hoyidata.com/aura/v1';
|
|
||||||
const APP_BASE = (import.meta.env.BASE_URL || '/').replace(/\/$/, '');
|
|
||||||
const withAppBase = (path: string) => `${APP_BASE}${path.startsWith('/') ? path : `/${path}`}`;
|
|
||||||
const withApiBase = (path: string) => `${API_BASE_URL}${path.startsWith('/') ? path : `/${path}`}`;
|
|
||||||
const isMockAuth = () => typeof localStorage !== 'undefined' && localStorage.getItem('mock-auth') === '1';
|
|
||||||
|
|
||||||
export const api = axios.create({
|
|
||||||
baseURL: API_BASE_URL,
|
|
||||||
timeout: 90000,
|
|
||||||
withCredentials: true // 关键:跨域请求带 cookie
|
|
||||||
});
|
|
||||||
|
|
||||||
// 401 拦截:自动跳登录
|
|
||||||
api.interceptors.response.use(
|
|
||||||
(r) => r,
|
|
||||||
(err) => {
|
|
||||||
const isLoginPage = location.pathname === '/login' || location.pathname === withAppBase('/login');
|
|
||||||
if (err?.response?.status === 401 && !isLoginPage && !isMockAuth()) {
|
|
||||||
const next = encodeURIComponent(location.pathname + location.search);
|
|
||||||
location.href = `${withAppBase('/login')}?next=${next}`;
|
|
||||||
}
|
|
||||||
return Promise.reject(err);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export type KnowledgeStatus = 'pending' | 'indexing' | 'ready' | 'failed';
|
|
||||||
|
|
||||||
export interface KnowledgeFile {
|
|
||||||
id: string;
|
|
||||||
originalName: string;
|
|
||||||
filename: string;
|
|
||||||
size: number;
|
|
||||||
mime: string;
|
|
||||||
status: KnowledgeStatus;
|
|
||||||
error?: string;
|
|
||||||
chunkCount: number;
|
|
||||||
createdAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SkillType = 'prompt' | 'http' | 'js';
|
|
||||||
|
|
||||||
export interface SkillBrief {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
filename: string;
|
|
||||||
description?: string;
|
|
||||||
type: SkillType;
|
|
||||||
enabled: number;
|
|
||||||
createdAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SkillDetail extends SkillBrief {
|
|
||||||
content: string;
|
|
||||||
parameters: string;
|
|
||||||
handler: string;
|
|
||||||
config: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Agent {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
avatar: string;
|
|
||||||
prompt: string;
|
|
||||||
model: string;
|
|
||||||
temperature: number;
|
|
||||||
owner_id?: string | null;
|
|
||||||
team_id?: string | null;
|
|
||||||
visibility?: 'private' | 'team' | 'public';
|
|
||||||
fork_count?: number;
|
|
||||||
forked_from?: string | null;
|
|
||||||
created_at: number;
|
|
||||||
updated_at: number;
|
|
||||||
knowledge?: KnowledgeFile[];
|
|
||||||
skills?: SkillBrief[];
|
|
||||||
_access?: 'owner' | 'team' | 'view' | 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RetrievedSnippet {
|
|
||||||
fileName: string;
|
|
||||||
chunkIndex: number;
|
|
||||||
score: number;
|
|
||||||
preview: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ToolCallTrace {
|
|
||||||
name: string;
|
|
||||||
args: any;
|
|
||||||
result: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChatMessage {
|
|
||||||
id: string;
|
|
||||||
role: 'user' | 'assistant';
|
|
||||||
content: string;
|
|
||||||
reasoning?: string | null;
|
|
||||||
parentId?: string | null;
|
|
||||||
createdAt: number;
|
|
||||||
meta?: {
|
|
||||||
retrieved?: RetrievedSnippet[];
|
|
||||||
toolCalls?: ToolCallTrace[];
|
|
||||||
aborted?: boolean;
|
|
||||||
reasoning?: string;
|
|
||||||
error?: string;
|
|
||||||
} | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BranchInfo {
|
|
||||||
total: number;
|
|
||||||
activeIndex: number;
|
|
||||||
ids: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChatHistoryResp {
|
|
||||||
messages: ChatMessage[];
|
|
||||||
branches: Record<string, BranchInfo>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AiModel {
|
|
||||||
id: string;
|
|
||||||
model_name: string;
|
|
||||||
icon: string;
|
|
||||||
model_ratio: number;
|
|
||||||
completion_ratio: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ModelAPI = {
|
|
||||||
list: () => api.get<{ data: AiModel[] }>('/models').then((r) => r.data.data),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AgentAPI = {
|
|
||||||
list: () => api.get<Agent[]>('/agents').then((r) => r.data),
|
|
||||||
detail: (id: string) => api.get<Agent>(`/agents/${id}`).then((r) => r.data),
|
|
||||||
create: (payload: Partial<Agent>) => api.post<Agent>('/agents', payload).then((r) => r.data),
|
|
||||||
update: (id: string, payload: Partial<Agent>) => api.put<Agent>(`/agents/${id}`, payload).then((r) => r.data),
|
|
||||||
remove: (id: string) => api.delete(`/agents/${id}`).then((r) => r.data),
|
|
||||||
|
|
||||||
updateAvatar: (id: string, avatar: string) => api.patch<Agent>(`/agents/${id}/avatar`, { avatar }).then((r) => r.data),
|
|
||||||
|
|
||||||
uploadKnowledge: (id: string, files: File[]) => {
|
|
||||||
const fd = new FormData();
|
|
||||||
files.forEach((f) => fd.append('files', f));
|
|
||||||
return api.post(`/agents/${id}/knowledge`, fd).then((r) => r.data);
|
|
||||||
},
|
|
||||||
reindexKnowledge: (agentId: string, fileId: string) =>
|
|
||||||
api.post(`/agents/${agentId}/knowledge/${fileId}/reindex`).then((r) => r.data),
|
|
||||||
deleteKnowledge: (agentId: string, fileId: string) =>
|
|
||||||
api.delete(`/agents/${agentId}/knowledge/${fileId}`).then((r) => r.data),
|
|
||||||
|
|
||||||
getKnowledgeStatus: (agentId: string) =>
|
|
||||||
api.get<{ files: KnowledgeFile[] }>(`/agents/${agentId}/knowledge/status`).then((r) => r.data),
|
|
||||||
|
|
||||||
uploadSkills: (id: string, files: File[]) => {
|
|
||||||
const fd = new FormData();
|
|
||||||
files.forEach((f) => fd.append('files', f));
|
|
||||||
return api.post(`/agents/${id}/skills`, fd).then((r) => r.data);
|
|
||||||
},
|
|
||||||
createSkill: (id: string, payload: { name: string; content: string }) =>
|
|
||||||
api.post(`/agents/${id}/skills/manual`, payload).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 }) =>
|
|
||||||
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)
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ChatAPI = {
|
|
||||||
history: (agentId: string, sessionId?: string) =>
|
|
||||||
api
|
|
||||||
.get<ChatHistoryResp>(`/chat/${agentId}/messages`, { params: sessionId ? { sessionId } : {} })
|
|
||||||
.then((r) => r.data),
|
|
||||||
send: (
|
|
||||||
agentId: string,
|
|
||||||
content: string,
|
|
||||||
sessionId?: string,
|
|
||||||
model?: string,
|
|
||||||
imageUrls?: string[]
|
|
||||||
) =>
|
|
||||||
api
|
|
||||||
.post<{ user: ChatMessage; assistant: ChatMessage }>(`/chat/${agentId}/messages`, {
|
|
||||||
content,
|
|
||||||
sessionId,
|
|
||||||
model,
|
|
||||||
imageUrls
|
|
||||||
})
|
|
||||||
.then((r) => r.data),
|
|
||||||
clear: (agentId: string, sessionId?: string) =>
|
|
||||||
api
|
|
||||||
.delete(`/chat/${agentId}/messages`, { params: sessionId ? { sessionId } : {} })
|
|
||||||
.then((r) => r.data),
|
|
||||||
/** 切换分支:把 user 消息下的某条 assistant 兄弟设为激活 */
|
|
||||||
switchBranch: (agentId: string, userMsgId: string, branchId: string) =>
|
|
||||||
api
|
|
||||||
.post(`/chat/${agentId}/messages/${userMsgId}/switch-branch`, { branchId })
|
|
||||||
.then((r) => r.data)
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface ChatAttachment {
|
|
||||||
name: string;
|
|
||||||
size: number;
|
|
||||||
mime: string;
|
|
||||||
text: string;
|
|
||||||
truncated: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ChatAttachmentsAPI = {
|
|
||||||
upload: (files: File[]) => {
|
|
||||||
const fd = new FormData();
|
|
||||||
files.forEach((f) => fd.append('files', f));
|
|
||||||
return api
|
|
||||||
.post<{ files: ChatAttachment[] }>('/chat/attachments', fd)
|
|
||||||
.then((r) => r.data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============== MCP ==============
|
|
||||||
|
|
||||||
export interface McpServer {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
transport: 'stdio' | 'sse' | 'http';
|
|
||||||
command: string;
|
|
||||||
args: string[];
|
|
||||||
env: Record<string, string>;
|
|
||||||
url: string;
|
|
||||||
enabled: boolean;
|
|
||||||
createdAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface McpStatus {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
error?: string;
|
|
||||||
toolCount: number;
|
|
||||||
resourceCount: number;
|
|
||||||
promptCount: number;
|
|
||||||
capabilities: { tools: boolean; resources: boolean; prompts: boolean };
|
|
||||||
tools: { name: string; description?: string }[];
|
|
||||||
resources: { uri: string; name?: string; description?: string; mimeType?: string }[];
|
|
||||||
prompts: { name: string; description?: string; arguments?: any[] }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const McpAPI = {
|
|
||||||
list: (agentId: string) => api.get<McpServer[]>(`/agents/${agentId}/mcp-servers`).then((r) => r.data),
|
|
||||||
status: (agentId: string) => api.get<McpStatus[]>(`/agents/${agentId}/mcp-status`).then((r) => r.data),
|
|
||||||
create: (agentId: string, payload: Partial<McpServer>) =>
|
|
||||||
api.post(`/agents/${agentId}/mcp-servers`, payload).then((r) => r.data),
|
|
||||||
update: (agentId: string, serverId: string, payload: Partial<McpServer>) =>
|
|
||||||
api.put(`/agents/${agentId}/mcp-servers/${serverId}`, payload).then((r) => r.data),
|
|
||||||
remove: (agentId: string, serverId: string) =>
|
|
||||||
api.delete(`/agents/${agentId}/mcp-servers/${serverId}`).then((r) => r.data),
|
|
||||||
restart: (agentId: string) => api.post(`/agents/${agentId}/mcp-restart`).then((r) => r.data),
|
|
||||||
importJSON: (agentId: string, config: any, replace = false) =>
|
|
||||||
api.post(`/agents/${agentId}/mcp-import`, { config, replace }).then((r) => r.data),
|
|
||||||
readResource: (agentId: string, serverId: string, uri: string) =>
|
|
||||||
api.post(`/agents/${agentId}/mcp-read-resource`, { serverId, uri }).then((r) => r.data),
|
|
||||||
getPrompt: (agentId: string, serverId: string, name: string, args: Record<string, any> = {}) =>
|
|
||||||
api.post(`/agents/${agentId}/mcp-get-prompt`, { serverId, name, args }).then((r) => r.data)
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============== 会话 ==============
|
|
||||||
|
|
||||||
export interface ChatSession {
|
|
||||||
id: string;
|
|
||||||
agentId: string;
|
|
||||||
title: string;
|
|
||||||
archived?: boolean;
|
|
||||||
createdAt: number;
|
|
||||||
updatedAt: number;
|
|
||||||
messageCount?: number;
|
|
||||||
lastPreview?: string;
|
|
||||||
lastAt?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SearchHit {
|
|
||||||
id: string;
|
|
||||||
sessionId: string;
|
|
||||||
sessionTitle: string;
|
|
||||||
sessionArchived: boolean;
|
|
||||||
role: 'user' | 'assistant';
|
|
||||||
content: string;
|
|
||||||
snippet: string;
|
|
||||||
createdAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SearchResult {
|
|
||||||
sessions: { id: string; title: string; archived: boolean; created_at: number; updated_at: number }[];
|
|
||||||
messages: SearchHit[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SessionAPI = {
|
|
||||||
list: (agentId: string, archived: '0' | '1' | 'all' = '0') =>
|
|
||||||
api.get<ChatSession[]>(`/agents/${agentId}/sessions`, { params: { archived } }).then((r) => r.data),
|
|
||||||
create: (agentId: string, title?: string) =>
|
|
||||||
api.post<ChatSession>(`/agents/${agentId}/sessions`, { title }).then((r) => r.data),
|
|
||||||
rename: (agentId: string, sessionId: string, title: string) =>
|
|
||||||
api.put(`/agents/${agentId}/sessions/${sessionId}`, { title }).then((r) => r.data),
|
|
||||||
remove: (agentId: string, sessionId: string) =>
|
|
||||||
api.delete(`/agents/${agentId}/sessions/${sessionId}`).then((r) => r.data),
|
|
||||||
archive: (agentId: string, sessionId: string, archived: boolean) =>
|
|
||||||
api.post(`/agents/${agentId}/sessions/${sessionId}/archive`, { archived }).then((r) => r.data),
|
|
||||||
share: (agentId: string, sessionId: string, ttlHours = 0) =>
|
|
||||||
api
|
|
||||||
.post<{ token: string; expiresAt?: number }>(`/agents/${agentId}/sessions/${sessionId}/share`, { ttlHours })
|
|
||||||
.then((r) => r.data),
|
|
||||||
revokeShare: (agentId: string, sessionId: string) =>
|
|
||||||
api.delete(`/agents/${agentId}/sessions/${sessionId}/share`).then((r) => r.data),
|
|
||||||
search: (agentId: string, q: string, opts: { includeArchived?: boolean; limit?: number } = {}) =>
|
|
||||||
api
|
|
||||||
.get<SearchResult>(`/agents/${agentId}/sessions/search`, {
|
|
||||||
params: {
|
|
||||||
q,
|
|
||||||
includeArchived: opts.includeArchived ? '1' : '0',
|
|
||||||
limit: opts.limit ?? 30
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then((r) => r.data),
|
|
||||||
/** 导出会话为文件并触发浏览器下载 */
|
|
||||||
exportSession: (agentId: string, sessionId: string, format: 'md' | 'json' = 'md') => {
|
|
||||||
const url = withApiBase(`/agents/${agentId}/sessions/${sessionId}/export?format=${format}`);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.rel = 'noopener';
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
a.remove();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 公开会话访问(不需要登录)
|
|
||||||
export const SharedAPI = {
|
|
||||||
get: (token: string) =>
|
|
||||||
api.get<{
|
|
||||||
agent: { name: string; description: string };
|
|
||||||
session: { id: string; title: string; createdAt: number; updatedAt: number };
|
|
||||||
messages: { id: string; role: 'user' | 'assistant'; content: string; createdAt: number }[];
|
|
||||||
}>(`/shared/${token}`).then((r) => r.data)
|
|
||||||
};
|
|
||||||
|
|
||||||
// 图床
|
|
||||||
export interface UploadedImage {
|
|
||||||
url: string;
|
|
||||||
name: string;
|
|
||||||
size: number;
|
|
||||||
mime: string;
|
|
||||||
}
|
|
||||||
export const ImageAPI = {
|
|
||||||
upload: (files: File[]) => {
|
|
||||||
const fd = new FormData();
|
|
||||||
files.forEach((f) => fd.append('file', f));
|
|
||||||
return api.post<{ files: UploadedImage[] }>('/uploads/image', fd).then((r) => r.data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============== 全局搜索(v0.7 命令面板) ==============
|
|
||||||
|
|
||||||
export interface GlobalSearchResult {
|
|
||||||
agents: { id: string; name: string; description: string; model: string }[];
|
|
||||||
sessions: { id: string; agentId: string; agentName: string; title: string; updatedAt: number; archived: boolean }[];
|
|
||||||
messages: {
|
|
||||||
id: string;
|
|
||||||
sessionId: string;
|
|
||||||
agentId: string;
|
|
||||||
agentName: string;
|
|
||||||
sessionTitle: string;
|
|
||||||
role: 'user' | 'assistant';
|
|
||||||
snippet: string;
|
|
||||||
createdAt: number;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SearchAPI = {
|
|
||||||
global: (q: string, limit = 8) =>
|
|
||||||
api.get<GlobalSearchResult>('/search', { params: { q, limit } }).then((r) => r.data)
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============== 鉴权 / 用户 / 团队 / 广场 ==============
|
|
||||||
|
|
||||||
export interface AuthUser {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
name: string;
|
|
||||||
role: 'admin' | 'user';
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AuthAPI = {
|
|
||||||
me: () => api.get<AuthUser>('/auth/me').then((r) => r.data),
|
|
||||||
verify: async (email: string, password: string) => {
|
|
||||||
// 根据要求,这里直接请求真实地址,未启动时 mock 返回 true
|
|
||||||
try {
|
|
||||||
const res = await axios.post('https://api.hoyidata.com/aura/v1/urser', { email, password }, { timeout: 3000 });
|
|
||||||
return res.data;
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Backend /urser not available, fallback to mock true', e);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
login: (email: string, password: string) =>
|
|
||||||
api.post<AuthUser>('/auth/login', { email, password }).then((r) => r.data),
|
|
||||||
register: (payload: { email: string; password: string; name: string; inviteCode?: string }) =>
|
|
||||||
api.post<AuthUser>('/auth/register', payload).then((r) => r.data),
|
|
||||||
logout: () => api.post('/auth/logout').then((r) => r.data),
|
|
||||||
listInvites: () => api.get('/auth/invites').then((r) => r.data),
|
|
||||||
createInvite: (payload: { email?: string; teamId?: string; role?: string; ttlHours?: number }) =>
|
|
||||||
api.post('/auth/invites', payload).then((r) => r.data),
|
|
||||||
deleteInvite: (code: string) => api.delete(`/auth/invites/${code}`).then((r) => r.data)
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface Team {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
ownerId: string;
|
|
||||||
createdAt: number;
|
|
||||||
myRole?: 'owner' | 'admin' | 'member';
|
|
||||||
members?: { id: string; email: string; name: string; role: string; joinedAt: number }[];
|
|
||||||
agentCount?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TeamAPI = {
|
|
||||||
list: () => api.get<Team[]>('/teams').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),
|
|
||||||
rename: (id: string, name: string) => api.put(`/teams/${id}`, { name }).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)
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface MarketplaceAgent extends Agent {
|
|
||||||
ownerName?: string;
|
|
||||||
fork_count: number;
|
|
||||||
skillCount: number;
|
|
||||||
kbCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MarketplaceAPI = {
|
|
||||||
list: () => api.get<MarketplaceAgent[]>('/agents/_/marketplace').then((r) => r.data),
|
|
||||||
detail: (id: string) => api.get(`/agents/_/marketplace/${id}`).then((r) => r.data),
|
|
||||||
fork: (id: string) => api.post(`/agents/_/marketplace/${id}/fork`).then((r) => r.data)
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============== Prompt 模板库 (v0.8 P1) ==============
|
|
||||||
|
|
||||||
export interface PromptTemplate {
|
|
||||||
id: string;
|
|
||||||
ownerId: string;
|
|
||||||
ownerName?: string;
|
|
||||||
title: string;
|
|
||||||
body: string;
|
|
||||||
category: string;
|
|
||||||
variables: string;
|
|
||||||
visibility: 'private' | 'public';
|
|
||||||
useCount: number;
|
|
||||||
createdAt: number;
|
|
||||||
updatedAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PromptTemplateAPI = {
|
|
||||||
list: (opts: { scope?: 'mine' | 'public' | 'all'; q?: string } = {}) =>
|
|
||||||
api
|
|
||||||
.get<PromptTemplate[]>('/prompt-templates', {
|
|
||||||
params: { scope: opts.scope ?? 'all', q: opts.q ?? '' }
|
|
||||||
})
|
|
||||||
.then((r) => r.data),
|
|
||||||
create: (payload: Partial<PromptTemplate>) =>
|
|
||||||
api.post<{ id: string }>('/prompt-templates', payload).then((r) => r.data),
|
|
||||||
update: (id: string, payload: Partial<PromptTemplate>) =>
|
|
||||||
api.put(`/prompt-templates/${id}`, payload).then((r) => r.data),
|
|
||||||
remove: (id: string) => api.delete(`/prompt-templates/${id}`).then((r) => r.data),
|
|
||||||
use: (id: string) => api.post(`/prompt-templates/${id}/use`).then((r) => r.data)
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============== 积分商城 (v1) ==============
|
|
||||||
|
|
||||||
export interface PointsMallMe {
|
|
||||||
points: number;
|
|
||||||
level?: string;
|
|
||||||
totalSpentUSD?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PointsExchangeRequest {
|
|
||||||
productId: string;
|
|
||||||
recipientName: string;
|
|
||||||
phone: string;
|
|
||||||
province: string;
|
|
||||||
city: string;
|
|
||||||
district: string;
|
|
||||||
address: string;
|
|
||||||
zipCode?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PointsExchangePrepareResponse {
|
|
||||||
orderId: string;
|
|
||||||
pointsDeducted: number;
|
|
||||||
remainingPoints: number;
|
|
||||||
expiresAt?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PointsExchangeShippingRequest = Omit<PointsExchangeRequest, 'productId'>;
|
|
||||||
|
|
||||||
export interface PointsExchangeResponse {
|
|
||||||
orderId: string;
|
|
||||||
pointsDeducted: number;
|
|
||||||
remainingPoints: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PointsMallCategory {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
sort: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PointsMallAnnouncement {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
content: string;
|
|
||||||
linkUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PointsMallBanner {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
subtitle?: string;
|
|
||||||
imageUrl: string;
|
|
||||||
linkUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PointsMallPromoEntry {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
subtitle?: string;
|
|
||||||
iconUrl?: string;
|
|
||||||
linkUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PointsMallProduct {
|
|
||||||
id: string;
|
|
||||||
categoryId: string;
|
|
||||||
name: string;
|
|
||||||
subtitle?: string;
|
|
||||||
coverUrl: string;
|
|
||||||
pointsPrice: number;
|
|
||||||
stock: number;
|
|
||||||
sold: number;
|
|
||||||
tags?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PointsMallOverview {
|
|
||||||
me: PointsMallMe;
|
|
||||||
categories: PointsMallCategory[];
|
|
||||||
announcements: PointsMallAnnouncement[];
|
|
||||||
banners: PointsMallBanner[];
|
|
||||||
promoEntries: PointsMallPromoEntry[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PointsMallProductsResponse {
|
|
||||||
page: number;
|
|
||||||
pageSize: number;
|
|
||||||
total: number;
|
|
||||||
items: PointsMallProduct[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PointsMallAPI = {
|
|
||||||
overview: () => api.get<PointsMallOverview>('/points-mall/overview').then((r) => r.data),
|
|
||||||
products: (opts: {
|
|
||||||
categoryId?: string;
|
|
||||||
q?: string;
|
|
||||||
sort?: string;
|
|
||||||
page?: number;
|
|
||||||
pageSize?: number;
|
|
||||||
} = {}) =>
|
|
||||||
api
|
|
||||||
.get<PointsMallProductsResponse>('/points-mall/products', {
|
|
||||||
params: {
|
|
||||||
categoryId: opts.categoryId,
|
|
||||||
q: opts.q ?? '',
|
|
||||||
sort: opts.sort ?? 'popular',
|
|
||||||
page: opts.page ?? 1,
|
|
||||||
pageSize: opts.pageSize ?? 24
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then((r) => r.data),
|
|
||||||
exchangePrepare: (productId: string) =>
|
|
||||||
api.post<PointsExchangePrepareResponse>('/points-mall/exchange/prepare', { productId }).then((r) => r.data),
|
|
||||||
exchangeSubmitShipping: (orderId: string, shippingInfo: PointsExchangeShippingRequest) =>
|
|
||||||
api.post<PointsExchangeResponse>(`/points-mall/exchange/${orderId}/shipping`, shippingInfo).then((r) => r.data),
|
|
||||||
exchange: (productId: string, shippingInfo: Omit<PointsExchangeRequest, 'productId'>) =>
|
|
||||||
api
|
|
||||||
.post<PointsExchangeResponse>('/points-mall/exchange', {
|
|
||||||
productId,
|
|
||||||
...shippingInfo
|
|
||||||
})
|
|
||||||
.then((r) => r.data)
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============== 调用统计 (v0.8 P1) ==============
|
|
||||||
|
|
||||||
export interface StatsOverview {
|
|
||||||
agentCount: number;
|
|
||||||
sessionCount: number;
|
|
||||||
messageCount: number;
|
|
||||||
daily: { day: string; total: number; user: number; assistant: number }[];
|
|
||||||
topAgents: { id: string; name: string; messageCount: number }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AgentStats {
|
|
||||||
sessionCount: number;
|
|
||||||
messageCount: number;
|
|
||||||
avgMessageLen: number;
|
|
||||||
assistantWithToolCalls: number;
|
|
||||||
knowledgeFiles: number;
|
|
||||||
skillCount: number;
|
|
||||||
daily: { day: string; count: number }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AgentTokenStatsByModel {
|
|
||||||
providerKind: string;
|
|
||||||
model: string;
|
|
||||||
calls: number;
|
|
||||||
promptTokens: number;
|
|
||||||
completionTokens: number;
|
|
||||||
totalTokens: number;
|
|
||||||
costUSD: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AgentTokenStatsDaily {
|
|
||||||
day: string;
|
|
||||||
calls: number;
|
|
||||||
promptTokens: number;
|
|
||||||
completionTokens: number;
|
|
||||||
totalTokens: number;
|
|
||||||
costUSD: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AgentTokenStats {
|
|
||||||
agentId: string;
|
|
||||||
days: number;
|
|
||||||
promptTokens: number;
|
|
||||||
completionTokens: number;
|
|
||||||
totalTokens: number;
|
|
||||||
costUSD: number;
|
|
||||||
byModel: AgentTokenStatsByModel[];
|
|
||||||
daily: AgentTokenStatsDaily[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const StatsAPI = {
|
|
||||||
overview: () => api.get<StatsOverview>('/stats/overview').then((r) => r.data),
|
|
||||||
agent: (id: string) => api.get<AgentStats>(`/stats/agents/${id}`).then((r) => r.data),
|
|
||||||
agentTokens: (id: string, opts: { days?: number; limit?: number } = {}) =>
|
|
||||||
api
|
|
||||||
.get<AgentTokenStats>(`/stats/agents/${id}/tokens`, {
|
|
||||||
params: { days: opts.days ?? 30, limit: opts.limit ?? 10 }
|
|
||||||
})
|
|
||||||
.then((r) => r.data)
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============== 流式(手写 SSE,不用 EventSource,因为它不能 POST) ==============
|
|
||||||
|
|
||||||
// ============== LLM 提供商 (v0.9) ==============
|
|
||||||
|
|
||||||
export type LLMKind = 'openai' | 'openai-compatible' | 'anthropic' | 'ollama' | 'tencent-tokenplan';
|
|
||||||
|
|
||||||
export interface LLMProvider {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
kind: LLMKind;
|
|
||||||
baseUrl: string;
|
|
||||||
apiKeyMasked: string;
|
|
||||||
hasApiKey: boolean;
|
|
||||||
models: string[];
|
|
||||||
defaultModel: string;
|
|
||||||
enabled: boolean;
|
|
||||||
isDefault: boolean;
|
|
||||||
createdAt: number;
|
|
||||||
updatedAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AgentAllowedLLMResponse {
|
|
||||||
agentId: string;
|
|
||||||
model: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const LLMProviderAPI = {
|
|
||||||
list: () => api.get<LLMProvider[]>('/llm-providers').then((r) => r.data),
|
|
||||||
allowedModels: (agentId: string) =>
|
|
||||||
api
|
|
||||||
.get<AgentAllowedLLMResponse>('/llm-providers', { params: { agent_id: agentId } })
|
|
||||||
.then((r) => r.data),
|
|
||||||
create: (payload: Partial<LLMProvider> & { apiKey?: string }) =>
|
|
||||||
api.post<{ id: string }>('/llm-providers', payload).then((r) => r.data),
|
|
||||||
update: (id: string, payload: Partial<LLMProvider> & { apiKey?: string }) =>
|
|
||||||
api.put(`/llm-providers/${id}`, payload).then((r) => r.data),
|
|
||||||
remove: (id: string) => api.delete(`/llm-providers/${id}`).then((r) => r.data),
|
|
||||||
test: (id: string, model?: string) =>
|
|
||||||
api.post<{ ok: boolean; reply?: string; error?: string; usage?: any; model?: string }>(
|
|
||||||
`/llm-providers/${id}/test`,
|
|
||||||
{ model }
|
|
||||||
).then((r) => r.data),
|
|
||||||
setDefault: (id: string) => api.post(`/llm-providers/${id}/default`).then((r) => r.data)
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
export interface StreamEvents {
|
|
||||||
onMeta?: (data: any) => void;
|
|
||||||
onReasoningDelta?: (text: string) => void;
|
|
||||||
onDelta?: (text: string) => void;
|
|
||||||
onRetry?: (data: any) => void;
|
|
||||||
onToolCall?: (data: { id: string; name: string; args: any }) => void;
|
|
||||||
onToolResult?: (data: { id: string; name: string; result: any }) => void;
|
|
||||||
onDone?: (data: { user: ChatMessage; assistant: ChatMessage }) => void;
|
|
||||||
onAborted?: (data: { assistant: ChatMessage }) => void;
|
|
||||||
onError?: (msg: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ModelOverrides {
|
|
||||||
model?: string;
|
|
||||||
model_id?: string;
|
|
||||||
temperature?: number;
|
|
||||||
topP?: number;
|
|
||||||
maxTokens?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function streamChat(
|
|
||||||
agentId: string,
|
|
||||||
content: string,
|
|
||||||
handlers: StreamEvents,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
sessionId?: string,
|
|
||||||
model?: string,
|
|
||||||
modelId?: string,
|
|
||||||
imageUrls?: string[]
|
|
||||||
) {
|
|
||||||
const resp = await fetch(`https://api.hoyidata.com/aura/v1/chat/${agentId}/messages/stream`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
content,
|
|
||||||
sessionId,
|
|
||||||
model,
|
|
||||||
model_id: modelId,
|
|
||||||
imageUrls: imageUrls ?? []
|
|
||||||
}),
|
|
||||||
signal,
|
|
||||||
credentials: 'include'
|
|
||||||
});
|
|
||||||
return await consumeSSE(resp, handlers, signal);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 重新生成(开新分支);行为同 streamChat */
|
|
||||||
export async function regenerateMessage(
|
|
||||||
agentId: string,
|
|
||||||
messageId: string,
|
|
||||||
handlers: StreamEvents,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
overrides?: ModelOverrides,
|
|
||||||
attachmentsText?: string
|
|
||||||
) {
|
|
||||||
const resp = await fetch(`https://api.hoyidata.com/aura/v1/chat/${agentId}/messages/${messageId}/regenerate`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' },
|
|
||||||
body: JSON.stringify({ overrides, attachmentsText }),
|
|
||||||
signal,
|
|
||||||
credentials: 'include'
|
|
||||||
});
|
|
||||||
return await consumeSSE(resp, handlers, signal);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function consumeSSE(resp: Response, h: StreamEvents, signal?: AbortSignal) {
|
|
||||||
if (!resp.ok || !resp.body) {
|
|
||||||
const txt = await resp.text().catch(() => '');
|
|
||||||
h.onError?.(`HTTP ${resp.status}: ${txt}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const reader = resp.body.getReader();
|
|
||||||
const decoder = new TextDecoder('utf-8');
|
|
||||||
let buf = '';
|
|
||||||
let hasError = false; // 标记是否已发生错误,避免后续事件继续处理
|
|
||||||
|
|
||||||
try {
|
|
||||||
while (true) {
|
|
||||||
const { value, done } = await reader.read();
|
|
||||||
if (done || hasError) break;
|
|
||||||
|
|
||||||
buf += decoder.decode(value, { stream: true });
|
|
||||||
buf = buf.replace(/\r\n/g, '\n');
|
|
||||||
|
|
||||||
let idx;
|
|
||||||
while ((idx = buf.indexOf('\n\n')) !== -1 && !hasError) {
|
|
||||||
const raw = buf.slice(0, idx);
|
|
||||||
buf = buf.slice(idx + 2);
|
|
||||||
if (!raw.trim() || raw.startsWith(':')) continue;
|
|
||||||
|
|
||||||
let event = 'message';
|
|
||||||
let dataStr = '';
|
|
||||||
for (const line of raw.split('\n')) {
|
|
||||||
if (line.startsWith('event:')) event = line.slice(6).trim();
|
|
||||||
else if (line.startsWith('data:')) {
|
|
||||||
let part = line.slice(5);
|
|
||||||
if (part.startsWith(' ')) part = part.slice(1);
|
|
||||||
dataStr += (dataStr ? '\n' : '') + part;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!dataStr) continue;
|
|
||||||
let data: any;
|
|
||||||
try {
|
|
||||||
data = JSON.parse(dataStr);
|
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
switch (event) {
|
|
||||||
case 'meta':
|
|
||||||
h.onMeta?.(data);
|
|
||||||
break;
|
|
||||||
case 'retry':
|
|
||||||
h.onRetry?.(data);
|
|
||||||
break;
|
|
||||||
case 'reasoning_delta':
|
|
||||||
h.onReasoningDelta?.(data.content || '');
|
|
||||||
break;
|
|
||||||
case 'delta':
|
|
||||||
h.onDelta?.(data.content || '');
|
|
||||||
break;
|
|
||||||
case 'tool_call':
|
|
||||||
h.onToolCall?.(data);
|
|
||||||
break;
|
|
||||||
case 'tool_result':
|
|
||||||
h.onToolResult?.(data);
|
|
||||||
break;
|
|
||||||
case 'done':
|
|
||||||
// 只有在没有错误的情况下才处理 done 事件
|
|
||||||
if (!hasError) {
|
|
||||||
h.onDone?.(data);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'aborted':
|
|
||||||
h.onAborted?.(data);
|
|
||||||
break;
|
|
||||||
case 'error':
|
|
||||||
hasError = true;
|
|
||||||
h.onError?.(data.message || 'stream error');
|
|
||||||
// 发生错误后立即停止读取
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
if (signal?.aborted || e?.name === 'AbortError') {
|
|
||||||
// 静默:本地已 abort
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
h.onError?.(e?.message ?? String(e));
|
|
||||||
} finally {
|
|
||||||
// 确保 reader 被释放
|
|
||||||
reader.cancel().catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============== Workflow (v1.1) ==============
|
|
||||||
|
|
||||||
export type WorkflowNodeType = 'agent' | 'skill' | 'http' | 'transform' | 'branch';
|
|
||||||
|
|
||||||
export interface WorkflowNode {
|
|
||||||
id: string;
|
|
||||||
type: WorkflowNodeType;
|
|
||||||
name: string;
|
|
||||||
config: Record<string, any>;
|
|
||||||
next?: string;
|
|
||||||
elseNext?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorkflowGraph {
|
|
||||||
entry: string;
|
|
||||||
nodes: WorkflowNode[];
|
|
||||||
variables?: Record<string, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Workflow {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
graph: WorkflowGraph;
|
|
||||||
scheduleCron: string;
|
|
||||||
scheduleEnabled: boolean;
|
|
||||||
enabled: boolean;
|
|
||||||
lastRunAt: number;
|
|
||||||
runCount: number;
|
|
||||||
createdAt: number;
|
|
||||||
updatedAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorkflowRun {
|
|
||||||
id: string;
|
|
||||||
workflowId: string;
|
|
||||||
trigger: 'manual' | 'cron' | 'api';
|
|
||||||
status: 'running' | 'success' | 'failed' | 'aborted';
|
|
||||||
error: string;
|
|
||||||
startedAt: number;
|
|
||||||
finishedAt: number;
|
|
||||||
durationMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorkflowRunStep {
|
|
||||||
id: string;
|
|
||||||
nodeId: string;
|
|
||||||
nodeType: string;
|
|
||||||
stepIndex: number;
|
|
||||||
status: 'running' | 'success' | 'failed' | 'skipped';
|
|
||||||
input: any;
|
|
||||||
output: any;
|
|
||||||
error: string;
|
|
||||||
startedAt: number;
|
|
||||||
finishedAt: number;
|
|
||||||
durationMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorkflowRunDetail extends WorkflowRun {
|
|
||||||
input: any;
|
|
||||||
output: any;
|
|
||||||
steps: WorkflowRunStep[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const WorkflowAPI = {
|
|
||||||
list: () => api.get<Workflow[]>('/workflows').then((r) => r.data),
|
|
||||||
get: (id: string) => api.get<Workflow>(`/workflows/${id}`).then((r) => r.data),
|
|
||||||
create: (payload: Partial<Workflow>) =>
|
|
||||||
api.post<{ id: string }>('/workflows', payload).then((r) => r.data),
|
|
||||||
update: (id: string, payload: Partial<Workflow>) =>
|
|
||||||
api.put(`/workflows/${id}`, payload).then((r) => r.data),
|
|
||||||
remove: (id: string) => api.delete(`/workflows/${id}`).then((r) => r.data),
|
|
||||||
run: (id: string, input?: Record<string, any>) =>
|
|
||||||
api.post<{ runId: string }>(`/workflows/${id}/run`, { input }).then((r) => r.data),
|
|
||||||
listRuns: (id: string, limit = 30) =>
|
|
||||||
api.get<WorkflowRun[]>(`/workflows/${id}/runs`, { params: { limit } }).then((r) => r.data),
|
|
||||||
getRun: (runId: string) =>
|
|
||||||
api.get<WorkflowRunDetail>(`/workflows/runs/${runId}`).then((r) => r.data)
|
|
||||||
};
|
|
||||||
|
|
||||||
// SSE 流式执行 workflow(用 EventSource,cookie 由浏览器自动带)
|
|
||||||
export function streamWorkflowRun(
|
|
||||||
workflowId: string,
|
|
||||||
input: Record<string, any> | undefined,
|
|
||||||
handlers: {
|
|
||||||
onReady?: (data: any) => void;
|
|
||||||
onStepStart?: (data: any) => void;
|
|
||||||
onStepFinish?: (data: any) => void;
|
|
||||||
onRunFinish?: (data: any) => void;
|
|
||||||
onError?: (msg: string) => void;
|
|
||||||
}
|
|
||||||
): () => void {
|
|
||||||
const qs = input ? `?input=${encodeURIComponent(JSON.stringify(input))}` : '';
|
|
||||||
const url = withApiBase(`/workflows/${workflowId}/run-stream${qs}`);
|
|
||||||
const es = new EventSource(url, { withCredentials: true } as any);
|
|
||||||
es.addEventListener('ready', (e: any) => handlers.onReady?.(JSON.parse(e.data)));
|
|
||||||
es.addEventListener('step_start', (e: any) => handlers.onStepStart?.(JSON.parse(e.data)));
|
|
||||||
es.addEventListener('step_finish', (e: any) => handlers.onStepFinish?.(JSON.parse(e.data)));
|
|
||||||
es.addEventListener('run_finish', (e: any) => {
|
|
||||||
handlers.onRunFinish?.(JSON.parse(e.data));
|
|
||||||
es.close();
|
|
||||||
});
|
|
||||||
es.addEventListener('error', () => {
|
|
||||||
if (es.readyState === EventSource.CLOSED) return;
|
|
||||||
handlers.onError?.('stream error');
|
|
||||||
es.close();
|
|
||||||
});
|
|
||||||
return () => es.close();
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { api } from './http';
|
||||||
|
|
||||||
|
export type KnowledgeStatus = 'pending' | 'indexing' | 'ready' | 'failed';
|
||||||
|
|
||||||
|
export interface KnowledgeFile {
|
||||||
|
id: string;
|
||||||
|
originalName: string;
|
||||||
|
filename: string;
|
||||||
|
size: number;
|
||||||
|
mime: string;
|
||||||
|
status: KnowledgeStatus;
|
||||||
|
error?: string;
|
||||||
|
chunkCount: number;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SkillType = 'prompt' | 'http' | 'js';
|
||||||
|
|
||||||
|
export interface SkillBrief {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
filename: string;
|
||||||
|
description?: string;
|
||||||
|
type: SkillType;
|
||||||
|
enabled: number;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillDetail extends SkillBrief {
|
||||||
|
content: string;
|
||||||
|
parameters: string;
|
||||||
|
handler: string;
|
||||||
|
config: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Agent {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
avatar: string;
|
||||||
|
prompt: string;
|
||||||
|
model: string;
|
||||||
|
temperature: number;
|
||||||
|
owner_id?: string | null;
|
||||||
|
team_id?: string | null;
|
||||||
|
visibility?: 'private' | 'team' | 'public';
|
||||||
|
fork_count?: number;
|
||||||
|
forked_from?: string | null;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
knowledge?: KnowledgeFile[];
|
||||||
|
skills?: SkillBrief[];
|
||||||
|
_access?: 'owner' | 'team' | 'view' | 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AgentAPI = {
|
||||||
|
list: () => api.get<Agent[]>('/agents').then((r) => r.data),
|
||||||
|
detail: (id: string) => api.get<Agent>(`/agents/${id}`).then((r) => r.data),
|
||||||
|
create: (payload: Partial<Agent>) => api.post<Agent>('/agents', payload).then((r) => r.data),
|
||||||
|
update: (id: string, payload: Partial<Agent>) => api.put<Agent>(`/agents/${id}`, payload).then((r) => r.data),
|
||||||
|
remove: (id: string) => api.delete(`/agents/${id}`).then((r) => r.data),
|
||||||
|
|
||||||
|
updateAvatar: (id: string, avatar: string) => api.patch<Agent>(`/agents/${id}/avatar`, { avatar }).then((r) => r.data),
|
||||||
|
|
||||||
|
uploadKnowledge: (id: string, files: File[]) => {
|
||||||
|
const fd = new FormData();
|
||||||
|
files.forEach((f) => fd.append('files', f));
|
||||||
|
return api.post(`/agents/${id}/knowledge`, fd).then((r) => r.data);
|
||||||
|
},
|
||||||
|
reindexKnowledge: (agentId: string, fileId: string) =>
|
||||||
|
api.post(`/agents/${agentId}/knowledge/${fileId}/reindex`).then((r) => r.data),
|
||||||
|
deleteKnowledge: (agentId: string, fileId: string) => api.delete(`/agents/${agentId}/knowledge/${fileId}`).then((r) => r.data),
|
||||||
|
|
||||||
|
getKnowledgeStatus: (agentId: string) => api.get<{ files: KnowledgeFile[] }>(`/agents/${agentId}/knowledge/status`).then((r) => r.data),
|
||||||
|
|
||||||
|
uploadSkills: (id: string, files: File[]) => {
|
||||||
|
const fd = new FormData();
|
||||||
|
files.forEach((f) => fd.append('files', f));
|
||||||
|
return api.post(`/agents/${id}/skills`, fd).then((r) => r.data);
|
||||||
|
},
|
||||||
|
createSkill: (id: string, payload: { name: string; content: string }) => api.post(`/agents/${id}/skills/manual`, payload).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 }) =>
|
||||||
|
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)
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
import axios from 'axios';
|
||||||
|
import { api } from './http';
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
role: 'admin' | 'user';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthAPI = {
|
||||||
|
me: () => api.get<AuthUser>('/auth/me').then((r) => r.data),
|
||||||
|
verify: async (email: string, password: string) => {
|
||||||
|
try {
|
||||||
|
const res = await axios.post('https://api.hoyidata.com/aura/v1/urser', { email, password }, { timeout: 3000 });
|
||||||
|
return res.data;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Backend /urser not available, fallback to mock true', e);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
login: (email: string, password: string) => api.post<AuthUser>('/auth/login', { email, password }).then((r) => r.data),
|
||||||
|
register: (payload: { email: string; password: string; name: string; inviteCode?: string }) =>
|
||||||
|
api.post<AuthUser>('/auth/register', payload).then((r) => r.data),
|
||||||
|
logout: () => api.post('/auth/logout').then((r) => r.data),
|
||||||
|
listInvites: () => api.get('/auth/invites').then((r) => r.data),
|
||||||
|
createInvite: (payload: { email?: string; teamId?: string; role?: string; ttlHours?: number }) => api.post('/auth/invites', payload).then((r) => r.data),
|
||||||
|
deleteInvite: (code: string) => api.delete(`/auth/invites/${code}`).then((r) => r.data)
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { api } from './http';
|
||||||
|
|
||||||
|
export interface RetrievedSnippet {
|
||||||
|
fileName: string;
|
||||||
|
chunkIndex: number;
|
||||||
|
score: number;
|
||||||
|
preview: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolCallTrace {
|
||||||
|
name: string;
|
||||||
|
args: any;
|
||||||
|
result: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
id: string;
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
reasoning?: string | null;
|
||||||
|
parentId?: string | null;
|
||||||
|
createdAt: number;
|
||||||
|
meta?: {
|
||||||
|
retrieved?: RetrievedSnippet[];
|
||||||
|
toolCalls?: ToolCallTrace[];
|
||||||
|
aborted?: boolean;
|
||||||
|
reasoning?: string;
|
||||||
|
error?: string;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BranchInfo {
|
||||||
|
total: number;
|
||||||
|
activeIndex: number;
|
||||||
|
ids: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatHistoryResp {
|
||||||
|
messages: ChatMessage[];
|
||||||
|
branches: Record<string, BranchInfo>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChatAPI = {
|
||||||
|
history: (agentId: string, sessionId?: string) =>
|
||||||
|
api.get<ChatHistoryResp>(`/chat/${agentId}/messages`, { params: sessionId ? { sessionId } : {} }).then((r) => r.data),
|
||||||
|
send: (agentId: string, content: string, sessionId?: string, model?: string, imageUrls?: string[]) =>
|
||||||
|
api
|
||||||
|
.post<{ user: ChatMessage; assistant: ChatMessage }>(`/chat/${agentId}/messages`, {
|
||||||
|
content,
|
||||||
|
sessionId,
|
||||||
|
model,
|
||||||
|
imageUrls
|
||||||
|
})
|
||||||
|
.then((r) => r.data),
|
||||||
|
clear: (agentId: string, sessionId?: string) =>
|
||||||
|
api.delete(`/chat/${agentId}/messages`, { params: sessionId ? { sessionId } : {} }).then((r) => r.data),
|
||||||
|
switchBranch: (agentId: string, userMsgId: string, branchId: string) =>
|
||||||
|
api.post(`/chat/${agentId}/messages/${userMsgId}/switch-branch`, { branchId }).then((r) => r.data)
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ChatAttachment {
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
mime: string;
|
||||||
|
text: string;
|
||||||
|
truncated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChatAttachmentsAPI = {
|
||||||
|
upload: (files: File[]) => {
|
||||||
|
const fd = new FormData();
|
||||||
|
files.forEach((f) => fd.append('files', f));
|
||||||
|
return api.post<{ files: ChatAttachment[] }>('/chat/attachments', fd).then((r) => r.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
export const API_BASE_URL = 'https://api.hoyidata.com/aura/v1';
|
||||||
|
const APP_BASE = (import.meta.env.BASE_URL || '/').replace(/\/$/, '');
|
||||||
|
export const withAppBase = (path: string) => `${APP_BASE}${path.startsWith('/') ? path : `/${path}`}`;
|
||||||
|
export const withApiBase = (path: string) => `${API_BASE_URL}${path.startsWith('/') ? path : `/${path}`}`;
|
||||||
|
const isMockAuth = () => typeof localStorage !== 'undefined' && localStorage.getItem('mock-auth') === '1';
|
||||||
|
|
||||||
|
export const api = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
timeout: 90000,
|
||||||
|
withCredentials: true
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(r) => r,
|
||||||
|
(err) => {
|
||||||
|
const isLoginPage = location.pathname === '/login' || location.pathname === withAppBase('/login');
|
||||||
|
if (err?.response?.status === 401 && !isLoginPage && !isMockAuth()) {
|
||||||
|
const next = encodeURIComponent(location.pathname + location.search);
|
||||||
|
location.href = `${withAppBase('/login')}?next=${next}`;
|
||||||
|
}
|
||||||
|
return Promise.reject(err);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
export * from './http';
|
||||||
|
export * from './models';
|
||||||
|
export * from './agents';
|
||||||
|
export * from './chat';
|
||||||
|
export * from './mcp';
|
||||||
|
export * from './sessions';
|
||||||
|
export * from './uploads';
|
||||||
|
export * from './search';
|
||||||
|
export * from './auth';
|
||||||
|
export * from './teams';
|
||||||
|
export * from './marketplace';
|
||||||
|
export * from './promptTemplates';
|
||||||
|
export * from './pointsMall';
|
||||||
|
export * from './stats';
|
||||||
|
export * from './llmProviders';
|
||||||
|
export * from './streamChat';
|
||||||
|
export * from './workflows';
|
||||||
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { api } from './http';
|
||||||
|
|
||||||
|
export type LLMKind = 'openai' | 'openai-compatible' | 'anthropic' | 'ollama' | 'tencent-tokenplan';
|
||||||
|
|
||||||
|
export interface LLMProvider {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
kind: LLMKind;
|
||||||
|
baseUrl: string;
|
||||||
|
apiKeyMasked: string;
|
||||||
|
hasApiKey: boolean;
|
||||||
|
models: string[];
|
||||||
|
defaultModel: string;
|
||||||
|
enabled: boolean;
|
||||||
|
isDefault: boolean;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentAllowedLLMResponse {
|
||||||
|
agentId: string;
|
||||||
|
model: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LLMProviderAPI = {
|
||||||
|
list: () => api.get<LLMProvider[]>('/llm-providers').then((r) => r.data),
|
||||||
|
allowedModels: (agentId: string) => api.get<AgentAllowedLLMResponse>('/llm-providers', { params: { agent_id: agentId } }).then((r) => r.data),
|
||||||
|
create: (payload: Partial<LLMProvider> & { apiKey?: string }) => api.post<{ id: string }>('/llm-providers', payload).then((r) => r.data),
|
||||||
|
update: (id: string, payload: Partial<LLMProvider> & { apiKey?: string }) => api.put(`/llm-providers/${id}`, payload).then((r) => r.data),
|
||||||
|
remove: (id: string) => api.delete(`/llm-providers/${id}`).then((r) => r.data),
|
||||||
|
test: (id: string, model?: string) =>
|
||||||
|
api.post<{ ok: boolean; reply?: string; error?: string; usage?: any; model?: string }>(`/llm-providers/${id}/test`, { model }).then((r) => r.data),
|
||||||
|
setDefault: (id: string) => api.post(`/llm-providers/${id}/default`).then((r) => r.data)
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { api } from './http';
|
||||||
|
import type { Agent } from './agents';
|
||||||
|
|
||||||
|
export interface MarketplaceAgent extends Agent {
|
||||||
|
ownerName?: string;
|
||||||
|
fork_count: number;
|
||||||
|
skillCount: number;
|
||||||
|
kbCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MarketplaceAPI = {
|
||||||
|
list: () => api.get<MarketplaceAgent[]>('/agents/_/marketplace').then((r) => r.data),
|
||||||
|
detail: (id: string) => api.get(`/agents/_/marketplace/${id}`).then((r) => r.data),
|
||||||
|
fork: (id: string) => api.post(`/agents/_/marketplace/${id}/fork`).then((r) => r.data)
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { api } from './http';
|
||||||
|
|
||||||
|
export interface McpServer {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
transport: 'stdio' | 'sse' | 'http';
|
||||||
|
command: string;
|
||||||
|
args: string[];
|
||||||
|
env: Record<string, string>;
|
||||||
|
url: string;
|
||||||
|
enabled: boolean;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface McpStatus {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
error?: string;
|
||||||
|
toolCount: number;
|
||||||
|
resourceCount: number;
|
||||||
|
promptCount: number;
|
||||||
|
capabilities: { tools: boolean; resources: boolean; prompts: boolean };
|
||||||
|
tools: { name: string; description?: string }[];
|
||||||
|
resources: { uri: string; name?: string; description?: string; mimeType?: string }[];
|
||||||
|
prompts: { name: string; description?: string; arguments?: any[] }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const McpAPI = {
|
||||||
|
list: (agentId: string) => api.get<McpServer[]>(`/agents/${agentId}/mcp-servers`).then((r) => r.data),
|
||||||
|
status: (agentId: string) => api.get<McpStatus[]>(`/agents/${agentId}/mcp-status`).then((r) => r.data),
|
||||||
|
create: (agentId: string, payload: Partial<McpServer>) => api.post(`/agents/${agentId}/mcp-servers`, payload).then((r) => r.data),
|
||||||
|
update: (agentId: string, serverId: string, payload: Partial<McpServer>) =>
|
||||||
|
api.put(`/agents/${agentId}/mcp-servers/${serverId}`, payload).then((r) => r.data),
|
||||||
|
remove: (agentId: string, serverId: string) => api.delete(`/agents/${agentId}/mcp-servers/${serverId}`).then((r) => r.data),
|
||||||
|
restart: (agentId: string) => api.post(`/agents/${agentId}/mcp-restart`).then((r) => r.data),
|
||||||
|
importJSON: (agentId: string, config: any, replace = false) => api.post(`/agents/${agentId}/mcp-import`, { config, replace }).then((r) => r.data),
|
||||||
|
readResource: (agentId: string, serverId: string, uri: string) => api.post(`/agents/${agentId}/mcp-read-resource`, { serverId, uri }).then((r) => r.data),
|
||||||
|
getPrompt: (agentId: string, serverId: string, name: string, args: Record<string, any> = {}) =>
|
||||||
|
api.post(`/agents/${agentId}/mcp-get-prompt`, { serverId, name, args }).then((r) => r.data)
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { api } from './http';
|
||||||
|
|
||||||
|
export interface AiModel {
|
||||||
|
id: string;
|
||||||
|
model_name: string;
|
||||||
|
icon: string;
|
||||||
|
model_ratio: number;
|
||||||
|
completion_ratio: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ModelAPI = {
|
||||||
|
list: () => api.get<{ data: AiModel[] }>('/models').then((r) => r.data.data)
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
import { api } from './http';
|
||||||
|
|
||||||
|
export interface PointsMallMe {
|
||||||
|
points: number;
|
||||||
|
level?: string;
|
||||||
|
totalSpentUSD?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PointsExchangeRequest {
|
||||||
|
productId: string;
|
||||||
|
recipientName: string;
|
||||||
|
phone: string;
|
||||||
|
province: string;
|
||||||
|
city: string;
|
||||||
|
district: string;
|
||||||
|
address: string;
|
||||||
|
zipCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PointsExchangePrepareResponse {
|
||||||
|
orderId: string;
|
||||||
|
pointsDeducted: number;
|
||||||
|
remainingPoints: number;
|
||||||
|
expiresAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PointsExchangeShippingRequest = Omit<PointsExchangeRequest, 'productId'>;
|
||||||
|
|
||||||
|
export interface PointsExchangeResponse {
|
||||||
|
orderId: string;
|
||||||
|
pointsDeducted: number;
|
||||||
|
remainingPoints: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PointsMallCategory {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
sort: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PointsMallAnnouncement {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
linkUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PointsMallBanner {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
imageUrl: string;
|
||||||
|
linkUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PointsMallPromoEntry {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
iconUrl?: string;
|
||||||
|
linkUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PointsMallProduct {
|
||||||
|
id: string;
|
||||||
|
categoryId: string;
|
||||||
|
name: string;
|
||||||
|
subtitle?: string;
|
||||||
|
coverUrl: string;
|
||||||
|
pointsPrice: number;
|
||||||
|
stock: number;
|
||||||
|
sold: number;
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PointsMallOverview {
|
||||||
|
me: PointsMallMe;
|
||||||
|
categories: PointsMallCategory[];
|
||||||
|
announcements: PointsMallAnnouncement[];
|
||||||
|
banners: PointsMallBanner[];
|
||||||
|
promoEntries: PointsMallPromoEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PointsMallProductsResponse {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
items: PointsMallProduct[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PointsMallAPI = {
|
||||||
|
overview: () => api.get<PointsMallOverview>('/points-mall/overview').then((r) => r.data),
|
||||||
|
products: (opts: { categoryId?: string; q?: string; sort?: string; page?: number; pageSize?: number } = {}) =>
|
||||||
|
api
|
||||||
|
.get<PointsMallProductsResponse>('/points-mall/products', {
|
||||||
|
params: {
|
||||||
|
categoryId: opts.categoryId,
|
||||||
|
q: opts.q ?? '',
|
||||||
|
sort: opts.sort ?? 'popular',
|
||||||
|
page: opts.page ?? 1,
|
||||||
|
pageSize: opts.pageSize ?? 24
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then((r) => r.data),
|
||||||
|
exchangePrepare: (productId: string) =>
|
||||||
|
api.post<PointsExchangePrepareResponse>('/points-mall/exchange/prepare', { productId }).then((r) => r.data),
|
||||||
|
exchangeSubmitShipping: (orderId: string, shippingInfo: PointsExchangeShippingRequest) =>
|
||||||
|
api.post<PointsExchangeResponse>(`/points-mall/exchange/${orderId}/shipping`, shippingInfo).then((r) => r.data),
|
||||||
|
exchange: (productId: string, shippingInfo: Omit<PointsExchangeRequest, 'productId'>) =>
|
||||||
|
api.post<PointsExchangeResponse>('/points-mall/exchange', { productId, ...shippingInfo }).then((r) => r.data)
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { api } from './http';
|
||||||
|
|
||||||
|
export interface PromptTemplate {
|
||||||
|
id: string;
|
||||||
|
ownerId: string;
|
||||||
|
ownerName?: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
category: string;
|
||||||
|
variables: string;
|
||||||
|
visibility: 'private' | 'public';
|
||||||
|
useCount: number;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PromptTemplateAPI = {
|
||||||
|
list: (opts: { scope?: 'mine' | 'public' | 'all'; q?: string } = {}) =>
|
||||||
|
api
|
||||||
|
.get<PromptTemplate[]>('/prompt-templates', {
|
||||||
|
params: { scope: opts.scope ?? 'all', q: opts.q ?? '' }
|
||||||
|
})
|
||||||
|
.then((r) => r.data),
|
||||||
|
create: (payload: Partial<PromptTemplate>) => api.post<{ id: string }>('/prompt-templates', payload).then((r) => r.data),
|
||||||
|
update: (id: string, payload: Partial<PromptTemplate>) => api.put(`/prompt-templates/${id}`, payload).then((r) => r.data),
|
||||||
|
remove: (id: string) => api.delete(`/prompt-templates/${id}`).then((r) => r.data),
|
||||||
|
use: (id: string) => api.post(`/prompt-templates/${id}/use`).then((r) => r.data)
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { api } from './http';
|
||||||
|
|
||||||
|
export interface GlobalSearchResult {
|
||||||
|
agents: { id: string; name: string; description: string; model: string }[];
|
||||||
|
sessions: { id: string; agentId: string; agentName: string; title: string; updatedAt: number; archived: boolean }[];
|
||||||
|
messages: {
|
||||||
|
id: string;
|
||||||
|
sessionId: string;
|
||||||
|
agentId: string;
|
||||||
|
agentName: string;
|
||||||
|
sessionTitle: string;
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
snippet: string;
|
||||||
|
createdAt: number;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SearchAPI = {
|
||||||
|
global: (q: string, limit = 8) => api.get<GlobalSearchResult>('/search', { params: { q, limit } }).then((r) => r.data)
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { api, withApiBase } from './http';
|
||||||
|
|
||||||
|
export interface ChatSession {
|
||||||
|
id: string;
|
||||||
|
agentId: string;
|
||||||
|
title: string;
|
||||||
|
archived?: boolean;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
messageCount?: number;
|
||||||
|
lastPreview?: string;
|
||||||
|
lastAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchHit {
|
||||||
|
id: string;
|
||||||
|
sessionId: string;
|
||||||
|
sessionTitle: string;
|
||||||
|
sessionArchived: boolean;
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
snippet: string;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchResult {
|
||||||
|
sessions: { id: string; title: string; archived: boolean; created_at: number; updated_at: number }[];
|
||||||
|
messages: SearchHit[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SessionAPI = {
|
||||||
|
list: (agentId: string, archived: '0' | '1' | 'all' = '0') =>
|
||||||
|
api.get<ChatSession[]>(`/agents/${agentId}/sessions`, { params: { archived } }).then((r) => r.data),
|
||||||
|
create: (agentId: string, title?: string) => api.post<ChatSession>(`/agents/${agentId}/sessions`, { title }).then((r) => r.data),
|
||||||
|
rename: (agentId: string, sessionId: string, title: string) => api.put(`/agents/${agentId}/sessions/${sessionId}`, { title }).then((r) => r.data),
|
||||||
|
remove: (agentId: string, sessionId: string) => api.delete(`/agents/${agentId}/sessions/${sessionId}`).then((r) => r.data),
|
||||||
|
archive: (agentId: string, sessionId: string, archived: boolean) =>
|
||||||
|
api.post(`/agents/${agentId}/sessions/${sessionId}/archive`, { archived }).then((r) => r.data),
|
||||||
|
share: (agentId: string, sessionId: string, ttlHours = 0) =>
|
||||||
|
api.post<{ token: string; expiresAt?: number }>(`/agents/${agentId}/sessions/${sessionId}/share`, { ttlHours }).then((r) => r.data),
|
||||||
|
revokeShare: (agentId: string, sessionId: string) => api.delete(`/agents/${agentId}/sessions/${sessionId}/share`).then((r) => r.data),
|
||||||
|
search: (agentId: string, q: string, opts: { includeArchived?: boolean; limit?: number } = {}) =>
|
||||||
|
api
|
||||||
|
.get<SearchResult>(`/agents/${agentId}/sessions/search`, {
|
||||||
|
params: {
|
||||||
|
q,
|
||||||
|
includeArchived: opts.includeArchived ? '1' : '0',
|
||||||
|
limit: opts.limit ?? 30
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then((r) => r.data),
|
||||||
|
exportSession: (agentId: string, sessionId: string, format: 'md' | 'json' = 'md') => {
|
||||||
|
const url = withApiBase(`/agents/${agentId}/sessions/${sessionId}/export?format=${format}`);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.rel = 'noopener';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SharedAPI = {
|
||||||
|
get: (token: string) =>
|
||||||
|
api
|
||||||
|
.get<{
|
||||||
|
agent: { name: string; description: string };
|
||||||
|
session: { id: string; title: string; createdAt: number; updatedAt: number };
|
||||||
|
messages: { id: string; role: 'user' | 'assistant'; content: string; createdAt: number }[];
|
||||||
|
}>(`/shared/${token}`)
|
||||||
|
.then((r) => r.data)
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { api } from './http';
|
||||||
|
|
||||||
|
export interface StatsOverview {
|
||||||
|
agentCount: number;
|
||||||
|
sessionCount: number;
|
||||||
|
messageCount: number;
|
||||||
|
daily: { day: string; total: number; user: number; assistant: number }[];
|
||||||
|
topAgents: { id: string; name: string; messageCount: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentStats {
|
||||||
|
sessionCount: number;
|
||||||
|
messageCount: number;
|
||||||
|
avgMessageLen: number;
|
||||||
|
assistantWithToolCalls: number;
|
||||||
|
knowledgeFiles: number;
|
||||||
|
skillCount: number;
|
||||||
|
daily: { day: string; count: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentTokenStatsByModel {
|
||||||
|
providerKind: string;
|
||||||
|
model: string;
|
||||||
|
calls: number;
|
||||||
|
promptTokens: number;
|
||||||
|
completionTokens: number;
|
||||||
|
totalTokens: number;
|
||||||
|
costUSD: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentTokenStatsDaily {
|
||||||
|
day: string;
|
||||||
|
calls: number;
|
||||||
|
promptTokens: number;
|
||||||
|
completionTokens: number;
|
||||||
|
totalTokens: number;
|
||||||
|
costUSD: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentTokenStats {
|
||||||
|
days?: number;
|
||||||
|
limit?: number;
|
||||||
|
calls?: number;
|
||||||
|
promptTokens?: number;
|
||||||
|
completionTokens?: number;
|
||||||
|
totalTokens?: number;
|
||||||
|
costUSD?: number;
|
||||||
|
byModel: AgentTokenStatsByModel[];
|
||||||
|
daily: AgentTokenStatsDaily[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StatsAPI = {
|
||||||
|
overview: () => api.get<StatsOverview>('/stats/overview').then((r) => r.data),
|
||||||
|
agent: (id: string) => api.get<AgentStats>(`/stats/agents/${id}`).then((r) => r.data),
|
||||||
|
agentTokens: (id: string, opts: { days?: number; limit?: number } = {}) =>
|
||||||
|
api.get<AgentTokenStats>(`/stats/agents/${id}/tokens`, { params: { days: opts.days ?? 30, limit: opts.limit ?? 10 } }).then((r) => r.data)
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,152 @@
|
||||||
|
import type { ChatMessage } from './chat';
|
||||||
|
|
||||||
|
export interface StreamEvents {
|
||||||
|
onMeta?: (data: any) => void;
|
||||||
|
onReasoningDelta?: (text: string) => void;
|
||||||
|
onDelta?: (text: string) => void;
|
||||||
|
onRetry?: (data: any) => void;
|
||||||
|
onToolCall?: (data: { id: string; name: string; args: any }) => void;
|
||||||
|
onToolResult?: (data: { id: string; name: string; result: any }) => void;
|
||||||
|
onDone?: (data: { user: ChatMessage; assistant: ChatMessage }) => void;
|
||||||
|
onAborted?: (data: { assistant: ChatMessage }) => void;
|
||||||
|
onError?: (msg: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelOverrides {
|
||||||
|
model?: string;
|
||||||
|
model_id?: string;
|
||||||
|
temperature?: number;
|
||||||
|
topP?: number;
|
||||||
|
maxTokens?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function streamChat(
|
||||||
|
agentId: string,
|
||||||
|
content: string,
|
||||||
|
handlers: StreamEvents,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
sessionId?: string,
|
||||||
|
model?: string,
|
||||||
|
modelId?: string,
|
||||||
|
imageUrls?: string[]
|
||||||
|
) {
|
||||||
|
const resp = await fetch(`https://api.hoyidata.com/aura/v1/chat/${agentId}/messages/stream`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
content,
|
||||||
|
sessionId,
|
||||||
|
model,
|
||||||
|
model_id: modelId,
|
||||||
|
imageUrls: imageUrls ?? []
|
||||||
|
}),
|
||||||
|
signal,
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
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(`https://api.hoyidata.com/aura/v1/chat/${agentId}/messages/${messageId}/regenerate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' },
|
||||||
|
body: JSON.stringify({ overrides, attachmentsText }),
|
||||||
|
signal,
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
return await consumeSSE(resp, handlers, signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function consumeSSE(resp: Response, h: StreamEvents, signal?: AbortSignal) {
|
||||||
|
if (!resp.ok || !resp.body) {
|
||||||
|
const txt = await resp.text().catch(() => '');
|
||||||
|
h.onError?.(`HTTP ${resp.status}: ${txt}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reader = resp.body.getReader();
|
||||||
|
const decoder = new TextDecoder('utf-8');
|
||||||
|
let buf = '';
|
||||||
|
let hasError = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done || hasError) break;
|
||||||
|
|
||||||
|
buf += decoder.decode(value, { stream: true });
|
||||||
|
buf = buf.replace(/\r\n/g, '\n');
|
||||||
|
|
||||||
|
let idx;
|
||||||
|
while ((idx = buf.indexOf('\n\n')) !== -1 && !hasError) {
|
||||||
|
const raw = buf.slice(0, idx);
|
||||||
|
buf = buf.slice(idx + 2);
|
||||||
|
if (!raw.trim() || raw.startsWith(':')) continue;
|
||||||
|
|
||||||
|
let event = 'message';
|
||||||
|
let dataStr = '';
|
||||||
|
for (const line of raw.split('\n')) {
|
||||||
|
if (line.startsWith('event:')) event = line.slice(6).trim();
|
||||||
|
else if (line.startsWith('data:')) {
|
||||||
|
let part = line.slice(5);
|
||||||
|
if (part.startsWith(' ')) part = part.slice(1);
|
||||||
|
dataStr += (dataStr ? '\n' : '') + part;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!dataStr) continue;
|
||||||
|
let data: any;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(dataStr);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
switch (event) {
|
||||||
|
case 'meta':
|
||||||
|
h.onMeta?.(data);
|
||||||
|
break;
|
||||||
|
case 'retry':
|
||||||
|
h.onRetry?.(data);
|
||||||
|
break;
|
||||||
|
case 'reasoning_delta':
|
||||||
|
h.onReasoningDelta?.(data.content || '');
|
||||||
|
break;
|
||||||
|
case 'delta':
|
||||||
|
h.onDelta?.(data.content || '');
|
||||||
|
break;
|
||||||
|
case 'tool_call':
|
||||||
|
h.onToolCall?.(data);
|
||||||
|
break;
|
||||||
|
case 'tool_result':
|
||||||
|
h.onToolResult?.(data);
|
||||||
|
break;
|
||||||
|
case 'done':
|
||||||
|
if (!hasError) {
|
||||||
|
h.onDone?.(data);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'aborted':
|
||||||
|
h.onAborted?.(data);
|
||||||
|
break;
|
||||||
|
case 'error':
|
||||||
|
hasError = true;
|
||||||
|
h.onError?.(data.message || 'stream error');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
if (signal?.aborted || e?.name === 'AbortError') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
h.onError?.(e?.message ?? String(e));
|
||||||
|
} finally {
|
||||||
|
reader.cancel().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { api } from './http';
|
||||||
|
|
||||||
|
export interface Team {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
ownerId: string;
|
||||||
|
createdAt: number;
|
||||||
|
myRole?: 'owner' | 'admin' | 'member';
|
||||||
|
members?: { id: string; email: string; name: string; role: string; joinedAt: number }[];
|
||||||
|
agentCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TeamAPI = {
|
||||||
|
list: () => api.get<Team[]>('/teams').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),
|
||||||
|
rename: (id: string, name: string) => api.put(`/teams/${id}`, { name }).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)
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { api } from './http';
|
||||||
|
|
||||||
|
export interface UploadedImage {
|
||||||
|
url: string;
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
mime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ImageAPI = {
|
||||||
|
upload: (files: File[]) => {
|
||||||
|
const fd = new FormData();
|
||||||
|
files.forEach((f) => fd.append('file', f));
|
||||||
|
return api.post<{ files: UploadedImage[] }>('/uploads/image', fd).then((r) => r.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
import { api, withApiBase } from './http';
|
||||||
|
|
||||||
|
export type WorkflowNodeType = 'agent' | 'skill' | 'http' | 'transform' | 'branch';
|
||||||
|
|
||||||
|
export interface WorkflowNode {
|
||||||
|
id: string;
|
||||||
|
type: WorkflowNodeType;
|
||||||
|
name: string;
|
||||||
|
config: Record<string, any>;
|
||||||
|
next?: string;
|
||||||
|
elseNext?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkflowGraph {
|
||||||
|
entry: string;
|
||||||
|
nodes: WorkflowNode[];
|
||||||
|
variables?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Workflow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
graph: WorkflowGraph;
|
||||||
|
scheduleCron: string;
|
||||||
|
scheduleEnabled: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
lastRunAt: number;
|
||||||
|
runCount: number;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkflowRun {
|
||||||
|
id: string;
|
||||||
|
workflowId: string;
|
||||||
|
trigger: 'manual' | 'cron' | 'api';
|
||||||
|
status: 'running' | 'success' | 'failed' | 'aborted';
|
||||||
|
error: string;
|
||||||
|
startedAt: number;
|
||||||
|
finishedAt: number;
|
||||||
|
durationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkflowRunStep {
|
||||||
|
id: string;
|
||||||
|
nodeId: string;
|
||||||
|
nodeType: string;
|
||||||
|
stepIndex: number;
|
||||||
|
status: 'running' | 'success' | 'failed' | 'skipped';
|
||||||
|
input: any;
|
||||||
|
output: any;
|
||||||
|
error: string;
|
||||||
|
startedAt: number;
|
||||||
|
finishedAt: number;
|
||||||
|
durationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkflowRunDetail extends WorkflowRun {
|
||||||
|
input: any;
|
||||||
|
output: any;
|
||||||
|
steps: WorkflowRunStep[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WorkflowAPI = {
|
||||||
|
list: () => api.get<Workflow[]>('/workflows').then((r) => r.data),
|
||||||
|
get: (id: string) => api.get<Workflow>(`/workflows/${id}`).then((r) => r.data),
|
||||||
|
create: (payload: Partial<Workflow>) => api.post<{ id: string }>('/workflows', payload).then((r) => r.data),
|
||||||
|
update: (id: string, payload: Partial<Workflow>) => api.put(`/workflows/${id}`, payload).then((r) => r.data),
|
||||||
|
remove: (id: string) => api.delete(`/workflows/${id}`).then((r) => r.data),
|
||||||
|
run: (id: string, input?: Record<string, any>) => api.post<{ runId: string }>(`/workflows/${id}/run`, { input }).then((r) => r.data),
|
||||||
|
listRuns: (id: string, limit = 30) => api.get<WorkflowRun[]>(`/workflows/${id}/runs`, { params: { limit } }).then((r) => r.data),
|
||||||
|
getRun: (runId: string) => api.get<WorkflowRunDetail>(`/workflows/runs/${runId}`).then((r) => r.data)
|
||||||
|
};
|
||||||
|
|
||||||
|
export function streamWorkflowRun(
|
||||||
|
workflowId: string,
|
||||||
|
input: Record<string, any> | undefined,
|
||||||
|
handlers: {
|
||||||
|
onReady?: (data: any) => void;
|
||||||
|
onStepStart?: (data: any) => void;
|
||||||
|
onStepFinish?: (data: any) => void;
|
||||||
|
onRunFinish?: (data: any) => void;
|
||||||
|
onError?: (msg: string) => void;
|
||||||
|
}
|
||||||
|
): () => void {
|
||||||
|
const qs = input ? `?input=${encodeURIComponent(JSON.stringify(input))}` : '';
|
||||||
|
const url = withApiBase(`/workflows/${workflowId}/run-stream${qs}`);
|
||||||
|
const es = new EventSource(url, { withCredentials: true } as any);
|
||||||
|
es.addEventListener('ready', (e: any) => handlers.onReady?.(JSON.parse(e.data)));
|
||||||
|
es.addEventListener('step_start', (e: any) => handlers.onStepStart?.(JSON.parse(e.data)));
|
||||||
|
es.addEventListener('step_finish', (e: any) => handlers.onStepFinish?.(JSON.parse(e.data)));
|
||||||
|
es.addEventListener('run_finish', (e: any) => {
|
||||||
|
handlers.onRunFinish?.(JSON.parse(e.data));
|
||||||
|
es.close();
|
||||||
|
});
|
||||||
|
es.addEventListener('error', () => {
|
||||||
|
if (es.readyState === EventSource.CLOSED) return;
|
||||||
|
handlers.onError?.('stream error');
|
||||||
|
es.close();
|
||||||
|
});
|
||||||
|
return () => es.close();
|
||||||
|
}
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,420 +1,2 @@
|
||||||
import { useEffect, useState } from 'react';
|
export { default } from './LLMProvidersPage/LLMProvidersPage';
|
||||||
import {
|
|
||||||
ApiOutlined,
|
|
||||||
CheckCircleOutlined,
|
|
||||||
LockOutlined,
|
|
||||||
PlusOutlined,
|
|
||||||
RocketOutlined,
|
|
||||||
StarFilled
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Modal,
|
|
||||||
Form,
|
|
||||||
Input,
|
|
||||||
Select,
|
|
||||||
Space,
|
|
||||||
Tag,
|
|
||||||
App as AntApp,
|
|
||||||
Empty,
|
|
||||||
Popconfirm,
|
|
||||||
Tooltip
|
|
||||||
} from 'antd';
|
|
||||||
import { LLMKind, LLMProvider, LLMProviderAPI } from '../api';
|
|
||||||
|
|
||||||
const KIND_OPTIONS: { value: LLMKind; label: string; baseUrl: string; hint?: string }[] = [
|
|
||||||
{ value: 'openai', label: 'OpenAI 官方', baseUrl: 'https://api.openai.com/v1' },
|
|
||||||
{
|
|
||||||
value: 'openai-compatible',
|
|
||||||
label: 'OpenAI 兼容(GLM/通义/DeepSeek/腾讯 Token Plan)',
|
|
||||||
baseUrl: '',
|
|
||||||
hint: '智谱:https://open.bigmodel.cn/api/paas/v4 · 通义:https://dashscope.aliyuncs.com/compatible-mode/v1 · DeepSeek:https://api.deepseek.com'
|
|
||||||
},
|
|
||||||
{ value: 'anthropic', label: 'Anthropic Claude', baseUrl: 'https://api.anthropic.com' },
|
|
||||||
{ value: 'ollama', label: 'Ollama 本地', baseUrl: 'http://localhost:11434' }
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function LLMProvidersPage() {
|
|
||||||
const { message } = AntApp.useApp();
|
|
||||||
const [list, setList] = useState<LLMProvider[]>([]);
|
|
||||||
const [editorOpen, setEditorOpen] = useState(false);
|
|
||||||
const [editing, setEditing] = useState<LLMProvider | null>(null);
|
|
||||||
const [form] = Form.useForm();
|
|
||||||
const [testing, setTesting] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const load = async () => {
|
|
||||||
try {
|
|
||||||
setList(await LLMProviderAPI.list());
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error('加载失败:' + (e?.message ?? e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
useEffect(() => {
|
|
||||||
load();
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const openCreate = () => {
|
|
||||||
setEditing(null);
|
|
||||||
form.resetFields();
|
|
||||||
form.setFieldsValue({ kind: 'openai-compatible', enabled: true, isDefault: false });
|
|
||||||
setEditorOpen(true);
|
|
||||||
};
|
|
||||||
const openEdit = (p: LLMProvider) => {
|
|
||||||
setEditing(p);
|
|
||||||
form.setFieldsValue({
|
|
||||||
name: p.name,
|
|
||||||
kind: p.kind,
|
|
||||||
baseUrl: p.baseUrl,
|
|
||||||
models: p.models?.join(', ') || '',
|
|
||||||
defaultModel: p.defaultModel,
|
|
||||||
enabled: p.enabled,
|
|
||||||
isDefault: p.isDefault
|
|
||||||
});
|
|
||||||
setEditorOpen(true);
|
|
||||||
};
|
|
||||||
const onSave = async () => {
|
|
||||||
const v = await form.validateFields();
|
|
||||||
const payload = {
|
|
||||||
...v,
|
|
||||||
models: typeof v.models === 'string'
|
|
||||||
? v.models.split(/[,,]/).map((s: string) => s.trim()).filter(Boolean)
|
|
||||||
: v.models
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
if (editing) {
|
|
||||||
await LLMProviderAPI.update(editing.id, payload);
|
|
||||||
message.success('已更新');
|
|
||||||
} else {
|
|
||||||
await LLMProviderAPI.create(payload);
|
|
||||||
message.success('已创建');
|
|
||||||
}
|
|
||||||
setEditorOpen(false);
|
|
||||||
load();
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error('保存失败:' + (e?.response?.data?.error ?? e?.message ?? e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const onDelete = async (p: LLMProvider) => {
|
|
||||||
await LLMProviderAPI.remove(p.id);
|
|
||||||
message.success('已删除');
|
|
||||||
load();
|
|
||||||
};
|
|
||||||
const onTest = async (p: LLMProvider) => {
|
|
||||||
setTesting(p.id);
|
|
||||||
try {
|
|
||||||
const r = await LLMProviderAPI.test(p.id, p.defaultModel);
|
|
||||||
if (r.ok) {
|
|
||||||
message.success(`✅ 连通:${r.model} · 用量 ${(r as any).usage?.TotalTokens ?? '?'} tokens`);
|
|
||||||
} else {
|
|
||||||
message.error('❌ 失败:' + (r.error || 'unknown'));
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error('测试失败:' + (e?.response?.data?.error ?? e?.message ?? e));
|
|
||||||
} finally {
|
|
||||||
setTesting(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const onSetDefault = async (p: LLMProvider) => {
|
|
||||||
await LLMProviderAPI.setDefault(p.id);
|
|
||||||
message.success('已设为默认');
|
|
||||||
load();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<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
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ApiOutlined style={{ color: 'var(--color-brand)' }} />
|
|
||||||
模型接入中心
|
|
||||||
</div>
|
|
||||||
<h1 className="page-title" style={{ marginBottom: 10 }}>LLM 提供商</h1>
|
|
||||||
<div className="page-subtitle" style={{ marginTop: 0, fontSize: 15, lineHeight: 1.75 }}>
|
|
||||||
把不同的模型能力接入到同一个工作台里。这里不只是存放接口配置,更是管理默认模型、候选模型与稳定连接状态的地方。
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
size="large"
|
|
||||||
icon={<PlusOutlined />}
|
|
||||||
onClick={() => {
|
|
||||||
setEditing(null);
|
|
||||||
setEditorOpen(true);
|
|
||||||
form.resetFields();
|
|
||||||
form.setFieldsValue({ kind: 'openai', enabled: 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: list.filter((item) => item.enabled).length, tone: 'rgba(34, 197, 94, 0.10)', color: 'var(--color-success)' },
|
|
||||||
{ label: '默认模型源', value: list.filter((item) => item.isDefault).length, tone: 'rgba(249, 115, 22, 0.10)', color: 'var(--color-warning)' }
|
|
||||||
].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>
|
|
||||||
|
|
||||||
{list.length === 0 ? (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
borderRadius: 22,
|
|
||||||
background: 'var(--color-surface)',
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
padding: '54px 24px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Empty description="暂无 LLM 提供商,点击右上角添加第一个模型源" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(360px, 1fr))', gap: 18 }}>
|
|
||||||
{list.map((p) => (
|
|
||||||
<div
|
|
||||||
key={p.id}
|
|
||||||
style={{
|
|
||||||
borderRadius: 20,
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
background: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(252,252,253,1) 100%)',
|
|
||||||
boxShadow: '0 12px 28px rgba(15, 23, 42, 0.045)',
|
|
||||||
padding: 20,
|
|
||||||
minHeight: 310,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginBottom: 16 }}>
|
|
||||||
<div style={{ minWidth: 0 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', marginBottom: 6 }}>
|
|
||||||
<span style={{ fontWeight: 700, fontSize: 18, color: 'var(--color-text)' }}>{p.name}</span>
|
|
||||||
{p.isDefault && (
|
|
||||||
<Tag bordered={false} icon={<StarFilled />} style={{ background: 'var(--color-success-soft)', color: 'var(--color-success)', borderRadius: 999, margin: 0 }}>
|
|
||||||
默认
|
|
||||||
</Tag>
|
|
||||||
)}
|
|
||||||
{!p.enabled && (
|
|
||||||
<Tag bordered={false} style={{ background: 'var(--color-surface-2)', color: 'var(--color-text-secondary)', borderRadius: 999, margin: 0 }}>
|
|
||||||
已禁用
|
|
||||||
</Tag>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 12.5, color: 'var(--color-text-tertiary)' }}>{p.baseUrl}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Space size={4}>
|
|
||||||
<Tooltip title="测试连通性(会发一条 ping 消息)">
|
|
||||||
<Button size="small" loading={testing === p.id} onClick={() => onTest(p)} style={{ borderRadius: 10 }}>
|
|
||||||
测试
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
{!p.isDefault && (
|
|
||||||
<Tooltip title="设为我的默认 provider">
|
|
||||||
<Button size="small" onClick={() => onSetDefault(p)} style={{ borderRadius: 10 }}>
|
|
||||||
设为默认
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 12, marginBottom: 16 }}>
|
|
||||||
<div style={{ borderRadius: 16, padding: '14px 14px 12px', 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>
|
|
||||||
<Tag bordered={false} style={{ background: 'var(--color-brand-soft)', color: 'var(--color-brand)', borderRadius: 999, margin: 0 }}>
|
|
||||||
{p.kind}
|
|
||||||
</Tag>
|
|
||||||
</div>
|
|
||||||
<div style={{ borderRadius: 16, padding: '14px 14px 12px', 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: 14, fontWeight: 600, color: 'var(--color-text)' }}>{p.defaultModel || '未设置'}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
borderRadius: 16,
|
|
||||||
padding: '16px 16px 14px',
|
|
||||||
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)',
|
|
||||||
marginBottom: 16
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5, color: 'var(--color-text-secondary)', marginBottom: 10 }}>
|
|
||||||
<LockOutlined />
|
|
||||||
密钥状态
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 13.5, color: 'var(--color-text)' }}>
|
|
||||||
{p.hasApiKey ? (
|
|
||||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
|
||||||
<CheckCircleOutlined style={{ color: 'var(--color-success)' }} />
|
|
||||||
已配置 {p.apiKeyMasked}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span style={{ color: 'var(--color-danger)' }}>尚未配置 API Key</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 12.5, color: 'var(--color-text-tertiary)', marginTop: 8, wordBreak: 'break-all' }}>{p.baseUrl}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{p.models?.length > 0 && (
|
|
||||||
<div style={{ marginBottom: 16 }}>
|
|
||||||
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', marginBottom: 10 }}>候选模型</div>
|
|
||||||
<Space size={6} wrap>
|
|
||||||
{p.models.map((m) => (
|
|
||||||
<Tag key={m} bordered={false} style={{ margin: 0, background: 'var(--color-surface-2)', color: 'var(--color-text-secondary)', borderRadius: 999 }}>
|
|
||||||
{m}
|
|
||||||
</Tag>
|
|
||||||
))}
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 'auto', paddingTop: 16, borderTop: '1px solid var(--color-border)' }}>
|
|
||||||
<RocketOutlined style={{ color: 'var(--color-brand)' }} />
|
|
||||||
<span style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', flex: 1 }}>
|
|
||||||
{p.enabled ? '可用于聊天、工作流和测试连接' : '当前已暂停使用,需要重新启用'}
|
|
||||||
</span>
|
|
||||||
<Space size={4}>
|
|
||||||
<Button size="small" type="text" onClick={() => openEdit(p)} style={{ borderRadius: 10 }}>
|
|
||||||
编辑
|
|
||||||
</Button>
|
|
||||||
<Popconfirm title={`删除 ${p.name}?`} onConfirm={() => onDelete(p)}>
|
|
||||||
<Button size="small" type="text" danger style={{ borderRadius: 10 }}>
|
|
||||||
删除
|
|
||||||
</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
open={editorOpen}
|
|
||||||
title={editing ? '编辑 Provider' : '添加 Provider'}
|
|
||||||
onCancel={() => setEditorOpen(false)}
|
|
||||||
onOk={onSave}
|
|
||||||
width={600}
|
|
||||||
okText="保存"
|
|
||||||
cancelText="取消"
|
|
||||||
destroyOnHidden
|
|
||||||
>
|
|
||||||
<Form
|
|
||||||
form={form}
|
|
||||||
layout="vertical"
|
|
||||||
onValuesChange={(changed) => {
|
|
||||||
if (changed.kind) {
|
|
||||||
const opt = KIND_OPTIONS.find((o) => o.value === changed.kind);
|
|
||||||
if (opt?.baseUrl && !form.getFieldValue('baseUrl')) {
|
|
||||||
form.setFieldsValue({ baseUrl: opt.baseUrl });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
|
||||||
<Input placeholder="例如:我的 OpenAI" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="kind" label="类型" rules={[{ required: true }]}>
|
|
||||||
<Select>
|
|
||||||
{KIND_OPTIONS.map((k) => (
|
|
||||||
<Select.Option key={k.value} value={k.value}>
|
|
||||||
{k.label}
|
|
||||||
</Select.Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
name="baseUrl"
|
|
||||||
label="Base URL"
|
|
||||||
rules={[{ required: true }]}
|
|
||||||
tooltip={KIND_OPTIONS.find((k) => k.value === form.getFieldValue('kind'))?.hint}
|
|
||||||
>
|
|
||||||
<Input placeholder="https://api.openai.com/v1" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
name="apiKey"
|
|
||||||
label={editing ? 'API Key(留空保留原值)' : 'API Key'}
|
|
||||||
rules={editing ? [] : [{ required: true }]}
|
|
||||||
>
|
|
||||||
<Input.Password placeholder="sk-..." autoComplete="new-password" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
name="models"
|
|
||||||
label="可用模型(逗号分隔)"
|
|
||||||
tooltip="用于在 agent 编辑器中下拉选择"
|
|
||||||
>
|
|
||||||
<Input placeholder="gpt-4o, gpt-4o-mini" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="defaultModel" label="默认模型">
|
|
||||||
<Input placeholder="例如 gpt-4o-mini" />
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { App as AntApp, Form } from 'antd';
|
||||||
|
import type { LLMProvider } from '../../api';
|
||||||
|
import { LLMProviderAPI } from '../../api';
|
||||||
|
import ProvidersHero from './components/ProvidersHero';
|
||||||
|
import ProviderGrid from './components/ProviderGrid';
|
||||||
|
import ProviderEditorModal from './components/ProviderEditorModal';
|
||||||
|
|
||||||
|
export default function LLMProvidersPage() {
|
||||||
|
const { message } = AntApp.useApp();
|
||||||
|
const [list, setList] = useState<LLMProvider[]>([]);
|
||||||
|
const [editorOpen, setEditorOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<LLMProvider | null>(null);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [testing, setTesting] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
setList(await LLMProviderAPI.list());
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error('加载失败:' + (e?.message ?? e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditing(null);
|
||||||
|
form.resetFields();
|
||||||
|
form.setFieldsValue({ kind: 'openai', enabled: true });
|
||||||
|
setEditorOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (p: LLMProvider) => {
|
||||||
|
setEditing(p);
|
||||||
|
form.setFieldsValue({
|
||||||
|
name: p.name,
|
||||||
|
kind: p.kind,
|
||||||
|
baseUrl: p.baseUrl,
|
||||||
|
models: p.models?.join(', ') || '',
|
||||||
|
defaultModel: p.defaultModel,
|
||||||
|
enabled: p.enabled,
|
||||||
|
isDefault: p.isDefault
|
||||||
|
});
|
||||||
|
setEditorOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSave = async () => {
|
||||||
|
const v = await form.validateFields();
|
||||||
|
const payload = {
|
||||||
|
...v,
|
||||||
|
models: typeof v.models === 'string' ? v.models.split(/[,,]/).map((s: string) => s.trim()).filter(Boolean) : v.models
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (editing) {
|
||||||
|
await LLMProviderAPI.update(editing.id, payload);
|
||||||
|
message.success('已更新');
|
||||||
|
} else {
|
||||||
|
await LLMProviderAPI.create(payload);
|
||||||
|
message.success('已创建');
|
||||||
|
}
|
||||||
|
setEditorOpen(false);
|
||||||
|
load();
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error('保存失败:' + (e?.response?.data?.error ?? e?.message ?? e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDelete = async (p: LLMProvider) => {
|
||||||
|
await LLMProviderAPI.remove(p.id);
|
||||||
|
message.success('已删除');
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTest = async (p: LLMProvider) => {
|
||||||
|
setTesting(p.id);
|
||||||
|
try {
|
||||||
|
const r = await LLMProviderAPI.test(p.id, p.defaultModel);
|
||||||
|
if (r.ok) {
|
||||||
|
message.success(`✅ 连通:${r.model} · 用量 ${(r as any).usage?.TotalTokens ?? '?'} tokens`);
|
||||||
|
} else {
|
||||||
|
message.error('❌ 失败:' + (r.error || 'unknown'));
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error('测试失败:' + (e?.response?.data?.error ?? e?.message ?? e));
|
||||||
|
} finally {
|
||||||
|
setTesting(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSetDefault = async (p: LLMProvider) => {
|
||||||
|
await LLMProviderAPI.setDefault(p.id);
|
||||||
|
message.success('已设为默认');
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-container" style={{ maxWidth: 1080 }}>
|
||||||
|
<ProvidersHero list={list} onAdd={openCreate} />
|
||||||
|
<ProviderGrid list={list} testingId={testing} onTest={onTest} onSetDefault={onSetDefault} onEdit={openEdit} onDelete={onDelete} />
|
||||||
|
<ProviderEditorModal open={editorOpen} editing={editing} form={form} onCancel={() => setEditorOpen(false)} onOk={onSave} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,158 @@
|
||||||
|
import { CheckCircleOutlined, LockOutlined, RocketOutlined, StarFilled } from '@ant-design/icons';
|
||||||
|
import { Button, Popconfirm, Space, Tag, Tooltip } from 'antd';
|
||||||
|
import type { LLMProvider } from '../../../api';
|
||||||
|
|
||||||
|
export default function ProviderCard(props: {
|
||||||
|
provider: LLMProvider;
|
||||||
|
testing: boolean;
|
||||||
|
onTest: () => void;
|
||||||
|
onSetDefault: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}) {
|
||||||
|
const { provider: p, testing, onTest, onSetDefault, onEdit, onDelete } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 20,
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
background: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(252,252,253,1) 100%)',
|
||||||
|
boxShadow: '0 12px 28px rgba(15, 23, 42, 0.045)',
|
||||||
|
padding: 20,
|
||||||
|
minHeight: 310,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginBottom: 16 }}>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', marginBottom: 6 }}>
|
||||||
|
<span style={{ fontWeight: 700, fontSize: 18, color: 'var(--color-text)' }}>{p.name}</span>
|
||||||
|
{p.isDefault && (
|
||||||
|
<Tag
|
||||||
|
bordered={false}
|
||||||
|
icon={<StarFilled />}
|
||||||
|
style={{ background: 'var(--color-success-soft)', color: 'var(--color-success)', borderRadius: 999, margin: 0 }}
|
||||||
|
>
|
||||||
|
默认
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
{!p.enabled && (
|
||||||
|
<Tag bordered={false} style={{ background: 'var(--color-surface-2)', color: 'var(--color-text-secondary)', borderRadius: 999, margin: 0 }}>
|
||||||
|
已禁用
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12.5, color: 'var(--color-text-tertiary)' }}>{p.baseUrl}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Space size={4}>
|
||||||
|
<Tooltip title="测试连通性(会发一条 ping 消息)">
|
||||||
|
<Button size="small" loading={testing} onClick={onTest} style={{ borderRadius: 10 }}>
|
||||||
|
测试
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
{!p.isDefault && (
|
||||||
|
<Tooltip title="设为我的默认 provider">
|
||||||
|
<Button size="small" onClick={onSetDefault} style={{ borderRadius: 10 }}>
|
||||||
|
设为默认
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 12, marginBottom: 16 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: '14px 14px 12px',
|
||||||
|
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>
|
||||||
|
<Tag bordered={false} style={{ background: 'var(--color-brand-soft)', color: 'var(--color-brand)', borderRadius: 999, margin: 0 }}>
|
||||||
|
{p.kind}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: '14px 14px 12px',
|
||||||
|
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: 14, fontWeight: 600, color: 'var(--color-text)' }}>{p.defaultModel || '未设置'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: '16px 16px 14px',
|
||||||
|
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)',
|
||||||
|
marginBottom: 16
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5, color: 'var(--color-text-secondary)', marginBottom: 10 }}>
|
||||||
|
<LockOutlined />
|
||||||
|
密钥状态
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13.5, color: 'var(--color-text)' }}>
|
||||||
|
{p.hasApiKey ? (
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<CheckCircleOutlined style={{ color: 'var(--color-success)' }} />
|
||||||
|
已配置 {p.apiKeyMasked}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: 'var(--color-danger)' }}>尚未配置 API Key</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12.5, color: 'var(--color-text-tertiary)', marginTop: 8, wordBreak: 'break-all' }}>{p.baseUrl}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{p.models?.length > 0 && (
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', marginBottom: 10 }}>候选模型</div>
|
||||||
|
<Space size={6} wrap>
|
||||||
|
{p.models.map((m) => (
|
||||||
|
<Tag key={m} bordered={false} style={{ margin: 0, background: 'var(--color-surface-2)', color: 'var(--color-text-secondary)', borderRadius: 999 }}>
|
||||||
|
{m}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
marginTop: 'auto',
|
||||||
|
paddingTop: 16,
|
||||||
|
borderTop: '1px solid var(--color-border)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RocketOutlined style={{ color: 'var(--color-brand)' }} />
|
||||||
|
<span style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', flex: 1 }}>{p.enabled ? '可用于聊天、工作流和测试连接' : '当前已暂停使用,需要重新启用'}</span>
|
||||||
|
<Space size={4}>
|
||||||
|
<Button size="small" type="text" onClick={onEdit} style={{ borderRadius: 10 }}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Popconfirm title={`删除 ${p.name}?`} onConfirm={onDelete}>
|
||||||
|
<Button size="small" type="text" danger style={{ borderRadius: 10 }}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { Form, Input, Modal, Select } from 'antd';
|
||||||
|
import type { FormInstance } from 'antd';
|
||||||
|
import type { LLMProvider } from '../../../api';
|
||||||
|
import { KIND_OPTIONS } from '../constants';
|
||||||
|
|
||||||
|
export default function ProviderEditorModal(props: {
|
||||||
|
open: boolean;
|
||||||
|
editing: LLMProvider | null;
|
||||||
|
form: FormInstance;
|
||||||
|
onCancel: () => void;
|
||||||
|
onOk: () => void;
|
||||||
|
}) {
|
||||||
|
const { open, editing, form, onCancel, onOk } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title={editing ? '编辑 Provider' : '添加 Provider'}
|
||||||
|
onCancel={onCancel}
|
||||||
|
onOk={onOk}
|
||||||
|
width={600}
|
||||||
|
okText="保存"
|
||||||
|
cancelText="取消"
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
onValuesChange={(changed) => {
|
||||||
|
if (changed.kind) {
|
||||||
|
const opt = KIND_OPTIONS.find((o) => o.value === changed.kind);
|
||||||
|
if (opt?.baseUrl && !form.getFieldValue('baseUrl')) {
|
||||||
|
form.setFieldsValue({ baseUrl: opt.baseUrl });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="例如:我的 OpenAI" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="kind" label="类型" rules={[{ required: true }]}>
|
||||||
|
<Select>
|
||||||
|
{KIND_OPTIONS.map((k) => (
|
||||||
|
<Select.Option key={k.value} value={k.value}>
|
||||||
|
{k.label}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="baseUrl"
|
||||||
|
label="Base URL"
|
||||||
|
rules={[{ required: true }]}
|
||||||
|
tooltip={KIND_OPTIONS.find((k) => k.value === form.getFieldValue('kind'))?.hint}
|
||||||
|
>
|
||||||
|
<Input placeholder="https://api.openai.com/v1" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="apiKey" label={editing ? 'API Key(留空保留原值)' : 'API Key'} rules={editing ? [] : [{ required: true }]}>
|
||||||
|
<Input.Password placeholder="sk-..." autoComplete="new-password" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="models" label="可用模型(逗号分隔)" tooltip="用于在 agent 编辑器中下拉选择">
|
||||||
|
<Input placeholder="gpt-4o, gpt-4o-mini" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="defaultModel" label="默认模型">
|
||||||
|
<Input placeholder="例如 gpt-4o-mini" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { Empty } from 'antd';
|
||||||
|
import type { LLMProvider } from '../../../api';
|
||||||
|
import ProviderCard from './ProviderCard';
|
||||||
|
|
||||||
|
export default function ProviderGrid(props: {
|
||||||
|
list: LLMProvider[];
|
||||||
|
testingId: string | null;
|
||||||
|
onTest: (p: LLMProvider) => void;
|
||||||
|
onSetDefault: (p: LLMProvider) => void;
|
||||||
|
onEdit: (p: LLMProvider) => void;
|
||||||
|
onDelete: (p: LLMProvider) => void;
|
||||||
|
}) {
|
||||||
|
const { list, testingId, onTest, onSetDefault, onEdit, onDelete } = props;
|
||||||
|
|
||||||
|
if (list.length === 0) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 22,
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
padding: '54px 24px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Empty description="暂无 LLM 提供商,点击右上角添加第一个模型源" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(360px, 1fr))', gap: 18 }}>
|
||||||
|
{list.map((p) => (
|
||||||
|
<ProviderCard
|
||||||
|
key={p.id}
|
||||||
|
provider={p}
|
||||||
|
testing={testingId === p.id}
|
||||||
|
onTest={() => onTest(p)}
|
||||||
|
onSetDefault={() => onSetDefault(p)}
|
||||||
|
onEdit={() => onEdit(p)}
|
||||||
|
onDelete={() => onDelete(p)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
import { ApiOutlined, PlusOutlined } from '@ant-design/icons';
|
||||||
|
import { Button } from 'antd';
|
||||||
|
import type { LLMProvider } from '../../../api';
|
||||||
|
|
||||||
|
export default function ProvidersHero(props: { list: LLMProvider[]; onAdd: () => void }) {
|
||||||
|
const { list, onAdd } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ApiOutlined style={{ color: 'var(--color-brand)' }} />
|
||||||
|
模型接入中心
|
||||||
|
</div>
|
||||||
|
<h1 className="page-title" style={{ marginBottom: 10 }}>
|
||||||
|
LLM 提供商
|
||||||
|
</h1>
|
||||||
|
<div className="page-subtitle" style={{ marginTop: 0, fontSize: 15, lineHeight: 1.75 }}>
|
||||||
|
把不同的模型能力接入到同一个工作台里。这里不只是存放接口配置,更是管理默认模型、候选模型与稳定连接状态的地方。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={onAdd}
|
||||||
|
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: list.filter((item) => item.enabled).length, tone: 'rgba(34, 197, 94, 0.10)', color: 'var(--color-success)' },
|
||||||
|
{ label: '默认模型源', value: list.filter((item) => item.isDefault).length, tone: 'rgba(249, 115, 22, 0.10)', color: 'var(--color-warning)' }
|
||||||
|
].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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import type { LLMKind } from '../../api';
|
||||||
|
|
||||||
|
export const KIND_OPTIONS: { value: LLMKind; label: string; baseUrl: string; hint?: string }[] = [
|
||||||
|
{ value: 'openai', label: 'OpenAI 官方', baseUrl: 'https://api.openai.com/v1' },
|
||||||
|
{
|
||||||
|
value: 'openai-compatible',
|
||||||
|
label: 'OpenAI 兼容(GLM/通义/DeepSeek/腾讯 Token Plan)',
|
||||||
|
baseUrl: '',
|
||||||
|
hint: '智谱:https://open.bigmodel.cn/api/paas/v4 · 通义:https://dashscope.aliyuncs.com/compatible-mode/v1 · DeepSeek:https://api.deepseek.com'
|
||||||
|
},
|
||||||
|
{ value: 'anthropic', label: 'Anthropic Claude', baseUrl: 'https://api.anthropic.com' },
|
||||||
|
{ value: 'ollama', label: 'Ollama 本地', baseUrl: 'http://localhost:11434' }
|
||||||
|
];
|
||||||
|
|
||||||
|
|
@ -1,495 +1,2 @@
|
||||||
import { useEffect, useState } from 'react';
|
export { default } from './PointsMallPage/PointsMallPage';
|
||||||
import { Button, Card, Empty, Input, Select, Space, Spin, Tag, Modal, Form, message } from 'antd';
|
|
||||||
import { SearchOutlined } from '@ant-design/icons';
|
|
||||||
import { PointsMallAPI, PointsMallCategory, PointsMallOverview, PointsMallProduct, PointsMallProductsResponse } from '../api';
|
|
||||||
|
|
||||||
type SortKey = 'popular' | 'price_asc' | 'price_desc' | 'newest';
|
|
||||||
|
|
||||||
const MOCK_OVERVIEW: PointsMallOverview = {
|
|
||||||
me: { points: 1280, level: 'Lv.2' },
|
|
||||||
categories: [
|
|
||||||
{ id: 'all', name: '全部', sort: 0 },
|
|
||||||
{ id: 'digital', name: '虚拟权益', sort: 1 },
|
|
||||||
{ id: 'tool', name: '工具周边', sort: 2 },
|
|
||||||
{ id: 'gift', name: '礼品卡券', sort: 3 },
|
|
||||||
{ id: 'limited', name: '限时活动', sort: 4 }
|
|
||||||
],
|
|
||||||
announcements: [],
|
|
||||||
banners: [
|
|
||||||
{ id: 'b1', title: '本期活动', subtitle: 'Up to 25% Off', imageUrl: '', linkUrl: '' }
|
|
||||||
],
|
|
||||||
promoEntries: [
|
|
||||||
{ id: 'p1', title: '促销活动', subtitle: '本周精选', linkUrl: '' },
|
|
||||||
{ id: 'p2', title: '积分任务', subtitle: '快速涨积分', linkUrl: '' }
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
const MOCK_PRODUCTS: PointsMallProduct[] = Array.from({ length: 12 }).map((_, i) => ({
|
|
||||||
id: String(i + 1),
|
|
||||||
categoryId: i % 2 === 0 ? 'digital' : 'tool',
|
|
||||||
name: `商品 ${i + 1}`,
|
|
||||||
subtitle: '这里是商品简短描述',
|
|
||||||
coverUrl: '',
|
|
||||||
pointsPrice: 199 + i * 10,
|
|
||||||
stock: 99,
|
|
||||||
sold: 12 + i,
|
|
||||||
tags: i % 3 === 0 ? ['限时'] : i % 3 === 1 ? ['热卖'] : []
|
|
||||||
}));
|
|
||||||
|
|
||||||
interface ExchangeFormValues {
|
|
||||||
recipientName: string;
|
|
||||||
phone: string;
|
|
||||||
province: string;
|
|
||||||
city: string;
|
|
||||||
district: string;
|
|
||||||
address: string;
|
|
||||||
zipCode?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PointsMallPage() {
|
|
||||||
const [overviewLoading, setOverviewLoading] = useState(false);
|
|
||||||
const [productsLoading, setProductsLoading] = useState(false);
|
|
||||||
const [overview, setOverview] = useState<PointsMallOverview | null>(null);
|
|
||||||
const [categories, setCategories] = useState<PointsMallCategory[]>([]);
|
|
||||||
|
|
||||||
const [categoryId, setCategoryId] = useState<string>('all');
|
|
||||||
const [q, setQ] = useState('');
|
|
||||||
const [sort, setSort] = useState<SortKey>('popular');
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [pageSize, setPageSize] = useState(24);
|
|
||||||
const [productsRes, setProductsRes] = useState<PointsMallProductsResponse | null>(null);
|
|
||||||
|
|
||||||
const [exchangeModalVisible, setExchangeModalVisible] = useState(false);
|
|
||||||
const [selectedProduct, setSelectedProduct] = useState<PointsMallProduct | null>(null);
|
|
||||||
const [pendingOrderId, setPendingOrderId] = useState<string | null>(null);
|
|
||||||
const [exchangeLoading, setExchangeLoading] = useState(false);
|
|
||||||
const [form] = Form.useForm<ExchangeFormValues>();
|
|
||||||
|
|
||||||
const loadOverview = async () => {
|
|
||||||
setOverviewLoading(true);
|
|
||||||
try {
|
|
||||||
const data = await PointsMallAPI.overview();
|
|
||||||
setOverview(data);
|
|
||||||
setCategories(data.categories || []);
|
|
||||||
if (!data.categories?.some((c) => c.id === categoryId) && data.categories?.[0]?.id) {
|
|
||||||
setCategoryId(data.categories[0].id);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
message.error('获取积分信息失败,请稍后重试');
|
|
||||||
setOverview({
|
|
||||||
...MOCK_OVERVIEW,
|
|
||||||
me: { points: 0, level: 'Lv.0' }
|
|
||||||
});
|
|
||||||
setCategories(MOCK_OVERVIEW.categories);
|
|
||||||
} finally {
|
|
||||||
setOverviewLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadProducts = async () => {
|
|
||||||
setProductsLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await PointsMallAPI.products({
|
|
||||||
categoryId: categoryId === 'all' ? undefined : categoryId,
|
|
||||||
q,
|
|
||||||
sort,
|
|
||||||
page,
|
|
||||||
pageSize
|
|
||||||
});
|
|
||||||
setProductsRes(res);
|
|
||||||
} catch {
|
|
||||||
const filtered = MOCK_PRODUCTS.filter((p) => (categoryId === 'all' ? true : p.categoryId === categoryId))
|
|
||||||
.filter((p) => (q ? (p.name + p.subtitle).toLowerCase().includes(q.toLowerCase()) : true));
|
|
||||||
setProductsRes({
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
total: filtered.length,
|
|
||||||
items: filtered.slice((page - 1) * pageSize, page * pageSize)
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setProductsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadOverview();
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadProducts();
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [categoryId, q, sort, page, pageSize]);
|
|
||||||
|
|
||||||
const banner = overview?.banners?.[0];
|
|
||||||
const promoEntries = overview?.promoEntries || [];
|
|
||||||
|
|
||||||
const products = productsRes?.items || [];
|
|
||||||
const total = productsRes?.total || 0;
|
|
||||||
|
|
||||||
const handleExchangeClick = async (product: PointsMallProduct) => {
|
|
||||||
if (userPoints < product.pointsPrice) return;
|
|
||||||
|
|
||||||
setExchangeLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await PointsMallAPI.exchangePrepare(product.id);
|
|
||||||
setSelectedProduct(product);
|
|
||||||
setPendingOrderId(res.orderId);
|
|
||||||
setExchangeModalVisible(true);
|
|
||||||
form.resetFields();
|
|
||||||
setOverview((prev) => {
|
|
||||||
if (!prev) return prev;
|
|
||||||
return { ...prev, me: { ...prev.me, points: res.remainingPoints } };
|
|
||||||
});
|
|
||||||
message.success('积分扣减成功,请填写收件信息完成兑换');
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error(e?.message || '兑换失败,请稍后重试');
|
|
||||||
} finally {
|
|
||||||
setExchangeLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleExchangeSubmit = async () => {
|
|
||||||
if (!selectedProduct || !pendingOrderId) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await form.validateFields();
|
|
||||||
setExchangeLoading(true);
|
|
||||||
|
|
||||||
await PointsMallAPI.exchangeSubmitShipping(pendingOrderId, form.getFieldsValue());
|
|
||||||
|
|
||||||
message.success('兑换成功!我们将尽快为您安排发货');
|
|
||||||
setExchangeModalVisible(false);
|
|
||||||
setPendingOrderId(null);
|
|
||||||
|
|
||||||
// 刷新积分余额
|
|
||||||
loadOverview();
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error?.errorFields) return;
|
|
||||||
message.error(error?.message || '提交失败,请稍后重试');
|
|
||||||
} finally {
|
|
||||||
setExchangeLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const userPoints = overview?.me?.points || 0;
|
|
||||||
const canAfford = selectedProduct ? userPoints >= selectedProduct.pointsPrice : false;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="page-container" style={{ maxWidth: 1400 }}>
|
|
||||||
<div className="points-mall-hero">
|
|
||||||
<div className="points-mall-header">
|
|
||||||
<div className="points-mall-title-section">
|
|
||||||
<h1 className="page-title stats-page-title">积分商城</h1>
|
|
||||||
<p className="page-subtitle stats-page-subtitle">
|
|
||||||
使用积分兑换权益、工具和活动礼包。积分通过 API 调用消费自动累积,1 美元 = 1000 积分。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="points-mall-balance-card">
|
|
||||||
{overviewLoading ? (
|
|
||||||
<Spin />
|
|
||||||
) : (
|
|
||||||
<div className="points-balance-row">
|
|
||||||
<div>
|
|
||||||
<div className="points-balance-label">我的积分</div>
|
|
||||||
<div className="points-balance-value">{userPoints.toLocaleString()}</div>
|
|
||||||
</div>
|
|
||||||
<Tag bordered={false} style={{ margin: 0, borderRadius: 999, background: 'var(--color-brand-soft)', color: 'var(--color-brand)' }}>
|
|
||||||
{String(overview?.me?.level || 'Lv.0')}
|
|
||||||
</Tag>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="points-mall-category-card" bodyStyle={{ padding: 0 }}>
|
|
||||||
<div className="points-mall-category-body">
|
|
||||||
<div className="points-mall-category-row">
|
|
||||||
<span className="points-mall-category-label">商品分类</span>
|
|
||||||
{categories.map((c) => (
|
|
||||||
<Button
|
|
||||||
key={c.id}
|
|
||||||
size="small"
|
|
||||||
type={c.id === categoryId ? 'primary' : 'default'}
|
|
||||||
style={{ borderRadius: 999 }}
|
|
||||||
onClick={() => {
|
|
||||||
setPage(1);
|
|
||||||
setCategoryId(c.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{c.name}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="points-mall-banner-section">
|
|
||||||
<div className="points-mall-banner-card">
|
|
||||||
<div className="points-mall-banner-header">
|
|
||||||
<div>
|
|
||||||
<div className="points-mall-banner-title">{banner?.title || '本期活动'}</div>
|
|
||||||
<div className="points-mall-banner-subtitle">{banner?.subtitle || 'Up to 25% Off'}</div>
|
|
||||||
</div>
|
|
||||||
<Button type="primary" style={{ borderRadius: 12, height: 40, fontWeight: 600 }}>
|
|
||||||
查看活动
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="points-mall-banner-footer">banner 图片与跳转链接由后端配置</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="points-mall-promo-grid">
|
|
||||||
{promoEntries.slice(0, 2).map((p) => (
|
|
||||||
<div key={p.id} className="points-mall-promo-card">
|
|
||||||
<div style={{ minWidth: 0 }}>
|
|
||||||
<div className="points-mall-promo-title">{p.title}</div>
|
|
||||||
<div className="points-mall-promo-subtitle">{p.subtitle}</div>
|
|
||||||
</div>
|
|
||||||
<Button size="small" className="points-mall-exchange-btn">进入</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{promoEntries.length < 2 && (
|
|
||||||
<div className="points-mall-promo-empty">促销入口由后端配置</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="stats-page-chart-card">
|
|
||||||
<div className="points-mall-filters-row">
|
|
||||||
<Space size={10} wrap className="points-mall-filters-left">
|
|
||||||
<Input
|
|
||||||
value={q}
|
|
||||||
onChange={(e) => {
|
|
||||||
setPage(1);
|
|
||||||
setQ(e.target.value);
|
|
||||||
}}
|
|
||||||
prefix={<SearchOutlined />}
|
|
||||||
placeholder="搜索商品"
|
|
||||||
allowClear
|
|
||||||
className="points-mall-search-input"
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
value={sort}
|
|
||||||
className="points-mall-filter-select"
|
|
||||||
onChange={(v: SortKey) => {
|
|
||||||
setPage(1);
|
|
||||||
setSort(v);
|
|
||||||
}}
|
|
||||||
options={[
|
|
||||||
{ value: 'popular', label: '热度优先' },
|
|
||||||
{ value: 'newest', label: '最新上架' },
|
|
||||||
{ value: 'price_asc', label: '积分从低到高' },
|
|
||||||
{ value: 'price_desc', label: '积分从高到低' }
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
value={pageSize}
|
|
||||||
className="points-mall-filter-select-small"
|
|
||||||
onChange={(v) => {
|
|
||||||
setPage(1);
|
|
||||||
setPageSize(v);
|
|
||||||
}}
|
|
||||||
options={[
|
|
||||||
{ value: 12, label: '每页 12' },
|
|
||||||
{ value: 24, label: '每页 24' },
|
|
||||||
{ value: 48, label: '每页 48' }
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Space>
|
|
||||||
<div className="points-mall-total-text">
|
|
||||||
共 {total.toLocaleString()} 件商品
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{productsLoading ? (
|
|
||||||
<Spin style={{ marginTop: 40, display: 'block' }} />
|
|
||||||
) : products.length === 0 ? (
|
|
||||||
<Empty description="暂无商品" style={{ marginTop: 60 }} />
|
|
||||||
) : (
|
|
||||||
<div className="points-mall-products-grid">
|
|
||||||
{products.map((p) => (
|
|
||||||
<div key={p.id} className="points-mall-product-card">
|
|
||||||
<div className="points-mall-product-cover" />
|
|
||||||
<div className="points-mall-product-body">
|
|
||||||
<div className="points-mall-product-header">
|
|
||||||
<div className="points-mall-product-info">
|
|
||||||
<div className="points-mall-product-name">{p.name}</div>
|
|
||||||
<div className="points-mall-product-desc">{p.subtitle}</div>
|
|
||||||
</div>
|
|
||||||
{p.tags?.length ? (
|
|
||||||
<Tag bordered={false} className="points-mall-product-tag">
|
|
||||||
{p.tags[0]}
|
|
||||||
</Tag>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="points-mall-product-price-row">
|
|
||||||
<div>
|
|
||||||
<span className="points-mall-product-price">{Number(p.pointsPrice).toLocaleString()}</span>
|
|
||||||
<span className="points-mall-product-price-label">积分</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
className="points-mall-exchange-btn points-mall-product-exchange-btn"
|
|
||||||
disabled={userPoints < p.pointsPrice}
|
|
||||||
onClick={() => handleExchangeClick(p)}
|
|
||||||
>
|
|
||||||
{userPoints < p.pointsPrice ? '积分不足' : '兑换'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="points-mall-product-footer">
|
|
||||||
<span>库存 {p.stock}</span>
|
|
||||||
<span>已兑 {p.sold}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!!productsRes && (
|
|
||||||
<div className="points-mall-pagination">
|
|
||||||
<Space size={10}>
|
|
||||||
<Button disabled={page <= 1} onClick={() => setPage((v) => Math.max(1, v - 1))} className="points-mall-exchange-btn">
|
|
||||||
上一页
|
|
||||||
</Button>
|
|
||||||
<Tag bordered={false} className="points-mall-pagination-tag">
|
|
||||||
第 {page} 页
|
|
||||||
</Tag>
|
|
||||||
<Button
|
|
||||||
disabled={page * pageSize >= total}
|
|
||||||
onClick={() => setPage((v) => v + 1)}
|
|
||||||
className="points-mall-exchange-btn"
|
|
||||||
>
|
|
||||||
下一页
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 兑换弹窗 */}
|
|
||||||
<Modal
|
|
||||||
title="兑换商品"
|
|
||||||
open={exchangeModalVisible}
|
|
||||||
onCancel={() => {
|
|
||||||
setExchangeModalVisible(false);
|
|
||||||
setPendingOrderId(null);
|
|
||||||
}}
|
|
||||||
footer={null}
|
|
||||||
width={500}
|
|
||||||
className="points-exchange-modal"
|
|
||||||
destroyOnClose
|
|
||||||
>
|
|
||||||
{selectedProduct && (
|
|
||||||
<>
|
|
||||||
<div className="points-exchange-modal-header">
|
|
||||||
<div className="points-mall-exchange-modal-title">{selectedProduct.name}</div>
|
|
||||||
<div className="points-mall-exchange-modal-points">{selectedProduct.pointsPrice.toLocaleString()} 积分</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!canAfford && (
|
|
||||||
<div className="points-exchange-balance-warning">
|
|
||||||
积分不足!当前余额:{userPoints.toLocaleString()} 积分,还需:{(selectedProduct.pointsPrice - userPoints).toLocaleString()} 积分
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Form form={form} layout="vertical" className="points-exchange-form">
|
|
||||||
<Form.Item
|
|
||||||
name="recipientName"
|
|
||||||
label={<span className="points-exchange-form-label">收货人姓名</span>}
|
|
||||||
rules={[{ required: true, message: '请输入收货人姓名' }]}
|
|
||||||
className="points-exchange-form-item"
|
|
||||||
>
|
|
||||||
<Input placeholder="请输入收货人姓名" className="points-exchange-form-input" />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name="phone"
|
|
||||||
label={<span className="points-exchange-form-label">手机号码</span>}
|
|
||||||
rules={[
|
|
||||||
{ required: true, message: '请输入手机号码' },
|
|
||||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码' }
|
|
||||||
]}
|
|
||||||
className="points-exchange-form-item"
|
|
||||||
>
|
|
||||||
<Input placeholder="请输入手机号码" className="points-exchange-form-input" />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<div className="points-mall-address-grid">
|
|
||||||
<Form.Item
|
|
||||||
name="province"
|
|
||||||
label={<span className="points-exchange-form-label">省份</span>}
|
|
||||||
rules={[{ required: true, message: '请选择省份' }]}
|
|
||||||
className="points-exchange-form-item points-exchange-form-item-small"
|
|
||||||
>
|
|
||||||
<Select placeholder="省份" className="points-exchange-form-input">
|
|
||||||
<Select.Option value="北京市">北京市</Select.Option>
|
|
||||||
<Select.Option value="上海市">上海市</Select.Option>
|
|
||||||
<Select.Option value="广东省">广东省</Select.Option>
|
|
||||||
<Select.Option value="浙江省">浙江省</Select.Option>
|
|
||||||
<Select.Option value="江苏省">江苏省</Select.Option>
|
|
||||||
</Select>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name="city"
|
|
||||||
label={<span className="points-exchange-form-label">城市</span>}
|
|
||||||
rules={[{ required: true, message: '请选择城市' }]}
|
|
||||||
className="points-exchange-form-item points-exchange-form-item-small"
|
|
||||||
>
|
|
||||||
<Select placeholder="城市" className="points-exchange-form-input">
|
|
||||||
<Select.Option value="深圳市">深圳市</Select.Option>
|
|
||||||
<Select.Option value="广州市">广州市</Select.Option>
|
|
||||||
<Select.Option value="杭州市">杭州市</Select.Option>
|
|
||||||
<Select.Option value="南京市">南京市</Select.Option>
|
|
||||||
</Select>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name="district"
|
|
||||||
label={<span className="points-exchange-form-label">区/县</span>}
|
|
||||||
rules={[{ required: true, message: '请选择区/县' }]}
|
|
||||||
className="points-exchange-form-item points-exchange-form-item-small"
|
|
||||||
>
|
|
||||||
<Select placeholder="区/县" className="points-exchange-form-input">
|
|
||||||
<Select.Option value="南山区">南山区</Select.Option>
|
|
||||||
<Select.Option value="福田区">福田区</Select.Option>
|
|
||||||
<Select.Option value="宝安区">宝安区</Select.Option>
|
|
||||||
</Select>
|
|
||||||
</Form.Item>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name="address"
|
|
||||||
label={<span className="points-exchange-form-label">详细地址</span>}
|
|
||||||
rules={[{ required: true, message: '请输入详细地址' }]}
|
|
||||||
className="points-exchange-form-item"
|
|
||||||
>
|
|
||||||
<Input.TextArea placeholder="请输入详细地址(街道、门牌号等)" rows={3} className="points-exchange-form-input" />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name="zipCode"
|
|
||||||
label={<span className="points-exchange-form-label">邮政编码(选填)</span>}
|
|
||||||
className="points-exchange-form-item"
|
|
||||||
>
|
|
||||||
<Input placeholder="请输入邮政编码" className="points-exchange-form-input" />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
className="points-exchange-submit-btn"
|
|
||||||
onClick={handleExchangeSubmit}
|
|
||||||
loading={exchangeLoading}
|
|
||||||
disabled={!canAfford}
|
|
||||||
>
|
|
||||||
{canAfford ? '确认兑换' : '积分不足,无法兑换'}
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,336 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Button, Card, Empty, Input, Select, Space, Spin, Tag, Form, message } from 'antd';
|
||||||
|
import { SearchOutlined } from '@ant-design/icons';
|
||||||
|
import { PointsMallAPI, PointsMallCategory, PointsMallOverview, PointsMallProduct, PointsMallProductsResponse } from '../../api';
|
||||||
|
import { MOCK_OVERVIEW, MOCK_PRODUCTS } from './mocks';
|
||||||
|
import type { ExchangeFormValues, SortKey } from './types';
|
||||||
|
import ExchangeModal from './components/ExchangeModal';
|
||||||
|
|
||||||
|
export default function PointsMallPage() {
|
||||||
|
const [overviewLoading, setOverviewLoading] = useState(false);
|
||||||
|
const [productsLoading, setProductsLoading] = useState(false);
|
||||||
|
const [overview, setOverview] = useState<PointsMallOverview | null>(null);
|
||||||
|
const [categories, setCategories] = useState<PointsMallCategory[]>([]);
|
||||||
|
|
||||||
|
const [categoryId, setCategoryId] = useState<string>('all');
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
const [sort, setSort] = useState<SortKey>('popular');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(24);
|
||||||
|
const [productsRes, setProductsRes] = useState<PointsMallProductsResponse | null>(null);
|
||||||
|
|
||||||
|
const [exchangeModalVisible, setExchangeModalVisible] = useState(false);
|
||||||
|
const [selectedProduct, setSelectedProduct] = useState<PointsMallProduct | null>(null);
|
||||||
|
const [pendingOrderId, setPendingOrderId] = useState<string | null>(null);
|
||||||
|
const [exchangeLoading, setExchangeLoading] = useState(false);
|
||||||
|
const [form] = Form.useForm<ExchangeFormValues>();
|
||||||
|
|
||||||
|
const loadOverview = async () => {
|
||||||
|
setOverviewLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await PointsMallAPI.overview();
|
||||||
|
setOverview(data);
|
||||||
|
setCategories(data.categories || []);
|
||||||
|
if (!data.categories?.some((c) => c.id === categoryId) && data.categories?.[0]?.id) {
|
||||||
|
setCategoryId(data.categories[0].id);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
message.error('获取积分信息失败,请稍后重试');
|
||||||
|
setOverview({ ...MOCK_OVERVIEW, me: { points: 0, level: 'Lv.0' } });
|
||||||
|
setCategories(MOCK_OVERVIEW.categories);
|
||||||
|
} finally {
|
||||||
|
setOverviewLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadProducts = async () => {
|
||||||
|
setProductsLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await PointsMallAPI.products({
|
||||||
|
categoryId: categoryId === 'all' ? undefined : categoryId,
|
||||||
|
q,
|
||||||
|
sort,
|
||||||
|
page,
|
||||||
|
pageSize
|
||||||
|
});
|
||||||
|
setProductsRes(res);
|
||||||
|
} catch {
|
||||||
|
const filtered = MOCK_PRODUCTS.filter((p) => (categoryId === 'all' ? true : p.categoryId === categoryId)).filter((p) =>
|
||||||
|
q ? (p.name + p.subtitle).toLowerCase().includes(q.toLowerCase()) : true
|
||||||
|
);
|
||||||
|
setProductsRes({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total: filtered.length,
|
||||||
|
items: filtered.slice((page - 1) * pageSize, page * pageSize)
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setProductsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadOverview();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadProducts();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [categoryId, q, sort, page, pageSize]);
|
||||||
|
|
||||||
|
const banner = overview?.banners?.[0];
|
||||||
|
const promoEntries = overview?.promoEntries || [];
|
||||||
|
|
||||||
|
const products = productsRes?.items || [];
|
||||||
|
const total = productsRes?.total || 0;
|
||||||
|
|
||||||
|
const userPoints = overview?.me?.points || 0;
|
||||||
|
const canAfford = selectedProduct ? userPoints >= selectedProduct.pointsPrice : false;
|
||||||
|
|
||||||
|
const handleExchangeClick = async (product: PointsMallProduct) => {
|
||||||
|
if (userPoints < product.pointsPrice) return;
|
||||||
|
|
||||||
|
setExchangeLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await PointsMallAPI.exchangePrepare(product.id);
|
||||||
|
setSelectedProduct(product);
|
||||||
|
setPendingOrderId(res.orderId);
|
||||||
|
setExchangeModalVisible(true);
|
||||||
|
form.resetFields();
|
||||||
|
setOverview((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
return { ...prev, me: { ...prev.me, points: res.remainingPoints } };
|
||||||
|
});
|
||||||
|
message.success('积分扣减成功,请填写收件信息完成兑换');
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.message || '兑换失败,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
setExchangeLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExchangeSubmit = async () => {
|
||||||
|
if (!selectedProduct || !pendingOrderId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await form.validateFields();
|
||||||
|
setExchangeLoading(true);
|
||||||
|
await PointsMallAPI.exchangeSubmitShipping(pendingOrderId, form.getFieldsValue());
|
||||||
|
message.success('兑换成功!我们将尽快为您安排发货');
|
||||||
|
setExchangeModalVisible(false);
|
||||||
|
setPendingOrderId(null);
|
||||||
|
loadOverview();
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.errorFields) return;
|
||||||
|
message.error(error?.message || '提交失败,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
setExchangeLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-container" style={{ maxWidth: 1400 }}>
|
||||||
|
<div className="points-mall-hero">
|
||||||
|
<div className="points-mall-header">
|
||||||
|
<div className="points-mall-title-section">
|
||||||
|
<h1 className="page-title stats-page-title">积分商城</h1>
|
||||||
|
<p className="page-subtitle stats-page-subtitle">使用积分兑换权益、工具和活动礼包。积分通过 API 调用消费自动累积,1 美元 = 1000 积分。</p>
|
||||||
|
</div>
|
||||||
|
<div className="points-mall-balance-card">
|
||||||
|
{overviewLoading ? (
|
||||||
|
<Spin />
|
||||||
|
) : (
|
||||||
|
<div className="points-balance-row">
|
||||||
|
<div>
|
||||||
|
<div className="points-balance-label">我的积分</div>
|
||||||
|
<div className="points-balance-value">{userPoints.toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
<Tag
|
||||||
|
bordered={false}
|
||||||
|
style={{ margin: 0, borderRadius: 999, background: 'var(--color-brand-soft)', color: 'var(--color-brand)' }}
|
||||||
|
>
|
||||||
|
{String(overview?.me?.level || 'Lv.0')}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="points-mall-category-card" bodyStyle={{ padding: 0 }}>
|
||||||
|
<div className="points-mall-category-body">
|
||||||
|
<div className="points-mall-category-row">
|
||||||
|
<span className="points-mall-category-label">商品分类</span>
|
||||||
|
{categories.map((c) => (
|
||||||
|
<Button
|
||||||
|
key={c.id}
|
||||||
|
size="small"
|
||||||
|
type={c.id === categoryId ? 'primary' : 'default'}
|
||||||
|
style={{ borderRadius: 999 }}
|
||||||
|
onClick={() => {
|
||||||
|
setPage(1);
|
||||||
|
setCategoryId(c.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.name}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="points-mall-banner-section">
|
||||||
|
<div className="points-mall-banner-card">
|
||||||
|
<div className="points-mall-banner-header">
|
||||||
|
<div>
|
||||||
|
<div className="points-mall-banner-title">{banner?.title || '本期活动'}</div>
|
||||||
|
<div className="points-mall-banner-subtitle">{banner?.subtitle || 'Up to 25% Off'}</div>
|
||||||
|
</div>
|
||||||
|
<Button type="primary" style={{ borderRadius: 12, height: 40, fontWeight: 600 }}>
|
||||||
|
查看活动
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="points-mall-banner-footer">banner 图片与跳转链接由后端配置</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="points-mall-promo-grid">
|
||||||
|
{promoEntries.slice(0, 2).map((p) => (
|
||||||
|
<div key={p.id} className="points-mall-promo-card">
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div className="points-mall-promo-title">{p.title}</div>
|
||||||
|
<div className="points-mall-promo-subtitle">{p.subtitle}</div>
|
||||||
|
</div>
|
||||||
|
<Button size="small" className="points-mall-exchange-btn">
|
||||||
|
进入
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{promoEntries.length < 2 && <div className="points-mall-promo-empty">促销入口由后端配置</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="stats-page-chart-card">
|
||||||
|
<div className="points-mall-filters-row">
|
||||||
|
<Space size={10} wrap className="points-mall-filters-left">
|
||||||
|
<Input
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPage(1);
|
||||||
|
setQ(e.target.value);
|
||||||
|
}}
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
placeholder="搜索商品"
|
||||||
|
allowClear
|
||||||
|
className="points-mall-search-input"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={sort}
|
||||||
|
className="points-mall-filter-select"
|
||||||
|
onChange={(v: SortKey) => {
|
||||||
|
setPage(1);
|
||||||
|
setSort(v);
|
||||||
|
}}
|
||||||
|
options={[
|
||||||
|
{ value: 'popular', label: '热度优先' },
|
||||||
|
{ value: 'newest', label: '最新上架' },
|
||||||
|
{ value: 'price_asc', label: '积分从低到高' },
|
||||||
|
{ value: 'price_desc', label: '积分从高到低' }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={pageSize}
|
||||||
|
className="points-mall-filter-select-small"
|
||||||
|
onChange={(v) => {
|
||||||
|
setPage(1);
|
||||||
|
setPageSize(v);
|
||||||
|
}}
|
||||||
|
options={[
|
||||||
|
{ value: 12, label: '每页 12' },
|
||||||
|
{ value: 24, label: '每页 24' },
|
||||||
|
{ value: 48, label: '每页 48' }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
<div className="points-mall-total-text">共 {total.toLocaleString()} 件商品</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{productsLoading ? (
|
||||||
|
<Spin style={{ marginTop: 40, display: 'block' }} />
|
||||||
|
) : products.length === 0 ? (
|
||||||
|
<Empty description="暂无商品" style={{ marginTop: 60 }} />
|
||||||
|
) : (
|
||||||
|
<div className="points-mall-products-grid">
|
||||||
|
{products.map((p) => (
|
||||||
|
<div key={p.id} className="points-mall-product-card">
|
||||||
|
<div className="points-mall-product-cover" />
|
||||||
|
<div className="points-mall-product-body">
|
||||||
|
<div className="points-mall-product-header">
|
||||||
|
<div className="points-mall-product-info">
|
||||||
|
<div className="points-mall-product-name">{p.name}</div>
|
||||||
|
<div className="points-mall-product-desc">{p.subtitle}</div>
|
||||||
|
</div>
|
||||||
|
{p.tags?.length ? (
|
||||||
|
<Tag bordered={false} className="points-mall-product-tag">
|
||||||
|
{p.tags[0]}
|
||||||
|
</Tag>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="points-mall-product-price-row">
|
||||||
|
<div>
|
||||||
|
<span className="points-mall-product-price">{Number(p.pointsPrice).toLocaleString()}</span>
|
||||||
|
<span className="points-mall-product-price-label">积分</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
className="points-mall-exchange-btn points-mall-product-exchange-btn"
|
||||||
|
disabled={userPoints < p.pointsPrice || exchangeLoading}
|
||||||
|
onClick={() => handleExchangeClick(p)}
|
||||||
|
>
|
||||||
|
{userPoints < p.pointsPrice ? '积分不足' : '兑换'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="points-mall-product-footer">
|
||||||
|
<span>库存 {p.stock}</span>
|
||||||
|
<span>已兑 {p.sold}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!!productsRes && (
|
||||||
|
<div className="points-mall-pagination">
|
||||||
|
<Space size={10}>
|
||||||
|
<Button disabled={page <= 1} onClick={() => setPage((v) => Math.max(1, v - 1))} className="points-mall-exchange-btn">
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<Tag bordered={false} className="points-mall-pagination-tag">
|
||||||
|
第 {page} 页
|
||||||
|
</Tag>
|
||||||
|
<Button disabled={page * pageSize >= total} onClick={() => setPage((v) => v + 1)} className="points-mall-exchange-btn">
|
||||||
|
下一页
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<ExchangeModal
|
||||||
|
open={exchangeModalVisible}
|
||||||
|
product={selectedProduct}
|
||||||
|
canAfford={canAfford}
|
||||||
|
userPoints={userPoints}
|
||||||
|
loading={exchangeLoading}
|
||||||
|
form={form}
|
||||||
|
onSubmit={handleExchangeSubmit}
|
||||||
|
onCancel={() => {
|
||||||
|
setExchangeModalVisible(false);
|
||||||
|
setPendingOrderId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
import { Button, Form, Input, Modal, Select } from 'antd';
|
||||||
|
import type { FormInstance } from 'antd';
|
||||||
|
import type { PointsMallProduct } from '../../../api';
|
||||||
|
import type { ExchangeFormValues } from '../types';
|
||||||
|
|
||||||
|
export default function ExchangeModal(props: {
|
||||||
|
open: boolean;
|
||||||
|
product: PointsMallProduct | null;
|
||||||
|
canAfford: boolean;
|
||||||
|
userPoints: number;
|
||||||
|
loading: boolean;
|
||||||
|
form: FormInstance<ExchangeFormValues>;
|
||||||
|
onSubmit: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
const { open, product, canAfford, userPoints, loading, form, onSubmit, onCancel } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title="兑换商品" open={open} onCancel={onCancel} footer={null} width={500} className="points-exchange-modal" destroyOnClose>
|
||||||
|
{product && (
|
||||||
|
<>
|
||||||
|
<div className="points-exchange-modal-header">
|
||||||
|
<div className="points-mall-exchange-modal-title">{product.name}</div>
|
||||||
|
<div className="points-mall-exchange-modal-points">{product.pointsPrice.toLocaleString()} 积分</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!canAfford && (
|
||||||
|
<div className="points-exchange-balance-warning">
|
||||||
|
积分不足!当前余额:{userPoints.toLocaleString()} 积分,还需:{(product.pointsPrice - userPoints).toLocaleString()} 积分
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Form form={form} layout="vertical" className="points-exchange-form">
|
||||||
|
<Form.Item
|
||||||
|
name="recipientName"
|
||||||
|
label={<span className="points-exchange-form-label">收货人姓名</span>}
|
||||||
|
rules={[{ required: true, message: '请输入收货人姓名' }]}
|
||||||
|
className="points-exchange-form-item"
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入收货人姓名" className="points-exchange-form-input" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="phone"
|
||||||
|
label={<span className="points-exchange-form-label">手机号码</span>}
|
||||||
|
rules={[{ required: true, message: '请输入手机号码' }, { pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码' }]}
|
||||||
|
className="points-exchange-form-item"
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入手机号码" className="points-exchange-form-input" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<div className="points-mall-address-grid">
|
||||||
|
<Form.Item
|
||||||
|
name="province"
|
||||||
|
label={<span className="points-exchange-form-label">省份</span>}
|
||||||
|
rules={[{ required: true, message: '请选择省份' }]}
|
||||||
|
className="points-exchange-form-item points-exchange-form-item-small"
|
||||||
|
>
|
||||||
|
<Select placeholder="省份" className="points-exchange-form-input">
|
||||||
|
<Select.Option value="北京市">北京市</Select.Option>
|
||||||
|
<Select.Option value="上海市">上海市</Select.Option>
|
||||||
|
<Select.Option value="广东省">广东省</Select.Option>
|
||||||
|
<Select.Option value="浙江省">浙江省</Select.Option>
|
||||||
|
<Select.Option value="江苏省">江苏省</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="city"
|
||||||
|
label={<span className="points-exchange-form-label">城市</span>}
|
||||||
|
rules={[{ required: true, message: '请选择城市' }]}
|
||||||
|
className="points-exchange-form-item points-exchange-form-item-small"
|
||||||
|
>
|
||||||
|
<Select placeholder="城市" className="points-exchange-form-input">
|
||||||
|
<Select.Option value="深圳市">深圳市</Select.Option>
|
||||||
|
<Select.Option value="广州市">广州市</Select.Option>
|
||||||
|
<Select.Option value="杭州市">杭州市</Select.Option>
|
||||||
|
<Select.Option value="南京市">南京市</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="district"
|
||||||
|
label={<span className="points-exchange-form-label">区/县</span>}
|
||||||
|
rules={[{ required: true, message: '请选择区/县' }]}
|
||||||
|
className="points-exchange-form-item points-exchange-form-item-small"
|
||||||
|
>
|
||||||
|
<Select placeholder="区/县" className="points-exchange-form-input">
|
||||||
|
<Select.Option value="南山区">南山区</Select.Option>
|
||||||
|
<Select.Option value="福田区">福田区</Select.Option>
|
||||||
|
<Select.Option value="宝安区">宝安区</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="address"
|
||||||
|
label={<span className="points-exchange-form-label">详细地址</span>}
|
||||||
|
rules={[{ required: true, message: '请输入详细地址' }]}
|
||||||
|
className="points-exchange-form-item"
|
||||||
|
>
|
||||||
|
<Input.TextArea placeholder="请输入详细地址(街道、门牌号等)" rows={3} className="points-exchange-form-input" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="zipCode"
|
||||||
|
label={<span className="points-exchange-form-label">邮政编码(选填)</span>}
|
||||||
|
className="points-exchange-form-item"
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入邮政编码" className="points-exchange-form-input" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
className="points-exchange-submit-btn"
|
||||||
|
onClick={onSubmit}
|
||||||
|
loading={loading}
|
||||||
|
disabled={!canAfford}
|
||||||
|
>
|
||||||
|
{canAfford ? '确认兑换' : '积分不足,无法兑换'}
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
import type { PointsMallOverview, PointsMallProduct } from '../../api';
|
||||||
|
|
||||||
|
export const MOCK_OVERVIEW: PointsMallOverview = {
|
||||||
|
me: { points: 1280, level: 'Lv.2' },
|
||||||
|
categories: [
|
||||||
|
{ id: 'all', name: '全部', sort: 0 },
|
||||||
|
{ id: 'digital', name: '虚拟权益', sort: 1 },
|
||||||
|
{ id: 'tool', name: '工具周边', sort: 2 },
|
||||||
|
{ id: 'gift', name: '礼品卡券', sort: 3 },
|
||||||
|
{ id: 'limited', name: '限时活动', sort: 4 }
|
||||||
|
],
|
||||||
|
announcements: [],
|
||||||
|
banners: [{ id: 'b1', title: '本期活动', subtitle: 'Up to 25% Off', imageUrl: '', linkUrl: '' }],
|
||||||
|
promoEntries: [
|
||||||
|
{ id: 'p1', title: '促销活动', subtitle: '本周精选', linkUrl: '' },
|
||||||
|
{ id: 'p2', title: '积分任务', subtitle: '快速涨积分', linkUrl: '' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MOCK_PRODUCTS: PointsMallProduct[] = Array.from({ length: 12 }).map((_, i) => ({
|
||||||
|
id: String(i + 1),
|
||||||
|
categoryId: i % 2 === 0 ? 'digital' : 'tool',
|
||||||
|
name: `商品 ${i + 1}`,
|
||||||
|
subtitle: '这里是商品简短描述',
|
||||||
|
coverUrl: '',
|
||||||
|
pointsPrice: 199 + i * 10,
|
||||||
|
stock: 99,
|
||||||
|
sold: 12 + i,
|
||||||
|
tags: i % 3 === 0 ? ['限时'] : i % 3 === 1 ? ['热卖'] : []
|
||||||
|
}));
|
||||||
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import type { PointsMallOverview, PointsMallProduct } from '../../api';
|
||||||
|
|
||||||
|
export type SortKey = 'popular' | 'price_asc' | 'price_desc' | 'newest';
|
||||||
|
|
||||||
|
export interface ExchangeFormValues {
|
||||||
|
recipientName: string;
|
||||||
|
phone: string;
|
||||||
|
province: string;
|
||||||
|
city: string;
|
||||||
|
district: string;
|
||||||
|
address: string;
|
||||||
|
zipCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PointsMallMockData {
|
||||||
|
overview: PointsMallOverview;
|
||||||
|
products: PointsMallProduct[];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1,499 +1,2 @@
|
||||||
import { useEffect, useState } from 'react';
|
export { default } from './PromptLibraryPage/PromptLibraryPage';
|
||||||
import {
|
|
||||||
AppstoreOutlined,
|
|
||||||
ClockCircleOutlined,
|
|
||||||
CopyOutlined,
|
|
||||||
EditOutlined,
|
|
||||||
GlobalOutlined,
|
|
||||||
LockOutlined,
|
|
||||||
PlusOutlined,
|
|
||||||
SearchOutlined
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Input,
|
|
||||||
Modal,
|
|
||||||
Form,
|
|
||||||
Select,
|
|
||||||
Space,
|
|
||||||
Tag,
|
|
||||||
App as AntApp,
|
|
||||||
Empty,
|
|
||||||
Tooltip,
|
|
||||||
Popconfirm,
|
|
||||||
Spin
|
|
||||||
} from 'antd';
|
|
||||||
import { PromptTemplate, PromptTemplateAPI } from '../api';
|
|
||||||
|
|
||||||
const CATEGORIES = ['通用', '编程', '写作', '翻译', '分析', '客服', '其他'];
|
|
||||||
const SCOPE_OPTIONS: Array<{ key: 'all' | 'mine' | 'public'; label: string }> = [
|
|
||||||
{ key: 'all', label: '全部模板' },
|
|
||||||
{ key: 'mine', label: '我的沉淀' },
|
|
||||||
{ key: 'public', label: '公开灵感' }
|
|
||||||
];
|
|
||||||
|
|
||||||
const CATEGORY_STYLES: Record<string, { bg: string; text: string }> = {
|
|
||||||
通用: { bg: 'rgba(8, 145, 178, 0.10)', text: '#0f766e' },
|
|
||||||
编程: { bg: 'rgba(59, 130, 246, 0.10)', text: '#1d4ed8' },
|
|
||||||
写作: { bg: 'rgba(217, 70, 239, 0.10)', text: '#a21caf' },
|
|
||||||
翻译: { bg: 'rgba(249, 115, 22, 0.10)', text: '#c2410c' },
|
|
||||||
分析: { bg: 'rgba(14, 165, 233, 0.10)', text: '#0369a1' },
|
|
||||||
客服: { bg: 'rgba(34, 197, 94, 0.10)', text: '#15803d' },
|
|
||||||
其他: { bg: 'rgba(100, 116, 139, 0.12)', text: '#475569' }
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function PromptLibraryPage({
|
|
||||||
onSelect
|
|
||||||
}: {
|
|
||||||
onSelect?: (tpl: PromptTemplate) => void;
|
|
||||||
}) {
|
|
||||||
const { message } = AntApp.useApp();
|
|
||||||
const [scope, setScope] = useState<'all' | 'mine' | 'public'>('all');
|
|
||||||
const [q, setQ] = useState('');
|
|
||||||
const [list, setList] = useState<PromptTemplate[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [editorOpen, setEditorOpen] = useState(false);
|
|
||||||
const [editing, setEditing] = useState<PromptTemplate | null>(null);
|
|
||||||
const [form] = Form.useForm();
|
|
||||||
|
|
||||||
const load = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const data = await PromptTemplateAPI.list({ scope, q });
|
|
||||||
setList(data);
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error('加载失败:' + (e?.message ?? e));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
load();
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [scope]);
|
|
||||||
|
|
||||||
const openCreate = () => {
|
|
||||||
setEditing(null);
|
|
||||||
form.resetFields();
|
|
||||||
form.setFieldsValue({ visibility: 'private', category: '通用' });
|
|
||||||
setEditorOpen(true);
|
|
||||||
};
|
|
||||||
const openEdit = (t: PromptTemplate) => {
|
|
||||||
setEditing(t);
|
|
||||||
form.setFieldsValue({
|
|
||||||
title: t.title,
|
|
||||||
body: t.body,
|
|
||||||
category: t.category,
|
|
||||||
visibility: t.visibility
|
|
||||||
});
|
|
||||||
setEditorOpen(true);
|
|
||||||
};
|
|
||||||
const onSave = async () => {
|
|
||||||
const v = await form.validateFields();
|
|
||||||
try {
|
|
||||||
if (editing) {
|
|
||||||
await PromptTemplateAPI.update(editing.id, v);
|
|
||||||
message.success('已更新');
|
|
||||||
} else {
|
|
||||||
await PromptTemplateAPI.create(v);
|
|
||||||
message.success('已创建');
|
|
||||||
}
|
|
||||||
setEditorOpen(false);
|
|
||||||
load();
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error('保存失败:' + (e?.response?.data?.error ?? e?.message ?? e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const onUse = async (t: PromptTemplate) => {
|
|
||||||
await PromptTemplateAPI.use(t.id).catch(() => {});
|
|
||||||
if (onSelect) onSelect(t);
|
|
||||||
else {
|
|
||||||
navigator.clipboard?.writeText(t.body).then(() => message.success('内容已复制到剪贴板'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const onDelete = async (t: PromptTemplate) => {
|
|
||||||
await PromptTemplateAPI.remove(t.id);
|
|
||||||
message.success('已删除');
|
|
||||||
load();
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (ts: number) =>
|
|
||||||
new Date(ts).toLocaleDateString('zh-CN', {
|
|
||||||
month: 'numeric',
|
|
||||||
day: 'numeric'
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="feature-cover-container">
|
|
||||||
<div className="page-container">
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
borderRadius: 24,
|
|
||||||
padding: '28px 28px 24px',
|
|
||||||
background:
|
|
||||||
'linear-gradient(135deg, rgba(255,255,255,0.96) 0%, rgba(236,253,245,0.92) 42%, rgba(240,249,255,0.96) 100%)',
|
|
||||||
border: '1px solid rgba(8, 145, 178, 0.12)',
|
|
||||||
boxShadow: '0 18px 50px rgba(15, 23, 42, 0.06)',
|
|
||||||
marginBottom: 24
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
gap: 20,
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
marginBottom: 22
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AppstoreOutlined style={{ color: 'var(--color-brand)' }} />
|
|
||||||
灵感模板中心
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 className="page-title" style={{ marginBottom: 10 }}>
|
|
||||||
Prompt 模板库
|
|
||||||
</h1>
|
|
||||||
<div className="page-subtitle" style={{ marginTop: 0, fontSize: 15, lineHeight: 1.75, maxWidth: 560 }}>
|
|
||||||
把高质量提示词沉淀成可复用模板,像挑选灵感卡片一样快速使用,而不是面对一页页生硬的配置项。
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
size="large"
|
|
||||||
icon={<PlusOutlined />}
|
|
||||||
onClick={openCreate}
|
|
||||||
style={{ borderRadius: 14, height: 46, padding: '0 18px', fontWeight: 600 }}
|
|
||||||
>
|
|
||||||
新建模板
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: 'minmax(0, 1.5fr) auto',
|
|
||||||
gap: 14,
|
|
||||||
alignItems: 'center'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
placeholder="搜索标题、分类或正文..."
|
|
||||||
value={q}
|
|
||||||
onChange={(e) => setQ(e.target.value)}
|
|
||||||
onPressEnter={load}
|
|
||||||
prefix={<SearchOutlined style={{ color: 'var(--color-text-tertiary)' }} />}
|
|
||||||
suffix={
|
|
||||||
q ? (
|
|
||||||
<Button type="link" size="small" onClick={load} style={{ padding: 0, height: 20 }}>
|
|
||||||
搜索
|
|
||||||
</Button>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
style={{ height: 48, borderRadius: 14, background: 'rgba(255,255,255,0.88)' }}
|
|
||||||
allowClear
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
|
||||||
{SCOPE_OPTIONS.map((item) => {
|
|
||||||
const active = scope === item.key;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={item.key}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setScope(item.key)}
|
|
||||||
style={{
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: active ? 'rgba(8, 145, 178, 0.18)' : 'var(--color-border)',
|
|
||||||
background: active ? 'rgba(8, 145, 178, 0.10)' : 'rgba(255,255,255,0.72)',
|
|
||||||
color: active ? 'var(--color-brand)' : 'var(--color-text-secondary)',
|
|
||||||
borderRadius: 999,
|
|
||||||
padding: '10px 14px',
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: active ? 600 : 500,
|
|
||||||
cursor: 'pointer'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
minHeight: 280,
|
|
||||||
borderRadius: 22,
|
|
||||||
background: 'var(--color-surface)',
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Spin size="large" />
|
|
||||||
</div>
|
|
||||||
) : list.length === 0 ? (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
borderRadius: 22,
|
|
||||||
background: 'var(--color-surface)',
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
padding: '54px 24px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Empty description="还没有找到合适的模板,试试换个关键词或新建一个灵感卡片" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))',
|
|
||||||
gap: 18
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{list.map((t) => (
|
|
||||||
<div
|
|
||||||
key={t.id}
|
|
||||||
style={{
|
|
||||||
borderRadius: 20,
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
background: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(252,252,253,1) 100%)',
|
|
||||||
boxShadow: '0 10px 30px rgba(15, 23, 42, 0.045)',
|
|
||||||
padding: 20,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
minHeight: 286
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
gap: 12,
|
|
||||||
marginBottom: 16
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
|
||||||
<Tag
|
|
||||||
bordered={false}
|
|
||||||
style={{
|
|
||||||
margin: 0,
|
|
||||||
borderRadius: 999,
|
|
||||||
paddingInline: 10,
|
|
||||||
height: 28,
|
|
||||||
lineHeight: '28px',
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 600,
|
|
||||||
background: CATEGORY_STYLES[t.category || '其他']?.bg || CATEGORY_STYLES['其他'].bg,
|
|
||||||
color: CATEGORY_STYLES[t.category || '其他']?.text || CATEGORY_STYLES['其他'].text
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t.category || '其他'}
|
|
||||||
</Tag>
|
|
||||||
<Tag
|
|
||||||
bordered={false}
|
|
||||||
icon={t.visibility === 'public' ? <GlobalOutlined /> : <LockOutlined />}
|
|
||||||
style={{
|
|
||||||
margin: 0,
|
|
||||||
borderRadius: 999,
|
|
||||||
paddingInline: 10,
|
|
||||||
height: 28,
|
|
||||||
lineHeight: '28px',
|
|
||||||
fontSize: 12,
|
|
||||||
background: 'var(--color-surface-2)',
|
|
||||||
color: 'var(--color-text-secondary)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t.visibility === 'public' ? '公开灵感' : '仅自己可见'}
|
|
||||||
</Tag>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tooltip title={onSelect ? '插入到当前对话' : '复制到剪贴板'}>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
icon={<CopyOutlined />}
|
|
||||||
onClick={() => onUse(t)}
|
|
||||||
style={{ borderRadius: 12, height: 38, fontWeight: 600 }}
|
|
||||||
>
|
|
||||||
{onSelect ? '使用' : '复制'}
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
gap: 16,
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
marginBottom: 14
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ minWidth: 0 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 19,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: 'var(--color-text)',
|
|
||||||
letterSpacing: '-0.02em',
|
|
||||||
marginBottom: 6
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t.title}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 12.5, color: 'var(--color-text-tertiary)' }}>
|
|
||||||
by {t.ownerName || '我'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
flexShrink: 0,
|
|
||||||
padding: '6px 10px',
|
|
||||||
borderRadius: 12,
|
|
||||||
background: 'rgba(15, 23, 42, 0.03)',
|
|
||||||
color: 'var(--color-text-secondary)',
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 600
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
使用 {t.useCount}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'relative',
|
|
||||||
borderRadius: 16,
|
|
||||||
background: 'linear-gradient(180deg, rgba(248,250,252,0.9) 0%, rgba(255,255,255,0.92) 100%)',
|
|
||||||
border: '1px solid rgba(148, 163, 184, 0.14)',
|
|
||||||
padding: '16px 16px 18px',
|
|
||||||
minHeight: 132,
|
|
||||||
overflow: 'hidden'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
inset: '0 auto 0 0',
|
|
||||||
width: 4,
|
|
||||||
background: CATEGORY_STYLES[t.category || '其他']?.text || CATEGORY_STYLES['其他'].text,
|
|
||||||
opacity: 0.18
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
fontSize: 13.5,
|
|
||||||
color: 'var(--color-text-secondary)',
|
|
||||||
lineHeight: 1.8,
|
|
||||||
minHeight: 96,
|
|
||||||
maxHeight: 130,
|
|
||||||
overflow: 'hidden'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t.body.slice(0, 220)}
|
|
||||||
{t.body.length > 220 ? '…' : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 10,
|
|
||||||
marginTop: 'auto',
|
|
||||||
paddingTop: 16,
|
|
||||||
borderTop: '1px solid var(--color-border)',
|
|
||||||
fontSize: 12.5,
|
|
||||||
color: 'var(--color-text-tertiary)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ClockCircleOutlined />
|
|
||||||
<span>最近更新于 {formatDate(t.updatedAt)}</span>
|
|
||||||
<span style={{ flex: 1 }} />
|
|
||||||
<Space size={2}>
|
|
||||||
<Button size="small" type="text" icon={<EditOutlined />} onClick={() => openEdit(t)} style={{ borderRadius: 10 }}>
|
|
||||||
编辑
|
|
||||||
</Button>
|
|
||||||
<Popconfirm title="删除此模板?" onConfirm={() => onDelete(t)}>
|
|
||||||
<Button size="small" type="text" danger style={{ borderRadius: 10 }}>
|
|
||||||
删除
|
|
||||||
</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
open={editorOpen}
|
|
||||||
title={editing ? '编辑模板' : '新建模板'}
|
|
||||||
onCancel={() => setEditorOpen(false)}
|
|
||||||
onOk={onSave}
|
|
||||||
width={680}
|
|
||||||
okText="保存"
|
|
||||||
cancelText="取消"
|
|
||||||
destroyOnHidden
|
|
||||||
>
|
|
||||||
<Form form={form} layout="vertical">
|
|
||||||
<Form.Item name="title" label="标题" rules={[{ required: true, max: 60 }]}>
|
|
||||||
<Input placeholder="例如:技术博客润色助手" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="category" label="分类">
|
|
||||||
<Select>
|
|
||||||
{CATEGORIES.map((c) => (
|
|
||||||
<Select.Option key={c} value={c}>
|
|
||||||
{c}
|
|
||||||
</Select.Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
name="body"
|
|
||||||
label="模板正文(支持 {{变量名}} 占位符)"
|
|
||||||
rules={[{ required: true }]}
|
|
||||||
tooltip="使用时会以此内容作为消息输入;目前为简单复制,后续可加变量填充表单"
|
|
||||||
>
|
|
||||||
<Input.TextArea rows={10} placeholder={'你是一个 {{role}},请帮我...'} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="visibility" label="可见性">
|
|
||||||
<Select>
|
|
||||||
<Select.Option value="private">仅自己(默认)</Select.Option>
|
|
||||||
<Select.Option value="public">公开(所有用户可见)</Select.Option>
|
|
||||||
</Select>
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
|
||||||
<div className="feature-cover">
|
|
||||||
<Empty description="功能规划中,本期不支持" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { App as AntApp, Empty, Form } from 'antd';
|
||||||
|
import type { PromptTemplate } from '../../api';
|
||||||
|
import { PromptTemplateAPI } from '../../api';
|
||||||
|
import PromptLibraryHero from './components/PromptLibraryHero';
|
||||||
|
import PromptTemplateGrid from './components/PromptTemplateGrid';
|
||||||
|
import PromptTemplateEditorModal from './components/PromptTemplateEditorModal';
|
||||||
|
|
||||||
|
export default function PromptLibraryPage(props: { onSelect?: (tpl: PromptTemplate) => void }) {
|
||||||
|
const { onSelect } = props;
|
||||||
|
const { message } = AntApp.useApp();
|
||||||
|
const [scope, setScope] = useState<'all' | 'mine' | 'public'>('all');
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
const [list, setList] = useState<PromptTemplate[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [editorOpen, setEditorOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<PromptTemplate | null>(null);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await PromptTemplateAPI.list({ scope, q });
|
||||||
|
setList(data);
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error('加载失败:' + (e?.message ?? e));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [scope]);
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditing(null);
|
||||||
|
form.resetFields();
|
||||||
|
form.setFieldsValue({ visibility: 'private', category: '通用' });
|
||||||
|
setEditorOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (t: PromptTemplate) => {
|
||||||
|
setEditing(t);
|
||||||
|
form.setFieldsValue({
|
||||||
|
title: t.title,
|
||||||
|
body: t.body,
|
||||||
|
category: t.category,
|
||||||
|
visibility: t.visibility
|
||||||
|
});
|
||||||
|
setEditorOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSave = async () => {
|
||||||
|
const v = await form.validateFields();
|
||||||
|
try {
|
||||||
|
if (editing) {
|
||||||
|
await PromptTemplateAPI.update(editing.id, v);
|
||||||
|
message.success('已更新');
|
||||||
|
} else {
|
||||||
|
await PromptTemplateAPI.create(v);
|
||||||
|
message.success('已创建');
|
||||||
|
}
|
||||||
|
setEditorOpen(false);
|
||||||
|
load();
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error('保存失败:' + (e?.response?.data?.error ?? e?.message ?? e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUse = async (t: PromptTemplate) => {
|
||||||
|
await PromptTemplateAPI.use(t.id).catch(() => {});
|
||||||
|
if (onSelect) onSelect(t);
|
||||||
|
else {
|
||||||
|
navigator.clipboard?.writeText(t.body).then(() => message.success('内容已复制到剪贴板'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDelete = async (t: PromptTemplate) => {
|
||||||
|
await PromptTemplateAPI.remove(t.id);
|
||||||
|
message.success('已删除');
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (ts: number) =>
|
||||||
|
new Date(ts).toLocaleDateString('zh-CN', {
|
||||||
|
month: 'numeric',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="feature-cover-container">
|
||||||
|
<div className="page-container">
|
||||||
|
<PromptLibraryHero scope={scope} q={q} onChangeScope={setScope} onChangeQ={setQ} onSearch={load} onCreate={openCreate} />
|
||||||
|
<PromptTemplateGrid
|
||||||
|
loading={loading}
|
||||||
|
list={list}
|
||||||
|
hasOnSelect={!!onSelect}
|
||||||
|
onUse={onUse}
|
||||||
|
onEdit={openEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
formatDate={formatDate}
|
||||||
|
/>
|
||||||
|
<PromptTemplateEditorModal open={editorOpen} editing={editing} form={form} onCancel={() => setEditorOpen(false)} onOk={onSave} />
|
||||||
|
</div>
|
||||||
|
<div className="feature-cover">
|
||||||
|
<Empty description="功能规划中,本期不支持" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
import { AppstoreOutlined, PlusOutlined, SearchOutlined } from '@ant-design/icons';
|
||||||
|
import { Button, Input } from 'antd';
|
||||||
|
import { SCOPE_OPTIONS } from '../constants';
|
||||||
|
|
||||||
|
export default function PromptLibraryHero(props: {
|
||||||
|
scope: 'all' | 'mine' | 'public';
|
||||||
|
q: string;
|
||||||
|
onChangeScope: (v: 'all' | 'mine' | 'public') => void;
|
||||||
|
onChangeQ: (v: string) => void;
|
||||||
|
onSearch: () => void;
|
||||||
|
onCreate: () => void;
|
||||||
|
}) {
|
||||||
|
const { scope, q, onChangeScope, onChangeQ, onSearch, onCreate } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 24,
|
||||||
|
padding: '28px 28px 24px',
|
||||||
|
background:
|
||||||
|
'linear-gradient(135deg, rgba(255,255,255,0.96) 0%, rgba(236,253,245,0.92) 42%, rgba(240,249,255,0.96) 100%)',
|
||||||
|
border: '1px solid rgba(8, 145, 178, 0.12)',
|
||||||
|
boxShadow: '0 18px 50px rgba(15, 23, 42, 0.06)',
|
||||||
|
marginBottom: 24
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 20, flexWrap: 'wrap', marginBottom: 22 }}>
|
||||||
|
<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
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AppstoreOutlined style={{ color: 'var(--color-brand)' }} />
|
||||||
|
灵感模板中心
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="page-title" style={{ marginBottom: 10 }}>
|
||||||
|
Prompt 模板库
|
||||||
|
</h1>
|
||||||
|
<div className="page-subtitle" style={{ marginTop: 0, fontSize: 15, lineHeight: 1.75, maxWidth: 560 }}>
|
||||||
|
把高质量提示词沉淀成可复用模板,像挑选灵感卡片一样快速使用,而不是面对一页页生硬的配置项。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="primary" size="large" icon={<PlusOutlined />} onClick={onCreate} style={{ borderRadius: 14, height: 46, padding: '0 18px', fontWeight: 600 }}>
|
||||||
|
新建模板
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1.5fr) auto', gap: 14, alignItems: 'center' }}>
|
||||||
|
<Input
|
||||||
|
placeholder="搜索标题、分类或正文..."
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => onChangeQ(e.target.value)}
|
||||||
|
onPressEnter={onSearch}
|
||||||
|
prefix={<SearchOutlined style={{ color: 'var(--color-text-tertiary)' }} />}
|
||||||
|
suffix={
|
||||||
|
q ? (
|
||||||
|
<Button type="link" size="small" onClick={onSearch} style={{ padding: 0, height: 20 }}>
|
||||||
|
搜索
|
||||||
|
</Button>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
style={{ height: 48, borderRadius: 14, background: 'rgba(255,255,255,0.88)' }}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||||
|
{SCOPE_OPTIONS.map((item) => {
|
||||||
|
const active = scope === item.key;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChangeScope(item.key)}
|
||||||
|
style={{
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: active ? 'rgba(8, 145, 178, 0.18)' : 'var(--color-border)',
|
||||||
|
background: active ? 'rgba(8, 145, 178, 0.10)' : 'rgba(255,255,255,0.72)',
|
||||||
|
color: active ? 'var(--color-brand)' : 'var(--color-text-secondary)',
|
||||||
|
borderRadius: 999,
|
||||||
|
padding: '10px 14px',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: active ? 600 : 500,
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,151 @@
|
||||||
|
import { ClockCircleOutlined, CopyOutlined, EditOutlined, GlobalOutlined, LockOutlined } from '@ant-design/icons';
|
||||||
|
import { Button, Popconfirm, Space, Tag, Tooltip } from 'antd';
|
||||||
|
import type { PromptTemplate } from '../../../api';
|
||||||
|
import { CATEGORY_STYLES } from '../constants';
|
||||||
|
|
||||||
|
export default function PromptTemplateCard(props: {
|
||||||
|
template: PromptTemplate;
|
||||||
|
hasOnSelect: boolean;
|
||||||
|
onUse: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
formatDate: (ts: number) => string;
|
||||||
|
}) {
|
||||||
|
const { template: t, hasOnSelect, onUse, onEdit, onDelete, formatDate } = props;
|
||||||
|
const tone = CATEGORY_STYLES[t.category || '其他'] || CATEGORY_STYLES['其他'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 20,
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
background: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(252,252,253,1) 100%)',
|
||||||
|
boxShadow: '0 10px 30px rgba(15, 23, 42, 0.045)',
|
||||||
|
padding: 20,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
minHeight: 286
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 16 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<Tag
|
||||||
|
bordered={false}
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
borderRadius: 999,
|
||||||
|
paddingInline: 10,
|
||||||
|
height: 28,
|
||||||
|
lineHeight: '28px',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
background: tone.bg,
|
||||||
|
color: tone.text
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.category || '其他'}
|
||||||
|
</Tag>
|
||||||
|
<Tag
|
||||||
|
bordered={false}
|
||||||
|
icon={t.visibility === 'public' ? <GlobalOutlined /> : <LockOutlined />}
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
borderRadius: 999,
|
||||||
|
paddingInline: 10,
|
||||||
|
height: 28,
|
||||||
|
lineHeight: '28px',
|
||||||
|
fontSize: 12,
|
||||||
|
background: 'var(--color-surface-2)',
|
||||||
|
color: 'var(--color-text-secondary)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.visibility === 'public' ? '公开灵感' : '仅自己可见'}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tooltip title={hasOnSelect ? '插入到当前对话' : '复制到剪贴板'}>
|
||||||
|
<Button type="primary" icon={<CopyOutlined />} onClick={onUse} style={{ borderRadius: 12, height: 38, fontWeight: 600 }}>
|
||||||
|
{hasOnSelect ? '使用' : '复制'}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 16, alignItems: 'flex-start', marginBottom: 14 }}>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 19, fontWeight: 700, color: 'var(--color-text)', letterSpacing: '-0.02em', marginBottom: 6 }}>{t.title}</div>
|
||||||
|
<div style={{ fontSize: 12.5, color: 'var(--color-text-tertiary)' }}>by {t.ownerName || '我'}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
padding: '6px 10px',
|
||||||
|
borderRadius: 12,
|
||||||
|
background: 'rgba(15, 23, 42, 0.03)',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
使用 {t.useCount}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
borderRadius: 16,
|
||||||
|
background: 'linear-gradient(180deg, rgba(248,250,252,0.9) 0%, rgba(255,255,255,0.92) 100%)',
|
||||||
|
border: '1px solid rgba(148, 163, 184, 0.14)',
|
||||||
|
padding: '16px 16px 18px',
|
||||||
|
minHeight: 132,
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ position: 'absolute', inset: '0 auto 0 0', width: 4, background: tone.text, opacity: 0.18 }} />
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
fontSize: 13.5,
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
lineHeight: 1.8,
|
||||||
|
minHeight: 96,
|
||||||
|
maxHeight: 130,
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.body.slice(0, 220)}
|
||||||
|
{t.body.length > 220 ? '…' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
marginTop: 'auto',
|
||||||
|
paddingTop: 16,
|
||||||
|
borderTop: '1px solid var(--color-border)',
|
||||||
|
fontSize: 12.5,
|
||||||
|
color: 'var(--color-text-tertiary)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ClockCircleOutlined />
|
||||||
|
<span>最近更新于 {formatDate(t.updatedAt)}</span>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<Space size={2}>
|
||||||
|
<Button size="small" type="text" icon={<EditOutlined />} onClick={onEdit} style={{ borderRadius: 10 }}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Popconfirm title="删除此模板?" onConfirm={onDelete}>
|
||||||
|
<Button size="small" type="text" danger style={{ borderRadius: 10 }}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { Form, Input, Modal, Select } from 'antd';
|
||||||
|
import type { FormInstance } from 'antd';
|
||||||
|
import type { PromptTemplate } from '../../../api';
|
||||||
|
import { CATEGORIES } from '../constants';
|
||||||
|
|
||||||
|
export default function PromptTemplateEditorModal(props: {
|
||||||
|
open: boolean;
|
||||||
|
editing: PromptTemplate | null;
|
||||||
|
form: FormInstance;
|
||||||
|
onCancel: () => void;
|
||||||
|
onOk: () => void;
|
||||||
|
}) {
|
||||||
|
const { open, editing, form, onCancel, onOk } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title={editing ? '编辑模板' : '新建模板'}
|
||||||
|
onCancel={onCancel}
|
||||||
|
onOk={onOk}
|
||||||
|
width={680}
|
||||||
|
okText="保存"
|
||||||
|
cancelText="取消"
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item name="title" label="标题" rules={[{ required: true, max: 60 }]}>
|
||||||
|
<Input placeholder="例如:技术博客润色助手" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="category" label="分类">
|
||||||
|
<Select>
|
||||||
|
{CATEGORIES.map((c) => (
|
||||||
|
<Select.Option key={c} value={c}>
|
||||||
|
{c}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="body"
|
||||||
|
label="模板正文(支持 {{变量名}} 占位符)"
|
||||||
|
rules={[{ required: true }]}
|
||||||
|
tooltip="使用时会以此内容作为消息输入;目前为简单复制,后续可加变量填充表单"
|
||||||
|
>
|
||||||
|
<Input.TextArea rows={10} placeholder={'你是一个 {{role}},请帮我...'} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="visibility" label="可见性">
|
||||||
|
<Select>
|
||||||
|
<Select.Option value="private">仅自己(默认)</Select.Option>
|
||||||
|
<Select.Option value="public">公开(所有用户可见)</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { Empty, Spin } from 'antd';
|
||||||
|
import type { PromptTemplate } from '../../../api';
|
||||||
|
import PromptTemplateCard from './PromptTemplateCard';
|
||||||
|
|
||||||
|
export default function PromptTemplateGrid(props: {
|
||||||
|
loading: boolean;
|
||||||
|
list: PromptTemplate[];
|
||||||
|
hasOnSelect: boolean;
|
||||||
|
onUse: (t: PromptTemplate) => void;
|
||||||
|
onEdit: (t: PromptTemplate) => void;
|
||||||
|
onDelete: (t: PromptTemplate) => void;
|
||||||
|
formatDate: (ts: number) => string;
|
||||||
|
}) {
|
||||||
|
const { loading, list, hasOnSelect, onUse, onEdit, onDelete, formatDate } = props;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minHeight: 280,
|
||||||
|
borderRadius: 22,
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (list.length === 0) {
|
||||||
|
return (
|
||||||
|
<div style={{ borderRadius: 22, background: 'var(--color-surface)', border: '1px solid var(--color-border)', padding: '54px 24px' }}>
|
||||||
|
<Empty description="还没有找到合适的模板,试试换个关键词或新建一个灵感卡片" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))', gap: 18 }}>
|
||||||
|
{list.map((t) => (
|
||||||
|
<PromptTemplateCard
|
||||||
|
key={t.id}
|
||||||
|
template={t}
|
||||||
|
hasOnSelect={hasOnSelect}
|
||||||
|
onUse={() => onUse(t)}
|
||||||
|
onEdit={() => onEdit(t)}
|
||||||
|
onDelete={() => onDelete(t)}
|
||||||
|
formatDate={formatDate}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
export const CATEGORIES = ['通用', '编程', '写作', '翻译', '分析', '客服', '其他'];
|
||||||
|
|
||||||
|
export const SCOPE_OPTIONS: Array<{ key: 'all' | 'mine' | 'public'; label: string }> = [
|
||||||
|
{ key: 'all', label: '全部模板' },
|
||||||
|
{ key: 'mine', label: '我的沉淀' },
|
||||||
|
{ key: 'public', label: '公开灵感' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const CATEGORY_STYLES: Record<string, { bg: string; text: string }> = {
|
||||||
|
通用: { bg: 'rgba(8, 145, 178, 0.10)', text: '#0f766e' },
|
||||||
|
编程: { bg: 'rgba(59, 130, 246, 0.10)', text: '#1d4ed8' },
|
||||||
|
写作: { bg: 'rgba(217, 70, 239, 0.10)', text: '#a21caf' },
|
||||||
|
翻译: { bg: 'rgba(249, 115, 22, 0.10)', text: '#c2410c' },
|
||||||
|
分析: { bg: 'rgba(14, 165, 233, 0.10)', text: '#0369a1' },
|
||||||
|
客服: { bg: 'rgba(34, 197, 94, 0.10)', text: '#15803d' },
|
||||||
|
其他: { bg: 'rgba(100, 116, 139, 0.12)', text: '#475569' }
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -27,7 +27,8 @@ export default function StatsPage() {
|
||||||
const enhanced = res as StatsOverviewWithPoints;
|
const enhanced = res as StatsOverviewWithPoints;
|
||||||
// 模拟积分数据:1 USD = 1000 积分
|
// 模拟积分数据:1 USD = 1000 积分
|
||||||
enhanced.totalSpentUSD = tokenData?.costUSD || 0;
|
enhanced.totalSpentUSD = tokenData?.costUSD || 0;
|
||||||
enhanced.totalPoints = Math.floor(enhanced.totalSpentUSD * 1000);
|
const spent = enhanced.totalSpentUSD || 0;
|
||||||
|
enhanced.totalPoints = Math.floor(spent * 1000);
|
||||||
enhanced.pointsEarned = enhanced.totalPoints;
|
enhanced.pointsEarned = enhanced.totalPoints;
|
||||||
setData(enhanced);
|
setData(enhanced);
|
||||||
if (!tokenAgentId && res.topAgents?.[0]?.id) {
|
if (!tokenAgentId && res.topAgents?.[0]?.id) {
|
||||||
|
|
@ -48,11 +49,12 @@ export default function StatsPage() {
|
||||||
// 更新总消费和积分
|
// 更新总消费和积分
|
||||||
setData((prev) => {
|
setData((prev) => {
|
||||||
if (prev) {
|
if (prev) {
|
||||||
|
const spent = res.costUSD || 0;
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
totalSpentUSD: res.costUSD,
|
totalSpentUSD: spent,
|
||||||
totalPoints: Math.floor(res.costUSD * 1000),
|
totalPoints: Math.floor(spent * 1000),
|
||||||
pointsEarned: Math.floor(res.costUSD * 1000)
|
pointsEarned: Math.floor(spent * 1000)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return prev;
|
return prev;
|
||||||
|
|
|
||||||
|
|
@ -1,780 +1,2 @@
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
export { default } from './WorkflowsPage/WorkflowsPage';
|
||||||
import {
|
|
||||||
ApartmentOutlined,
|
|
||||||
ClockCircleOutlined,
|
|
||||||
PlayCircleOutlined,
|
|
||||||
PlusOutlined,
|
|
||||||
ThunderboltOutlined
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Drawer,
|
|
||||||
Empty,
|
|
||||||
Form,
|
|
||||||
Input,
|
|
||||||
Modal,
|
|
||||||
Select,
|
|
||||||
Space,
|
|
||||||
Switch,
|
|
||||||
Table,
|
|
||||||
Tag,
|
|
||||||
Tooltip,
|
|
||||||
Typography,
|
|
||||||
App as AntApp,
|
|
||||||
Popconfirm,
|
|
||||||
Tabs
|
|
||||||
} from 'antd';
|
|
||||||
import {
|
|
||||||
Workflow,
|
|
||||||
WorkflowAPI,
|
|
||||||
WorkflowGraph,
|
|
||||||
WorkflowNode,
|
|
||||||
WorkflowNodeType,
|
|
||||||
WorkflowRun,
|
|
||||||
WorkflowRunDetail,
|
|
||||||
streamWorkflowRun
|
|
||||||
} from '../api';
|
|
||||||
|
|
||||||
const { Text, Paragraph } = Typography;
|
|
||||||
|
|
||||||
const NODE_TYPE_LABEL: Record<WorkflowNodeType, string> = {
|
|
||||||
agent: '🤖 Agent',
|
|
||||||
skill: '🛠️ Skill',
|
|
||||||
http: '🌐 HTTP',
|
|
||||||
transform: '🔧 Transform',
|
|
||||||
branch: '🔀 Branch'
|
|
||||||
};
|
|
||||||
|
|
||||||
const STATUS_COLOR: Record<string, string> = {
|
|
||||||
running: 'blue',
|
|
||||||
success: 'green',
|
|
||||||
failed: 'red',
|
|
||||||
aborted: 'orange',
|
|
||||||
skipped: 'default'
|
|
||||||
};
|
|
||||||
|
|
||||||
const SAMPLE_GRAPH: WorkflowGraph = {
|
|
||||||
entry: 'fetch',
|
|
||||||
variables: { topic: 'Go 1.23 新特性' },
|
|
||||||
nodes: [
|
|
||||||
{
|
|
||||||
id: 'fetch',
|
|
||||||
type: 'agent',
|
|
||||||
name: '搜集资料',
|
|
||||||
config: {
|
|
||||||
agentId: '',
|
|
||||||
prompt: '请围绕主题"{{vars.topic}}"列出 5 条最近的关键信息,要点形式。'
|
|
||||||
},
|
|
||||||
next: 'summarize'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'summarize',
|
|
||||||
type: 'agent',
|
|
||||||
name: '总结成稿',
|
|
||||||
config: {
|
|
||||||
agentId: '',
|
|
||||||
prompt: '基于以下要点写一段 200 字日报:\n{{steps.fetch.output.text}}'
|
|
||||||
},
|
|
||||||
next: ''
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function WorkflowsPage() {
|
|
||||||
const { message, modal } = AntApp.useApp();
|
|
||||||
const [list, setList] = useState<Workflow[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [editorOpen, setEditorOpen] = useState(false);
|
|
||||||
const [editing, setEditing] = useState<Workflow | null>(null);
|
|
||||||
const [runsOpen, setRunsOpen] = useState(false);
|
|
||||||
const [runsFor, setRunsFor] = useState<Workflow | null>(null);
|
|
||||||
|
|
||||||
const load = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const data = await WorkflowAPI.list();
|
|
||||||
setList(data);
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error('加载失败:' + (e?.message ?? e));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
load();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onCreate = () => {
|
|
||||||
setEditing({
|
|
||||||
id: '',
|
|
||||||
name: '新工作流',
|
|
||||||
description: '',
|
|
||||||
graph: SAMPLE_GRAPH,
|
|
||||||
scheduleCron: '',
|
|
||||||
scheduleEnabled: false,
|
|
||||||
enabled: true,
|
|
||||||
lastRunAt: 0,
|
|
||||||
runCount: 0,
|
|
||||||
createdAt: 0,
|
|
||||||
updatedAt: 0
|
|
||||||
});
|
|
||||||
setEditorOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onEdit = (w: Workflow) => {
|
|
||||||
setEditing(w);
|
|
||||||
setEditorOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDelete = async (w: Workflow) => {
|
|
||||||
await WorkflowAPI.remove(w.id);
|
|
||||||
message.success('已删除');
|
|
||||||
load();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="feature-cover-container">
|
|
||||||
<div className="page-container" style={{ maxWidth: 1400 }}>
|
|
||||||
<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: 680 }}>
|
|
||||||
<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
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ApartmentOutlined 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 }}>
|
|
||||||
让多个 Agent、技能、HTTP 请求与数据转换连成一条可运行的自动化链路。这里更像一个流程画廊,而不是传统表格后台。
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
size="large"
|
|
||||||
icon={<PlusOutlined />}
|
|
||||||
onClick={onCreate}
|
|
||||||
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: list.filter((item) => item.enabled).length, tone: 'rgba(34, 197, 94, 0.10)', color: 'var(--color-success)' },
|
|
||||||
{ label: '定时运行', value: list.filter((item) => item.scheduleEnabled && item.scheduleCron).length, tone: 'rgba(249, 115, 22, 0.10)', color: 'var(--color-warning)' }
|
|
||||||
].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>
|
|
||||||
|
|
||||||
{loading ? null : list.length === 0 ? (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
borderRadius: 22,
|
|
||||||
background: 'var(--color-surface)',
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
padding: '54px 24px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Empty description="还没有工作流,点击上方开始搭建第一条自动化流程" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))', gap: 18 }}>
|
|
||||||
{list.map((r) => (
|
|
||||||
<div
|
|
||||||
key={r.id}
|
|
||||||
style={{
|
|
||||||
borderRadius: 20,
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
background: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(252,252,253,1) 100%)',
|
|
||||||
boxShadow: '0 12px 28px rgba(15, 23, 42, 0.045)',
|
|
||||||
padding: 20,
|
|
||||||
minHeight: 308,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginBottom: 16 }}>
|
|
||||||
<div style={{ minWidth: 0 }}>
|
|
||||||
<div style={{ fontSize: 19, fontWeight: 700, color: 'var(--color-text)', marginBottom: 6 }}>{r.name}</div>
|
|
||||||
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.7 }}>
|
|
||||||
{r.description || '还没有补充描述,可以说明这个流程负责什么自动化任务。'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Tag
|
|
||||||
bordered={false}
|
|
||||||
style={{
|
|
||||||
margin: 0,
|
|
||||||
borderRadius: 999,
|
|
||||||
background: r.enabled ? 'var(--color-success-soft)' : 'var(--color-surface-2)',
|
|
||||||
color: r.enabled ? 'var(--color-success)' : 'var(--color-text-secondary)',
|
|
||||||
height: 28,
|
|
||||||
lineHeight: '28px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{r.enabled ? '已启用' : '已停用'}
|
|
||||||
</Tag>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 12, marginBottom: 16 }}>
|
|
||||||
<div style={{ borderRadius: 16, padding: '14px 14px 12px', 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)' }}>{r.graph?.nodes?.length ?? 0}</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ borderRadius: 16, padding: '14px 14px 12px', 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)' }}>{r.runCount}</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ borderRadius: 16, padding: '14px 14px 12px', 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: 14, fontWeight: 600, color: 'var(--color-text)' }}>
|
|
||||||
{r.scheduleEnabled && r.scheduleCron ? '定时' : '手动'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
borderRadius: 16,
|
|
||||||
padding: '16px 16px 14px',
|
|
||||||
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)',
|
|
||||||
marginBottom: 16
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5, color: 'var(--color-text-secondary)', marginBottom: 10 }}>
|
|
||||||
<ClockCircleOutlined />
|
|
||||||
调度与运行
|
|
||||||
</div>
|
|
||||||
{r.scheduleEnabled && r.scheduleCron ? (
|
|
||||||
<Tag bordered={false} style={{ margin: 0, background: 'var(--color-brand-soft)', color: 'var(--color-brand)', borderRadius: 999 }}>
|
|
||||||
cron: {r.scheduleCron}
|
|
||||||
</Tag>
|
|
||||||
) : (
|
|
||||||
<Tag bordered={false} style={{ margin: 0, background: 'var(--color-surface-2)', color: 'var(--color-text-secondary)', borderRadius: 999 }}>
|
|
||||||
手动触发
|
|
||||||
</Tag>
|
|
||||||
)}
|
|
||||||
<div style={{ fontSize: 12.5, color: 'var(--color-text-tertiary)', marginTop: 10 }}>
|
|
||||||
上次运行 {r.lastRunAt ? new Date(r.lastRunAt).toLocaleString() : '尚未运行'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 'auto', paddingTop: 16, borderTop: '1px solid var(--color-border)' }}>
|
|
||||||
<Button type="primary" icon={<ThunderboltOutlined />} onClick={() => onEdit(r)} style={{ borderRadius: 12, height: 40, fontWeight: 600 }}>
|
|
||||||
编辑流程
|
|
||||||
</Button>
|
|
||||||
<Button icon={<PlayCircleOutlined />} onClick={() => { setRunsFor(r); setRunsOpen(true); }} style={{ borderRadius: 12, height: 40 }}>
|
|
||||||
运行 / 历史
|
|
||||||
</Button>
|
|
||||||
<Popconfirm title="删除?" onConfirm={() => onDelete(r)}>
|
|
||||||
<Button danger style={{ borderRadius: 12, height: 40 }}>
|
|
||||||
删除
|
|
||||||
</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{editing && (
|
|
||||||
<WorkflowEditor
|
|
||||||
open={editorOpen}
|
|
||||||
workflow={editing}
|
|
||||||
onClose={() => setEditorOpen(false)}
|
|
||||||
onSaved={() => {
|
|
||||||
setEditorOpen(false);
|
|
||||||
load();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{runsFor && (
|
|
||||||
<RunsDrawer
|
|
||||||
open={runsOpen}
|
|
||||||
workflow={runsFor}
|
|
||||||
onClose={() => setRunsOpen(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="feature-cover">
|
|
||||||
<Empty description="功能规划中,本期不支持" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ================== 编辑器 ==================
|
|
||||||
|
|
||||||
function WorkflowEditor({
|
|
||||||
open, workflow, onClose, onSaved
|
|
||||||
}: {
|
|
||||||
open: boolean;
|
|
||||||
workflow: Workflow;
|
|
||||||
onClose: () => void;
|
|
||||||
onSaved: () => void;
|
|
||||||
}) {
|
|
||||||
const { message } = AntApp.useApp();
|
|
||||||
const [form] = Form.useForm();
|
|
||||||
const [graphText, setGraphText] = useState(JSON.stringify(workflow.graph, null, 2));
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
form.setFieldsValue({
|
|
||||||
name: workflow.name,
|
|
||||||
description: workflow.description,
|
|
||||||
scheduleCron: workflow.scheduleCron,
|
|
||||||
scheduleEnabled: workflow.scheduleEnabled,
|
|
||||||
enabled: workflow.enabled
|
|
||||||
});
|
|
||||||
setGraphText(JSON.stringify(workflow.graph, null, 2));
|
|
||||||
}, [workflow]);
|
|
||||||
|
|
||||||
const onSave = async () => {
|
|
||||||
const values = await form.validateFields();
|
|
||||||
let graph: WorkflowGraph;
|
|
||||||
try {
|
|
||||||
graph = JSON.parse(graphText);
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error('Graph JSON 解析失败:' + e.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!graph.entry || !Array.isArray(graph.nodes)) {
|
|
||||||
message.error('Graph 必须包含 entry + nodes 数组');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
const payload = { ...values, graph };
|
|
||||||
if (workflow.id) {
|
|
||||||
await WorkflowAPI.update(workflow.id, payload);
|
|
||||||
} else {
|
|
||||||
await WorkflowAPI.create(payload);
|
|
||||||
}
|
|
||||||
message.success('已保存');
|
|
||||||
onSaved();
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error('保存失败:' + (e?.message ?? e));
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Drawer
|
|
||||||
title={workflow.id ? `编辑「${workflow.name}」` : '新建工作流'}
|
|
||||||
width={840}
|
|
||||||
open={open}
|
|
||||||
onClose={onClose}
|
|
||||||
styles={{ body: { background: '#fcfcfd' }, header: { background: '#fff', borderBottom: '1px solid var(--color-border)' } }}
|
|
||||||
extra={
|
|
||||||
<Button type="primary" loading={saving} onClick={onSave} style={{ borderRadius: 10 }}>
|
|
||||||
保存
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Form form={form} layout="vertical">
|
|
||||||
<Form.Item label="名称" name="name" rules={[{ required: true, message: '必填' }]}>
|
|
||||||
<Input />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item label="描述" name="description">
|
|
||||||
<Input.TextArea rows={2} />
|
|
||||||
</Form.Item>
|
|
||||||
<Space size="large" style={{ marginBottom: 12 }}>
|
|
||||||
<Form.Item name="enabled" valuePropName="checked" noStyle>
|
|
||||||
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="scheduleEnabled" valuePropName="checked" noStyle>
|
|
||||||
<Switch checkedChildren="定时开" unCheckedChildren="定时关" />
|
|
||||||
</Form.Item>
|
|
||||||
</Space>
|
|
||||||
<Form.Item
|
|
||||||
label={
|
|
||||||
<Space>
|
|
||||||
<span>Cron 表达式</span>
|
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>5 段:分 时 日 月 周,例如 "*/30 * * * *"</Text>
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
name="scheduleCron"
|
|
||||||
>
|
|
||||||
<Input placeholder="例如 0 8 * * 1-5(工作日早 8 点)" />
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
|
|
||||||
<NodeQuickAdd
|
|
||||||
onAdd={(node) => {
|
|
||||||
try {
|
|
||||||
const g: WorkflowGraph = JSON.parse(graphText);
|
|
||||||
g.nodes = [...(g.nodes || []), node];
|
|
||||||
if (!g.entry) g.entry = node.id;
|
|
||||||
setGraphText(JSON.stringify(g, null, 2));
|
|
||||||
} catch {
|
|
||||||
message.error('Graph JSON 不合法,无法追加');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Card
|
|
||||||
size="small"
|
|
||||||
title={<Text strong>Graph JSON(节点列表)</Text>}
|
|
||||||
style={{ marginTop: 12, borderRadius: 16, boxShadow: 'var(--shadow-xs)' }}
|
|
||||||
extra={
|
|
||||||
<Tooltip title="还原为示例">
|
|
||||||
<Button size="small" onClick={() => setGraphText(JSON.stringify(SAMPLE_GRAPH, null, 2))} style={{ borderRadius: 8 }}>
|
|
||||||
示例
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Input.TextArea
|
|
||||||
rows={20}
|
|
||||||
value={graphText}
|
|
||||||
onChange={(e) => setGraphText(e.target.value)}
|
|
||||||
style={{ fontFamily: 'ui-monospace, monospace', fontSize: 12 }}
|
|
||||||
/>
|
|
||||||
<Paragraph type="secondary" style={{ fontSize: 12, marginTop: 8 }}>
|
|
||||||
支持模板 <code>{'{{input.x}} {{vars.x}} {{steps.<nodeId>.output...}}'}</code>。<br />
|
|
||||||
节点类型:agent / skill / http / transform / branch;branch 节点用 <code>condition</code>(返回 bool 的 JS 片段),引擎按 next/elseNext 走。
|
|
||||||
</Paragraph>
|
|
||||||
</Card>
|
|
||||||
</Drawer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function NodeQuickAdd({ onAdd }: { onAdd: (n: WorkflowNode) => void }) {
|
|
||||||
const [type, setType] = useState<WorkflowNodeType>('agent');
|
|
||||||
const [id, setId] = useState('');
|
|
||||||
return (
|
|
||||||
<Card size="small" title="快速追加节点" style={{ marginTop: 12, borderRadius: 16, boxShadow: 'var(--shadow-xs)' }}>
|
|
||||||
<Space wrap>
|
|
||||||
<Select
|
|
||||||
value={type}
|
|
||||||
style={{ width: 140 }}
|
|
||||||
onChange={(v) => setType(v as WorkflowNodeType)}
|
|
||||||
options={Object.entries(NODE_TYPE_LABEL).map(([k, label]) => ({
|
|
||||||
value: k, label
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
placeholder="节点 id(如 step1)"
|
|
||||||
value={id}
|
|
||||||
onChange={(e) => setId(e.target.value)}
|
|
||||||
style={{ width: 200 }}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
disabled={!id.trim()}
|
|
||||||
style={{ borderRadius: 10 }}
|
|
||||||
onClick={() => {
|
|
||||||
const base: WorkflowNode = {
|
|
||||||
id: id.trim(),
|
|
||||||
type,
|
|
||||||
name: NODE_TYPE_LABEL[type],
|
|
||||||
config: defaultConfig(type),
|
|
||||||
next: ''
|
|
||||||
};
|
|
||||||
if (type === 'branch') base.elseNext = '';
|
|
||||||
onAdd(base);
|
|
||||||
setId('');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
追加
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultConfig(type: WorkflowNodeType): Record<string, any> {
|
|
||||||
switch (type) {
|
|
||||||
case 'agent':
|
|
||||||
return { agentId: '', prompt: '请帮我处理:{{input.text}}' };
|
|
||||||
case 'skill':
|
|
||||||
return { skillId: '', args: {} };
|
|
||||||
case 'http':
|
|
||||||
return { method: 'GET', url: 'https://example.com/api', headers: {}, body: null };
|
|
||||||
case 'transform':
|
|
||||||
return { code: 'return { value: input.text };' };
|
|
||||||
case 'branch':
|
|
||||||
return { condition: 'steps.prev?.output?.value === true' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ================== 运行抽屉 ==================
|
|
||||||
|
|
||||||
function RunsDrawer({
|
|
||||||
open, workflow, onClose
|
|
||||||
}: {
|
|
||||||
open: boolean;
|
|
||||||
workflow: Workflow;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const { message } = AntApp.useApp();
|
|
||||||
const [tab, setTab] = useState('run');
|
|
||||||
const [runs, setRuns] = useState<WorkflowRun[]>([]);
|
|
||||||
const [detail, setDetail] = useState<WorkflowRunDetail | null>(null);
|
|
||||||
const [streaming, setStreaming] = useState(false);
|
|
||||||
const [steps, setSteps] = useState<any[]>([]);
|
|
||||||
const [finalRun, setFinalRun] = useState<any>(null);
|
|
||||||
const [inputJson, setInputJson] = useState('{}');
|
|
||||||
|
|
||||||
const loadRuns = async () => {
|
|
||||||
try {
|
|
||||||
const data = await WorkflowAPI.listRuns(workflow.id, 30);
|
|
||||||
setRuns(data);
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error('加载历史失败:' + (e?.message ?? e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) loadRuns();
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
const onRunStream = () => {
|
|
||||||
let input: any = undefined;
|
|
||||||
if (inputJson.trim()) {
|
|
||||||
try {
|
|
||||||
input = JSON.parse(inputJson);
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error('input JSON 不合法');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setSteps([]);
|
|
||||||
setFinalRun(null);
|
|
||||||
setStreaming(true);
|
|
||||||
streamWorkflowRun(workflow.id, input, {
|
|
||||||
onStepStart: (d) => {
|
|
||||||
setSteps((s) => [...s, { ...d, status: 'running' }]);
|
|
||||||
},
|
|
||||||
onStepFinish: (d) => {
|
|
||||||
setSteps((s) => {
|
|
||||||
const idx = [...s].reverse().findIndex((x) => x.nodeId === d.nodeId && x.status === 'running');
|
|
||||||
if (idx === -1) return [...s, d];
|
|
||||||
const realIdx = s.length - 1 - idx;
|
|
||||||
const cp = [...s];
|
|
||||||
cp[realIdx] = d;
|
|
||||||
return cp;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onRunFinish: (d) => {
|
|
||||||
setStreaming(false);
|
|
||||||
setFinalRun(d);
|
|
||||||
loadRuns();
|
|
||||||
},
|
|
||||||
onError: (msg) => {
|
|
||||||
setStreaming(false);
|
|
||||||
message.error(msg);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const showDetail = async (runId: string) => {
|
|
||||||
try {
|
|
||||||
const d = await WorkflowAPI.getRun(runId);
|
|
||||||
setDetail(d);
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error('加载详情失败:' + (e?.message ?? e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Drawer
|
|
||||||
title={`运行:${workflow.name}`}
|
|
||||||
width={760}
|
|
||||||
open={open}
|
|
||||||
onClose={() => { setDetail(null); onClose(); }}
|
|
||||||
styles={{ body: { background: '#fcfcfd' }, header: { background: '#fff', borderBottom: '1px solid var(--color-border)' } }}
|
|
||||||
>
|
|
||||||
<Tabs
|
|
||||||
activeKey={tab}
|
|
||||||
onChange={setTab}
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
key: 'run',
|
|
||||||
label: '触发',
|
|
||||||
children: (
|
|
||||||
<div>
|
|
||||||
<Form layout="vertical">
|
|
||||||
<Form.Item label="Input(JSON,会作为 input.* 注入)">
|
|
||||||
<Input.TextArea
|
|
||||||
rows={4}
|
|
||||||
value={inputJson}
|
|
||||||
onChange={(e) => setInputJson(e.target.value)}
|
|
||||||
placeholder='{"text":"hello"}'
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
<Space style={{ marginBottom: 12 }}>
|
|
||||||
<Button type="primary" loading={streaming} onClick={onRunStream} style={{ borderRadius: 10 }}>
|
|
||||||
▶ 流式执行
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
style={{ borderRadius: 10 }}
|
|
||||||
onClick={async () => {
|
|
||||||
try {
|
|
||||||
const r = await WorkflowAPI.run(workflow.id, JSON.parse(inputJson || '{}'));
|
|
||||||
message.success(`已触发 run=${r.runId}`);
|
|
||||||
loadRuns();
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error('触发失败:' + (e?.message ?? e));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
同步执行(不看进度)
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
|
|
||||||
{steps.length > 0 && (
|
|
||||||
<Card size="small" title="实时进度" style={{ marginBottom: 12, borderRadius: 16 }}>
|
|
||||||
{steps.map((s, i) => (
|
|
||||||
<div key={i} style={{ marginBottom: 8 }}>
|
|
||||||
<Space>
|
|
||||||
<Tag color={STATUS_COLOR[s.status] || 'default'}>{s.status}</Tag>
|
|
||||||
<Text strong>{s.nodeId}</Text>
|
|
||||||
<Text type="secondary">{s.nodeType}</Text>
|
|
||||||
{s.durationMs != null && <Text type="secondary">{s.durationMs}ms</Text>}
|
|
||||||
</Space>
|
|
||||||
{s.error && <Paragraph type="danger" style={{ marginBottom: 0 }}>{s.error}</Paragraph>}
|
|
||||||
{s.output != null && (
|
|
||||||
<pre style={{ background: '#f5f5f5', padding: 6, borderRadius: 4, fontSize: 12, marginTop: 4, maxHeight: 160, overflow: 'auto' }}>
|
|
||||||
{JSON.stringify(s.output, null, 2)}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{finalRun && (
|
|
||||||
<Card size="small" title="最终结果" style={{ borderRadius: 16 }}>
|
|
||||||
<pre style={{ fontSize: 12, margin: 0 }}>
|
|
||||||
{JSON.stringify(finalRun, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'history',
|
|
||||||
label: `历史 (${runs.length})`,
|
|
||||||
children: (
|
|
||||||
<div>
|
|
||||||
<Button size="small" onClick={loadRuns} style={{ marginBottom: 8, borderRadius: 8 }}>刷新</Button>
|
|
||||||
<Table<WorkflowRun>
|
|
||||||
rowKey="id"
|
|
||||||
size="small"
|
|
||||||
pagination={false}
|
|
||||||
dataSource={runs}
|
|
||||||
columns={[
|
|
||||||
{ title: '状态', dataIndex: 'status', render: (s) => <Tag color={STATUS_COLOR[s] || 'default'}>{s}</Tag>, width: 100 },
|
|
||||||
{ title: '触发', dataIndex: 'trigger', width: 80 },
|
|
||||||
{ title: '开始', dataIndex: 'startedAt', render: (v) => new Date(v).toLocaleString(), width: 180 },
|
|
||||||
{ title: '耗时', dataIndex: 'durationMs', render: (v) => `${v} ms`, width: 100 },
|
|
||||||
{ title: '', render: (_, r) => <Button size="small" onClick={() => showDetail(r.id)}>详情</Button> }
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
{detail && (
|
|
||||||
<Modal
|
|
||||||
title={`Run ${detail.id}`}
|
|
||||||
open
|
|
||||||
onCancel={() => setDetail(null)}
|
|
||||||
onOk={() => setDetail(null)}
|
|
||||||
width={680}
|
|
||||||
>
|
|
||||||
<p><b>状态:</b><Tag color={STATUS_COLOR[detail.status] || 'default'}>{detail.status}</Tag>{detail.error && <Text type="danger" style={{ marginLeft: 8 }}>{detail.error}</Text>}</p>
|
|
||||||
<p><b>耗时:</b>{detail.durationMs} ms</p>
|
|
||||||
<p><b>Input:</b></p>
|
|
||||||
<pre style={{ background: '#f5f5f5', padding: 8, fontSize: 12 }}>{JSON.stringify(detail.input, null, 2)}</pre>
|
|
||||||
<p><b>Steps:</b></p>
|
|
||||||
{detail.steps.map((s) => (
|
|
||||||
<Card size="small" key={s.id} style={{ marginBottom: 8, borderRadius: 14 }}>
|
|
||||||
<Space>
|
|
||||||
<Tag color={STATUS_COLOR[s.status] || 'default'}>{s.status}</Tag>
|
|
||||||
<Text strong>{s.nodeId}</Text>
|
|
||||||
<Text type="secondary">{s.nodeType}</Text>
|
|
||||||
<Text type="secondary">{s.durationMs} ms</Text>
|
|
||||||
</Space>
|
|
||||||
{s.error && <p style={{ color: 'red', marginTop: 4 }}>{s.error}</p>}
|
|
||||||
{s.output != null && (
|
|
||||||
<pre style={{ fontSize: 12, marginTop: 4, maxHeight: 200, overflow: 'auto' }}>
|
|
||||||
{JSON.stringify(s.output, null, 2)}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</Modal>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Drawer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,287 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { ApartmentOutlined, ClockCircleOutlined, PlayCircleOutlined, PlusOutlined, ThunderboltOutlined } from '@ant-design/icons';
|
||||||
|
import { Button, Empty, Tag, App as AntApp, Popconfirm } from 'antd';
|
||||||
|
import type { Workflow } from '../../api';
|
||||||
|
import { WorkflowAPI } from '../../api';
|
||||||
|
import { SAMPLE_GRAPH } from './constants';
|
||||||
|
import WorkflowEditorDrawer from './components/WorkflowEditorDrawer';
|
||||||
|
import RunsDrawer from './components/RunsDrawer';
|
||||||
|
|
||||||
|
export default function WorkflowsPage() {
|
||||||
|
const { message } = AntApp.useApp();
|
||||||
|
const [list, setList] = useState<Workflow[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [editorOpen, setEditorOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<Workflow | null>(null);
|
||||||
|
const [runsOpen, setRunsOpen] = useState(false);
|
||||||
|
const [runsFor, setRunsFor] = useState<Workflow | null>(null);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await WorkflowAPI.list();
|
||||||
|
setList(data);
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error('加载失败:' + (e?.message ?? e));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onCreate = () => {
|
||||||
|
setEditing({
|
||||||
|
id: '',
|
||||||
|
name: '新工作流',
|
||||||
|
description: '',
|
||||||
|
graph: SAMPLE_GRAPH,
|
||||||
|
scheduleCron: '',
|
||||||
|
scheduleEnabled: false,
|
||||||
|
enabled: true,
|
||||||
|
lastRunAt: 0,
|
||||||
|
runCount: 0,
|
||||||
|
createdAt: 0,
|
||||||
|
updatedAt: 0
|
||||||
|
});
|
||||||
|
setEditorOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onEdit = (w: Workflow) => {
|
||||||
|
setEditing(w);
|
||||||
|
setEditorOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDelete = async (w: Workflow) => {
|
||||||
|
await WorkflowAPI.remove(w.id);
|
||||||
|
message.success('已删除');
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="feature-cover-container">
|
||||||
|
<div className="page-container" style={{ maxWidth: 1400 }}>
|
||||||
|
<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: 680 }}>
|
||||||
|
<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
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ApartmentOutlined 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 }}>
|
||||||
|
让多个 Agent、技能、HTTP 请求与数据转换连成一条可运行的自动化链路。这里更像一个流程画廊,而不是传统表格后台。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button type="primary" size="large" icon={<PlusOutlined />} onClick={onCreate} 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: list.filter((item) => item.enabled).length, tone: 'rgba(34, 197, 94, 0.10)', color: 'var(--color-success)' },
|
||||||
|
{
|
||||||
|
label: '定时运行',
|
||||||
|
value: list.filter((item) => item.scheduleEnabled && item.scheduleCron).length,
|
||||||
|
tone: 'rgba(249, 115, 22, 0.10)',
|
||||||
|
color: 'var(--color-warning)'
|
||||||
|
}
|
||||||
|
].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>
|
||||||
|
|
||||||
|
{loading ? null : list.length === 0 ? (
|
||||||
|
<div style={{ borderRadius: 22, background: 'var(--color-surface)', border: '1px solid var(--color-border)', padding: '54px 24px' }}>
|
||||||
|
<Empty description="还没有工作流,点击上方开始搭建第一条自动化流程" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))', gap: 18 }}>
|
||||||
|
{list.map((r) => (
|
||||||
|
<div
|
||||||
|
key={r.id}
|
||||||
|
style={{
|
||||||
|
borderRadius: 20,
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
background: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(252,252,253,1) 100%)',
|
||||||
|
boxShadow: '0 12px 28px rgba(15, 23, 42, 0.045)',
|
||||||
|
padding: 20,
|
||||||
|
minHeight: 308,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginBottom: 16 }}>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 19, fontWeight: 700, color: 'var(--color-text)', marginBottom: 6 }}>{r.name}</div>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.7 }}>
|
||||||
|
{r.description || '还没有补充描述,可以说明这个流程负责什么自动化任务。'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Tag
|
||||||
|
bordered={false}
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
borderRadius: 999,
|
||||||
|
background: r.enabled ? 'var(--color-success-soft)' : 'var(--color-surface-2)',
|
||||||
|
color: r.enabled ? 'var(--color-success)' : 'var(--color-text-secondary)',
|
||||||
|
height: 28,
|
||||||
|
lineHeight: '28px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{r.enabled ? '已启用' : '已停用'}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 12, marginBottom: 16 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: '14px 14px 12px',
|
||||||
|
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)' }}>{r.graph?.nodes?.length ?? 0}</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: '14px 14px 12px',
|
||||||
|
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)' }}>{r.runCount}</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: '14px 14px 12px',
|
||||||
|
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: 14, fontWeight: 600, color: 'var(--color-text)' }}>{r.scheduleEnabled && r.scheduleCron ? '定时' : '手动'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: '16px 16px 14px',
|
||||||
|
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)',
|
||||||
|
marginBottom: 16
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5, color: 'var(--color-text-secondary)', marginBottom: 10 }}>
|
||||||
|
<ClockCircleOutlined />
|
||||||
|
调度与运行
|
||||||
|
</div>
|
||||||
|
{r.scheduleEnabled && r.scheduleCron ? (
|
||||||
|
<Tag bordered={false} style={{ margin: 0, background: 'var(--color-brand-soft)', color: 'var(--color-brand)', borderRadius: 999 }}>
|
||||||
|
cron: {r.scheduleCron}
|
||||||
|
</Tag>
|
||||||
|
) : (
|
||||||
|
<Tag bordered={false} style={{ margin: 0, background: 'var(--color-surface-2)', color: 'var(--color-text-secondary)', borderRadius: 999 }}>
|
||||||
|
手动触发
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
<div style={{ fontSize: 12.5, color: 'var(--color-text-tertiary)', marginTop: 10 }}>
|
||||||
|
上次运行 {r.lastRunAt ? new Date(r.lastRunAt).toLocaleString() : '尚未运行'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 'auto', paddingTop: 16, borderTop: '1px solid var(--color-border)' }}>
|
||||||
|
<Button type="primary" icon={<ThunderboltOutlined />} onClick={() => onEdit(r)} style={{ borderRadius: 12, height: 40, fontWeight: 600 }}>
|
||||||
|
编辑流程
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
icon={<PlayCircleOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
setRunsFor(r);
|
||||||
|
setRunsOpen(true);
|
||||||
|
}}
|
||||||
|
style={{ borderRadius: 12, height: 40 }}
|
||||||
|
>
|
||||||
|
运行 / 历史
|
||||||
|
</Button>
|
||||||
|
<Popconfirm title="删除?" onConfirm={() => onDelete(r)}>
|
||||||
|
<Button danger style={{ borderRadius: 12, height: 40 }}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<WorkflowEditorDrawer
|
||||||
|
open={editorOpen}
|
||||||
|
workflow={editing}
|
||||||
|
onClose={() => setEditorOpen(false)}
|
||||||
|
onSaved={() => {
|
||||||
|
setEditorOpen(false);
|
||||||
|
load();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{runsFor && <RunsDrawer open={runsOpen} workflow={runsFor} onClose={() => setRunsOpen(false)} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="feature-cover">
|
||||||
|
<Empty description="功能规划中,本期不支持" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,224 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Button, Card, Drawer, Form, Input, Modal, Space, Table, Tabs, Tag, Typography, App as AntApp } from 'antd';
|
||||||
|
import type { Workflow, WorkflowRun, WorkflowRunDetail } from '../../../api';
|
||||||
|
import { WorkflowAPI, streamWorkflowRun } from '../../../api';
|
||||||
|
import { STATUS_COLOR } from '../constants';
|
||||||
|
|
||||||
|
const { Text, Paragraph } = Typography;
|
||||||
|
|
||||||
|
export default function RunsDrawer(props: { open: boolean; workflow: Workflow; onClose: () => void }) {
|
||||||
|
const { open, workflow, onClose } = props;
|
||||||
|
const { message } = AntApp.useApp();
|
||||||
|
const [tab, setTab] = useState('run');
|
||||||
|
const [runs, setRuns] = useState<WorkflowRun[]>([]);
|
||||||
|
const [detail, setDetail] = useState<WorkflowRunDetail | null>(null);
|
||||||
|
const [streaming, setStreaming] = useState(false);
|
||||||
|
const [steps, setSteps] = useState<any[]>([]);
|
||||||
|
const [finalRun, setFinalRun] = useState<any>(null);
|
||||||
|
const [inputJson, setInputJson] = useState('{}');
|
||||||
|
|
||||||
|
const loadRuns = async () => {
|
||||||
|
try {
|
||||||
|
const data = await WorkflowAPI.listRuns(workflow.id, 30);
|
||||||
|
setRuns(data);
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error('加载历史失败:' + (e?.message ?? e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) loadRuns();
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const onRunStream = () => {
|
||||||
|
let input: any = undefined;
|
||||||
|
if (inputJson.trim()) {
|
||||||
|
try {
|
||||||
|
input = JSON.parse(inputJson);
|
||||||
|
} catch {
|
||||||
|
message.error('input JSON 不合法');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSteps([]);
|
||||||
|
setFinalRun(null);
|
||||||
|
setStreaming(true);
|
||||||
|
streamWorkflowRun(workflow.id, input, {
|
||||||
|
onStepStart: (d) => setSteps((s) => [...s, { ...d, status: 'running' }]),
|
||||||
|
onStepFinish: (d) => {
|
||||||
|
setSteps((s) => {
|
||||||
|
const idx = [...s].reverse().findIndex((x) => x.nodeId === d.nodeId && x.status === 'running');
|
||||||
|
if (idx === -1) return [...s, d];
|
||||||
|
const realIdx = s.length - 1 - idx;
|
||||||
|
const cp = [...s];
|
||||||
|
cp[realIdx] = d;
|
||||||
|
return cp;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onRunFinish: (d) => {
|
||||||
|
setStreaming(false);
|
||||||
|
setFinalRun(d);
|
||||||
|
loadRuns();
|
||||||
|
},
|
||||||
|
onError: (msg) => {
|
||||||
|
setStreaming(false);
|
||||||
|
message.error(msg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const showDetail = async (runId: string) => {
|
||||||
|
try {
|
||||||
|
const d = await WorkflowAPI.getRun(runId);
|
||||||
|
setDetail(d);
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error('加载详情失败:' + (e?.message ?? e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
title={`运行:${workflow.name}`}
|
||||||
|
width={760}
|
||||||
|
open={open}
|
||||||
|
onClose={() => {
|
||||||
|
setDetail(null);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
styles={{ body: { background: '#fcfcfd' }, header: { background: '#fff', borderBottom: '1px solid var(--color-border)' } }}
|
||||||
|
>
|
||||||
|
<Tabs
|
||||||
|
activeKey={tab}
|
||||||
|
onChange={setTab}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'run',
|
||||||
|
label: '触发',
|
||||||
|
children: (
|
||||||
|
<div>
|
||||||
|
<Form layout="vertical">
|
||||||
|
<Form.Item label="Input(JSON,会作为 input.* 注入)">
|
||||||
|
<Input.TextArea rows={4} value={inputJson} onChange={(e) => setInputJson(e.target.value)} placeholder='{"text":"hello"}' />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
<Space style={{ marginBottom: 12 }}>
|
||||||
|
<Button type="primary" loading={streaming} onClick={onRunStream} style={{ borderRadius: 10 }}>
|
||||||
|
▶ 流式执行
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
style={{ borderRadius: 10 }}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const r = await WorkflowAPI.run(workflow.id, JSON.parse(inputJson || '{}'));
|
||||||
|
message.success(`已触发 run=${r.runId}`);
|
||||||
|
loadRuns();
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error('触发失败:' + (e?.message ?? e));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
同步执行(不看进度)
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
{steps.length > 0 && (
|
||||||
|
<Card size="small" title="实时进度" style={{ marginBottom: 12, borderRadius: 16 }}>
|
||||||
|
{steps.map((s, i) => (
|
||||||
|
<div key={i} style={{ marginBottom: 8 }}>
|
||||||
|
<Space>
|
||||||
|
<Tag color={STATUS_COLOR[s.status] || 'default'}>{s.status}</Tag>
|
||||||
|
<Text strong>{s.nodeId}</Text>
|
||||||
|
<Text type="secondary">{s.nodeType}</Text>
|
||||||
|
{s.durationMs != null && <Text type="secondary">{s.durationMs}ms</Text>}
|
||||||
|
</Space>
|
||||||
|
{s.error && (
|
||||||
|
<Paragraph type="danger" style={{ marginBottom: 0 }}>
|
||||||
|
{s.error}
|
||||||
|
</Paragraph>
|
||||||
|
)}
|
||||||
|
{s.output != null && (
|
||||||
|
<pre style={{ background: '#f5f5f5', padding: 6, borderRadius: 4, fontSize: 12, marginTop: 4, maxHeight: 160, overflow: 'auto' }}>
|
||||||
|
{JSON.stringify(s.output, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{finalRun && (
|
||||||
|
<Card size="small" title="最终结果" style={{ borderRadius: 16 }}>
|
||||||
|
<pre style={{ fontSize: 12, margin: 0 }}>{JSON.stringify(finalRun, null, 2)}</pre>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'history',
|
||||||
|
label: `历史 (${runs.length})`,
|
||||||
|
children: (
|
||||||
|
<div>
|
||||||
|
<Button size="small" onClick={loadRuns} style={{ marginBottom: 8, borderRadius: 8 }}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
<Table<WorkflowRun>
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
dataSource={runs}
|
||||||
|
columns={[
|
||||||
|
{ title: '状态', dataIndex: 'status', render: (s) => <Tag color={STATUS_COLOR[s] || 'default'}>{s}</Tag>, width: 100 },
|
||||||
|
{ title: '触发', dataIndex: 'trigger', width: 80 },
|
||||||
|
{ title: '开始', dataIndex: 'startedAt', render: (v) => new Date(v).toLocaleString(), width: 180 },
|
||||||
|
{ title: '耗时', dataIndex: 'durationMs', render: (v) => `${v} ms`, width: 100 },
|
||||||
|
{ title: '', render: (_, r) => <Button size="small" onClick={() => showDetail(r.id)}>详情</Button> }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{detail && (
|
||||||
|
<Modal title={`Run ${detail.id}`} open onCancel={() => setDetail(null)} onOk={() => setDetail(null)} width={680}>
|
||||||
|
<p>
|
||||||
|
<b>状态:</b>
|
||||||
|
<Tag color={STATUS_COLOR[detail.status] || 'default'}>{detail.status}</Tag>
|
||||||
|
{detail.error && (
|
||||||
|
<Text type="danger" style={{ marginLeft: 8 }}>
|
||||||
|
{detail.error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<b>耗时:</b>
|
||||||
|
{detail.durationMs} ms
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<b>Input:</b>
|
||||||
|
</p>
|
||||||
|
<pre style={{ background: '#f5f5f5', padding: 8, fontSize: 12 }}>{JSON.stringify(detail.input, null, 2)}</pre>
|
||||||
|
<p>
|
||||||
|
<b>Steps:</b>
|
||||||
|
</p>
|
||||||
|
{detail.steps.map((s) => (
|
||||||
|
<Card size="small" key={s.id} style={{ marginBottom: 8, borderRadius: 14 }}>
|
||||||
|
<Space>
|
||||||
|
<Tag color={STATUS_COLOR[s.status] || 'default'}>{s.status}</Tag>
|
||||||
|
<Text strong>{s.nodeId}</Text>
|
||||||
|
<Text type="secondary">{s.nodeType}</Text>
|
||||||
|
<Text type="secondary">{s.durationMs} ms</Text>
|
||||||
|
</Space>
|
||||||
|
{s.error && <p style={{ color: 'red', marginTop: 4 }}>{s.error}</p>}
|
||||||
|
{s.output != null && (
|
||||||
|
<pre style={{ fontSize: 12, marginTop: 4, maxHeight: 200, overflow: 'auto' }}>{JSON.stringify(s.output, null, 2)}</pre>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,191 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Button, Card, Drawer, Form, Input, Select, Space, Switch, Tooltip, Typography, App as AntApp } from 'antd';
|
||||||
|
import type { Workflow, WorkflowGraph, WorkflowNode, WorkflowNodeType } from '../../../api';
|
||||||
|
import { WorkflowAPI } from '../../../api';
|
||||||
|
import { NODE_TYPE_LABEL, SAMPLE_GRAPH } from '../constants';
|
||||||
|
|
||||||
|
const { Text, Paragraph } = Typography;
|
||||||
|
|
||||||
|
export default function WorkflowEditorDrawer(props: {
|
||||||
|
open: boolean;
|
||||||
|
workflow: Workflow;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
}) {
|
||||||
|
const { open, workflow, onClose, onSaved } = props;
|
||||||
|
const { message } = AntApp.useApp();
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [graphText, setGraphText] = useState(JSON.stringify(workflow.graph, null, 2));
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form.setFieldsValue({
|
||||||
|
name: workflow.name,
|
||||||
|
description: workflow.description,
|
||||||
|
scheduleCron: workflow.scheduleCron,
|
||||||
|
scheduleEnabled: workflow.scheduleEnabled,
|
||||||
|
enabled: workflow.enabled
|
||||||
|
});
|
||||||
|
setGraphText(JSON.stringify(workflow.graph, null, 2));
|
||||||
|
}, [workflow]);
|
||||||
|
|
||||||
|
const onSave = async () => {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
let graph: WorkflowGraph;
|
||||||
|
try {
|
||||||
|
graph = JSON.parse(graphText);
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error('Graph JSON 解析失败:' + e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!graph.entry || !Array.isArray(graph.nodes)) {
|
||||||
|
message.error('Graph 必须包含 entry + nodes 数组');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const payload = { ...values, graph };
|
||||||
|
if (workflow.id) {
|
||||||
|
await WorkflowAPI.update(workflow.id, payload);
|
||||||
|
} else {
|
||||||
|
await WorkflowAPI.create(payload);
|
||||||
|
}
|
||||||
|
message.success('已保存');
|
||||||
|
onSaved();
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error('保存失败:' + (e?.message ?? e));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
title={workflow.id ? `编辑「${workflow.name}」` : '新建工作流'}
|
||||||
|
width={840}
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
styles={{ body: { background: '#fcfcfd' }, header: { background: '#fff', borderBottom: '1px solid var(--color-border)' } }}
|
||||||
|
extra={
|
||||||
|
<Button type="primary" loading={saving} onClick={onSave} style={{ borderRadius: 10 }}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item label="名称" name="name" rules={[{ required: true, message: '必填' }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="描述" name="description">
|
||||||
|
<Input.TextArea rows={2} />
|
||||||
|
</Form.Item>
|
||||||
|
<Space size="large" style={{ marginBottom: 12 }}>
|
||||||
|
<Form.Item name="enabled" valuePropName="checked" noStyle>
|
||||||
|
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="scheduleEnabled" valuePropName="checked" noStyle>
|
||||||
|
<Switch checkedChildren="定时开" unCheckedChildren="定时关" />
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
<Form.Item
|
||||||
|
label={
|
||||||
|
<Space>
|
||||||
|
<span>Cron 表达式</span>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
5 段:分 时 日 月 周,例如 \"*/30 * * * *\"
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
name="scheduleCron"
|
||||||
|
>
|
||||||
|
<Input placeholder="例如 0 8 * * 1-5(工作日早 8 点)" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<NodeQuickAdd
|
||||||
|
onAdd={(node) => {
|
||||||
|
try {
|
||||||
|
const g: WorkflowGraph = JSON.parse(graphText);
|
||||||
|
g.nodes = [...(g.nodes || []), node];
|
||||||
|
if (!g.entry) g.entry = node.id;
|
||||||
|
setGraphText(JSON.stringify(g, null, 2));
|
||||||
|
} catch {
|
||||||
|
message.error('Graph JSON 不合法,无法追加');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
title={<Text strong>Graph JSON(节点列表)</Text>}
|
||||||
|
style={{ marginTop: 12, borderRadius: 16, boxShadow: 'var(--shadow-xs)' }}
|
||||||
|
extra={
|
||||||
|
<Tooltip title="还原为示例">
|
||||||
|
<Button size="small" onClick={() => setGraphText(JSON.stringify(SAMPLE_GRAPH, null, 2))} style={{ borderRadius: 8 }}>
|
||||||
|
示例
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Input.TextArea
|
||||||
|
rows={20}
|
||||||
|
value={graphText}
|
||||||
|
onChange={(e) => setGraphText(e.target.value)}
|
||||||
|
style={{ fontFamily: 'ui-monospace, monospace', fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
<Paragraph type="secondary" style={{ fontSize: 12, marginTop: 8 }}>
|
||||||
|
支持模板 <code>{'{{input.x}} {{vars.x}} {{steps.<nodeId>.output...}}'}</code>。
|
||||||
|
<br />
|
||||||
|
节点类型:agent / skill / http / transform / branch;branch 节点用 <code>condition</code>(返回 bool 的 JS 片段),引擎按 next/elseNext 走。
|
||||||
|
</Paragraph>
|
||||||
|
</Card>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NodeQuickAdd({ onAdd }: { onAdd: (n: WorkflowNode) => void }) {
|
||||||
|
const [type, setType] = useState<WorkflowNodeType>('agent');
|
||||||
|
const [id, setId] = useState('');
|
||||||
|
return (
|
||||||
|
<Card size="small" title="快速追加节点" style={{ marginTop: 12, borderRadius: 16, boxShadow: 'var(--shadow-xs)' }}>
|
||||||
|
<Space wrap>
|
||||||
|
<Select
|
||||||
|
value={type}
|
||||||
|
style={{ width: 140 }}
|
||||||
|
onChange={(v) => setType(v as WorkflowNodeType)}
|
||||||
|
options={Object.entries(NODE_TYPE_LABEL).map(([k, label]) => ({ value: k, label }))}
|
||||||
|
/>
|
||||||
|
<Input placeholder="节点 id(如 step1)" value={id} onChange={(e) => setId(e.target.value)} style={{ width: 200 }} />
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
disabled={!id.trim()}
|
||||||
|
style={{ borderRadius: 10 }}
|
||||||
|
onClick={() => {
|
||||||
|
const base: WorkflowNode = { id: id.trim(), type, name: NODE_TYPE_LABEL[type], config: defaultConfig(type), next: '' };
|
||||||
|
if (type === 'branch') base.elseNext = '';
|
||||||
|
onAdd(base);
|
||||||
|
setId('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
追加
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultConfig(type: WorkflowNodeType): Record<string, any> {
|
||||||
|
switch (type) {
|
||||||
|
case 'agent':
|
||||||
|
return { agentId: '', prompt: '请帮我处理:{{input.text}}' };
|
||||||
|
case 'skill':
|
||||||
|
return { skillId: '', args: {} };
|
||||||
|
case 'http':
|
||||||
|
return { method: 'GET', url: 'https://example.com/api', headers: {}, body: null };
|
||||||
|
case 'transform':
|
||||||
|
return { code: 'return { value: input.text };' };
|
||||||
|
case 'branch':
|
||||||
|
return { condition: 'steps.prev?.output?.value === true' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
import type { WorkflowGraph, WorkflowNodeType } from '../../api';
|
||||||
|
|
||||||
|
export const NODE_TYPE_LABEL: Record<WorkflowNodeType, string> = {
|
||||||
|
agent: '🤖 Agent',
|
||||||
|
skill: '🛠️ Skill',
|
||||||
|
http: '🌐 HTTP',
|
||||||
|
transform: '🔧 Transform',
|
||||||
|
branch: '🔀 Branch'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STATUS_COLOR: Record<string, string> = {
|
||||||
|
running: 'blue',
|
||||||
|
success: 'green',
|
||||||
|
failed: 'red',
|
||||||
|
aborted: 'orange',
|
||||||
|
skipped: 'default'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SAMPLE_GRAPH: WorkflowGraph = {
|
||||||
|
entry: 'fetch',
|
||||||
|
variables: { topic: 'Go 1.23 新特性' },
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: 'fetch',
|
||||||
|
type: 'agent',
|
||||||
|
name: '搜集资料',
|
||||||
|
config: {
|
||||||
|
agentId: '',
|
||||||
|
prompt: '请围绕主题\"{{vars.topic}}\"列出 5 条最近的关键信息,要点形式。'
|
||||||
|
},
|
||||||
|
next: 'summarize'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'summarize',
|
||||||
|
type: 'agent',
|
||||||
|
name: '总结成稿',
|
||||||
|
config: {
|
||||||
|
agentId: '',
|
||||||
|
prompt: '基于以下要点写一段 200 字日报:\\n{{steps.fetch.output.text}}'
|
||||||
|
},
|
||||||
|
next: ''
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,159 @@
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { App as AntApp, Empty } from 'antd';
|
||||||
|
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||||
|
import type { ModelOverrides } from '../../api';
|
||||||
|
import AgentSidebar from './components/AgentSidebar';
|
||||||
|
import ChatHeader from './components/ChatHeader';
|
||||||
|
import ChatBody from './components/ChatBody';
|
||||||
|
import ChatInput from './components/ChatInput';
|
||||||
|
import ChatDrawers from './components/ChatDrawers';
|
||||||
|
import { useChatScroll } from './hooks/useChatScroll';
|
||||||
|
import { useChatData } from './hooks/useChatData';
|
||||||
|
import { useChatSender } from './hooks/useChatSender';
|
||||||
|
|
||||||
|
export default function ChatPage() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const { message } = AntApp.useApp();
|
||||||
|
|
||||||
|
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||||
|
const [highlightId, setHighlightId] = useState<string | null>(null);
|
||||||
|
const [overrides, setOverrides] = useState<ModelOverrides>({});
|
||||||
|
|
||||||
|
const [historyDrawerOpen, setHistoryDrawerOpen] = useState(false);
|
||||||
|
const [mcpDrawerOpen, setMcpDrawerOpen] = useState(false);
|
||||||
|
const [paramsDrawerOpen, setParamsDrawerOpen] = useState(false);
|
||||||
|
const [tplDrawerOpen, setTplDrawerOpen] = useState(false);
|
||||||
|
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
const { bodyRef, scrollBottom, initialScrollDoneRef } = useChatScroll();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const s = searchParams.get('session');
|
||||||
|
const m = searchParams.get('msg');
|
||||||
|
if (s) {
|
||||||
|
setSessionId(s);
|
||||||
|
if (m) setHighlightId(m);
|
||||||
|
const next = new URLSearchParams(searchParams);
|
||||||
|
next.delete('session');
|
||||||
|
next.delete('msg');
|
||||||
|
setSearchParams(next, { replace: true });
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
const { agent, agentList, messages, setMessages, branches, setBranches, loadMessages } = useChatData({
|
||||||
|
agentId: id,
|
||||||
|
sessionId,
|
||||||
|
highlightId,
|
||||||
|
setHighlightId,
|
||||||
|
scrollBottom,
|
||||||
|
initialScrollDoneRef,
|
||||||
|
setOverrides: (updater) => setOverrides(updater),
|
||||||
|
abort: () => abortRef.current?.abort()
|
||||||
|
});
|
||||||
|
|
||||||
|
const sender = useChatSender({
|
||||||
|
agentId: id,
|
||||||
|
agent,
|
||||||
|
sessionId,
|
||||||
|
overrides,
|
||||||
|
setOverrides: (updater) => setOverrides(updater),
|
||||||
|
messages,
|
||||||
|
setMessages,
|
||||||
|
setBranches,
|
||||||
|
loadMessages,
|
||||||
|
scrollBottom,
|
||||||
|
notify: { success: (t) => message.success(t), error: (t) => message.error(t) },
|
||||||
|
abortRef
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chat-shell">
|
||||||
|
<AgentSidebar
|
||||||
|
agentList={agentList}
|
||||||
|
activeAgentId={id}
|
||||||
|
onCreate={() => navigate('/agents/new')}
|
||||||
|
onSelect={(aid) => navigate(`/chat/${aid}`)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section className="chat-main">
|
||||||
|
{!agent ? (
|
||||||
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<Empty description="请在左侧选择一个智能体开始对话" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ChatHeader
|
||||||
|
agent={agent}
|
||||||
|
useStream={sender.useStream}
|
||||||
|
setUseStream={sender.setUseStream}
|
||||||
|
onOpenHistory={() => setHistoryDrawerOpen(true)}
|
||||||
|
onOpenParams={() => setParamsDrawerOpen(true)}
|
||||||
|
onOpenMcp={() => setMcpDrawerOpen(true)}
|
||||||
|
onManageAgent={() => navigate(`/agents/${id}`)}
|
||||||
|
onClear={sender.handleClear}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ChatBody
|
||||||
|
bodyRef={bodyRef}
|
||||||
|
agent={agent}
|
||||||
|
messages={messages}
|
||||||
|
branches={branches}
|
||||||
|
highlightId={highlightId}
|
||||||
|
sending={sender.sending}
|
||||||
|
streaming={sender.streaming}
|
||||||
|
onRegenerate={sender.handleRegenerate}
|
||||||
|
onSwitchBranch={sender.handleSwitchBranch}
|
||||||
|
onCopy={(text) => navigator.clipboard?.writeText(text).then(() => message.success('已复制'))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ChatInput
|
||||||
|
input={sender.input}
|
||||||
|
setInput={sender.setInput}
|
||||||
|
sending={sender.sending}
|
||||||
|
attachments={sender.attachments}
|
||||||
|
setAttachments={(updater) => sender.setAttachments(updater)}
|
||||||
|
imageUrls={sender.imageUrls}
|
||||||
|
setImageUrls={(updater) => sender.setImageUrls(updater)}
|
||||||
|
onSend={sender.handleSend}
|
||||||
|
onStop={sender.handleStop}
|
||||||
|
onAttach={sender.handleAttach}
|
||||||
|
onOpenTpl={() => setTplDrawerOpen(true)}
|
||||||
|
modelOptions={sender.modelOptions}
|
||||||
|
activeModelValue={sender.activeModelValue}
|
||||||
|
onChangeModel={(modelId) => {
|
||||||
|
const selected = sender.modelOptions.find((m) => m.value === modelId);
|
||||||
|
setOverrides((o) => ({ ...o, model_id: String(modelId), model: selected?.label || String(modelId) }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<ChatDrawers
|
||||||
|
agentId={id}
|
||||||
|
agent={agent}
|
||||||
|
input={sender.input}
|
||||||
|
setInput={(updater) => sender.setInput(updater(sender.input))}
|
||||||
|
overrides={overrides}
|
||||||
|
setOverrides={(updater) => setOverrides(updater)}
|
||||||
|
sessionId={sessionId}
|
||||||
|
setSessionId={setSessionId}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { Button } from 'antd';
|
||||||
|
import type { Agent } from '../../../api';
|
||||||
|
|
||||||
|
const isImageUrl = (url: string) => url?.startsWith('http') || url?.startsWith('/');
|
||||||
|
|
||||||
|
export default function AgentSidebar(props: {
|
||||||
|
agentList: Agent[];
|
||||||
|
activeAgentId?: string;
|
||||||
|
onCreate: () => void;
|
||||||
|
onSelect: (agentId: string) => void;
|
||||||
|
}) {
|
||||||
|
const { agentList, activeAgentId, onCreate, onSelect } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="chat-side">
|
||||||
|
<div style={{ padding: '16px', borderBottom: '1px solid var(--color-border)' }}>
|
||||||
|
<Button block type="dashed" onClick={onCreate}>
|
||||||
|
+ 创建智能体
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 0' }}>
|
||||||
|
{agentList.map((a) => {
|
||||||
|
const isActive = a.id === activeAgentId;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={a.id}
|
||||||
|
onClick={() => onSelect(a.id)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
padding: '10px 16px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: isActive ? 'var(--color-surface-2)' : 'transparent',
|
||||||
|
borderLeft: `3px solid ${isActive ? 'var(--color-brand)' : 'transparent'}`,
|
||||||
|
transition: 'background 0.2s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: a.avatar || 'var(--gradient-brand)',
|
||||||
|
color: '#fff',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: 14,
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isImageUrl(a.avatar) ? <img src={a.avatar} className="w-full h-full object-cover" alt="avatar" /> : (a.name?.charAt(0) || '?').toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontWeight: isActive ? 600 : 500,
|
||||||
|
fontSize: 14,
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{a.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
import { Divider, Tag } from 'antd';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import type { Agent, BranchInfo, ChatMessage } from '../../../api';
|
||||||
|
import type { StreamingState } from '../hooks/useChatSender';
|
||||||
|
import MessageItem from './messages/MessageItem';
|
||||||
|
import { RetrievedView, ToolCallView } from './messages/MetaViews';
|
||||||
|
|
||||||
|
const isImageUrl = (url: string) => url?.startsWith('http') || url?.startsWith('/');
|
||||||
|
|
||||||
|
export default function ChatBody(props: {
|
||||||
|
bodyRef: React.RefObject<HTMLDivElement>;
|
||||||
|
agent: Agent;
|
||||||
|
messages: ChatMessage[];
|
||||||
|
branches: Record<string, BranchInfo>;
|
||||||
|
highlightId: string | null;
|
||||||
|
sending: boolean;
|
||||||
|
streaming: StreamingState;
|
||||||
|
onRegenerate: (assistantId: string) => void;
|
||||||
|
onSwitchBranch: (userMsgId: string, branchId: string) => void;
|
||||||
|
onCopy: (text: string) => void;
|
||||||
|
}) {
|
||||||
|
const { bodyRef, agent, messages, branches, highlightId, sending, streaming, onRegenerate, onSwitchBranch, onCopy } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={bodyRef} className="chat-body">
|
||||||
|
<div className="messages-container">
|
||||||
|
{messages.length === 0 && !streaming.active ? (
|
||||||
|
<div style={{ textAlign: 'center', marginTop: 120 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 68,
|
||||||
|
height: 68,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: agent.avatar || 'var(--gradient-brand)',
|
||||||
|
color: '#fff',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: 32,
|
||||||
|
margin: '0 auto 20px',
|
||||||
|
boxShadow: 'var(--shadow-lg)',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isImageUrl(agent.avatar) ? <img src={agent.avatar} className="w-full h-full object-cover" alt="avatar" /> : (agent.name?.charAt(0) || '?').toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<h2 style={{ fontSize: 28, fontWeight: 700, color: 'var(--color-text)', marginBottom: 8, letterSpacing: '-0.02em' }}>你好,今天想一起完成什么?</h2>
|
||||||
|
<p style={{ color: 'var(--color-text-secondary)', fontSize: 15, lineHeight: 1.7 }}>{agent.description || '我是你的专属 AI 助手,随时准备为你服务。'}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{messages.map((m) => (
|
||||||
|
<MessageItem
|
||||||
|
key={m.id}
|
||||||
|
message={m}
|
||||||
|
highlighted={highlightId === m.id}
|
||||||
|
branch={m.role === 'assistant' && m.parentId ? branches[m.parentId] : undefined}
|
||||||
|
busy={sending}
|
||||||
|
onRegenerate={onRegenerate}
|
||||||
|
onSwitchBranch={onSwitchBranch}
|
||||||
|
onCopy={onCopy}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{streaming.active && (
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<div className="bubble assistant">
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{!!streaming.retryInfo?.message && (
|
||||||
|
<div style={{ padding: '8px 10px', borderRadius: 10, background: 'rgba(59, 130, 246, 0.08)', border: '1px solid rgba(59, 130, 246, 0.18)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
||||||
|
<span style={{ fontSize: 12.5, color: 'var(--color-text)', fontWeight: 600 }}>{streaming.retryInfo.stage === 'fallback_model' ? '自动切换模型' : '自动重试'}</span>
|
||||||
|
{streaming.retryInfo.stage === 'fallback_model' ? (
|
||||||
|
<Tag color="processing" style={{ marginInlineEnd: 0 }}>
|
||||||
|
{String(streaming.retryInfo.fromModel || '')} → {String(streaming.retryInfo.toModel || '')}
|
||||||
|
</Tag>
|
||||||
|
) : (
|
||||||
|
<Tag color="processing" style={{ marginInlineEnd: 0 }}>
|
||||||
|
{String(streaming.retryInfo.model || '')}
|
||||||
|
{streaming.retryInfo.attempt ? ` · 第${streaming.retryInfo.attempt}次` : ''}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 4, fontSize: 12.5, color: 'var(--color-text-secondary)', lineHeight: 1.55 }}>{String(streaming.retryInfo.message)}</div>
|
||||||
|
{!!streaming.retryInfo.reason && (
|
||||||
|
<div style={{ marginTop: 4, fontSize: 12, color: 'var(--color-text-tertiary)', lineHeight: 1.5 }}>{String(streaming.retryInfo.reason)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginBottom: 4 }}>推理过程</div>
|
||||||
|
{streaming.reasoningText ? <ReactMarkdown>{streaming.reasoningText + '▍'}</ReactMarkdown> : <span style={{ color: 'var(--color-text-tertiary)' }}>等待推理…</span>}
|
||||||
|
</div>
|
||||||
|
<Divider style={{ margin: '6px 0' }} />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginBottom: 4 }}>正式回答</div>
|
||||||
|
{streaming.answerText ? <ReactMarkdown>{streaming.answerText + '▍'}</ReactMarkdown> : <span style={{ color: 'var(--color-text-tertiary)' }}>等待输出…</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{(streaming.retrieved.length > 0 || streaming.toolCalls.length > 0) && (
|
||||||
|
<div style={{ maxWidth: '85%' }}>
|
||||||
|
{streaming.retrieved.length > 0 && <RetrievedView retrieved={streaming.retrieved} />}
|
||||||
|
{streaming.toolCalls.length > 0 && <ToolCallView calls={streaming.toolCalls} liveStyle />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,163 @@
|
||||||
|
import { Button, Drawer, Empty, InputNumber, Slider } from 'antd';
|
||||||
|
import type { Agent, ModelOverrides } from '../../../api';
|
||||||
|
import McpResourcesDrawer from '../../../components/McpResourcesDrawer';
|
||||||
|
import SessionSidebar from '../../../components/SessionSidebar';
|
||||||
|
import PromptLibraryPage from '../../PromptLibraryPage';
|
||||||
|
|
||||||
|
export default function ChatDrawers(props: {
|
||||||
|
agentId?: string;
|
||||||
|
agent: Agent | null;
|
||||||
|
input: string;
|
||||||
|
setInput: (updater: (prev: string) => string) => void;
|
||||||
|
overrides: ModelOverrides;
|
||||||
|
setOverrides: (updater: (prev: ModelOverrides) => ModelOverrides) => void;
|
||||||
|
sessionId: string | null;
|
||||||
|
setSessionId: (v: string | null) => void;
|
||||||
|
setHighlightId: (v: string | null) => void;
|
||||||
|
sessionRefresh: number;
|
||||||
|
mcpDrawerOpen: boolean;
|
||||||
|
setMcpDrawerOpen: (v: boolean) => void;
|
||||||
|
tplDrawerOpen: boolean;
|
||||||
|
setTplDrawerOpen: (v: boolean) => void;
|
||||||
|
paramsDrawerOpen: boolean;
|
||||||
|
setParamsDrawerOpen: (v: boolean) => void;
|
||||||
|
historyDrawerOpen: boolean;
|
||||||
|
setHistoryDrawerOpen: (v: boolean) => void;
|
||||||
|
notify: { success: (t: string) => void };
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
agentId,
|
||||||
|
agent,
|
||||||
|
setInput,
|
||||||
|
overrides,
|
||||||
|
setOverrides,
|
||||||
|
sessionId,
|
||||||
|
setSessionId,
|
||||||
|
setHighlightId,
|
||||||
|
sessionRefresh,
|
||||||
|
mcpDrawerOpen,
|
||||||
|
setMcpDrawerOpen,
|
||||||
|
tplDrawerOpen,
|
||||||
|
setTplDrawerOpen,
|
||||||
|
paramsDrawerOpen,
|
||||||
|
setParamsDrawerOpen,
|
||||||
|
historyDrawerOpen,
|
||||||
|
setHistoryDrawerOpen,
|
||||||
|
notify
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{agentId && (
|
||||||
|
<McpResourcesDrawer
|
||||||
|
agentId={agentId}
|
||||||
|
open={mcpDrawerOpen}
|
||||||
|
onClose={() => setMcpDrawerOpen(false)}
|
||||||
|
onUse={(text) => setInput((cur) => cur + text)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Drawer title="📚 选择 Prompt 模板" open={tplDrawerOpen} onClose={() => setTplDrawerOpen(false)} width={520}>
|
||||||
|
<PromptLibraryPage
|
||||||
|
onSelect={(t) => {
|
||||||
|
setInput((cur) => (cur ? cur + '\n\n' : '') + t.body);
|
||||||
|
setTplDrawerOpen(false);
|
||||||
|
notify.success('已插入到输入框');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
<Drawer title="⚙️ 模型参数(仅本会话临时生效)" open={paramsDrawerOpen} onClose={() => setParamsDrawerOpen(false)} width={380}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: 16,
|
||||||
|
padding: 12,
|
||||||
|
background: 'var(--color-warning-soft)',
|
||||||
|
borderRadius: 10,
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--color-warning)',
|
||||||
|
border: '1px solid var(--color-border)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
💡 这里设置的参数会临时覆盖智能体的默认配置,<b>仅作用于当前浏览器会话</b>,不会写回到智能体本身。
|
||||||
|
<br />
|
||||||
|
关闭页面或清空字段即可恢复默认。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
||||||
|
<span>🌡 Temperature(创造性)</span>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>默认:{agent?.temperature ?? 0.7}</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
min={0}
|
||||||
|
max={2}
|
||||||
|
step={0.05}
|
||||||
|
value={overrides.temperature ?? agent?.temperature ?? 0.7}
|
||||||
|
onChange={(v) => setOverrides((o) => ({ ...o, temperature: v as number }))}
|
||||||
|
marks={{ 0: '严谨', 0.7: '默认', 1.4: '发散', 2: '混沌' }}
|
||||||
|
/>
|
||||||
|
{overrides.temperature !== undefined && (
|
||||||
|
<Button size="small" type="link" style={{ padding: 0 }} onClick={() => setOverrides((o) => ({ ...o, temperature: undefined }))}>
|
||||||
|
↺ 恢复默认
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
||||||
|
<span>🎯 Top P(核采样)</span>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>未设置时跟随模型默认</span>
|
||||||
|
</div>
|
||||||
|
<Slider min={0.1} max={1} step={0.05} value={overrides.topP ?? 1} onChange={(v) => setOverrides((o) => ({ ...o, topP: v as number }))} disabled={overrides.topP === undefined} />
|
||||||
|
{overrides.topP === undefined ? (
|
||||||
|
<Button size="small" type="link" style={{ padding: 0 }} onClick={() => setOverrides((o) => ({ ...o, topP: 1 }))}>
|
||||||
|
✚ 启用 Top P
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button size="small" type="link" style={{ padding: 0 }} onClick={() => setOverrides((o) => ({ ...o, topP: undefined }))}>
|
||||||
|
↺ 取消
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
||||||
|
<span>📏 Max Tokens(生成长度上限)</span>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>未设置时由模型决定</span>
|
||||||
|
</div>
|
||||||
|
<InputNumber
|
||||||
|
value={overrides.maxTokens}
|
||||||
|
onChange={(v) => setOverrides((o) => ({ ...o, maxTokens: v == null ? undefined : Number(v) }))}
|
||||||
|
min={1}
|
||||||
|
max={16384}
|
||||||
|
placeholder="例如 2048"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button block onClick={() => setOverrides(() => ({}))}>
|
||||||
|
🔄 全部恢复默认
|
||||||
|
</Button>
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
<Drawer title="历史对话" placement="right" width={320} onClose={() => setHistoryDrawerOpen(false)} open={historyDrawerOpen} bodyStyle={{ padding: 0 }}>
|
||||||
|
{agentId ? (
|
||||||
|
<SessionSidebar
|
||||||
|
agentId={agentId}
|
||||||
|
activeSessionId={sessionId}
|
||||||
|
onChange={(sid, opts) => {
|
||||||
|
setSessionId(sid);
|
||||||
|
setHighlightId(opts?.highlightMessageId || null);
|
||||||
|
setHistoryDrawerOpen(false);
|
||||||
|
}}
|
||||||
|
refreshTick={sessionRefresh}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Empty description="未选择 Agent" />
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { ApiOutlined, DeleteOutlined, DownOutlined, EditOutlined, SettingOutlined } from '@ant-design/icons';
|
||||||
|
import { Button, Dropdown, Modal, Space, Switch } from 'antd';
|
||||||
|
import type { Agent } from '../../../api';
|
||||||
|
|
||||||
|
export default function ChatHeader(props: {
|
||||||
|
agent: Agent;
|
||||||
|
useStream: boolean;
|
||||||
|
setUseStream: (v: boolean) => void;
|
||||||
|
onOpenHistory: () => void;
|
||||||
|
onOpenParams: () => void;
|
||||||
|
onOpenMcp: () => void;
|
||||||
|
onManageAgent: () => void;
|
||||||
|
onClear: () => void;
|
||||||
|
}) {
|
||||||
|
const { agent, useStream, setUseStream, onOpenHistory, onOpenParams, onOpenMcp, onManageAgent, onClear } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chat-header">
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 16, color: 'var(--color-text)' }}>{agent.name}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)' }}>{agent.model || '默认模型'} · T={agent.temperature}</div>
|
||||||
|
</div>
|
||||||
|
<Space>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginRight: 12 }}>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>流式输出</span>
|
||||||
|
<Switch size="small" checked={useStream} onChange={setUseStream} />
|
||||||
|
</div>
|
||||||
|
<Button size="small" onClick={onOpenHistory}>
|
||||||
|
历史对话
|
||||||
|
</Button>
|
||||||
|
<Dropdown
|
||||||
|
menu={{
|
||||||
|
items: [
|
||||||
|
{ key: 'params', label: '模型参数', icon: <SettingOutlined />, onClick: onOpenParams },
|
||||||
|
{ key: 'mcp', label: 'MCP 资源', icon: <ApiOutlined />, onClick: onOpenMcp },
|
||||||
|
{ key: 'edit', label: '管理 Agent', icon: <EditOutlined />, onClick: onManageAgent },
|
||||||
|
{ type: 'divider' as const },
|
||||||
|
{
|
||||||
|
key: 'clear',
|
||||||
|
label: '清空对话',
|
||||||
|
icon: <DeleteOutlined />,
|
||||||
|
danger: true,
|
||||||
|
onClick: () => {
|
||||||
|
Modal.confirm({ title: '清空当前会话所有消息?', onOk: onClear });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button size="small">
|
||||||
|
更多 <DownOutlined />
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
import { ArrowUpOutlined, BookOutlined, CloseOutlined, DownOutlined, PaperClipOutlined } from '@ant-design/icons';
|
||||||
|
import { Button, Image as AntImage, Input, Select, Tag, Upload } from 'antd';
|
||||||
|
import type { ChatAttachment } from '../../../api';
|
||||||
|
|
||||||
|
export default function ChatInput(props: {
|
||||||
|
input: string;
|
||||||
|
setInput: (v: string) => void;
|
||||||
|
sending: boolean;
|
||||||
|
attachments: ChatAttachment[];
|
||||||
|
setAttachments: (updater: (prev: ChatAttachment[]) => ChatAttachment[]) => void;
|
||||||
|
imageUrls: string[];
|
||||||
|
setImageUrls: (updater: (prev: string[]) => string[]) => void;
|
||||||
|
onSend: () => void;
|
||||||
|
onStop: () => void;
|
||||||
|
onAttach: (files: File[]) => void;
|
||||||
|
onOpenTpl: () => void;
|
||||||
|
modelOptions: Array<{ value: string; label: string }>;
|
||||||
|
activeModelValue: string;
|
||||||
|
onChangeModel: (modelId: string) => void;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
input,
|
||||||
|
setInput,
|
||||||
|
sending,
|
||||||
|
attachments,
|
||||||
|
setAttachments,
|
||||||
|
imageUrls,
|
||||||
|
setImageUrls,
|
||||||
|
onSend,
|
||||||
|
onStop,
|
||||||
|
onAttach,
|
||||||
|
onOpenTpl,
|
||||||
|
modelOptions,
|
||||||
|
activeModelValue,
|
||||||
|
onChangeModel
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chat-input-wrapper">
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 }}>
|
||||||
|
{attachments.map((a, i) => (
|
||||||
|
<Tag key={i} color="blue" closable style={{ borderRadius: 6, padding: '4px 8px' }} onClose={() => setAttachments((arr) => arr.filter((_, j) => j !== i))}>
|
||||||
|
📎 {a.name}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
{imageUrls.map((u, i) => (
|
||||||
|
<div key={i} style={{ position: 'relative' }}>
|
||||||
|
<AntImage src={u} width={48} height={48} style={{ objectFit: 'cover', borderRadius: 8, border: '1px solid var(--color-border)' }} />
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
shape="circle"
|
||||||
|
icon={<CloseOutlined style={{ fontSize: 8 }} />}
|
||||||
|
style={{ position: 'absolute', top: -6, right: -6, width: 16, height: 16, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||||
|
onClick={() => setImageUrls((arr) => arr.filter((_, j) => j !== i))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="chat-input-card">
|
||||||
|
<div className="chat-input-stack">
|
||||||
|
<Input.TextArea
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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 className="chat-disclaimer">AI 可能会产生错误信息,请核实重要信息。</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { Button, Space, Tag, Tooltip } from 'antd';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import type { BranchInfo, ChatMessage } from '../../../../api';
|
||||||
|
import { ReasoningView, RetrievedView, ToolCallView } from './MetaViews';
|
||||||
|
|
||||||
|
export default function MessageItem(props: {
|
||||||
|
message: ChatMessage;
|
||||||
|
highlighted?: boolean;
|
||||||
|
branch?: BranchInfo;
|
||||||
|
busy?: boolean;
|
||||||
|
onRegenerate?: (id: string) => void;
|
||||||
|
onSwitchBranch?: (userMsgId: string, branchId: string) => void;
|
||||||
|
onCopy?: (text: string) => void;
|
||||||
|
}) {
|
||||||
|
const { message, highlighted, branch, busy, onRegenerate, onSwitchBranch, onCopy } = props;
|
||||||
|
const hasBranches = !!branch && branch.total > 1;
|
||||||
|
const activeIdx = branch?.activeIndex ?? 0;
|
||||||
|
const total = branch?.total ?? 1;
|
||||||
|
|
||||||
|
const goPrev = () => {
|
||||||
|
if (!branch || !message.parentId) return;
|
||||||
|
const i = Math.max(0, activeIdx - 1);
|
||||||
|
onSwitchBranch?.(message.parentId, branch.ids[i]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const goNext = () => {
|
||||||
|
if (!branch || !message.parentId) return;
|
||||||
|
const i = Math.min(total - 1, activeIdx + 1);
|
||||||
|
onSwitchBranch?.(message.parentId, branch.ids[i]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id={'msg-' + message.id}
|
||||||
|
style={{
|
||||||
|
marginBottom: 12,
|
||||||
|
padding: highlighted ? 8 : 0,
|
||||||
|
borderRadius: highlighted ? 10 : 0,
|
||||||
|
background: highlighted ? 'rgba(254, 243, 199, 0.6)' : 'transparent',
|
||||||
|
transition: 'background 0.4s, padding 0.4s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={`bubble ${message.role}`}>
|
||||||
|
{message.role === 'assistant' ? (
|
||||||
|
<ReactMarkdown>{message.content}</ReactMarkdown>
|
||||||
|
) : message.content.includes(' ? (
|
||||||
|
<ReactMarkdown>{message.content}</ReactMarkdown>
|
||||||
|
) : (
|
||||||
|
message.content
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message.role === 'assistant' && (
|
||||||
|
<div className="monica-msg-actions" style={{ maxWidth: '78%' }}>
|
||||||
|
{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>}
|
||||||
|
<Tooltip title="复制">
|
||||||
|
<Button size="small" type="text" onClick={() => onCopy?.(message.content)}>
|
||||||
|
📋
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="重新生成(开新分支)">
|
||||||
|
<Button size="small" type="text" disabled={busy} onClick={() => onRegenerate?.(message.id)}>
|
||||||
|
🔄
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{message.role === 'assistant' && message.meta && (
|
||||||
|
<div style={{ maxWidth: '78%' }}>
|
||||||
|
{!!message.meta.reasoning && <ReasoningView reasoning={message.meta.reasoning} />}
|
||||||
|
{!!message.meta.retrieved?.length && <RetrievedView retrieved={message.meta.retrieved} />}
|
||||||
|
{!!message.meta.toolCalls?.length && <ToolCallView calls={message.meta.toolCalls} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
import { Collapse, Tag } from 'antd';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import type { RetrievedSnippet, ToolCallTrace } from '../../../../api';
|
||||||
|
|
||||||
|
export function ReasoningView({ reasoning }: { reasoning: string }) {
|
||||||
|
return (
|
||||||
|
<Collapse
|
||||||
|
size="small"
|
||||||
|
ghost
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'reasoning',
|
||||||
|
label: <span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>🧠 推理过程</span>,
|
||||||
|
children: (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.6, maxHeight: 240, overflow: 'auto' }}>
|
||||||
|
<ReactMarkdown>{reasoning}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RetrievedView({ retrieved }: { retrieved: RetrievedSnippet[] }) {
|
||||||
|
return (
|
||||||
|
<Collapse
|
||||||
|
size="small"
|
||||||
|
ghost
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'rag',
|
||||||
|
label: <span style={{ fontSize: 12, color: '#6366f1' }}>🔍 RAG 命中片段 ({retrieved.length})</span>,
|
||||||
|
children: (
|
||||||
|
<div style={{ fontSize: 12 }}>
|
||||||
|
{retrieved.map((r, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
padding: 8,
|
||||||
|
background: '#f6f8ff',
|
||||||
|
borderRadius: 6,
|
||||||
|
marginBottom: 6,
|
||||||
|
borderLeft: '3px solid #6366f1'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ color: '#6366f1', fontWeight: 600, marginBottom: 4 }}>
|
||||||
|
📄 {r.fileName} · 切片#{r.chunkIndex} · score {r.score.toFixed(3)}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#374151', whiteSpace: 'pre-wrap' }}>
|
||||||
|
{r.preview}
|
||||||
|
{r.preview.length >= 200 ? '…' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToolCallView({ calls, liveStyle }: { calls: ToolCallTrace[]; liveStyle?: boolean }) {
|
||||||
|
return (
|
||||||
|
<Collapse
|
||||||
|
size="small"
|
||||||
|
ghost
|
||||||
|
defaultActiveKey={liveStyle ? ['tc'] : undefined}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'tc',
|
||||||
|
label: <span style={{ fontSize: 12, color: '#f97316' }}>🛠 工具调用 ({calls.length})</span>,
|
||||||
|
children: (
|
||||||
|
<div style={{ fontSize: 12 }}>
|
||||||
|
{calls.map((t, i) => {
|
||||||
|
const isPending = (t.result as any)?.pending;
|
||||||
|
const isFailed = t.result?.ok === false;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
padding: 8,
|
||||||
|
background: '#fff7ed',
|
||||||
|
borderRadius: 6,
|
||||||
|
marginBottom: 6,
|
||||||
|
borderLeft: '3px solid #f97316'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ color: '#c2410c', fontWeight: 600, marginBottom: 4 }}>
|
||||||
|
⚙️ {t.name} {isPending && <Tag color="processing">运行中…</Tag>}
|
||||||
|
{!isPending && t.result?.durationMs != null && <Tag>{t.result.durationMs}ms</Tag>}
|
||||||
|
{isFailed && <Tag color="error">失败</Tag>}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#6b7280' }}>
|
||||||
|
<b>args:</b> {JSON.stringify(t.args)}
|
||||||
|
</div>
|
||||||
|
{!isPending && (
|
||||||
|
<div style={{ color: '#374151', marginTop: 4 }}>
|
||||||
|
<b>result:</b>{' '}
|
||||||
|
<code style={{ wordBreak: 'break-all' }}>
|
||||||
|
{JSON.stringify(t.result?.result ?? t.result?.error ?? t.result).slice(0, 500)}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { Agent, BranchInfo, ChatMessage, ModelOverrides } from '../../../api';
|
||||||
|
import { AgentAPI, ChatAPI } from '../../../api';
|
||||||
|
import { parseAgentModels } from '../utils/agentModels';
|
||||||
|
|
||||||
|
export function useChatData(args: {
|
||||||
|
agentId?: string;
|
||||||
|
sessionId: string | null;
|
||||||
|
highlightId: string | null;
|
||||||
|
setHighlightId: (v: string | null) => void;
|
||||||
|
scrollBottom: (force?: boolean) => void;
|
||||||
|
initialScrollDoneRef: { current: boolean };
|
||||||
|
setOverrides: (updater: (prev: ModelOverrides) => ModelOverrides) => void;
|
||||||
|
abort: () => void;
|
||||||
|
}) {
|
||||||
|
const { agentId, sessionId, highlightId, setHighlightId, scrollBottom, initialScrollDoneRef, setOverrides, abort } = args;
|
||||||
|
const [agent, setAgent] = useState<Agent | null>(null);
|
||||||
|
const [agentList, setAgentList] = useState<Agent[]>([]);
|
||||||
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
|
const [branches, setBranches] = useState<Record<string, BranchInfo>>({});
|
||||||
|
|
||||||
|
const loadAgent = async () => {
|
||||||
|
if (!agentId) {
|
||||||
|
setAgent(null);
|
||||||
|
setMessages([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const a = await AgentAPI.detail(agentId);
|
||||||
|
setAgent(a);
|
||||||
|
const models = parseAgentModels(a.model);
|
||||||
|
const firstModel = models[0];
|
||||||
|
if (firstModel) {
|
||||||
|
setOverrides((o) => ({
|
||||||
|
...o,
|
||||||
|
model: o.model || firstModel.name,
|
||||||
|
model_id: o.model_id || firstModel.id
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadAgentList = async () => {
|
||||||
|
try {
|
||||||
|
const list = await AgentAPI.list();
|
||||||
|
setAgentList(list);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMessages = async () => {
|
||||||
|
if (!agentId) return;
|
||||||
|
const his = await ChatAPI.history(agentId, sessionId || undefined);
|
||||||
|
setMessages(Array.isArray(his.messages) ? his.messages : []);
|
||||||
|
setBranches(his.branches || {});
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (highlightId) {
|
||||||
|
const el = document.getElementById('msg-' + highlightId);
|
||||||
|
if (el) {
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
setTimeout(() => setHighlightId(null), 3000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!initialScrollDoneRef.current) {
|
||||||
|
scrollBottom(true);
|
||||||
|
initialScrollDoneRef.current = true;
|
||||||
|
} else {
|
||||||
|
scrollBottom();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadAgentList();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!agentId) {
|
||||||
|
setAgent(null);
|
||||||
|
setMessages([]);
|
||||||
|
setOverrides(() => ({}));
|
||||||
|
return abort;
|
||||||
|
}
|
||||||
|
loadAgent();
|
||||||
|
setOverrides(() => ({}));
|
||||||
|
return abort;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [agentId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initialScrollDoneRef.current = false;
|
||||||
|
}, [agentId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!agentId) return;
|
||||||
|
loadMessages();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [sessionId, highlightId, agentId]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
agent,
|
||||||
|
agentList,
|
||||||
|
messages,
|
||||||
|
setMessages,
|
||||||
|
branches,
|
||||||
|
setBranches,
|
||||||
|
loadMessages
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
export function useChatScroll() {
|
||||||
|
const bodyRef = useRef<HTMLDivElement>(null);
|
||||||
|
const autoScrollRef = useRef(true);
|
||||||
|
const initialScrollDoneRef = useRef(false);
|
||||||
|
const scrollRafRef = useRef<number | null>(null);
|
||||||
|
const scrollProgrammaticAtRef = useRef(0);
|
||||||
|
const lastScrollTopRef = useRef(0);
|
||||||
|
const userScrollLockRef = useRef(false);
|
||||||
|
|
||||||
|
const scrollBottom = (force = false) => {
|
||||||
|
if (force) {
|
||||||
|
userScrollLockRef.current = false;
|
||||||
|
autoScrollRef.current = true;
|
||||||
|
}
|
||||||
|
if (!force && (!autoScrollRef.current || userScrollLockRef.current)) return;
|
||||||
|
if (scrollRafRef.current) {
|
||||||
|
cancelAnimationFrame(scrollRafRef.current);
|
||||||
|
scrollRafRef.current = null;
|
||||||
|
}
|
||||||
|
scrollRafRef.current = requestAnimationFrame(() => {
|
||||||
|
scrollRafRef.current = null;
|
||||||
|
const el = bodyRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
scrollProgrammaticAtRef.current = Date.now();
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = bodyRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const onScroll = () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const isProgrammatic = now - scrollProgrammaticAtRef.current < 50;
|
||||||
|
const nextTop = el.scrollTop;
|
||||||
|
const lastTop = lastScrollTopRef.current;
|
||||||
|
const distance = el.scrollHeight - nextTop - el.clientHeight;
|
||||||
|
|
||||||
|
if (!isProgrammatic) {
|
||||||
|
const scrollDelta = nextTop - lastTop;
|
||||||
|
if (scrollDelta < -5) {
|
||||||
|
userScrollLockRef.current = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastScrollTopRef.current = nextTop;
|
||||||
|
|
||||||
|
if (distance < 8) {
|
||||||
|
userScrollLockRef.current = false;
|
||||||
|
}
|
||||||
|
autoScrollRef.current = distance < 32 && !userScrollLockRef.current;
|
||||||
|
if ((!autoScrollRef.current || userScrollLockRef.current) && scrollRafRef.current) {
|
||||||
|
cancelAnimationFrame(scrollRafRef.current);
|
||||||
|
scrollRafRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
el.addEventListener('scroll', onScroll, { passive: true });
|
||||||
|
setTimeout(onScroll, 100);
|
||||||
|
return () => el.removeEventListener('scroll', onScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
bodyRef,
|
||||||
|
scrollBottom,
|
||||||
|
initialScrollDoneRef,
|
||||||
|
autoScrollRef,
|
||||||
|
userScrollLockRef
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,339 @@
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import type { Agent, BranchInfo, ChatAttachment, ChatMessage, ModelOverrides, RetrievedSnippet, ToolCallTrace } from '../../../api';
|
||||||
|
import { ChatAPI, ChatAttachmentsAPI, ImageAPI, regenerateMessage, streamChat } from '../../../api';
|
||||||
|
import { buildAttachmentsText } from '../utils/attachments';
|
||||||
|
import { parseAgentModels } from '../utils/agentModels';
|
||||||
|
|
||||||
|
export interface StreamingState {
|
||||||
|
active: boolean;
|
||||||
|
reasoningText: string;
|
||||||
|
answerText: string;
|
||||||
|
errorMessage: string | null;
|
||||||
|
retryInfo: any | null;
|
||||||
|
retrieved: RetrievedSnippet[];
|
||||||
|
toolCalls: ToolCallTrace[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChatSender(args: {
|
||||||
|
agentId?: string;
|
||||||
|
agent: Agent | null;
|
||||||
|
sessionId: string | null;
|
||||||
|
overrides: ModelOverrides;
|
||||||
|
setOverrides: (updater: (prev: ModelOverrides) => ModelOverrides) => void;
|
||||||
|
messages: ChatMessage[];
|
||||||
|
setMessages: (updater: (prev: ChatMessage[]) => ChatMessage[]) => void;
|
||||||
|
setBranches: (v: Record<string, BranchInfo>) => void;
|
||||||
|
loadMessages: () => Promise<void>;
|
||||||
|
scrollBottom: (force?: boolean) => void;
|
||||||
|
notify: { success: (t: string) => void; error: (t: string) => void };
|
||||||
|
abortRef: { current: AbortController | null };
|
||||||
|
}) {
|
||||||
|
const { agentId, agent, sessionId, overrides, setOverrides, setBranches, loadMessages, scrollBottom, notify, abortRef } = args;
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [useStream, setUseStream] = useState(true);
|
||||||
|
const [attachments, setAttachments] = useState<ChatAttachment[]>([]);
|
||||||
|
const [imageUrls, setImageUrls] = useState<string[]>([]);
|
||||||
|
const [uploadingAtt, setUploadingAtt] = useState(false);
|
||||||
|
const [streaming, setStreaming] = useState<StreamingState>({
|
||||||
|
active: false,
|
||||||
|
reasoningText: '',
|
||||||
|
answerText: '',
|
||||||
|
errorMessage: null,
|
||||||
|
retryInfo: null,
|
||||||
|
retrieved: [],
|
||||||
|
toolCalls: []
|
||||||
|
});
|
||||||
|
const [sessionRefresh, setSessionRefresh] = useState(0);
|
||||||
|
|
||||||
|
const agentModels = useMemo(() => parseAgentModels(agent?.model), [agent?.model]);
|
||||||
|
const modelOptions = useMemo(() => agentModels.map((model) => ({ value: model.id, label: model.name })), [agentModels]);
|
||||||
|
const activeModelValue = overrides.model_id || '';
|
||||||
|
|
||||||
|
const handleSendStream = async (text: string) => {
|
||||||
|
if (!agentId) return;
|
||||||
|
const tempUser: ChatMessage = { id: 'tmp-' + Date.now(), role: 'user', content: text, createdAt: Date.now() };
|
||||||
|
args.setMessages((m) => [...(m || []), tempUser]);
|
||||||
|
setStreaming({ active: true, reasoningText: '', answerText: '', errorMessage: null, retryInfo: null, retrieved: [], toolCalls: [] });
|
||||||
|
scrollBottom(true);
|
||||||
|
|
||||||
|
abortRef.current?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
abortRef.current = ctrl;
|
||||||
|
|
||||||
|
const model = overrides.model || agentModels[0]?.name || '';
|
||||||
|
const modelId = overrides.model_id || agentModels[0]?.id || '';
|
||||||
|
const attText = buildAttachmentsText(attachments);
|
||||||
|
const content = attText ? `${text}\n\n${attText}` : text;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await streamChat(
|
||||||
|
agentId,
|
||||||
|
content,
|
||||||
|
{
|
||||||
|
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: (data) => {
|
||||||
|
setStreaming((s) => {
|
||||||
|
const assistant: any = data.assistant;
|
||||||
|
const reasoningText = assistant?.reasoning || assistant?.meta?.reasoning || assistant?.meta?.reasoningText || s.reasoningText;
|
||||||
|
const nextAssistant: ChatMessage = {
|
||||||
|
...data.assistant,
|
||||||
|
meta: {
|
||||||
|
...(data.assistant.meta || {}),
|
||||||
|
retrieved: data.assistant.meta?.retrieved || s.retrieved,
|
||||||
|
toolCalls: data.assistant.meta?.toolCalls || s.toolCalls,
|
||||||
|
reasoning: reasoningText || undefined
|
||||||
|
}
|
||||||
|
};
|
||||||
|
args.setMessages((m) => [...(m || []).filter((x) => x.id !== tempUser.id), data.user, nextAssistant]);
|
||||||
|
return { active: false, reasoningText: '', answerText: '', errorMessage: null, retryInfo: null, retrieved: [], toolCalls: [] };
|
||||||
|
});
|
||||||
|
setSessionRefresh((t) => t + 1);
|
||||||
|
setAttachments([]);
|
||||||
|
setImageUrls([]);
|
||||||
|
scrollBottom();
|
||||||
|
},
|
||||||
|
onAborted: (data) => {
|
||||||
|
const assistant: ChatMessage = { ...data.assistant, meta: { ...(data.assistant.meta || {}), aborted: true } };
|
||||||
|
args.setMessages((m) => [...(m || []).filter((x) => x.id !== tempUser.id), { ...tempUser, id: 'u-' + data.assistant.id }, assistant]);
|
||||||
|
setStreaming({ active: false, reasoningText: '', answerText: '', errorMessage: null, retryInfo: null, retrieved: [], toolCalls: [] });
|
||||||
|
setSessionRefresh((t) => t + 1);
|
||||||
|
loadMessages();
|
||||||
|
},
|
||||||
|
onError: (errMsg) => {
|
||||||
|
notify.error('流式失败:' + errMsg);
|
||||||
|
const errorMessage: ChatMessage = {
|
||||||
|
id: 'error-' + Date.now(),
|
||||||
|
role: 'assistant',
|
||||||
|
content: `❌ 请求失败:${errMsg}`,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
meta: { error: errMsg }
|
||||||
|
};
|
||||||
|
args.setMessages((m) => [...(m || []), errorMessage]);
|
||||||
|
setStreaming({ active: false, reasoningText: '', answerText: '', errorMessage: errMsg, retryInfo: null, retrieved: [], toolCalls: [] });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ctrl.signal,
|
||||||
|
sessionId || undefined,
|
||||||
|
model,
|
||||||
|
modelId,
|
||||||
|
imageUrls
|
||||||
|
);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.name !== 'AbortError') {
|
||||||
|
notify.error('请求失败:' + (e?.message ?? e));
|
||||||
|
const errorMessage: ChatMessage = {
|
||||||
|
id: 'error-' + Date.now(),
|
||||||
|
role: 'assistant',
|
||||||
|
content: `❌ 请求失败:${e?.message ?? String(e)}`,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
meta: { error: e?.message ?? String(e) }
|
||||||
|
};
|
||||||
|
args.setMessages((m) => [...(m || []), errorMessage]);
|
||||||
|
}
|
||||||
|
setStreaming({ active: false, reasoningText: '', answerText: '', errorMessage: e?.message ?? String(e), retryInfo: null, retrieved: [], toolCalls: [] });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendNonStream = async (text: string) => {
|
||||||
|
if (!agentId) return;
|
||||||
|
const tempUser: ChatMessage = { id: 'tmp-' + Date.now(), role: 'user', content: text, createdAt: Date.now() };
|
||||||
|
args.setMessages((m) => [...(m || []), tempUser]);
|
||||||
|
scrollBottom(true);
|
||||||
|
const attText = buildAttachmentsText(attachments);
|
||||||
|
const content = attText ? `${text}\n\n${attText}` : text;
|
||||||
|
const model = overrides.model || agentModels[0]?.name || '';
|
||||||
|
try {
|
||||||
|
const res = await ChatAPI.send(agentId, content, sessionId || undefined, model, imageUrls);
|
||||||
|
args.setMessages((m) => [...(m || []).filter((x) => x.id !== tempUser.id), res.user, res.assistant]);
|
||||||
|
setSessionRefresh((t) => t + 1);
|
||||||
|
setAttachments([]);
|
||||||
|
setImageUrls([]);
|
||||||
|
scrollBottom();
|
||||||
|
} catch (e: any) {
|
||||||
|
notify.error('发送失败:' + (e?.message ?? e));
|
||||||
|
args.setMessages((m) => (m || []).filter((x) => x.id !== tempUser.id));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
const text = input.trim();
|
||||||
|
if (!text || !agentId || sending) return;
|
||||||
|
setInput('');
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
if (useStream) await handleSendStream(text);
|
||||||
|
else await handleSendNonStream(text);
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStop = () => {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
setSending(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = async () => {
|
||||||
|
if (!agentId) return;
|
||||||
|
await ChatAPI.clear(agentId, sessionId || undefined);
|
||||||
|
args.setMessages(() => []);
|
||||||
|
setBranches({});
|
||||||
|
notify.success('对话已清空');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRegenerate = async (assistantId: string) => {
|
||||||
|
if (!agentId || sending) return;
|
||||||
|
setSending(true);
|
||||||
|
setStreaming({ active: true, reasoningText: '', answerText: '', errorMessage: null, retryInfo: null, retrieved: [], toolCalls: [] });
|
||||||
|
abortRef.current?.abort();
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
abortRef.current = ctrl;
|
||||||
|
try {
|
||||||
|
await regenerateMessage(
|
||||||
|
agentId,
|
||||||
|
assistantId,
|
||||||
|
{
|
||||||
|
onMeta: (m) => setStreaming((s) => ({ ...s, retrieved: m.retrieved || [] })),
|
||||||
|
onRetry: (data) => {
|
||||||
|
setStreaming((s) => ({ ...s, retryInfo: data }));
|
||||||
|
if (data?.stage === 'fallback_model' && data?.toModel) {
|
||||||
|
setOverrides((o) => ({ ...o, model: String(data.toModel) }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onReasoningDelta: (chunk) =>
|
||||||
|
setStreaming((s) => {
|
||||||
|
const next = { ...s, reasoningText: s.reasoningText + chunk };
|
||||||
|
scrollBottom();
|
||||||
|
return next;
|
||||||
|
}),
|
||||||
|
onDelta: (chunk) =>
|
||||||
|
setStreaming((s) => {
|
||||||
|
const next = { ...s, answerText: s.answerText + chunk };
|
||||||
|
scrollBottom();
|
||||||
|
return next;
|
||||||
|
}),
|
||||||
|
onToolCall: (data) => setStreaming((s) => ({ ...s, toolCalls: [...s.toolCalls, { name: data.name, args: data.args, result: { pending: true } }] })),
|
||||||
|
onToolResult: (data) =>
|
||||||
|
setStreaming((s) => {
|
||||||
|
const list = [...s.toolCalls];
|
||||||
|
for (let i = list.length - 1; i >= 0; i--) {
|
||||||
|
if (list[i].name === data.name && (list[i].result as any)?.pending) {
|
||||||
|
list[i] = { ...list[i], result: data.result };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...s, toolCalls: list };
|
||||||
|
}),
|
||||||
|
onDone: () => {
|
||||||
|
setStreaming({ active: false, reasoningText: '', answerText: '', errorMessage: null, retryInfo: null, retrieved: [], toolCalls: [] });
|
||||||
|
loadMessages();
|
||||||
|
},
|
||||||
|
onAborted: () => {
|
||||||
|
setStreaming({ active: false, reasoningText: '', answerText: '', errorMessage: null, retryInfo: null, retrieved: [], toolCalls: [] });
|
||||||
|
loadMessages();
|
||||||
|
},
|
||||||
|
onError: (errMsg) => {
|
||||||
|
notify.error('重新生成失败:' + errMsg);
|
||||||
|
setStreaming({ active: false, reasoningText: '', answerText: '', errorMessage: errMsg, retryInfo: null, retrieved: [], toolCalls: [] });
|
||||||
|
loadMessages();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ctrl.signal,
|
||||||
|
overrides
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSwitchBranch = async (userMsgId: string, branchId: string) => {
|
||||||
|
if (!agentId) return;
|
||||||
|
await ChatAPI.switchBranch(agentId, userMsgId, branchId);
|
||||||
|
await loadMessages();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAttach = async (files: File[]) => {
|
||||||
|
if (!files.length) return;
|
||||||
|
setUploadingAtt(true);
|
||||||
|
try {
|
||||||
|
const images = files.filter((f) => f.type.startsWith('image/'));
|
||||||
|
const docs = files.filter((f) => !f.type.startsWith('image/'));
|
||||||
|
let imgN = 0;
|
||||||
|
let docN = 0;
|
||||||
|
if (images.length) {
|
||||||
|
const r = await ImageAPI.upload(images);
|
||||||
|
setImageUrls((arr) => [...arr, ...r.files.map((f) => f.url)]);
|
||||||
|
imgN = r.files.length;
|
||||||
|
}
|
||||||
|
if (docs.length) {
|
||||||
|
const r = await ChatAttachmentsAPI.upload(docs);
|
||||||
|
setAttachments((a) => [...a, ...r.files]);
|
||||||
|
docN = r.files.length;
|
||||||
|
}
|
||||||
|
const parts = [];
|
||||||
|
if (imgN) parts.push(`${imgN} 张图片`);
|
||||||
|
if (docN) parts.push(`${docN} 个文档`);
|
||||||
|
if (parts.length) notify.success(`已附加 ${parts.join(' + ')}`);
|
||||||
|
} catch (e: any) {
|
||||||
|
notify.error('附件上传失败:' + (e?.message ?? e));
|
||||||
|
} finally {
|
||||||
|
setUploadingAtt(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
input,
|
||||||
|
setInput,
|
||||||
|
sending,
|
||||||
|
useStream,
|
||||||
|
setUseStream,
|
||||||
|
sessionRefresh,
|
||||||
|
overrides,
|
||||||
|
setOverrides,
|
||||||
|
attachments,
|
||||||
|
setAttachments,
|
||||||
|
imageUrls,
|
||||||
|
setImageUrls,
|
||||||
|
uploadingAtt,
|
||||||
|
streaming,
|
||||||
|
handleSend,
|
||||||
|
handleStop,
|
||||||
|
handleClear,
|
||||||
|
handleRegenerate,
|
||||||
|
handleSwitchBranch,
|
||||||
|
handleAttach,
|
||||||
|
modelOptions,
|
||||||
|
activeModelValue
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
export interface AgentModelOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const parseAgentModels = (value?: string): AgentModelOption[] => {
|
||||||
|
if (!value) return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed.map((item: any) => ({
|
||||||
|
id: typeof item === 'object' ? item.id : String(item),
|
||||||
|
name: typeof item === 'object' ? item.name : String(item)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return String(value || '')
|
||||||
|
.split(',')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((item) => ({ id: item, name: item }));
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import type { ChatAttachment } from '../../../api';
|
||||||
|
|
||||||
|
export const buildAttachmentsText = (attachments: ChatAttachment[]) => {
|
||||||
|
if (!attachments.length) return '';
|
||||||
|
return attachments
|
||||||
|
.map((a, i) => `### 附件 ${i + 1}: ${a.name}${a.truncated ? ' (已截断)' : ''}\n${a.text}`)
|
||||||
|
.join('\n\n---\n\n');
|
||||||
|
};
|
||||||
|
|
||||||
Loading…
Reference in New Issue