refactor: 拆分超400行文件并模块化API
parent
c23496a56c
commit
83aadde08e
970
src/api.ts
970
src/api.ts
|
|
@ -1,969 +1 @@
|
|||
import axios from 'axios';
|
||||
|
||||
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();
|
||||
}
|
||||
export * from './api/index';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
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';
|
||||
export { default } from './LLMProvidersPage/LLMProvidersPage';
|
||||
|
||||
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';
|
||||
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';
|
||||
export { default } from './PointsMallPage/PointsMallPage';
|
||||
|
||||
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';
|
||||
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';
|
||||
export { default } from './PromptLibraryPage/PromptLibraryPage';
|
||||
|
||||
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;
|
||||
// 模拟积分数据:1 USD = 1000 积分
|
||||
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;
|
||||
setData(enhanced);
|
||||
if (!tokenAgentId && res.topAgents?.[0]?.id) {
|
||||
|
|
@ -48,11 +49,12 @@ export default function StatsPage() {
|
|||
// 更新总消费和积分
|
||||
setData((prev) => {
|
||||
if (prev) {
|
||||
const spent = res.costUSD || 0;
|
||||
return {
|
||||
...prev,
|
||||
totalSpentUSD: res.costUSD,
|
||||
totalPoints: Math.floor(res.costUSD * 1000),
|
||||
pointsEarned: Math.floor(res.costUSD * 1000)
|
||||
totalSpentUSD: spent,
|
||||
totalPoints: Math.floor(spent * 1000),
|
||||
pointsEarned: Math.floor(spent * 1000)
|
||||
};
|
||||
}
|
||||
return prev;
|
||||
|
|
|
|||
|
|
@ -1,780 +1,2 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
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';
|
||||
export { default } from './WorkflowsPage/WorkflowsPage';
|
||||
|
||||
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