refactor: 拆分超400行文件并模块化API

main
sp mac bookpro 2605 2026-06-03 16:50:53 +08:00
parent c23496a56c
commit 83aadde08e
58 changed files with 4684 additions and 4647 deletions

View File

@ -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用 EventSourcecookie 由浏览器自动带)
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';

87
src/api/agents.ts Normal file
View File

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

30
src/api/auth.ts Normal file
View File

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

76
src/api/chat.ts Normal file
View File

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

26
src/api/http.ts Normal file
View File

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

18
src/api/index.ts Normal file
View File

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

35
src/api/llmProviders.ts Normal file
View File

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

16
src/api/marketplace.ts Normal file
View File

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

41
src/api/mcp.ts Normal file
View File

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

14
src/api/models.ts Normal file
View File

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

112
src/api/pointsMall.ts Normal file
View File

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

View File

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

21
src/api/search.ts Normal file
View File

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

73
src/api/sessions.ts Normal file
View File

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

57
src/api/stats.ts Normal file
View File

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

152
src/api/streamChat.ts Normal file
View File

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

21
src/api/teams.ts Normal file
View File

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

17
src/api/uploads.ts Normal file
View File

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

104
src/api/workflows.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 · DeepSeekhttps://api.deepseek.com'
},
{ value: 'anthropic', label: 'Anthropic Claude', baseUrl: 'https://api.anthropic.com' },
{ value: 'ollama', label: 'Ollama 本地', baseUrl: 'http://localhost:11434' }
];

View File

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

View File

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

View File

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

View File

@ -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 ? ['热卖'] : []
}));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 }}>
AgentHTTP
</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 / branchbranch <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="InputJSON会作为 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>
);
}

View File

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

View File

@ -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="InputJSON会作为 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>
);
}

View File

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

View File

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

159
src/pages/chat/ChatPage.tsx Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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('![image](') ? (
<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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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