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
+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' }}>