feat: add workflow studio and postgres support

This commit is contained in:
boos
2026-05-25 16:03:07 +08:00
parent 7cdb02ef1d
commit cb35d5fc6e
96 changed files with 12414 additions and 15548 deletions
+8 -8
View File
@@ -7,8 +7,8 @@ const FEATURES = [
{ icon: 'RT', title: '模型路由与容灾', desc: '按模型、账号、站点和通道优先级调度,失败后自动冷却并切换到可用通道。' },
{ icon: 'AC', title: '站点与账号管理', desc: '集中维护站点、账号、API Key、OAuth、下游 Key 和普通用户可用模型。' },
{ icon: 'MD', title: '模型广场', desc: '聚合各站点模型可用性、账号覆盖、绑定令牌与路由状态,便于快速修路由。' },
{ icon: 'CU', title: 'ComfyUI 工作台', desc: '内置多租户 ComfyUI 容器池,支持用 BoosAPI 模型驱动文本、图像和视频节点。' },
{ icon: 'AG', title: 'ComfyUI Agent', desc: '面向工作流创作的对话助手,支持附件、多会话、用户 API Key 和模型路由选择。' },
{ icon: 'WF', title: '工作流画布', desc: '沿用 ComfyUI 节点画布体验,后续接入 BoosAPI 模型路由、图像、视频、音频和工具节点。' },
{ icon: 'AG', title: '视频助手', desc: '面向工作流创作的对话助手,支持附件、多会话、用户 API Key 和模型路由选择。' },
{ icon: 'PI', title: '生图助手', desc: '提供提示词模板、比例尺寸控制和图像模型调用,管理员与普通用户均可使用。' },
{ icon: 'VD', title: '视频任务模板', desc: '支持火山方舟 Step1 创建视频任务、Step2 查询任务结果,并可复用 BoosAPI 模型路由。' },
{ icon: 'CK', title: '签到与余额刷新', desc: '按站点/账号执行签到、余额刷新、健康检查和连接状态追踪。' },
@@ -17,18 +17,18 @@ const FEATURES = [
const TECH_STACK = [
{ name: 'Rust', desc: '后端核心服务,提供代理、路由、认证、任务和静态资源服务。' },
{ name: 'Axum / Tokio', desc: '异步 HTTP 服务、SSE、后台任务和容器生命周期管理。' },
{ name: 'Axum / Tokio', desc: '异步 HTTP 服务、SSE、后台任务和代理执行。' },
{ name: 'SQLx', desc: '类型化数据库访问与迁移管理。' },
{ name: 'SQLite', desc: '默认嵌入式数据库,适合单机部署和低运维成本场景。' },
{ name: 'React / Vite', desc: '管理后台、普通用户面板、模型测试器和 Agent 界面。' },
{ name: 'ComfyUI / Docker', desc: '视频工作台和多租户工作流容器运行环境。' },
{ name: 'External ComfyUI', desc: '可选外部 ComfyUI 代理;BoosAPI 不再内置 Docker 工作台。' },
];
const ENDPOINTS = [
{ path: '/v1/chat/completions', desc: 'OpenAI 兼容对话代理,支持非流式与流式。' },
{ path: '/v1/messages', desc: 'Claude Messages API 兼容入口,支持 Anthropic 请求、响应和流式事件。' },
{ path: '/v1/responses', desc: 'Responses API 兼容代理,可接入支持该协议的上游。' },
{ path: '/v1/images/generations', desc: '图像生成代理,供生图助手和 ComfyUI 图像节点使用。' },
{ path: '/v1/images/generations', desc: '图像生成代理,供生图助手和工作流图像节点使用。' },
{ path: '/v1/videos', desc: '视频生成任务入口,已适配火山方舟任务创建。' },
{ path: '/v1/videos/{id}', desc: '视频任务查询入口,已适配火山方舟任务轮询。' },
{ path: '/v1/models', desc: '仅暴露当前可路由、未冷却、账号有效的模型列表。' },
@@ -56,10 +56,10 @@ export default function About() {
</div>
</div>
<div style={{ fontSize: 14, color: 'var(--color-text-secondary)', lineHeight: 1.8 }}>
{tr('BoosAPI 是一个自托管 AI 模型网关与工作台。它把多站点账号、下游 Key、模型路由、ComfyUI、图像生成、视频任务和普通用户面板收束到同一个 Rust 服务中,让前端、Agent 和 ComfyUI 节点都能使用后台已经配置好的模型。')}
{tr('BoosAPI 是一个自托管 AI 模型网关与工作台。它把多站点账号、下游 Key、模型路由、图像生成、视频任务、ComfyUI 风格工作流和普通用户面板收束到同一个 Rust 服务中,让前端、Agent 和工作流节点都能使用后台已经配置好的模型。')}
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 14 }}>
{['Rust Backend', 'OpenAI Compatible', 'ComfyUI', 'Picture Agent', 'Volcengine Video'].map((tag) => (
{['Rust Backend', 'OpenAI Compatible', 'ComfyUI Canvas', 'Picture Agent', 'Volcengine Video'].map((tag) => (
<span
key={tag}
style={{
@@ -171,7 +171,7 @@ export default function About() {
<div className="card animate-slide-up stagger-5" style={{ padding: 22 }}>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 10 }}>{tr('数据与隐私')}</h3>
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.8 }}>
{tr('BoosAPI 完全自托管,账号、令牌、路由、日志、普通用户和工作台状态均保存在你的运行环境中。代理请求只在你的服务器、ComfyUI 容器与已配置上游之间流转;内置系统 Key 会被保护,后台列表中只展示脱敏值。')}
{tr('BoosAPI 完全自托管,账号、令牌、路由、日志、普通用户和工作台状态均保存在你的运行环境中。代理请求只在你的服务器、可选外部 ComfyUI 与已配置上游之间流转;内置系统 Key 会被保护,后台列表中只展示脱敏值。')}
</div>
</div>
</div>
+77
View File
@@ -493,7 +493,37 @@ export default function Accounts() {
credentialMode,
skipModelFetch: tokenForm.skipModelFetch,
});
const createdAccountIds = result?.batch
? Array.isArray(result?.data)
? result.data
.map((item: any) => Number(item?.id))
.filter((id: number) => Number.isFinite(id) && id > 0)
: []
: Number(result?.id) > 0
? [Number(result.id)]
: [];
if (result?.batch) {
if (
activeSegment === "apikey" &&
createdAccountIds.length > 0
) {
try {
const syncResults = await Promise.allSettled(
createdAccountIds.map((accountId: number) =>
api.checkModels(accountId),
),
);
const syncedCount = syncResults.filter(
(item) => item.status === "fulfilled",
).length;
if (syncedCount > 0) {
await api.rebuildRoutes(false, false);
toast.success("批量 API Key 已自动同步模型并重建路由");
}
} catch (syncErr: any) {
toast.error(syncErr?.message || "批量连接已添加,但自动建路由失败");
}
}
closeAddPanel();
const createdCount = Number(result.createdCount) || 0;
const failedCount = Number(result.failedCount) || 0;
@@ -527,6 +557,7 @@ export default function Accounts() {
createdAccountId,
recommendedModels,
);
await api.rebuildRoutes(false, false);
seededRecommendedModels = true;
} catch (seedErr: any) {
toast.error(seedErr?.message || "连接已添加,但推荐模型补录失败");
@@ -549,6 +580,52 @@ export default function Accounts() {
`已补入 ${recommendedModels.length} 个推荐模型并重建路由`,
);
}
const shouldAutoSyncModels =
credentialMode === "apikey" &&
!tokenForm.skipModelFetch &&
createdAccountIds.length > 0;
if (shouldAutoSyncModels) {
if (
!result?.batch &&
verifyResult?.success &&
Array.isArray(verifyResult.models) &&
verifyResult.models.length > 0
) {
try {
await api.addAccountAvailableModels(
createdAccountIds[0],
verifyResult.models,
);
await api.rebuildRoutes(false, false);
toast.success("已自动同步模型并重建路由");
} catch (syncErr: any) {
toast.error(syncErr?.message || "连接已添加,但模型同步失败");
}
} else {
const syncResults = await Promise.allSettled(
createdAccountIds.map((accountId: number) =>
api.checkModels(accountId),
),
);
const syncedCount = syncResults.filter(
(item) => item.status === "fulfilled",
).length;
const failedCount = syncResults.length - syncedCount;
if (syncedCount > 0) {
try {
await api.rebuildRoutes(false, false);
toast.success(
`已自动同步 ${syncedCount} 个连接的模型并重建路由`,
);
} catch (rebuildErr: any) {
toast.error(rebuildErr?.message || "模型已同步,但路由重建失败");
}
}
if (failedCount > 0) {
toast.error(`有 ${failedCount} 个连接同步模型失败`);
}
}
}
load(true);
} catch (e: any) {
toast.error(e.message || "添加失败");
+93 -135
View File
@@ -1,16 +1,20 @@
import React from 'react';
import { useLocation } from 'react-router-dom';
import { getSessionToken } from '../authSession.js';
export default function ComfyUI() {
const [isDark, setIsDark] = React.useState(true);
const [iframeSrc, setIframeSrc] = React.useState('');
const [starting, setStarting] = React.useState(true);
const [message, setMessage] = React.useState('正在启动 ComfyUI...');
const [error, setError] = React.useState('');
const location = useLocation();
const audience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
const token = getSessionToken(audience, localStorage);
const [available, setAvailable] = React.useState(false);
const [message, setMessage] = React.useState('正在检查外部 ComfyUI...');
const iframeRef = React.useRef<HTMLIFrameElement | null>(null);
const openFullscreen = () => {
window.open('/api/comfyui/proxy/', '_blank', 'noopener,noreferrer');
};
const reloadFrame = () => {
if (iframeRef.current) {
iframeRef.current.src = '/api/comfyui/proxy/';
}
};
React.useEffect(() => {
const el = document.documentElement;
@@ -23,121 +27,25 @@ export default function ComfyUI() {
React.useEffect(() => {
let cancelled = false;
const encodedToken = token ? encodeURIComponent(token) : '';
const requestTimeoutMs = 35_000;
const fetchWithTimeout = async (input: RequestInfo | URL, init?: RequestInit) => {
const controller = new AbortController();
const timer = window.setTimeout(() => controller.abort(), requestTimeoutMs);
try {
return await fetch(input, { ...init, signal: controller.signal });
} finally {
window.clearTimeout(timer);
}
};
const resolveIframeFromPayload = (payload: any) => {
const comfyui = payload?.comfyui || payload || {};
const mode = String(comfyui?.mode || '').toLowerCase();
const status = String(comfyui?.status || '').toLowerCase();
const lastError = String(comfyui?.lastError || comfyui?.last_error || '').trim();
if (mode === 'container') {
if (status === 'running') return `/comfy/?token=${encodedToken}`;
if (status === 'unhealthy' || status === 'failed') {
throw new Error(lastError || 'ComfyUI 容器启动失败');
}
return '';
}
if (status && status !== 'disabled') {
return '/api/comfyui/proxy/';
}
if (lastError) {
throw new Error(lastError);
}
return '';
};
if (!token) {
setIframeSrc('');
setStarting(false);
setMessage('登录状态已过期,请重新登录。');
setError('');
return () => {
cancelled = true;
};
}
setStarting(true);
setIframeSrc('');
setError('');
setMessage('正在启动 ComfyUI...');
const start = async () => {
try {
const response = await fetchWithTimeout('/api/video-agent/start', {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
});
if (!cancelled) {
if (!response.ok) {
throw new Error(`启动接口返回 ${response.status}`);
}
const payload = await response.json().catch(() => null);
const nextSrc = resolveIframeFromPayload(payload);
if (nextSrc) {
setIframeSrc(nextSrc);
return;
}
}
} catch (e: any) {
if (!cancelled) {
setMessage('ComfyUI 启动较慢,正在检查状态...');
if (e?.name !== 'AbortError') setError(e?.message || '启动请求失败');
}
}
const deadline = Date.now() + 120_000;
while (!cancelled && Date.now() < deadline) {
try {
const response = await fetchWithTimeout('/api/video-agent/status', {
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
const payload = await response.json().catch(() => null);
const nextSrc = resolveIframeFromPayload(payload);
if (nextSrc) {
setIframeSrc(nextSrc);
return;
}
setMessage('ComfyUI 仍在启动,等待容器就绪...');
} else {
setError(`状态接口返回 ${response.status}`);
}
} catch (e: any) {
setError(e?.name === 'AbortError' ? '状态检查超时' : (e?.message || '状态检查失败'));
}
await new Promise((resolve) => window.setTimeout(resolve, 2000));
}
if (!cancelled) {
setStarting(false);
setMessage('ComfyUI 启动超时');
setError((current) => current || '请检查 /api/video-agent/status、Docker 容器日志和 4000 后端进程。');
}
};
void start().finally(() => {
if (!cancelled) setStarting(false);
});
const controller = new AbortController();
void fetch('/api/comfyui/proxy/', { method: 'GET', signal: controller.signal })
.then((response) => {
if (cancelled) return;
setAvailable(response.ok);
setMessage(response.ok
? ''
: '外部 ComfyUI 不可达。请在 config.toml 中配置 comfyui.base_url,或使用工作流。');
})
.catch((err) => {
if (cancelled || err?.name === 'AbortError') return;
setAvailable(false);
setMessage('外部 ComfyUI 不可达。请在 config.toml 中配置 comfyui.base_url,或使用工作流。');
});
return () => {
cancelled = true;
controller.abort();
};
}, [token]);
}, []);
return (
<div
@@ -149,15 +57,23 @@ export default function ComfyUI() {
background: isDark ? '#1a1a2e' : '#f5f5f5',
}}
>
{iframeSrc ? (
<div style={toolbarStyle}>
<div style={toolbarTitleStyle}>外部 ComfyUI</div>
<div style={toolbarActionsStyle}>
<button type="button" onClick={reloadFrame} disabled={!available} style={toolbarButtonStyle}>
刷新
</button>
<button type="button" onClick={openFullscreen} disabled={!available} style={primaryToolbarButtonStyle}>
新窗口打开
</button>
</div>
</div>
{available ? (
<iframe
src={iframeSrc}
style={{
width: '100%',
height: '100%',
border: 'none',
}}
title="ComfyUI"
ref={iframeRef}
src="/api/comfyui/proxy/"
style={{ width: '100%', height: '100%', border: 'none' }}
title="External ComfyUI"
allow="clipboard-read; clipboard-write"
/>
) : (
@@ -168,16 +84,58 @@ export default function ComfyUI() {
color: 'var(--color-text-muted)',
fontSize: 14,
textAlign: 'center',
gap: 8,
padding: 24,
}}>
<div>{starting ? message : message}</div>
{error && (
<div style={{ color: '#ef4444', fontSize: 12, maxWidth: 520 }}>
{error}
<div>
<div style={{ fontWeight: 700, color: 'var(--color-text-primary)', marginBottom: 8 }}>
内置 Docker ComfyUI 工作台已移除
</div>
)}
<div style={{ maxWidth: 560, lineHeight: 1.7 }}>{message}</div>
</div>
</div>
)}
</div>
);
}
const toolbarStyle: React.CSSProperties = {
minHeight: 44,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
padding: '8px 12px',
borderBottom: '1px solid var(--color-border-light)',
background: 'var(--color-surface)',
};
const toolbarTitleStyle: React.CSSProperties = {
fontSize: 14,
fontWeight: 800,
color: 'var(--color-text-primary)',
};
const toolbarActionsStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 8,
};
const toolbarButtonStyle: React.CSSProperties = {
minHeight: 30,
border: '1px solid var(--color-border)',
borderRadius: 8,
padding: '5px 10px',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
cursor: 'pointer',
fontSize: 12,
};
const primaryToolbarButtonStyle: React.CSSProperties = {
...toolbarButtonStyle,
borderColor: 'var(--color-primary)',
background: 'var(--color-primary)',
color: '#fff',
fontWeight: 700,
};
File diff suppressed because it is too large Load Diff
+340
View File
@@ -0,0 +1,340 @@
import React, { CSSProperties, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { fetchSessionResponse } from '../api.js';
import type { SessionAudience } from '../authSession.js';
type Mode = 'audio' | 'image' | 'text';
const MODE_OPTIONS: Array<{ value: Mode; label: string; description: string }> = [
{ value: 'audio', label: '音频驱动', description: '人物图 + 音频,适合口播、播报和虚拟主播。' },
{ value: 'image', label: '图像驱动', description: '人物图 + 台词,交给上游数字人/视频模型生成。' },
{ value: 'text', label: '文本生成', description: '只输入角色描述和台词,让模型生成数字人视频任务。' },
];
const MODEL_PRESETS = [
'longcat-video-avatar',
'doubao-seedance-1-0-lite-i2v-250428',
'doubao-seedance-1-0-lite-t2v-250428',
'wan2-1-14b-i2v-250225',
];
const VOICE_STYLE_OPTIONS = ['自然口播', '新闻播报', '带货讲解', '课程讲师', '双人对话'];
async function parseError(response: Response) {
const text = await response.text().catch(() => '');
if (!text) return `HTTP ${response.status}`;
try {
const value = JSON.parse(text);
return value?.error?.message || value?.message || text;
} catch {
return text;
}
}
function extractTaskId(value: any): string {
return value?.response?.id
|| value?.response?.task_id
|| value?.response?.data?.id
|| value?.response?.data?.task_id
|| value?.id
|| value?.task_id
|| '';
}
function copyText(value: string) {
if (!value) return;
void navigator.clipboard?.writeText(value);
}
export default function DigitalHuman() {
const location = useLocation();
const audience: SessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
const [mode, setMode] = useState<Mode>('audio');
const [model, setModel] = useState(MODEL_PRESETS[0]);
const [prompt, setPrompt] = useState('一位专业中文数字人主播,坐在简洁演播室中,面向镜头自然讲解 BoosAPI 的模型路由和工作流能力。');
const [script, setScript] = useState('大家好,欢迎使用 BoosAPI。今天我们演示如何用统一模型路由生成数字人口播视频。');
const [imageUrl, setImageUrl] = useState('');
const [audioUrl, setAudioUrl] = useState('');
const [voiceStyle, setVoiceStyle] = useState(VOICE_STYLE_OPTIONS[0]);
const [resolution, setResolution] = useState('720p');
const [ratio, setRatio] = useState('9:16');
const [duration, setDuration] = useState(8);
const [refImageIndex, setRefImageIndex] = useState(0);
const [audioCfg, setAudioCfg] = useState(4.5);
const [segments, setSegments] = useState(false);
const [multiPerson, setMultiPerson] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const [result, setResult] = useState<any>(null);
const bodyPreview = useMemo(() => ({
model: model.trim(),
prompt: [
prompt.trim(),
script.trim() ? `台词:${script.trim()}` : '',
`风格:${voiceStyle}`,
].filter(Boolean).join('\n'),
mode,
ratio,
resolution,
duration,
audio_url: mode === 'audio' ? audioUrl.trim() : undefined,
image_url: mode !== 'text' ? imageUrl.trim() : undefined,
audio_cfg: audioCfg,
ref_img_index: refImageIndex,
segmented: segments,
multi_person: multiPerson,
}), [audioCfg, audioUrl, duration, imageUrl, mode, model, multiPerson, prompt, ratio, refImageIndex, resolution, script, segments, voiceStyle]);
const submit = async () => {
setSubmitting(true);
setError('');
setResult(null);
try {
const response = await fetchSessionResponse(audience, '/api/digital-human/generate', {
method: 'POST',
body: JSON.stringify(bodyPreview),
timeoutMs: 300_000,
});
if (!response.ok) throw new Error(await parseError(response));
setResult(await response.json());
} catch (err: any) {
setError(err?.message || '数字人任务创建失败');
} finally {
setSubmitting(false);
}
};
const taskId = extractTaskId(result);
return (
<div className="page">
<div className="page-header">
<div>
<h1 className="page-title">数字人</h1>
<p className="page-subtitle">参考 LongCat-Video Avatar 的任务形态:人物图、音频、台词、分段和多人数字人编排,底层走 BoosAPI 视频模型路由。</p>
</div>
<div className="page-actions">
<button type="button" className="btn btn-primary" onClick={submit} disabled={submitting}>
{submitting ? '创建中...' : '创建数字人任务'}
</button>
</div>
</div>
{error && <div className="alert alert-error" style={{ marginBottom: 12 }}>{error}</div>}
<div style={layoutStyle}>
<section style={panelStyle}>
<div style={modeGridStyle}>
{MODE_OPTIONS.map((item) => (
<button
key={item.value}
type="button"
onClick={() => setMode(item.value)}
style={mode === item.value ? activeModeStyle : modeStyle}
>
<strong>{item.label}</strong>
<span>{item.description}</span>
</button>
))}
</div>
<div style={formGridStyle}>
<label style={fieldStyle}>
<span>模型</span>
<input value={model} onChange={(event) => setModel(event.target.value)} list="digital-human-models" style={inputStyle} />
<datalist id="digital-human-models">
{MODEL_PRESETS.map((item) => <option key={item} value={item} />)}
</datalist>
</label>
<label style={fieldStyle}>
<span>画幅</span>
<select value={ratio} onChange={(event) => setRatio(event.target.value)} style={inputStyle}>
<option value="9:16">9:16 竖屏</option>
<option value="16:9">16:9 横屏</option>
<option value="1:1">1:1 方屏</option>
</select>
</label>
<label style={fieldStyle}>
<span>分辨率</span>
<select value={resolution} onChange={(event) => setResolution(event.target.value)} style={inputStyle}>
<option value="720p">720p</option>
<option value="1080p">1080p</option>
<option value="480p">480p</option>
</select>
</label>
<label style={fieldStyle}>
<span>时长(秒)</span>
<input type="number" min={1} max={120} value={duration} onChange={(event) => setDuration(Number(event.target.value) || 1)} style={inputStyle} />
</label>
</div>
<label style={fieldStyle}>
<span>人物/场景描述</span>
<textarea value={prompt} onChange={(event) => setPrompt(event.target.value)} rows={4} style={textareaStyle} />
</label>
<label style={fieldStyle}>
<span>台词/脚本</span>
<textarea value={script} onChange={(event) => setScript(event.target.value)} rows={5} style={textareaStyle} />
</label>
<div style={formGridStyle}>
{mode !== 'text' && (
<label style={fieldStyle}>
<span>人物图 URL</span>
<input value={imageUrl} onChange={(event) => setImageUrl(event.target.value)} placeholder="https://..." style={inputStyle} />
</label>
)}
{mode === 'audio' && (
<label style={fieldStyle}>
<span>音频 URL</span>
<input value={audioUrl} onChange={(event) => setAudioUrl(event.target.value)} placeholder="https://..." style={inputStyle} />
</label>
)}
<label style={fieldStyle}>
<span>口播风格</span>
<select value={voiceStyle} onChange={(event) => setVoiceStyle(event.target.value)} style={inputStyle}>
{VOICE_STYLE_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
</label>
<label style={fieldStyle}>
<span>ref_img_index</span>
<input type="number" min={0} value={refImageIndex} onChange={(event) => setRefImageIndex(Number(event.target.value) || 0)} style={inputStyle} />
</label>
<label style={fieldStyle}>
<span>audio CFG</span>
<input type="number" min={0} step={0.1} value={audioCfg} onChange={(event) => setAudioCfg(Number(event.target.value) || 0)} style={inputStyle} />
</label>
</div>
<div style={toggleRowStyle}>
<label><input type="checkbox" checked={segments} onChange={(event) => setSegments(event.target.checked)} /> 分段长视频</label>
<label><input type="checkbox" checked={multiPerson} onChange={(event) => setMultiPerson(event.target.checked)} /> 多人数字人</label>
</div>
</section>
<aside style={panelStyle}>
<h2 style={sideTitleStyle}>任务请求</h2>
<pre style={codeStyle}>{JSON.stringify(bodyPreview, null, 2)}</pre>
<button type="button" className="btn btn-ghost" onClick={() => copyText(JSON.stringify(bodyPreview, null, 2))}>复制请求</button>
<h2 style={{ ...sideTitleStyle, marginTop: 18 }}>返回结果</h2>
{result ? (
<>
{taskId && <div style={taskIdStyle}>任务 ID:{taskId}</div>}
<pre style={codeStyle}>{JSON.stringify(result.response || result, null, 2)}</pre>
<button type="button" className="btn btn-ghost" onClick={() => copyText(JSON.stringify(result.response || result, null, 2))}>复制结果</button>
</>
) : (
<div className="empty-state" style={{ minHeight: 160 }}>
<div className="empty-state-title">暂无任务</div>
<div className="empty-state-desc">创建后会显示上游返回,可用任务 ID 到模型站点或后续查询接口查看状态。</div>
</div>
)}
</aside>
</div>
</div>
);
}
const layoutStyle: CSSProperties = {
display: 'grid',
gridTemplateColumns: 'minmax(0, 1.25fr) minmax(320px, 0.75fr)',
gap: 16,
};
const panelStyle: CSSProperties = {
border: '1px solid var(--color-border)',
background: 'var(--color-surface)',
borderRadius: 8,
padding: 16,
};
const modeGridStyle: CSSProperties = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
gap: 10,
marginBottom: 16,
};
const modeStyle: CSSProperties = {
textAlign: 'left',
border: '1px solid var(--color-border)',
background: 'var(--color-bg)',
borderRadius: 8,
padding: 12,
display: 'flex',
flexDirection: 'column',
gap: 6,
color: 'var(--color-text-primary)',
};
const activeModeStyle: CSSProperties = {
...modeStyle,
borderColor: 'var(--color-primary)',
boxShadow: '0 0 0 2px color-mix(in srgb, var(--color-primary) 15%, transparent)',
};
const formGridStyle: CSSProperties = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
gap: 12,
marginBottom: 12,
};
const fieldStyle: CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: 6,
fontSize: 12,
color: 'var(--color-text-secondary)',
};
const inputStyle: CSSProperties = {
width: '100%',
border: '1px solid var(--color-border)',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
borderRadius: 6,
padding: '10px 12px',
fontSize: 13,
};
const textareaStyle: CSSProperties = {
...inputStyle,
minHeight: 110,
resize: 'vertical',
};
const toggleRowStyle: CSSProperties = {
display: 'flex',
gap: 16,
flexWrap: 'wrap',
fontSize: 13,
color: 'var(--color-text-secondary)',
};
const sideTitleStyle: CSSProperties = {
margin: '0 0 10px',
fontSize: 14,
};
const codeStyle: CSSProperties = {
maxHeight: 280,
overflow: 'auto',
background: 'var(--color-bg)',
border: '1px solid var(--color-border)',
borderRadius: 6,
padding: 12,
fontSize: 12,
whiteSpace: 'pre-wrap',
};
const taskIdStyle: CSSProperties = {
marginBottom: 8,
padding: '8px 10px',
borderRadius: 6,
background: 'color-mix(in srgb, var(--color-primary) 10%, transparent)',
color: 'var(--color-text-primary)',
fontSize: 12,
};
+151 -86
View File
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { api } from '../api.js';
import { api, userApi } from '../api.js';
import { BrandGlyph, getBrand, hashColor, BrandIcon, type BrandInfo } from '../components/BrandIcon.js';
import SiteBadgeLink from '../components/SiteBadgeLink.js';
import { useToast } from '../components/Toast.js';
@@ -125,6 +125,24 @@ function renderGroupPricingValue(pricing: ModelGroupPricing): string {
return `${pricing.perCallTotal ?? 0} USD / call`;
}
async function copyTextToClipboard(text: string) {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.style.left = '-9999px';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
}
const PAGE_SIZES = [10, 20, 50];
function compareModels(a: ModelRow, b: ModelRow, sortBy: SortColumn, sortDir: 'asc' | 'desc'): number {
@@ -153,6 +171,9 @@ function compareModels(a: ModelRow, b: ModelRow, sortBy: SortColumn, sortDir: 'a
/* ---- component ---- */
export default function Models() {
const toast = useToast();
const location = useLocation();
const isUserView = location.pathname.startsWith('/user/');
const marketplaceApi = isUserView ? userApi : api;
const [data, setData] = useState<ModelsMarketplaceResponse>({ models: [] });
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
@@ -172,7 +193,6 @@ export default function Models() {
const filterPanelPresence = useAnimatedVisibility(!isMobile && !filterCollapsed, 220);
const latestPrimaryRequestRef = useRef(0);
const latestMetadataRequestRef = useRef(0);
const location = useLocation();
const siteIdByName = useMemo(() => {
const index = new Map<string, number>();
for (const model of data.models) {
@@ -192,7 +212,7 @@ export default function Models() {
setMetadataHydrating(false);
setLoading(true);
try {
const res = await api.getModelsMarketplace({
const res = await marketplaceApi.getModelsMarketplace({
refresh,
includePricing: false,
});
@@ -216,7 +236,7 @@ export default function Models() {
setLoading(false);
}
}
}, [toast]);
}, [marketplaceApi, toast]);
const hydrateMarketplaceMetadata = useCallback(async (baseModels: ModelRow[]) => {
if (!shouldHydrateMarketplaceMetadata(baseModels)) return;
@@ -225,7 +245,7 @@ export default function Models() {
const baseRequestId = latestPrimaryRequestRef.current;
setMetadataHydrating(true);
try {
const res = await api.getModelsMarketplace({
const res = await marketplaceApi.getModelsMarketplace({
includePricing: true,
});
if (metadataRequestId !== latestMetadataRequestRef.current) return;
@@ -244,7 +264,7 @@ export default function Models() {
setMetadataHydrating(false);
}
}
}, []);
}, [marketplaceApi]);
useEffect(() => {
if (!isMobile) return;
@@ -311,6 +331,7 @@ export default function Models() {
/* ---- derived: site list ---- */
const siteMap = useMemo(() => {
if (isUserView) return [];
const m = new Map<string, number>();
for (const model of data.models) {
for (const a of model.accounts) {
@@ -318,7 +339,7 @@ export default function Models() {
}
}
return [...m.entries()].sort((a, b) => b[1] - a[1]);
}, [data.models]);
}, [data.models, isUserView]);
/* ---- filtered ---- */
const filteredModels = useMemo(() => {
@@ -366,8 +387,8 @@ export default function Models() {
};
}) : filteredModels;
return [...scopedModels].sort((a, b) => compareModels(a, b, sortBy, sortDir));
}, [filteredModels, activeSite, sortBy, sortDir]);
return [...scopedModels].sort((a, b) => compareModels(a, b, isUserView ? 'name' : sortBy, isUserView ? 'asc' : sortDir));
}, [filteredModels, activeSite, sortBy, sortDir, isUserView]);
/* ---- pagination ---- */
const totalPages = Math.max(1, Math.ceil(detailModels.length / pageSize));
@@ -395,10 +416,15 @@ export default function Models() {
: null;
/* ---- copy ---- */
const copyName = (name: string) => {
navigator.clipboard.writeText(name).catch(() => { });
setCopied(name);
setTimeout(() => setCopied(null), 1500);
const copyName = async (name: string) => {
try {
await copyTextToClipboard(name);
setCopied(name);
toast.success(tr('已复制模型名'));
setTimeout(() => setCopied(null), 1500);
} catch (error: any) {
toast.error(error?.message || tr('复制模型名失败'));
}
};
const filterControls = (
@@ -441,27 +467,29 @@ export default function Models() {
)}
</div>
<div className="filter-panel-section">
<div className="filter-panel-title">
{tr('供应商')}
{activeSite && <button onClick={() => setActiveSite(null)}>{tr('重置')}</button>}
</div>
{siteMap.map(([site, count]) => (
<div
key={site}
className={`filter-item ${activeSite === site ? 'active' : ''}`}
onClick={() => setActiveSite(activeSite === site ? null : site)}
>
<span className="filter-item-icon" style={{ background: hashColor(site), color: 'white', fontSize: 9, borderRadius: 4 }}>
{site.slice(0, 2).toUpperCase()}
</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{site}</span>
<span className="filter-item-count">{count}</span>
{!isUserView && (
<div className="filter-panel-section">
<div className="filter-panel-title">
{tr('供应商')}
{activeSite && <button onClick={() => setActiveSite(null)}>{tr('重置')}</button>}
</div>
))}
</div>
{siteMap.map(([site, count]) => (
<div
key={site}
className={`filter-item ${activeSite === site ? 'active' : ''}`}
onClick={() => setActiveSite(activeSite === site ? null : site)}
>
<span className="filter-item-icon" style={{ background: hashColor(site), color: 'white', fontSize: 9, borderRadius: 4 }}>
{site.slice(0, 2).toUpperCase()}
</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{site}</span>
<span className="filter-item-count">{count}</span>
</div>
))}
</div>
)}
<div className="filter-panel-section">
{!isUserView && <div className="filter-panel-section">
<div className="filter-panel-title">{tr('排序方式')}</div>
{[
{ key: 'accountCount' as SortColumn, label: tr('账号数') },
@@ -490,7 +518,7 @@ export default function Models() {
)}
</div>
))}
</div>
</div>}
</>
);
@@ -626,15 +654,17 @@ export default function Models() {
/>
</div>
{/* Quick stats */}
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: 'var(--color-text-muted)', alignItems: 'center' }}>
<span data-tooltip={tr('所有模型 accountCount 累计值,同一账号在多个模型中会重复计数')}>
{tr('覆盖档位')} <b style={{ color: 'var(--color-text-primary)' }}>{totalCoverageSlots}</b>
</span>
<span data-tooltip={tr('当前筛选范围内去重后的唯一账号数')}>
{tr('去重账号')} <b style={{ color: 'var(--color-text-primary)' }}>{uniqueAccountCount}</b>
</span>
<span>{tr('平均延迟')} <b style={{ color: getMetricColor(avgLatency) }}>{formatLatency(avgLatency)}</b></span>
</div>
{!isUserView && (
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: 'var(--color-text-muted)', alignItems: 'center' }}>
<span data-tooltip={tr('所有模型 accountCount 累计值,同一账号在多个模型中会重复计数')}>
{tr('覆盖档位')} <b style={{ color: 'var(--color-text-primary)' }}>{totalCoverageSlots}</b>
</span>
<span data-tooltip={tr('当前筛选范围内去重后的唯一账号数')}>
{tr('去重账号')} <b style={{ color: 'var(--color-text-primary)' }}>{uniqueAccountCount}</b>
</span>
<span>{tr('平均延迟')} <b style={{ color: getMetricColor(avgLatency) }}>{formatLatency(avgLatency)}</b></span>
</div>
)}
</div>
{/* Empty */}
@@ -660,14 +690,14 @@ export default function Models() {
<div className="model-card-info">
<div className="model-card-name">{m.name}</div>
<div className="model-card-meta">
<span>
{!isUserView && <span>
<svg width="12" height="12" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
{m.accountCount} {tr('个账号')}
</span>
<span>
</span>}
{!isUserView && <span>
<svg width="12" height="12" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg>
{m.tokenCount} {tr('令牌')}
</span>
</span>}
<span
className={`badge ${getLatencyBadgeClass(m.avgLatency)}`}
style={{ fontVariantNumeric: 'tabular-nums' }}
@@ -685,7 +715,16 @@ export default function Models() {
</div>
</div>
<div className="model-card-actions" onClick={e => e.stopPropagation()}>
<button className="model-card-action-btn" data-tooltip={tr('复制模型名')} aria-label={tr('复制模型名')} onClick={() => copyName(m.name)}>
<button
type="button"
className="model-card-action-btn"
data-tooltip={tr('复制模型名')}
aria-label={tr('复制模型名')}
onClick={(event) => {
event?.stopPropagation?.();
void copyName(m.name);
}}
>
{copied === m.name ? (
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="var(--color-success)"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
) : (
@@ -709,7 +748,7 @@ export default function Models() {
{getBrand(m.name) && (
<span className="model-tag model-tag-purple">{getBrand(m.name)!.name}</span>
)}
{m.accounts.map(a => a.site).filter((v, i, arr) => arr.indexOf(v) === i).map(site => (
{!isUserView && m.accounts.map(a => a.site).filter((v, i, arr) => arr.indexOf(v) === i).map(site => (
<span key={site} className="model-tag model-tag-blue">{site}</span>
))}
{m.successRate != null && m.successRate >= 90 && (
@@ -750,35 +789,48 @@ export default function Models() {
</div>
</div>
<div className="card" style={{ padding: 10 }}>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 6 }}>{tr('分组计费')}</div>
{m.pricingSources.length > 0 ? (
<div style={{ display: 'grid', gap: 8 }}>
{m.pricingSources.map((source) => (
<div
key={`${source.siteId}-${source.accountId}`}
style={{ border: '1px solid var(--color-border-light)', borderRadius: 8, padding: 8 }}
>
<div style={{ fontSize: 12, marginBottom: 6 }}>
<SiteBadgeLink siteId={source.siteId} siteName={source.siteName} badgeStyle={{ fontSize: 11 }} /> · {source.username || `ID:${source.accountId}`}
{!isUserView && (
<div className="card" style={{ padding: 10 }}>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 6 }}>{tr('分组计费')}</div>
{m.pricingSources.length > 0 ? (
<div style={{ display: 'grid', gap: 8 }}>
{m.pricingSources.map((source) => (
<div
key={`${source.siteId}-${source.accountId}`}
style={{ border: '1px solid var(--color-border-light)', borderRadius: 8, padding: 8 }}
>
<div style={{ fontSize: 12, marginBottom: 6 }}>
{isUserView ? tr('隐藏账号详情') : <><SiteBadgeLink siteId={source.siteId} siteName={source.siteName} badgeStyle={{ fontSize: 11 }} /> · {source.username || `ID:${source.accountId}`}</>}
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{Object.entries(source.groupPricing).map(([group, pricing]) => (
<span key={group} className="badge badge-info">
{group}: {renderGroupPricingValue(pricing)}
</span>
))}
</div>
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{Object.entries(source.groupPricing).map(([group, pricing]) => (
<span key={group} className="badge badge-info">
{group}: {renderGroupPricingValue(pricing)}
</span>
))}
</div>
</div>
))}
</div>
) : (
<span className="badge badge-muted">{metadataHydrating ? tr('正在加载价格元数据...') : tr('暂无价格元数据')}</span>
)}
</div>
))}
</div>
) : (
<span className="badge badge-muted">{metadataHydrating ? tr('正在加载价格元数据...') : tr('暂无价格元数据')}</span>
)}
</div>
)}
</div>
{isMobile ? (
{isUserView ? (
<div style={{ display: 'grid', gap: 8 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-text-secondary)' }}>{tr('品牌')}</div>
<div className="card" style={{ padding: 10 }}>
{getBrand(m.name) ? (
<span className="model-tag model-tag-purple">{getBrand(m.name)!.name}</span>
) : (
<span className="badge badge-muted">{tr('未识别品牌')}</span>
)}
</div>
</div>
) : isMobile ? (
<div style={{ display: 'grid', gap: 8 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-text-secondary)' }}>{tr('账号明细')}</div>
{m.accounts.map((a) => (
@@ -828,8 +880,8 @@ export default function Models() {
<tbody>
{m.accounts.map(a => (
<tr key={a.id}>
<td><SiteBadgeLink siteId={siteIdByName.get(a.site)} siteName={a.site} badgeClassName="badge badge-info" badgeStyle={{ fontSize: 11 }} /></td>
<td style={{ fontSize: 12 }}>{a.username || `ID:${a.id}`}</td>
<td>{isUserView ? <span className="badge badge-muted">{tr('隐藏')}</span> : <SiteBadgeLink siteId={siteIdByName.get(a.site)} siteName={a.site} badgeClassName="badge badge-info" badgeStyle={{ fontSize: 11 }} />}</td>
<td style={{ fontSize: 12 }}>{isUserView ? tr('隐藏') : (a.username || `ID:${a.id}`)}</td>
<td style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{a.tokens.length > 0 ? a.tokens.map(t => (
<span key={t.id} className={`badge ${t.isDefault ? 'badge-success' : 'badge-muted'}`} style={{ fontSize: 11 }}>{t.name}</span>
@@ -864,12 +916,16 @@ export default function Models() {
<th style={{ cursor: 'pointer' }} onClick={() => { setSortBy('name'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}>
{tr('模型名称')} {sortBy === 'name' ? (sortDir === 'desc' ? '↓' : '↑') : ''}
</th>
<th style={{ cursor: 'pointer' }} onClick={() => { setSortBy('accountCount'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}>
{tr('账号数')} {sortBy === 'accountCount' ? (sortDir === 'desc' ? '↓' : '↑') : ''}
</th>
<th style={{ cursor: 'pointer' }} onClick={() => { setSortBy('tokenCount'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}>
{tr('令牌数')} {sortBy === 'tokenCount' ? (sortDir === 'desc' ? '↓' : '↑') : ''}
</th>
{!isUserView && (
<th style={{ cursor: 'pointer' }} onClick={() => { setSortBy('accountCount'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}>
{tr('账号数')} {sortBy === 'accountCount' ? (sortDir === 'desc' ? '↓' : '↑') : ''}
</th>
)}
{!isUserView && (
<th style={{ cursor: 'pointer' }} onClick={() => { setSortBy('tokenCount'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}>
{tr('令牌数')} {sortBy === 'tokenCount' ? (sortDir === 'desc' ? '↓' : '↑') : ''}
</th>
)}
<th style={{ cursor: 'pointer' }} onClick={() => { setSortBy('avgLatency'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}>
{tr('延迟')} {sortBy === 'avgLatency' ? (sortDir === 'desc' ? '↓' : '↑') : ''}
</th>
@@ -893,8 +949,8 @@ export default function Models() {
{m.name}
</code>
</td>
<td><span className="badge badge-info">{m.accountCount}</span></td>
<td><span className="badge badge-muted">{m.tokenCount}</span></td>
{!isUserView && <td><span className="badge badge-info">{m.accountCount}</span></td>}
{!isUserView && <td><span className="badge badge-muted">{m.tokenCount}</span></td>}
<td>
<span
className={`badge ${getLatencyBadgeClass(m.avgLatency)}`}
@@ -912,7 +968,16 @@ export default function Models() {
</span>
</td>
<td onClick={e => e.stopPropagation()}>
<button className="model-card-action-btn" data-tooltip={tr('复制')} aria-label={tr('复制')} onClick={() => copyName(m.name)}>
<button
type="button"
className="model-card-action-btn"
data-tooltip={tr('复制')}
aria-label={tr('复制')}
onClick={(event) => {
event?.stopPropagation?.();
void copyName(m.name);
}}
>
{copied === m.name ? (
<svg width="12" height="12" fill="none" viewBox="0 0 24 24" stroke="var(--color-success)"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
) : (
@@ -923,7 +988,7 @@ export default function Models() {
</tr>
{isExpanded ? (
<tr className="log-detail-row">
<td colSpan={7} style={{ padding: 0 }}>
<td colSpan={isUserView ? 5 : 7} style={{ padding: 0 }}>
<div className="anim-collapse is-open">
<div className="anim-collapse-inner">
<div style={{ padding: '12px 16px 12px 54px' }}>
+390 -13
View File
@@ -13,6 +13,36 @@ type AgentImage = {
};
type PictureMode = 'generate' | 'edit';
type PictureJobStatus = 'pending' | 'running' | 'succeeded' | 'failed' | 'cancelled';
type PictureJob = {
id: string;
status: PictureJobStatus | string;
model?: string | null;
message?: string | null;
error?: string | null;
progress?: number;
progressTotal?: number;
result?: {
prompt?: string;
model?: string;
mode?: PictureMode | string;
response?: any;
images?: any[];
};
createdAt?: string;
updatedAt?: string;
finishedAt?: string | null;
};
function staleRunningHint(job: PictureJob) {
if (job.status !== 'pending' && job.status !== 'running') return '';
const updatedAt = parseServerTime(job.updatedAt || job.createdAt);
if (!Number.isFinite(updatedAt)) return '';
const ageMs = Date.now() - updatedAt;
if (ageMs < 180_000) return '';
return '图片模型响应时间较长,任务仍在后台等待。大型提示词或高峰期通常需要 3-5 分钟。';
}
type PictureAgentStatus = {
success?: boolean;
@@ -47,6 +77,7 @@ type GalleryItem = {
const DEFAULT_PROMPT = 'A precise editorial product photo of a translucent glass robot painting a small neon city, sharp focus, clean background';
const TEMPLATE_GALLERY_JSON_URL = 'https://weiyyin.github.io/linux-do-gpt-image/linux_do_2036927_prompts_images.filtered.json';
const TEMPLATE_LIMIT = 144;
const QUALITY_OPTIONS = ['auto', 'low', 'medium', 'high'];
const ASPECT_OPTIONS = [
{ value: '1:1', label: '1:1 方图', width: 1024, height: 1024 },
@@ -84,7 +115,13 @@ function makeId() {
}
function extractImageUrls(payload: any): string[] {
const data = Array.isArray(payload?.data) ? payload.data : [];
const data = Array.isArray(payload?.data)
? payload.data
: Array.isArray(payload?.response?.data)
? payload.response.data
: Array.isArray(payload?.images)
? payload.images
: [];
return data
.map((item: any) => {
if (typeof item?.url === 'string' && item.url.trim()) return item.url.trim();
@@ -112,6 +149,28 @@ function getFilename(image: AgentImage) {
return `shengtu-${safeModel}-${new Date(image.createdAt).toISOString().slice(0, 19).replace(/[:T]/g, '-')}.png`;
}
function parseServerTime(value?: string | null): number {
if (!value) return Number.NaN;
const trimmed = value.trim();
if (!trimmed) return Number.NaN;
const normalized = trimmed.includes('T') ? trimmed : trimmed.replace(' ', 'T');
const withTimezone = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(normalized)
? normalized
: `${normalized}Z`;
return Date.parse(withTimezone);
}
function formatLocalTime(value: number | string | null | undefined): string {
const timestamp = typeof value === 'number' ? value : parseServerTime(value);
if (!Number.isFinite(timestamp)) return '-';
return new Date(timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
}
function clampDimension(value: number) {
if (!Number.isFinite(value)) return 1024;
return Math.min(4096, Math.max(256, Math.round(value)));
@@ -168,7 +227,62 @@ function templateSize(prompt: string) {
return 'auto';
}
function normalizeGalleryItems(items: GalleryItem[], limit = 48): PromptTemplate[] {
function isPictureModel(model: string) {
const normalized = model.trim().toLowerCase();
if (!normalized) return false;
const imageKeywords = [
'gpt-image',
'dall-e',
'dalle',
'midjourney',
'recraft',
'kolors',
'cogview',
'seedream',
'sdxl',
'stable-diffusion',
'stablediffusion',
'pixart',
'playground',
'firefly',
'imagen',
'flux',
'image',
'img',
'canvas',
'painting',
'draw',
];
if (imageKeywords.some((keyword) => normalized.includes(keyword))) return true;
const chatKeywords = [
'claude',
'chatgpt',
'gpt-4',
'gpt-3',
'gpt-5',
'o1',
'o3',
'o4',
'gemini',
'deepseek',
'qwen',
'llama',
'mistral',
'command',
'kimi',
'doubao',
'glm',
'hunyuan',
'spark',
'baichuan',
'ernie',
'wenxin',
'grok',
];
return !chatKeywords.some((keyword) => normalized.includes(keyword));
}
function normalizeGalleryItems(items: GalleryItem[], limit = TEMPLATE_LIMIT): PromptTemplate[] {
return items
.map<PromptTemplate | null>((item) => {
const prompt = (item.prompt || '').trim();
@@ -194,7 +308,7 @@ function normalizeGalleryItems(items: GalleryItem[], limit = 48): PromptTemplate
.slice(0, limit);
}
async function loadGalleryTemplatesDirectly(limit = 48): Promise<PromptTemplate[]> {
async function loadGalleryTemplatesDirectly(limit = TEMPLATE_LIMIT): Promise<PromptTemplate[]> {
const response = await fetch(TEMPLATE_GALLERY_JSON_URL, { cache: 'no-store' });
if (!response.ok) throw new Error(`gallery HTTP ${response.status}`);
const items = await response.json();
@@ -221,20 +335,68 @@ export default function PictureAgent() {
const [sourceFile, setSourceFile] = useState<File | null>(null);
const [sourcePreview, setSourcePreview] = useState('');
const [images, setImages] = useState<AgentImage[]>([]);
const [jobs, setJobs] = useState<PictureJob[]>([]);
const [selectedId, setSelectedId] = useState('');
const [loading, setLoading] = useState(false);
const [cancellingJobId, setCancellingJobId] = useState('');
const [clearingJobs, setClearingJobs] = useState(false);
const [jobDialogOpen, setJobDialogOpen] = useState(false);
const [bootError, setBootError] = useState('');
const [error, setError] = useState('');
const selectedImage = images.find((image) => image.id === selectedId) ?? images[0] ?? null;
const activeJobs = jobs.filter((job) => job.status === 'pending' || job.status === 'running');
const modeLabel = mode === 'edit' ? '图片编辑' : '文生图';
const resultLabel = selectedImage?.mode === 'edit' ? '编辑结果' : '生成结果';
const resolvedSize = buildSize(aspect, customWidth, customHeight);
const latestJob = jobs[0] ?? null;
const latestJobLabel = latestJob?.status === 'running'
? '正在处理最新任务'
: latestJob?.status === 'pending'
? '最新任务已排队'
: latestJob?.status === 'failed'
? '最新任务失败'
: latestJob?.status === 'cancelled'
? '最新任务已取消'
: latestJob?.status === 'succeeded'
? '最新任务已完成'
: '暂无最新任务';
const terminalJobs = useMemo(
() => jobs.filter((job) => job.status === 'succeeded' || job.status === 'failed' || job.status === 'cancelled'),
[jobs],
);
const authedFetch = useCallback((url: string, options: RequestInit & { timeoutMs?: number } = {}) => {
return fetchSessionResponse(audience, url, options);
}, [audience]);
const hydrateImagesFromJobs = useCallback((items: PictureJob[]) => {
const completed = items.filter((job) => job.status === 'succeeded');
if (!completed.length) return;
setImages((current) => {
const seen = new Set(current.map((image) => image.url));
const next: AgentImage[] = [];
for (const job of completed) {
const urls = extractImageUrls(job.result || {});
for (const url of urls) {
if (seen.has(url)) continue;
seen.add(url);
next.push({
id: makeId(),
url,
prompt: job.result?.prompt || '',
model: job.result?.model || model || 'image',
mode: job.result?.mode === 'edit' ? 'edit' : 'generate',
createdAt: parseServerTime(job.finishedAt) || Date.now(),
});
}
}
if (!next.length) return current;
setSelectedId((selected) => selected || next[0]?.id || '');
return [...next, ...current].slice(0, 48);
});
}, [model]);
useEffect(() => {
let ignore = false;
async function load() {
@@ -247,10 +409,11 @@ export default function PictureAgent() {
setTemplatesLoading(true);
setTemplatesError('');
try {
const [statusRes, modelsRes, templatesRes] = await Promise.all([
const [statusRes, modelsRes, templatesRes, jobsRes] = await Promise.all([
authedFetch('/api/picture-agent/status', { timeoutMs: 15_000 }),
authedFetch('/api/picture-agent/models', { timeoutMs: 15_000 }),
authedFetch('/api/picture-agent/templates?limit=48', { timeoutMs: 30_000 }),
authedFetch(`/api/picture-agent/templates?limit=${TEMPLATE_LIMIT}`, { timeoutMs: 30_000 }),
authedFetch('/api/picture-agent/jobs?limit=20', { timeoutMs: 15_000 }),
]);
if (!statusRes.ok) throw new Error(await parseError(statusRes));
if (!modelsRes.ok) throw new Error(await parseError(modelsRes));
@@ -262,17 +425,32 @@ export default function PictureAgent() {
nextTemplates = Array.isArray(templatePayload?.templates) ? templatePayload.templates : [];
} else {
try {
nextTemplates = await loadGalleryTemplatesDirectly(48);
nextTemplates = await loadGalleryTemplatesDirectly(TEMPLATE_LIMIT);
} catch {
setTemplatesError(await parseError(templatesRes));
}
}
let nextJobs: PictureJob[] = [];
if (jobsRes.ok) {
const jobsPayload = await jobsRes.json();
nextJobs = Array.isArray(jobsPayload?.jobs) ? jobsPayload.jobs : [];
}
if (ignore) return;
const availableModels = Array.isArray(nextModels?.models) ? nextModels.models : [];
const availableModels = Array.isArray(nextModels?.models)
? nextModels.models.filter((item: string) => isPictureModel(item))
: [];
setStatus(nextStatus);
setModels(availableModels);
setTemplates(nextTemplates);
setModel((current) => current || nextModels?.defaultModel || availableModels[0] || 'gpt-image-2');
setJobs(nextJobs);
hydrateImagesFromJobs(nextJobs);
setModel((current) => {
if (current && isPictureModel(current)) return current;
const fallback = nextModels?.defaultModel && isPictureModel(nextModels.defaultModel)
? nextModels.defaultModel
: availableModels[0];
return fallback || 'gpt-image-2';
});
} catch (err: any) {
if (!ignore) setBootError(err?.message || '生图助手初始化失败');
} finally {
@@ -283,7 +461,36 @@ export default function PictureAgent() {
return () => {
ignore = true;
};
}, [audience, authedFetch, token]);
}, [audience, authedFetch, hydrateImagesFromJobs, token]);
useEffect(() => {
if (!activeJobs.length || !token) return;
let stopped = false;
const timer = window.setInterval(async () => {
try {
const updated = await Promise.all(activeJobs.map(async (job) => {
const response = await authedFetch(`/api/picture-agent/jobs/${encodeURIComponent(job.id)}`, { timeoutMs: 15_000 });
if (!response.ok) return job;
const payload = await response.json();
return payload?.job || job;
}));
if (stopped) return;
setJobs((current) => {
const map = new Map(current.map((job) => [job.id, job]));
for (const job of updated) map.set(job.id, job);
const next = Array.from(map.values()).sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')));
hydrateImagesFromJobs(next);
return next;
});
} catch {
// Keep polling quietly; task status is also visible after refresh.
}
}, 1800);
return () => {
stopped = true;
window.clearInterval(timer);
};
}, [activeJobs, authedFetch, hydrateImagesFromJobs, token]);
useEffect(() => {
if (!sourceFile) {
@@ -322,7 +529,7 @@ export default function PictureAgent() {
timeoutMs: 180_000,
});
} else {
response = await authedFetch('/api/picture-agent/images/generations', {
response = await authedFetch('/api/picture-agent/jobs', {
method: 'POST',
body: JSON.stringify({
model: model.trim(),
@@ -330,12 +537,17 @@ export default function PictureAgent() {
size: resolvedSize,
quality,
}),
timeoutMs: 180_000,
timeoutMs: 30_000,
});
}
if (!response.ok) throw new Error(await parseError(response));
const payload = await response.json();
if (mode === 'generate' && payload?.job) {
setJobs((current) => [payload.job, ...current.filter((job) => job.id !== payload.job.id)].slice(0, 20));
setError('');
return;
}
const urls = extractImageUrls(payload);
if (urls.length === 0) throw new Error('上游没有返回图片 URL 或 base64 图片。');
@@ -357,6 +569,56 @@ export default function PictureAgent() {
}
};
const refreshJobs = async () => {
const response = await authedFetch('/api/picture-agent/jobs?limit=20', { timeoutMs: 15_000 });
if (!response.ok) throw new Error(await parseError(response));
const payload = await response.json();
const nextJobs = Array.isArray(payload?.jobs) ? payload.jobs : [];
setJobs(nextJobs);
hydrateImagesFromJobs(nextJobs);
};
const clearJobs = async () => {
if (clearingJobs) return;
setClearingJobs(true);
setError('');
try {
const response = await authedFetch('/api/picture-agent/jobs/clear', {
method: 'POST',
timeoutMs: 15_000,
});
if (!response.ok) throw new Error(await parseError(response));
await refreshJobs();
} catch (err: any) {
setError(err?.message || '清空日志失败');
} finally {
setClearingJobs(false);
}
};
const cancelJob = async (jobId: string) => {
if (!jobId || cancellingJobId) return;
setCancellingJobId(jobId);
setError('');
try {
const response = await authedFetch(`/api/picture-agent/jobs/${encodeURIComponent(jobId)}/cancel`, {
method: 'POST',
timeoutMs: 15_000,
});
if (!response.ok) throw new Error(await parseError(response));
const payload = await response.json();
if (payload?.job) {
setJobs((current) => current.map((job) => job.id === jobId ? payload.job : job));
} else {
await refreshJobs();
}
} catch (err: any) {
setError(err?.message || '取消任务失败');
} finally {
setCancellingJobId('');
}
};
const handleDownload = (image: AgentImage | null) => {
if (!image) return;
const link = document.createElement('a');
@@ -390,6 +652,59 @@ export default function PictureAgent() {
setError('');
};
const renderJobCard = (job: PictureJob) => {
const total = Math.max(1, Number(job.progressTotal || 4));
const progress = Math.min(total, Math.max(0, Number(job.progress || 0)));
const percent = Math.round((progress / total) * 100);
const hint = staleRunningHint(job);
const isActive = job.status === 'pending' || job.status === 'running';
const isFinished = !isActive;
const statusText = job.status === 'succeeded'
? '完成'
: job.status === 'failed'
? '失败'
: job.status === 'cancelled'
? '已取消'
: `${percent}%`;
return (
<div key={job.id} className={`picture-agent-job picture-agent-job-${job.status}`}>
<div className="picture-agent-job-row picture-agent-job-row-top">
<div className="picture-agent-job-title-wrap">
<strong>{job.result?.prompt || job.message || '生图任务'}</strong>
<span>{job.model || job.result?.model || '未知模型'}</span>
</div>
<span className={`picture-agent-job-status picture-agent-job-status-${job.status}`}>{statusText}</span>
</div>
<div className="picture-agent-job-progress-line">
<div className="picture-agent-job-progress-labels">
<span>{job.message || job.id}</span>
<em>{percent}%</em>
</div>
<div className="picture-agent-job-progress">
<span style={{ width: `${percent}%` }} />
</div>
</div>
<div className="picture-agent-job-meta">
<span>{formatLocalTime(job.createdAt)}</span>
{job.error ? <em>{job.error}</em> : <span>{isFinished ? '已结束' : '进行中'}</span>}
</div>
{hint ? <div className="picture-agent-job-hint">{hint}</div> : null}
{isActive ? (
<button
type="button"
className="picture-agent-job-cancel"
disabled={cancellingJobId === job.id}
onClick={() => void cancelJob(job.id)}
>
{cancellingJobId === job.id ? '取消中...' : '取消任务'}
</button>
) : (
<div className="picture-agent-job-terminal">{job.result?.images?.length ? `结果 ${job.result.images.length} 张` : '可在历史结果中查看'}</div>
)}
</div>
);
};
if (bootError) {
return (
<div className="empty-state" style={{ minHeight: 320 }}>
@@ -539,8 +854,18 @@ export default function PictureAgent() {
{error && <div className="alert alert-error">{error}</div>}
<button type="button" className="btn btn-primary picture-agent-submit" disabled={!canSubmit} onClick={handleSubmit}>
{loading ? '处理中...' : mode === 'edit' ? '编辑图片' : '生成图片'}
{loading ? '提交中...' : mode === 'edit' ? '编辑图片' : '生成图片'}
</button>
<div className="picture-agent-job-summary">
<div>
<strong>生图进度</strong>
<span>{latestJobLabel} · {jobs.length} 条记录</span>
</div>
<button type="button" className="btn btn-ghost" onClick={() => setJobDialogOpen(true)}>
查看进度
</button>
</div>
</aside>
<section className="picture-agent-canvas">
@@ -581,7 +906,7 @@ export default function PictureAgent() {
<div className="picture-agent-board-meta">
<div>
<strong>{resultLabel}</strong>
<span>{selectedImage.model} · {new Date(selectedImage.createdAt).toLocaleTimeString()}</span>
<span>{selectedImage.model} · {formatLocalTime(selectedImage.createdAt)}</span>
</div>
<button type="button" className="btn btn-ghost" onClick={() => handleDownload(selectedImage)}>下载</button>
</div>
@@ -618,6 +943,58 @@ export default function PictureAgent() {
</div>
</section>
</div>
{jobDialogOpen && (
<div
className="picture-agent-job-dialog-backdrop"
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) setJobDialogOpen(false);
}}
>
<section
className="picture-agent-job-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="picture-agent-job-dialog-title"
>
<div className="picture-agent-job-dialog-head">
<div>
<strong id="picture-agent-job-dialog-title">生图进度</strong>
<span>{latestJobLabel} · {jobs.length} 条记录</span>
</div>
<div className="picture-agent-job-actions">
<button
type="button"
className="btn btn-ghost"
onClick={() => {
refreshJobs().catch((err: any) => setError(err?.message || '刷新任务失败'));
}}
>
刷新
</button>
<button
type="button"
className="btn btn-ghost"
onClick={() => void clearJobs()}
disabled={clearingJobs || terminalJobs.length === 0}
>
{clearingJobs ? '清理中...' : '清空日志'}
</button>
<button type="button" className="btn btn-ghost" onClick={() => setJobDialogOpen(false)}>
关闭
</button>
</div>
</div>
{jobs.length === 0 ? (
<div className="picture-agent-job-empty">提交后会先进入队列,完成后会自动出现在这里和使用记录里。</div>
) : (
<div className="picture-agent-job-list picture-agent-job-list-dialog">
{jobs.map(renderJobCard)}
</div>
)}
</section>
</div>
)}
</div>
);
}
+23 -2
View File
@@ -9,6 +9,7 @@ interface LogItem {
status: string | null;
http_status: number | null;
latency_ms: number | null;
first_byte_latency_ms?: number | null;
prompt_tokens: number | null;
completion_tokens: number | null;
total_tokens: number | null;
@@ -42,6 +43,23 @@ function latencyColor(ms: number | null): string {
return 'var(--color-danger)';
}
function displayLatency(log: LogItem): string {
const firstByte = log.first_byte_latency_ms;
const total = log.latency_ms;
if (firstByte != null && Number.isFinite(firstByte)) {
return `${firstByte}ms / ${total != null ? `${total}ms` : '-'}`;
}
return total != null ? `${total}ms` : '-';
}
function latencyTextColor(log: LogItem): string {
const firstByte = log.first_byte_latency_ms;
if (firstByte != null && Number.isFinite(firstByte)) {
return latencyColor(firstByte);
}
return latencyColor(log.latency_ms);
}
export default function UsageRecords() {
const [logs, setLogs] = useState<LogItem[]>([]);
const [summary, setSummary] = useState<Summary | null>(null);
@@ -181,6 +199,9 @@ export default function UsageRecords() {
</button>
)}
</div>
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--color-text-muted)' }}>
延迟优先显示首字延迟,Tokens 与费用依赖上游返回的 usage 数据。
</div>
</div>
{/* Table */}
@@ -236,8 +257,8 @@ export default function UsageRecords() {
<span style={{ marginLeft: 4, fontSize: 10, color: 'var(--color-text-muted)' }}>流式</span>
)}
</td>
<td style={{ fontSize: 13, color: latencyColor(log.latency_ms), fontFamily: 'var(--font-mono)' }}>
{log.latency_ms != null ? `${log.latency_ms}ms` : '-'}
<td style={{ fontSize: 13, color: latencyTextColor(log), fontFamily: 'var(--font-mono)' }}>
{displayLatency(log)}
</td>
<td style={{ fontSize: 12, fontFamily: 'var(--font-mono)' }}>
{log.total_tokens != null ? log.total_tokens.toLocaleString() : '-'}
+3 -3
View File
@@ -157,9 +157,9 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
<section className="card" style={{ padding: '20px' }}>
<h2 style={{ fontSize: '16px', fontWeight: 600, margin: '0 0 16px 0' }}>快速入口</h2>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<Link to="/user/video-agent" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)', height: 'auto', padding: '16px', display: 'flex', flexDirection: 'column', gap: '8px', alignItems: 'center' }}>
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
<span>视频工作台</span>
<Link to="/user/workflows" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)', height: 'auto', padding: '16px', display: 'flex', flexDirection: 'column', gap: '8px', alignItems: 'center' }}>
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6h5v5H4zM15 4h5v5h-5zM15 15h5v5h-5zM9 8h6M17.5 9v6M9 11l6 6" /></svg>
<span>工作流</span>
</Link>
<Link to="/user/comfyui-agent" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)', height: 'auto', padding: '16px', display: 'flex', flexDirection: 'column', gap: '8px', alignItems: 'center' }}>
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
+85 -89
View File
@@ -1,6 +1,7 @@
import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { commitSession } from '../authSession.js';
import LoginShell from './auth/LoginShell.js';
export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
const navigate = useNavigate();
@@ -46,95 +47,90 @@ export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
};
return (
<div className="login-shell">
<div className="login-surface animate-scale-in" style={{ maxWidth: 420 }}>
<section className="login-brand-panel login-brand-panel-light" style={{ padding: '28px 24px' }}>
<div className="login-brand-header" style={{ justifyContent: 'center' }}>
<div className="brand-mark-frame brand-mark-frame-hero">
<div className="brand-mark-canvas">
<img src="/logo.png" alt="BoosAPI" className="login-brand-logo" />
</div>
</div>
<div className="login-brand-summary" style={{ textAlign: 'center' }}>
<div className="login-brand-name">BoosAPI</div>
<div className="login-brand-kicker">用户注册</div>
</div>
</div>
</section>
<section className="login-auth-stage">
<div className="login-auth-panel">
<h2 className="login-auth-title">创建账号</h2>
<p className="login-auth-copy">注册后即可使用 API 代理服务。</p>
<form onSubmit={handleSubmit}>
<label className="login-auth-label" htmlFor="reg-username-input">用户名</label>
<input
id="reg-username-input"
type="text"
placeholder="你的昵称"
value={username}
onChange={(e) => { setUsername(e.target.value); setError(''); }}
className="login-auth-input"
autoComplete="username"
required
<LoginShell
t={(text) => text}
variant="user"
eyebrow="用户注册"
title="创建账号"
description="注册后即可使用 API 代理服务。"
error={error}
footer={(
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'center' }}>
<span>
已有账号?
<Link to="/user/login" style={{ color: 'var(--color-primary)', textDecoration: 'none', marginLeft: 4 }}>
登录
</Link>
</span>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
<Link to="/boos" style={{ color: 'var(--color-text-muted)' }}>← 管理员登录</Link>
</span>
</div>
)}
>
<form onSubmit={handleSubmit} className="login-register-form">
<label className="login-auth-label" htmlFor="reg-username-input">用户名</label>
<input
id="reg-username-input"
type="text"
placeholder="你的昵称"
value={username}
onChange={(e) => { setUsername(e.target.value); setError(''); }}
className="login-auth-input"
autoComplete="username"
required
/>
<label className="login-auth-label" htmlFor="reg-email-input">邮箱</label>
<input
id="reg-email-input"
type="email"
placeholder="your@email.com"
value={email}
onChange={(e) => { setEmail(e.target.value); setError(''); }}
className="login-auth-input"
autoComplete="email"
required
/>
<label className="login-auth-label" htmlFor="reg-password-input">密码</label>
<input
id="reg-password-input"
type="password"
placeholder="至少 6 位"
value={password}
onChange={(e) => { setPassword(e.target.value); setError(''); }}
className="login-auth-input"
autoComplete="new-password"
required
/>
<label className="login-auth-label" htmlFor="reg-confirm-password-input">确认密码</label>
<input
id="reg-confirm-password-input"
type="password"
placeholder="再次输入密码"
value={confirmPassword}
onChange={(e) => { setConfirmPassword(e.target.value); setError(''); }}
className="login-auth-input"
autoComplete="new-password"
required
/>
<button
type="submit"
disabled={loading || !username.trim() || !email.trim() || !password || !confirmPassword}
className="btn btn-primary login-auth-submit"
>
{loading ? (
<>
<span
className="spinner spinner-sm"
style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }}
/>
<label className="login-auth-label" htmlFor="reg-email-input" style={{ marginTop: 12 }}>邮箱</label>
<input
id="reg-email-input"
type="email"
placeholder="your@email.com"
value={email}
onChange={(e) => { setEmail(e.target.value); setError(''); }}
className="login-auth-input"
autoComplete="email"
required
/>
<label className="login-auth-label" htmlFor="reg-password-input" style={{ marginTop: 12 }}>密码</label>
<input
id="reg-password-input"
type="password"
placeholder="至少 6 位"
value={password}
onChange={(e) => { setPassword(e.target.value); setError(''); }}
className="login-auth-input"
autoComplete="new-password"
required
/>
<label className="login-auth-label" htmlFor="reg-confirm-password-input" style={{ marginTop: 12 }}>确认密码</label>
<input
id="reg-confirm-password-input"
type="password"
placeholder="再次输入密码"
value={confirmPassword}
onChange={(e) => { setConfirmPassword(e.target.value); setError(''); }}
className="login-auth-input"
autoComplete="new-password"
required
/>
{error && (
<div className="alert alert-error animate-shake" style={{ marginBottom: 12, marginTop: 8 }}>
{error}
</div>
)}
<button
type="submit"
disabled={loading || !username.trim() || !email.trim() || !password || !confirmPassword}
className="btn btn-primary login-auth-submit"
style={{ marginTop: 4 }}
>
{loading ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 注册中...</> : '注册'}
</button>
</form>
<div className="login-auth-footer" style={{ flexDirection: 'column', gap: 8, marginTop: 16 }}>
<span>
已有账号?<Link to="/user/login" style={{ color: 'var(--color-primary)', textDecoration: 'none' }}>登录</Link>
</span>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
<Link to="/" style={{ color: 'var(--color-text-muted)' }}>← 管理员登录</Link>
</span>
</div>
</div>
</section>
</div>
</div>
注册中...
</>
) : (
'注册'
)}
</button>
</form>
</LoginShell>
);
}
+111
View File
@@ -0,0 +1,111 @@
import React from 'react';
export default function Workflows() {
const [ready, setReady] = React.useState(false);
const [message, setMessage] = React.useState('正在加载工作流前端...');
const iframeRef = React.useRef<HTMLIFrameElement | null>(null);
const openFullscreen = () => {
window.open('/workflow-ui/', '_blank', 'noopener,noreferrer');
};
const reloadFrame = () => {
if (iframeRef.current) {
iframeRef.current.src = '/workflow-ui/';
}
};
React.useEffect(() => {
let cancelled = false;
const controller = new AbortController();
void fetch('/workflow-ui/', { signal: controller.signal })
.then((response) => {
if (cancelled) return;
setReady(response.ok);
setMessage(response.ok ? '' : 'Workflow Studio 前端未构建,请先执行 workflow-ui:install');
})
.catch((error) => {
if (cancelled || error?.name === 'AbortError') return;
setReady(false);
setMessage('Workflow Studio 前端未构建,请先执行 workflow-ui:install');
});
return () => {
cancelled = true;
controller.abort();
};
}, []);
return (
<div style={{ width: '100%', height: 'calc(100vh - 56px)', display: 'flex', flexDirection: 'column' }}>
<div style={toolbarStyle}>
<div style={toolbarTitleStyle}>工作流</div>
<div style={toolbarActionsStyle}>
<button type="button" onClick={reloadFrame} disabled={!ready} style={toolbarButtonStyle}>
刷新
</button>
<button type="button" onClick={openFullscreen} style={primaryToolbarButtonStyle}>
新窗口打开
</button>
</div>
</div>
{ready ? (
<iframe
ref={iframeRef}
src="/workflow-ui/"
title="Workflow Studio"
allow="clipboard-read; clipboard-write"
style={{ width: '100%', height: '100%', border: 'none', display: 'block' }}
/>
) : (
<div style={{ flex: 1, display: 'grid', placeItems: 'center', padding: 24, textAlign: 'center' }}>
<div>
<div style={{ fontSize: 18, fontWeight: 700, marginBottom: 8 }}>Workflow Studio</div>
<div style={{ color: 'var(--color-text-secondary)', lineHeight: 1.7 }}>{message}</div>
</div>
</div>
)}
</div>
);
}
const toolbarStyle: React.CSSProperties = {
minHeight: 44,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
padding: '8px 12px',
borderBottom: '1px solid var(--color-border-light)',
background: 'var(--color-surface)',
};
const toolbarTitleStyle: React.CSSProperties = {
fontSize: 14,
fontWeight: 800,
color: 'var(--color-text-primary)',
};
const toolbarActionsStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 8,
};
const toolbarButtonStyle: React.CSSProperties = {
minHeight: 30,
border: '1px solid var(--color-border)',
borderRadius: 8,
padding: '5px 10px',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
cursor: 'pointer',
fontSize: 12,
};
const primaryToolbarButtonStyle: React.CSSProperties = {
...toolbarButtonStyle,
borderColor: 'var(--color-primary)',
background: 'var(--color-primary)',
color: '#fff',
fontWeight: 700,
};
+12 -6
View File
@@ -2,13 +2,14 @@ import React, { CSSProperties, useCallback, useEffect, useMemo, useState } from
import { useLocation } from 'react-router-dom';
import { api, fetchSessionResponse, userApi, type WorkspaceAsset } from '../api.js';
import type { SessionAudience } from '../authSession.js';
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
const SOURCE_OPTIONS = [
{ value: 'all', label: '全部来源' },
{ value: 'picture-agent', label: '生图助手' },
{ value: 'comfyui-agent', label: '视频助手' },
{ value: 'video-agent', label: '视频工作台' },
{ value: 'comfyui', label: 'ComfyUI' },
{ value: 'workflow-studio', label: '工作流' },
{ value: 'comfyui', label: '外部 ComfyUI' },
];
const KIND_OPTIONS = [
@@ -46,6 +47,10 @@ function assetTitle(asset: WorkspaceAsset) {
return asset.title || asset.prompt || asset.fileName || asset.publicId;
}
function assetCreatedAtLabel(asset: WorkspaceAsset) {
return formatDateTimeLocal(asset.createdAt);
}
export default function Workspace() {
const location = useLocation();
const audience: SessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
@@ -167,7 +172,7 @@ export default function Workspace() {
<div>
<h1 className="page-title">工作空间</h1>
<p className="page-subtitle">
{audience === 'user' ? '你的创作成品库' : '全部用户的创作成品库'} · 自动归档生图助手、视频助手和 ComfyUI 产出
{audience === 'user' ? '你的创作成品库' : '全部用户的创作成品库'} · 自动归档生图助手、视频助手、工作流和外部 ComfyUI 产出
</p>
</div>
<div className="page-actions">
@@ -235,7 +240,7 @@ export default function Workspace() {
{items.length === 0 ? (
<div className="empty-state" style={{ minHeight: 360, gridColumn: '1 / -1' }}>
<div className="empty-state-title">{loading ? '正在加载工作空间' : '暂无成品产出'}</div>
<div className="empty-state-desc">生图助手、视频助手和 ComfyUI 的产出会归档到这里。</div>
<div className="empty-state-desc">生图助手、视频助手、工作流和外部 ComfyUI 的产出会归档到这里。</div>
</div>
) : items.map((asset) => {
const preview = previewUrls[asset.publicId] || asset.remoteUrl || '';
@@ -267,8 +272,8 @@ export default function Workspace() {
<strong style={cardTitleStyle}>{assetTitle(asset)}</strong>
<span style={metaStyle}>{sourceLabel(asset.source)} · {asset.model || '未记录模型'}</span>
<div style={cardFooterStyle}>
<span style={statusPillStyle}>{statusLabel(asset.status)}</span>
<span style={metaStyle}>{asset.createdAt || ''}</span>
<span style={statusPillStyle}>{statusLabel(asset.status)}</span>
<span style={metaStyle}>{assetCreatedAtLabel(asset)}</span>
</div>
</div>
</button>
@@ -303,6 +308,7 @@ export default function Workspace() {
<div><span>模型</span><strong>{selected.model || '未记录'}</strong></div>
<div><span>大小</span><strong>{formatBytes(selected.sizeBytes)}</strong></div>
<div><span>文件</span><strong>{selected.hasLocalFile ? '本地归档' : '远程引用'}</strong></div>
<div><span>时间</span><strong>{assetCreatedAtLabel(selected)}</strong></div>
</div>
{selected.prompt && (
<div style={promptPanelStyle}>
+36 -13
View File
@@ -5,16 +5,18 @@ import { ToastProvider } from '../components/Toast.js';
import Accounts from './Accounts.js';
import { installAccountsSnapshotCompat } from './testApiCompat.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getAccounts: vi.fn(),
getAccountsSnapshot: vi.fn(),
getSites: vi.fn(),
getAccountTokens: vi.fn(),
addAccount: vi.fn(),
addAccountAvailableModels: vi.fn(),
},
}));
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getAccounts: vi.fn(),
getAccountsSnapshot: vi.fn(),
getSites: vi.fn(),
getAccountTokens: vi.fn(),
addAccount: vi.fn(),
addAccountAvailableModels: vi.fn(),
checkModels: vi.fn(),
rebuildRoutes: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
@@ -44,6 +46,15 @@ describe('Accounts CodingPlan initialization', () => {
{ id: 10, name: 'Aliyun CodingPlan', url: 'https://coding.dashscope.aliyuncs.com/v1', platform: 'openai', status: 'active' },
]);
apiMock.getAccountTokens.mockResolvedValue([]);
apiMock.checkModels.mockResolvedValue({
message: '模型刷新成功',
refresh: {
status: 'success',
modelCount: 3,
modelsPreview: ['qwen3-coder-plus', 'qwen3.5-plus', 'glm-5'],
},
});
apiMock.rebuildRoutes.mockResolvedValue({ success: true });
apiMock.addAccount.mockResolvedValue({
id: 18,
siteId: 10,
@@ -73,7 +84,7 @@ describe('Accounts CodingPlan initialization', () => {
const rendered = JSON.stringify(root.toJSON());
expect(rendered).toContain('添加 API Key 连接');
expect(rendered).toContain('阿里云 CodingPlan / OpenAI');
expect(rendered).toContain('阿里云 Coding Plan (OpenAI)');
expect(rendered).toContain('qwen3-coder-plus');
const checkedBoxes = root.root.findAll((node) => (
@@ -110,8 +121,10 @@ describe('Accounts CodingPlan initialization', () => {
}));
expect(apiMock.addAccountAvailableModels).toHaveBeenCalledWith(
18,
expect.arrayContaining(['qwen3-coder-plus', 'qwen3.5-plus', 'glm-5']),
['qwen3-coder-plus', 'qwen3.5-plus'],
);
expect(apiMock.checkModels).not.toHaveBeenCalled();
expect(apiMock.rebuildRoutes).toHaveBeenCalledWith(false, false);
} finally {
root?.unmount();
}
@@ -127,6 +140,14 @@ describe('Accounts CodingPlan initialization', () => {
tokenType: 'apikey',
queued: false,
});
apiMock.checkModels.mockResolvedValue({
message: '模型刷新成功',
refresh: {
status: 'success',
modelCount: 2,
modelsPreview: ['deepseek-chat', 'deepseek-reasoner'],
},
});
let root!: ReactTestRenderer;
try {
@@ -142,7 +163,7 @@ describe('Accounts CodingPlan initialization', () => {
await flushMicrotasks();
const rendered = JSON.stringify(root.toJSON());
expect(rendered).toContain('DeepSeek / OpenAI');
expect(rendered).toContain('DeepSeek (OpenAI)');
expect(rendered).toContain('deepseek-chat');
expect(rendered).toContain('deepseek-reasoner');
@@ -175,6 +196,8 @@ describe('Accounts CodingPlan initialization', () => {
19,
['deepseek-chat', 'deepseek-reasoner'],
);
expect(apiMock.checkModels).not.toHaveBeenCalled();
expect(apiMock.rebuildRoutes).toHaveBeenCalledWith(false, false);
} finally {
root?.unmount();
}
+189 -43
View File
@@ -31,14 +31,20 @@ async function readErrorReason(res: Response): Promise<string> {
}
}
type AdminLoginMode = 'token' | 'password';
export default function AdminLoginPage({ t, onLogin }: AdminLoginPageProps) {
const [mode, setMode] = useState<AdminLoginMode>('token');
const [token, setToken] = useState('');
const [loading, setLoading] = useState(false);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loadingMode, setLoadingMode] = useState<AdminLoginMode | null>(null);
const [error, setError] = useState('');
const handleTokenLogin = async () => {
const handleTokenLogin = async (event?: React.FormEvent) => {
event?.preventDefault();
if (!token.trim()) return;
setLoading(true);
setLoadingMode('token');
setError('');
try {
const res = await fetch('/api/settings/auth/info', {
@@ -54,58 +60,198 @@ export default function AdminLoginPage({ t, onLogin }: AdminLoginPageProps) {
} catch {
setError(t(resolveLoginErrorMessage('network', '')));
} finally {
setLoading(false);
setLoadingMode(null);
}
};
const canSubmit = !!token.trim();
const handlePasswordLogin = async (event?: React.FormEvent) => {
event?.preventDefault();
if (!email.trim() || !password) return;
setLoadingMode('password');
setError('');
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.trim(), password }),
});
const text = await res.text();
let parsed: {
token?: unknown;
user?: { role?: unknown };
data?: { token?: unknown; user?: { role?: unknown } };
message?: unknown;
error?: unknown;
} = {};
if (text) {
try {
parsed = JSON.parse(text);
} catch {
parsed = { message: text };
}
}
if (!res.ok) {
const reason =
typeof parsed.message === 'string'
? parsed.message
: typeof parsed.error === 'string'
? parsed.error
: text;
setError(t(resolveLoginErrorMessage(res.status, reason)));
return;
}
const jwt = typeof parsed.token === 'string' ? parsed.token : parsed.data?.token;
const role =
typeof parsed.user?.role === 'string' ? parsed.user.role : parsed.data?.user?.role;
if (typeof jwt !== 'string' || !jwt) {
setError(t('登录响应缺少访问令牌。'));
return;
}
if (role !== 'admin') {
setError(t('该账号不是管理员账号。'));
return;
}
commitSession('admin', jwt);
onLogin(jwt);
} catch {
setError(t(resolveLoginErrorMessage('network', '')));
} finally {
setLoadingMode(null);
}
};
const isLoading = loadingMode !== null;
const canTokenSubmit = !!token.trim();
const canPasswordSubmit = !!email.trim() && !!password;
return (
<LoginShell
t={t}
variant="admin"
eyebrow={t('管理员入口')}
title={t('登录')}
description={t('请输入管理员令牌后继续。')}
title={t('管理员登录')}
description={t('使用管理员令牌,或管理员账号密码进入控制台。')}
error={error}
footer={<span>{t('管理员登录后继续。')}</span>}
footer={<span>{t('管理员身份用于管理站点、模型路由、用户与计费。')}</span>}
>
<label className="login-auth-label" htmlFor="admin-token-input">
{t('管理员令牌')}
</label>
<input
id="admin-token-input"
type="password"
placeholder={t('管理员令牌')}
value={token}
onChange={(e) => {
setToken(e.target.value);
setError('');
}}
onKeyDown={(e) => e.key === 'Enter' && handleTokenLogin()}
className="login-auth-input"
autoComplete="current-password"
/>
<button
onClick={handleTokenLogin}
disabled={loading || !canSubmit}
className="btn btn-primary login-auth-submit"
>
{loading ? (
<>
<span
className="spinner spinner-sm"
style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }}
/>
{t('验证中...')}
</>
) : (
t('登录')
)}
</button>
<div className="login-auth-note">
{t('仅校验本地服务访问权限,不会把令牌发送到第三方。')}
<div className="login-mode-tabs" role="tablist" aria-label={t('管理员登录方式')}>
<button
type="button"
className={`login-mode-tab${mode === 'token' ? ' active' : ''}`}
onClick={() => {
setMode('token');
setError('');
}}
aria-selected={mode === 'token'}
>
Auth Token
</button>
<button
type="button"
className={`login-mode-tab${mode === 'password' ? ' active' : ''}`}
onClick={() => {
setMode('password');
setError('');
}}
aria-selected={mode === 'password'}
>
{t('账号密码')}
</button>
</div>
{mode === 'token' ? (
<form onSubmit={handleTokenLogin}>
<label className="login-auth-label" htmlFor="admin-token-input">
{t('管理员令牌')}
</label>
<input
id="admin-token-input"
type="password"
placeholder={t('管理员令牌')}
value={token}
onChange={(e) => {
setToken(e.target.value);
setError('');
}}
className="login-auth-input"
autoComplete="current-password"
/>
<button
type="submit"
disabled={isLoading || !canTokenSubmit}
className="btn btn-primary login-auth-submit"
>
{loadingMode === 'token' ? (
<>
<span
className="spinner spinner-sm"
style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }}
/>
{t('验证中...')}
</>
) : (
t('进入控制台')
)}
</button>
<div className="login-auth-note">
{t('仅校验本地服务访问权限,不会把令牌发送到第三方。')}
</div>
</form>
) : (
<form onSubmit={handlePasswordLogin}>
<label className="login-auth-label" htmlFor="admin-email-input">
{t('管理员邮箱')}
</label>
<input
id="admin-email-input"
type="email"
placeholder={t('管理员邮箱')}
value={email}
onChange={(e) => {
setEmail(e.target.value);
setError('');
}}
className="login-auth-input"
autoComplete="username"
/>
<label className="login-auth-label" htmlFor="admin-password-input">
{t('密码')}
</label>
<input
id="admin-password-input"
type="password"
placeholder={t('密码')}
value={password}
onChange={(e) => {
setPassword(e.target.value);
setError('');
}}
className="login-auth-input"
autoComplete="current-password"
/>
<button
type="submit"
disabled={isLoading || !canPasswordSubmit}
className="btn btn-primary login-auth-submit"
>
{loadingMode === 'password' ? (
<>
<span
className="spinner spinner-sm"
style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }}
/>
{t('验证中...')}
</>
) : (
t('进入控制台')
)}
</button>
<div className="login-auth-note">
{t('只有 role=admin 的账号可以进入管理员控制台。')}
</div>
</form>
)}
</LoginShell>
);
}
-26
View File
@@ -1,5 +1,4 @@
import React from 'react';
import { SITE_DOCS_URL, SITE_GITHUB_URL } from '../../docsLink.js';
export type LoginShellVariant = 'admin' | 'user';
@@ -82,31 +81,6 @@ export default function LoginShell({
</div>
))}
</div>
<div className="login-brand-footer">
<a
href={SITE_GITHUB_URL}
target="_blank"
rel="noopener noreferrer"
className="login-icon-link"
aria-label="GitHub"
title="GitHub"
>
<svg viewBox="0 0 24 24" aria-hidden="true" className="login-icon-link-svg">
<path
fill="currentColor"
d="M12 2C6.48 2 2 6.59 2 12.25c0 4.53 2.87 8.38 6.84 9.73.5.1.68-.22.68-.49 0-.24-.01-1.04-.01-1.88-2.78.62-3.37-1.22-3.37-1.22-.45-1.2-1.11-1.52-1.11-1.52-.91-.64.07-.63.07-.63 1 .07 1.53 1.06 1.53 1.06.9 1.57 2.35 1.12 2.92.85.09-.67.35-1.12.64-1.38-2.22-.26-4.55-1.15-4.55-5.13 0-1.13.39-2.05 1.03-2.77-.1-.26-.45-1.31.1-2.73 0 0 .84-.28 2.75 1.06A9.3 9.3 0 0 1 12 6.91c.85 0 1.71.12 2.51.35 1.91-1.34 2.75-1.06 2.75-1.06.55 1.42.2 2.47.1 2.73.64.72 1.03 1.64 1.03 2.77 0 3.99-2.34 4.86-4.57 5.12.36.33.68.97.68 1.96 0 1.42-.01 2.56-.01 2.91 0 .27.18.59.69.49A10.27 10.27 0 0 0 22 12.25C22 6.59 17.52 2 12 2Z"
/>
</svg>
</a>
<a
href={SITE_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="login-doc-link"
>
{t('部署文档')}
</a>
</div>
</>
)}
</section>
+91
View File
@@ -0,0 +1,91 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import Models from './Models.js';
const { apiMock, toastMock } = vi.hoisted(() => ({
apiMock: {
getModelsMarketplace: vi.fn(),
},
toastMock: {
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
},
}));
vi.mock('../api.js', () => ({ api: apiMock }));
vi.mock('../components/Toast.js', () => ({
ToastProvider: ({ children }: { children: React.ReactNode }) => children,
useToast: () => toastMock,
}));
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => (
typeof child === 'string' ? child : collectText(child)
)).join('');
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('Models copy action', () => {
beforeEach(() => {
vi.clearAllMocks();
apiMock.getModelsMarketplace.mockResolvedValue({
models: [
{
name: 'doubao-seedance-2-0-260128',
accountCount: 1,
tokenCount: 1,
avgLatency: null,
successRate: null,
description: null,
tags: [],
supportedEndpointTypes: [],
pricingSources: [],
accounts: [],
},
],
});
Object.defineProperty(globalThis.navigator, 'clipboard', {
value: { writeText: vi.fn().mockResolvedValue(undefined) },
configurable: true,
});
});
afterEach(() => {
vi.clearAllMocks();
});
it('copies the model name from the action button', async () => {
let root!: ReturnType<typeof create>;
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/models']}>
<ToastProvider>
<Models />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const copyBtn = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& node.props['aria-label'] === '复制模型名'
));
await act(async () => {
await copyBtn.props.onClick();
});
expect(globalThis.navigator.clipboard.writeText).toHaveBeenCalledWith('doubao-seedance-2-0-260128');
expect(toastMock.success).toHaveBeenCalled();
});
});
+92
View File
@@ -0,0 +1,92 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import Models from './Models.js';
const { apiMock, userApiMock } = vi.hoisted(() => ({
apiMock: {
getModelsMarketplace: vi.fn(),
},
userApiMock: {
getModelsMarketplace: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
userApi: userApiMock,
}));
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => (
typeof child === 'string' ? child : collectText(child)
)).join('');
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('Models user view', () => {
beforeEach(() => {
vi.clearAllMocks();
userApiMock.getModelsMarketplace.mockResolvedValue({
models: [
{
name: 'gpt-4o',
accountCount: 1,
tokenCount: 1,
avgLatency: null,
successRate: null,
description: null,
tags: [],
supportedEndpointTypes: [],
pricingSources: [],
accounts: [
{
id: 1,
site: 'Site A',
username: 'tester',
latency: null,
balance: 1,
tokens: [{ id: 1, name: 'default', isDefault: true }],
},
],
},
],
});
});
afterEach(() => {
vi.clearAllMocks();
});
it('hides vendor and account details for user path', async () => {
let root!: ReturnType<typeof create>;
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/user/models']}>
<ToastProvider>
<Models />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const text = collectText(root.root);
expect(text).toContain('模型广场');
expect(text).not.toContain('供应商');
expect(text).not.toContain('排序方式');
expect(text).not.toContain('账号数');
expect(text).not.toContain('令牌数');
expect(text).not.toContain('Site A');
expect(text).not.toContain('tester');
expect(userApiMock.getModelsMarketplace).toHaveBeenCalled();
expect(apiMock.getModelsMarketplace).not.toHaveBeenCalled();
});
});