feat(web): add React workbench administration UI
This commit is contained in:
Generated
+2364
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "yawen-workbench-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xyflow/react": "^12.11.3",
|
||||
"lucide-react": "^1.39.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^7.18.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.3"
|
||||
}
|
||||
}
|
||||
Generated
+1344
File diff suppressed because it is too large
Load Diff
+242
@@ -0,0 +1,242 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api, localizeError } from './api'
|
||||
import CanvasPage from './CanvasPage'
|
||||
import { DialogProvider, useDialog } from './dialog'
|
||||
import type { Seg, Camp, CampaignState, RuntimeDetail, CampaignCreateResponse, ComposeResponse,
|
||||
AgentCreateResponse, AgentSummary, KnowledgeBaseSummary, NotificationSummary, AuthUser, AuthStatus,
|
||||
TraceSummary, TraceDetail, UsageSummary, UsageEventRow, PointState, PointAccountRow, UsageResponse,
|
||||
AdminUsageResponse, AgentListRow, MemoryListRow, KbFileRow, KbCategoryRow, KbDetailResponse, KbUsageResponse } from './types'
|
||||
import { STATUS_LABEL, KB_STATUS_LABEL, CLASSIFICATION_STATUS_LABEL, RUNTIME_EVENT_LABEL,
|
||||
RUNTIME_PHASE_LABEL, RUNTIME_TOOL_LABEL, PAGE_META, RULE_TEMPLATES } from './constants'
|
||||
import { Tag, WorkspaceErrorBoundary } from './ui'
|
||||
|
||||
|
||||
function errorText(error: unknown) {
|
||||
return localizeError(error)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return <AuthGate />
|
||||
}
|
||||
|
||||
function AuthGate() {
|
||||
const [status, setStatus] = useState<AuthStatus | null>(null)
|
||||
const [routePath, setRoutePath] = useState(() => window.location.pathname)
|
||||
const [username, setUsername] = useState('')
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const refresh = async () => {
|
||||
try {
|
||||
// A stalled tunnel or database lock must never leave users on the
|
||||
// indefinite “正在验证” screen. Authentication is a short read-only
|
||||
// request; show the login screen after a bounded wait so it can retry.
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error('认证请求超时')), 10000)
|
||||
})
|
||||
try { setStatus(await Promise.race([api.get<AuthStatus>('/auth/status'), timeout])) }
|
||||
finally { if (timer) clearTimeout(timer) }
|
||||
}
|
||||
catch { api.clearToken(); setStatus({ bootstrap_required: false, authenticated: false, user: null }) }
|
||||
}
|
||||
useEffect(() => { refresh() }, [])
|
||||
useEffect(() => {
|
||||
const onPopState = () => setRoutePath(window.location.pathname)
|
||||
window.addEventListener('popstate', onPopState)
|
||||
return () => window.removeEventListener('popstate', onPopState)
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
if (!status?.authenticated || !status.user || portalForPath(routePath)) return
|
||||
const next = expectedPortal(status.user) === 'admin' ? '/admin' : '/app'
|
||||
window.history.replaceState({}, '', next)
|
||||
setRoutePath(next)
|
||||
}, [status, routePath])
|
||||
const submit = async () => {
|
||||
setBusy(true); setError('')
|
||||
try {
|
||||
const path = status?.bootstrap_required ? '/auth/bootstrap' : '/auth/login'
|
||||
const payload = status?.bootstrap_required ? { username, display_name: displayName, password } : { username, password }
|
||||
const result = await api.post<{ token: string; user: AuthUser }>(path, payload)
|
||||
api.setToken(result.token)
|
||||
setStatus({ bootstrap_required: false, authenticated: true, user: result.user })
|
||||
const next = expectedPortal(result.user) === 'admin' ? '/admin' : '/app'
|
||||
window.history.replaceState({}, '', next)
|
||||
setRoutePath(next)
|
||||
setPassword('')
|
||||
} catch (requestError: unknown) { setError(errorText(requestError)) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
if (!status) return <div className="auth-shell"><div className="auth-card auth-card-loading"><span className="auth-spinner" /><strong>正在验证工作台访问状态…</strong></div></div>
|
||||
if (!status.authenticated || !status.user) {
|
||||
const bootstrap = status.bootstrap_required
|
||||
return <div className="auth-shell">
|
||||
<section className="auth-brand-pane" aria-hidden="true">
|
||||
<div className="auth-brand-glow" />
|
||||
<div className="auth-brand-mark">私</div>
|
||||
<div className="auth-brand-copy">
|
||||
<p className="eyebrow">YAWEN CONTENT OPS</p>
|
||||
<h1>私域Agent</h1>
|
||||
<p className="auth-brand-tagline">导图进,成稿出。多智能体协作的内容生产中枢。</p>
|
||||
</div>
|
||||
<ul className="auth-brand-points">
|
||||
<li><b>◇</b><span>Agent 画布按拓扑编排,拆解、生产、质检、成稿一条流水线</span></li>
|
||||
<li><b>▤</b><span>知识库片段消费锁与引用溯源,成稿有据可查</span></li>
|
||||
<li><b>◷</b><span>每次调用都有 Trace 与用量账单,成本清清楚楚</span></li>
|
||||
</ul>
|
||||
<div className="auth-brand-foot">© 2026 私域Agent · 内容生产中枢</div>
|
||||
</section>
|
||||
<section className="auth-card">
|
||||
<div className="auth-card-inner">
|
||||
<div className="auth-mobile-mark">私</div>
|
||||
<h1>{bootstrap ? '初始化管理员账号' : '欢迎回来'}</h1>
|
||||
<p>{bootstrap ? '这是首次启动。创建管理员后,后续成员和权限都在工作台内管理。' : '使用已创建的本地账号进入内容工作台。'}</p>
|
||||
<label>用户名
|
||||
<input autoComplete="username" placeholder="输入登录名" value={username} onChange={event => setUsername(event.target.value)} />
|
||||
</label>
|
||||
{bootstrap && <label>显示名称
|
||||
<input placeholder="如:私域团队" value={displayName} onChange={event => setDisplayName(event.target.value)} />
|
||||
</label>}
|
||||
<label>密码
|
||||
<input type="password" autoComplete={bootstrap ? 'new-password' : 'current-password'} placeholder={bootstrap ? '至少 10 个字符' : '输入密码'} value={password} onChange={event => setPassword(event.target.value)} onKeyDown={event => event.key === 'Enter' && submit()} />
|
||||
</label>
|
||||
{error && <div className="err">{error}</div>}
|
||||
<button className="primary-action auth-submit" onClick={submit} disabled={busy || !username.trim() || (bootstrap && password.length < 10)}>{bootstrap ? '创建管理员并进入' : '登录'}{busy && <span className="auth-spinner light" />}</button>
|
||||
<p className="auth-card-foot">{bootstrap ? '管理员账号拥有全部管理权限。' : '忘记密码?请联系管理员在访问控制中重设。'}</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
const logout = async () => {
|
||||
try { await api.post('/auth/logout') } catch { /* 本地清令牌仍然应生效 */ }
|
||||
api.clearToken(); setStatus({ bootstrap_required: false, authenticated: false, user: null }); setRoutePath('/')
|
||||
}
|
||||
const requestedPortal = portalForPath(routePath)
|
||||
const userPortal = expectedPortal(status.user)
|
||||
if (requestedPortal && requestedPortal !== userPortal) {
|
||||
return <PortalDenied currentUser={status.user} expectedPortal={userPortal} onLogout={logout} />
|
||||
}
|
||||
return <WorkspaceErrorBoundary><DialogProvider><AppWorkspace currentUser={status.user} portal={requestedPortal || userPortal} onLogout={logout} /></DialogProvider></WorkspaceErrorBoundary>
|
||||
}
|
||||
|
||||
type Portal = 'user' | 'admin'
|
||||
|
||||
function portalForPath(pathname: string): Portal | null {
|
||||
if (pathname === '/admin' || pathname.startsWith('/admin/')) return 'admin'
|
||||
if (pathname === '/app' || pathname.startsWith('/app/')) return 'user'
|
||||
return null
|
||||
}
|
||||
|
||||
function expectedPortal(user: AuthUser): Portal {
|
||||
return user.role === 'admin' || user.permissions?.includes('*') || user.permissions?.includes('admin:manage') ? 'admin' : 'user'
|
||||
}
|
||||
|
||||
function PortalDenied({ currentUser, expectedPortal, onLogout }: { currentUser: AuthUser; expectedPortal: Portal; onLogout: () => Promise<void> }) {
|
||||
return <div className="auth-shell"><section className="auth-card auth-denied"><div className="auth-card-inner">
|
||||
<div className="auth-mobile-mark">私</div>
|
||||
<h1>当前入口不可用</h1>
|
||||
<p>{currentUser.display_name} 的账号只能进入{expectedPortal === 'admin' ? '管理控制台' : '用户工作台'}。请使用与账号匹配的入口地址。</p>
|
||||
<button className="primary-action auth-submit" onClick={() => onLogout()}>退出当前账号</button>
|
||||
</div></section></div>
|
||||
}
|
||||
|
||||
function AppWorkspace({ currentUser, portal, onLogout }: { currentUser: AuthUser; portal: Portal; onLogout: () => Promise<void> }) {
|
||||
const [tab, setTab] = useState('overview')
|
||||
const [sidebarOpen, setSidebarOpen] = useState(() => window.innerWidth > 760)
|
||||
const [notifs, setNotifs] = useState<NotificationSummary[]>([])
|
||||
// Hermes 融合:侧栏显示真实系统自检(数据库/向量库/磁盘/调度)。
|
||||
const [sysStatus, setSysStatus] = useState<{ status: string; checks: Record<string, { ok: boolean; label: string; detail?: string }> } | null>(null)
|
||||
const adminNavGroups = [
|
||||
{ label: '工作台', items: [['overview', '总览', '⌂'], ['tasks', '任务看板', '▦'], ['ops', '运营看板', '◈']] },
|
||||
{ label: '智能生产', items: [['canvas', 'Agent 画布', '◇'], ['agents', 'Agent 与技能', '◎']] },
|
||||
{ label: '资产中心', items: [['kbs', '知识库', '▤'], ['models', '模型管理', '◈']] },
|
||||
{ label: '系统', items: [['logs', '日志与通知', '◷'], ['memories', '记忆库', '▣'], ['usage', '用量与账单', '¥'], ['access', '访问控制', '◉']] },
|
||||
]
|
||||
const canManageConsole = currentUser.role === 'admin' || currentUser.permissions?.includes('*') || currentUser.permissions?.includes('admin:manage')
|
||||
// 用户端保留全部业务能力;只有拥有管理控制台权限的自定义角色才显示访问控制。
|
||||
const userNavGroups = adminNavGroups.map(group => ({
|
||||
label: group.label,
|
||||
items: group.items.filter(item => item[0] !== 'access' || canManageConsole),
|
||||
}))
|
||||
const isAdminPortal = portal === 'admin'
|
||||
const navGroups = isAdminPortal ? adminNavGroups : userNavGroups
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => api.get('/notifications').then(setNotifs).catch(() => {}), 4000)
|
||||
return () => clearInterval(t)
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
const load = () => api.get('/system/status').then(setSysStatus).catch(() => setSysStatus(null))
|
||||
load()
|
||||
const t = setInterval(load, 15000)
|
||||
return () => clearInterval(t)
|
||||
}, [])
|
||||
const unread = notifs.filter(n => n.status === 'unread').length
|
||||
const currentLabel = navGroups.flatMap(group => group.items).find(item => item[0] === tab)?.[1] || '总览'
|
||||
const pageMeta = PAGE_META[tab] || PAGE_META.overview
|
||||
const currentDate = new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: 'long', day: 'numeric', weekday: 'short' }).format(new Date())
|
||||
return (
|
||||
<div className={`app-shell ${sidebarOpen ? '' : 'sidebar-collapsed'}`}>
|
||||
<aside className="sidebar">
|
||||
<div className="brand-block">
|
||||
<div className="brand-mark">私</div>
|
||||
<div className="brand-copy"><strong>私域Agent</strong><span>内容生产中枢</span></div>
|
||||
</div>
|
||||
<div className="workspace-switcher" aria-label={isAdminPortal ? '管理控制台入口' : '用户工作台入口'}><span className="status-dot" /> <span>{isAdminPortal ? '管理控制台' : '用户工作台'}</span></div>
|
||||
<nav className="side-nav" aria-label="主导航">
|
||||
{navGroups.map(group => <div className="nav-group" key={group.label}>
|
||||
<div className="nav-label">{group.label}</div>
|
||||
{group.items.map(([k, v, icon]) => <button key={k} className={`side-nav-item ${tab === k ? 'active' : ''}`} onClick={() => setTab(k)} title={sidebarOpen ? undefined : v}>
|
||||
<span className="nav-icon">{icon}</span><span className="nav-text">{v}</span>{k === 'logs' && unread > 0 && <span className="nav-count">{unread}</span>}
|
||||
</button>)}
|
||||
</div>)}
|
||||
</nav>
|
||||
<div className="sidebar-footer">
|
||||
<div className={`system-health ${sysStatus && sysStatus.status !== 'ok' ? 'degraded' : ''}`} title={sysStatus ? Object.values(sysStatus.checks).filter(c => !c.ok).map(c => `${c.label}:${c.detail || '异常'}`).join(';') || '全部组件正常' : '正在检查系统状态…'}>
|
||||
<span className={`health-pulse ${sysStatus && sysStatus.status !== 'ok' ? 'warn' : ''}`} />
|
||||
<div><strong>{sysStatus ? (sysStatus.status === 'ok' ? '系统运行正常' : '部分组件异常') : '系统自检中…'}</strong>
|
||||
<span>{sysStatus ? Object.values(sysStatus.checks).map(c => c.ok ? c.label : `⚠ ${c.label}`).join(' · ') || 'API · RAG · 调度' : 'API · RAG · 调度'}</span></div>
|
||||
</div>
|
||||
<div className="user-chip"><span className="avatar">{currentUser.display_name.slice(0, 1) || '雅'}</span><div><strong>{currentUser.display_name}</strong><span>{currentUser.role_label || ({ admin: '管理员', editor: '编辑', reviewer: '审查', viewer: '只读' } as Record<string, string>)[currentUser.role] || '自定义角色'}</span></div><button className="mini user-logout" onClick={() => onLogout()}>退出</button></div>
|
||||
</div>
|
||||
</aside>
|
||||
<div className="workspace">
|
||||
<header className="topbar">
|
||||
<div className="topbar-left"><button className="icon-button menu-button" onClick={() => setSidebarOpen(v => !v)} aria-label={sidebarOpen ? '收起导航' : '展开导航'} title={sidebarOpen ? '收起导航' : '展开导航'}>☰</button><div className="breadcrumb"><span>{isAdminPortal ? '管理控制台' : '用户工作台'}</span><b>/</b><strong>{currentLabel}</strong></div></div>
|
||||
<div className="topbar-actions"><span className="live-indicator"><i />实时同步</span><button className="icon-button" onClick={() => setTab('logs')} aria-label="通知" title="通知">◷{unread > 0 && <em>{unread}</em>}</button><span className="topbar-divider" /><span className="topbar-date">{currentDate}</span></div>
|
||||
</header>
|
||||
<main className="content-area">
|
||||
<div className="page-heading"><div><p className="eyebrow">{pageMeta.eyebrow}</p><h1>{currentLabel}</h1><p className="page-subtitle">{pageMeta.subtitle}</p></div><div className="heading-actions">{tab === 'overview' && <button className="primary-action" onClick={() => setTab('tasks')}><span>+</span> 新建内容任务</button>}</div></div>
|
||||
{tab === 'overview' && <OverviewPage onNavigate={setTab} portal={isAdminPortal ? 'admin' : 'user'} canAudit={currentUser.role !== 'viewer'} />}
|
||||
{tab === 'ops' && <OpsPage />}
|
||||
{tab === 'tasks' && <TasksPage />}
|
||||
{tab === 'canvas' && <CanvasPage />}
|
||||
{tab === 'agents' && <AgentsPage />}
|
||||
{tab === 'skills' && <SkillsPage />}
|
||||
{tab === 'tags' && <TagsPage />}
|
||||
{tab === 'models' && <ModelsPage readOnly={!isAdminPortal} />}
|
||||
{tab === 'kbs' && <KBsPage />}
|
||||
{tab === 'memories' && <MemoriesPage />}
|
||||
{tab === 'logs' && <LogsPage />}
|
||||
{tab === 'usage' && <UsagePage currentUser={currentUser} adminMode={isAdminPortal} />}
|
||||
{tab === 'access' && canManageConsole && <AccessPage />}
|
||||
</main>
|
||||
</div>
|
||||
<ProgressToast />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
import OverviewPage from './pages/OverviewPage'
|
||||
import OpsPage from './pages/OpsPage'
|
||||
import TasksPage from './pages/TasksPage'
|
||||
import AgentsPage from './pages/AgentsPage'
|
||||
import SkillsPage from './pages/SkillsPage'
|
||||
import TagsPage from './pages/TagsPage'
|
||||
import ModelsPage from './pages/ModelsPage'
|
||||
import KBsPage from './pages/KBsPage'
|
||||
import MemoriesPage from './pages/MemoriesPage'
|
||||
import AccessPage from './pages/AccessPage'
|
||||
import UsagePage from './pages/UsagePage'
|
||||
import { ProgressToast } from './progress'
|
||||
import LogsPage from './pages/LogsPage'
|
||||
@@ -0,0 +1,216 @@
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { HashRouter, Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { api, localizeError } from './api'
|
||||
import { DialogProvider } from './dialog'
|
||||
import { AppShell } from './shell'
|
||||
import type { Portal } from './shell'
|
||||
import type { AuthUser, AuthStatus } from './types'
|
||||
import { WorkspaceErrorBoundary } from './ui'
|
||||
|
||||
import { lazyRoute, PageLoading } from './lazyRoute'
|
||||
import { prefetchAllRoutes } from './routePreload'
|
||||
|
||||
const OverviewPage = lazyRoute(() => import('./pages/OverviewPage'))
|
||||
const OpsPage = lazyRoute(() => import('./pages/OpsPage'))
|
||||
const TrainingMapPage = lazyRoute(() => import('./pages/TrainingMapPage'))
|
||||
const TaskListPage = lazyRoute(() => import('./pages/TaskListPage'))
|
||||
const TaskDetailPage = lazyRoute(() => import('./pages/TaskDetailPage'))
|
||||
const AgentWorkbench = lazyRoute(() => import('./pages/AgentWorkbench'))
|
||||
const SkillsPage = lazyRoute(() => import('./pages/SkillsPage'))
|
||||
const TagsPage = lazyRoute(() => import('./pages/TagsPage'))
|
||||
const ModelsPage = lazyRoute(() => import('./pages/ModelsPage'))
|
||||
const KnowledgePage = lazyRoute(() => import('./pages/KnowledgePage'))
|
||||
const MemoriesPage = lazyRoute(() => import('./pages/MemoriesPage'))
|
||||
const AccessPage = lazyRoute(() => import('./pages/AccessPage'))
|
||||
const FeatureFlagsPage = lazyRoute(() => import('./pages/FeatureFlagsPage'))
|
||||
const DigitalHumansPage = lazyRoute(() => import('./pages/DigitalHumansPage'))
|
||||
const UsagePage = lazyRoute(() => import('./pages/UsagePage'))
|
||||
const RunsPage = lazyRoute(() => import('./pages/RunsPage'))
|
||||
const AuditLogsPage = lazyRoute(() => import('./pages/AuditLogsPage'))
|
||||
const SystemLogsPage = lazyRoute(() => import('./pages/SystemLogsPage'))
|
||||
const CanvasPage = lazyRoute(() => import('./CanvasPage'))
|
||||
const UserManualPage = lazyRoute(() => import('./pages/UserManualPage'))
|
||||
|
||||
function RouteLoading() { return <PageLoading /> }
|
||||
|
||||
/** 功能级权限门禁:侧栏隐藏只是第一层,直接输入地址也必须得到明确提示。 */
|
||||
function FeatureGate({ user, required, children }: { user: AuthUser; required: string[]; children: React.ReactNode }) {
|
||||
const feature = required.find(item => item.startsWith('feature:'))
|
||||
const allowed = user.role === 'admin' || user.permissions?.includes('*') || (feature ? user.permissions?.includes(feature) : required.some(item => user.permissions?.includes(item)))
|
||||
if (allowed) return <>{children}</>
|
||||
return <div className="empty-state permission-denied" style={{ padding: 60 }}>
|
||||
<strong>暂无此功能权限</strong>
|
||||
<span>当前角色未开通该功能,请联系管理员在访问控制中调整权限。</span>
|
||||
<a className="primary-action" href="#/admin/overview">返回工作台</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
function errorText(error: unknown) {
|
||||
return localizeError(error)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return <HashRouter><AuthGate /></HashRouter>
|
||||
}
|
||||
|
||||
function AuthGate() {
|
||||
const [status, setStatus] = useState<AuthStatus | null>(null)
|
||||
const [username, setUsername] = useState('')
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error('认证请求超时')), 10000)
|
||||
})
|
||||
try { setStatus(await Promise.race([api.get<AuthStatus>('/auth/status'), timeout])) }
|
||||
finally { if (timer) clearTimeout(timer) }
|
||||
}
|
||||
catch { api.clearToken(); setStatus({ bootstrap_required: false, authenticated: false, user: null }) }
|
||||
}
|
||||
useEffect(() => { refresh() }, [])
|
||||
|
||||
// Once authenticated, warm navigation chunks in idle time. This removes the
|
||||
// first-click download penalty while keeping the login bundle lightweight.
|
||||
useEffect(() => {
|
||||
if (!status?.authenticated || !status.user) return
|
||||
prefetchAllRoutes()
|
||||
}, [status?.authenticated, status?.user?.id])
|
||||
|
||||
const portalBase = '/admin'
|
||||
// 登录后自动归位:仅当当前无有效路由时
|
||||
useEffect(() => {
|
||||
if (!status?.authenticated || !status.user) return
|
||||
const here = location.pathname
|
||||
const hasDeepLink = here.startsWith('/admin') || here.startsWith('/app')
|
||||
if (!hasDeepLink) navigate(portalBase + '/overview', { replace: true })
|
||||
}, [status, location.pathname])
|
||||
|
||||
const submit = async () => {
|
||||
setBusy(true); setError('')
|
||||
try {
|
||||
const path = status?.bootstrap_required ? '/auth/bootstrap' : '/auth/login'
|
||||
const payload = status?.bootstrap_required ? { username, display_name: displayName, password } : { username, password }
|
||||
const result = await api.post<{ token: string; user: AuthUser }>(path, payload)
|
||||
api.setToken(result.token)
|
||||
setStatus({ bootstrap_required: false, authenticated: true, user: result.user })
|
||||
const target = location.pathname.startsWith('/admin') || location.pathname.startsWith('/app')
|
||||
? location.pathname : '/admin/overview'
|
||||
navigate(target, { replace: true })
|
||||
setPassword('')
|
||||
} catch (requestError: unknown) { setError(errorText(requestError)) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
const logout = async () => {
|
||||
try { await api.post('/auth/logout') } catch { /* 本地清令牌仍然应生效 */ }
|
||||
api.clearToken(); setStatus({ bootstrap_required: false, authenticated: false, user: null })
|
||||
navigate('/', { replace: true })
|
||||
}
|
||||
|
||||
if (!status) return <div className="auth-shell"><div className="auth-card auth-card-loading"><span className="auth-spinner" /><strong>正在验证工作台访问状态…</strong><span className="loading-hint">正在连接服务,请稍候,不要重复点击。</span></div></div>
|
||||
|
||||
if (!status.authenticated || !status.user) {
|
||||
const bootstrap = status.bootstrap_required
|
||||
return <div className="auth-shell">
|
||||
<section className="auth-brand-pane" aria-hidden="true">
|
||||
<div className="auth-brand-glow" />
|
||||
<div className="auth-brand-mark">私</div>
|
||||
<div className="auth-brand-copy">
|
||||
<p className="eyebrow">YAWEN CONTENT OPS</p>
|
||||
<h1>私域Agent</h1>
|
||||
<p className="auth-brand-tagline">导图进,成稿出。多智能体协作的内容生产中枢。</p>
|
||||
</div>
|
||||
<ul className="auth-brand-points">
|
||||
<li><b>◇</b><span>Agent 画布按拓扑编排,拆解、生产、质检、成稿一条流水线</span></li>
|
||||
<li><b>▤</b><span>知识库片段消费锁与引用溯源,成稿有据可查</span></li>
|
||||
<li><b>◷</b><span>每次调用都有 Trace 与用量账单,成本清清楚楚</span></li>
|
||||
</ul>
|
||||
<div className="auth-brand-foot">© 2026 私域Agent · 内容生产中枢</div>
|
||||
</section>
|
||||
<section className="auth-card">
|
||||
<div className="auth-card-inner">
|
||||
<div className="auth-mobile-mark">私</div>
|
||||
<h1>{bootstrap ? '初始化管理员账号' : '欢迎回来'}</h1>
|
||||
<p>{bootstrap ? '这是首次启动。创建管理员后,后续成员和权限都在工作台内管理。' : '使用已创建的本地账号进入内容工作台。'}</p>
|
||||
<label>用户名
|
||||
<input autoComplete="username" placeholder="输入登录名" value={username} onChange={event => setUsername(event.target.value)} />
|
||||
</label>
|
||||
{bootstrap && <label>显示名称
|
||||
<input placeholder="如:私域团队" value={displayName} onChange={event => setDisplayName(event.target.value)} />
|
||||
</label>}
|
||||
<label>密码
|
||||
<input type="password" autoComplete={bootstrap ? 'new-password' : 'current-password'} placeholder={bootstrap ? '至少 10 个字符' : '输入密码'} value={password} onChange={event => setPassword(event.target.value)} onKeyDown={event => event.key === 'Enter' && submit()} />
|
||||
</label>
|
||||
{error && <div className="err">{error}</div>}
|
||||
<button className="primary-action auth-submit" onClick={submit} disabled={busy || !username.trim() || (bootstrap && password.length < 10)}>{bootstrap ? '创建管理员并进入' : '登录'}{busy && <span className="auth-spinner light" />}</button>
|
||||
<p className="auth-card-foot">{bootstrap ? '管理员账号拥有全部管理权限。' : '忘记密码?请联系管理员在访问控制中重设。'}</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
|
||||
// 重构后不再按入口硬拒:所有角色共用一套工作台壳,功能可见性与操作权限由后端权限矩阵控制。
|
||||
// /admin 与 /app 是等价前缀(/app 保留兼容旧入口),导航与路由都跟随统一 /admin。
|
||||
const userPortal = expectedPortal(status.user)
|
||||
const portal: Portal = 'admin'
|
||||
const base = '/admin'
|
||||
|
||||
return <WorkspaceErrorBoundary><DialogProvider>
|
||||
<AppShell currentUser={status.user} portal={portal} onLogout={logout}>
|
||||
<Suspense fallback={<RouteLoading />}><Routes>
|
||||
<Route path="/" element={<Navigate to="/admin/overview" replace />} />
|
||||
<Route path={`/${portal}`} element={<Navigate to="/admin/overview" replace />} />
|
||||
<Route path={`/${portal}/manual`} element={<UserManualPage />} />
|
||||
<Route path={`/${portal}/overview`} element={<OverviewPage onNavigate={(t: string) => {
|
||||
const map: Record<string, string> = {
|
||||
overview: '', tasks: '/tasks', kbs: '/knowledge', knowledge: '/knowledge',
|
||||
agents: '/agents', canvas: '/flows', flows: '/flows', logs: '/runs', runs: '/runs',
|
||||
skills: '/skills', tags: '/tags',
|
||||
memories: '/memories', models: '/settings/providers', access: '/settings/access',
|
||||
usage: '/settings/usage', ops: '/ops',
|
||||
}
|
||||
navigate(base + (map[t] ?? `/${t}`), { replace: false })
|
||||
}} portal={portal} canAudit={status.user.permissions?.includes('*') || status.user.permissions?.includes('logs:read') || false} />} />
|
||||
<Route path={`/${portal}/training-maps`} element={<FeatureGate user={status.user} required={['feature:tasks', 'tasks:read', 'tasks:write']}><TrainingMapPage /></FeatureGate>} />
|
||||
<Route path={`/${portal}/tasks`} element={<FeatureGate user={status.user} required={['feature:tasks', 'tasks:read', 'tasks:write', 'tasks:review', 'tasks:deliver']}><TaskListPage /></FeatureGate>} />
|
||||
<Route path={`/${portal}/tasks/:id`} element={<FeatureGate user={status.user} required={['feature:tasks', 'tasks:read', 'tasks:write', 'tasks:review', 'tasks:deliver']}><TaskDetailPage /></FeatureGate>} />
|
||||
<Route path={`/${portal}/agents`} element={<FeatureGate user={status.user} required={['feature:agents', 'agents:read', 'agents:write']}><AgentWorkbench /></FeatureGate>} />
|
||||
<Route path={`/${portal}/agents/:id`} element={<FeatureGate user={status.user} required={['feature:agents', 'agents:read', 'agents:write']}><AgentWorkbench /></FeatureGate>} />
|
||||
<Route path={`/${portal}/skills`} element={<Navigate to={`/${portal}/agents?tab=skills`} replace />} />
|
||||
<Route path={`/${portal}/knowledge`} element={<FeatureGate user={status.user} required={['feature:knowledge', 'kb:read']}><KnowledgePage /></FeatureGate>} />
|
||||
<Route path={`/${portal}/knowledge/:id`} element={<FeatureGate user={status.user} required={['feature:knowledge', 'kb:read']}><KnowledgePage /></FeatureGate>} />
|
||||
<Route path={`/${portal}/tags`} element={<Navigate to={`/${portal}/knowledge?tab=tags`} replace />} />
|
||||
<Route path={`/${portal}/flows`} element={<FeatureGate user={status.user} required={['feature:flows', 'flows:read', 'agents:write']}><CanvasPage /></FeatureGate>} />
|
||||
<Route path={`/${portal}/runs`} element={<FeatureGate user={status.user} required={['feature:runs', 'runs:read', 'logs:read']}><RunsPage /></FeatureGate>} />
|
||||
{portal === 'admin' && <Route path={`/${portal}/audit-logs`} element={<FeatureGate user={status.user} required={['feature:audit', 'audit:read', 'audit:manage']}><AuditLogsPage /></FeatureGate>} />}
|
||||
{portal === 'admin' && <Route path={`/${portal}/system-logs`} element={<FeatureGate user={status.user} required={['feature:system-logs', 'system_logs:read', 'logs:read']}><SystemLogsPage /></FeatureGate>} />}
|
||||
|
||||
<Route path={`/${portal}/settings/providers`} element={<FeatureGate user={status.user} required={['feature:models', 'providers:read', 'providers:write']}><ModelsPage readOnly={portal !== 'admin'} /></FeatureGate>} />
|
||||
<Route path={`/${portal}/settings/usage`} element={<FeatureGate user={status.user} required={['feature:usage', 'usage:read', 'logs:read', 'billing:manage']}><UsagePage currentUser={status.user} adminMode={userPortal === 'admin'} /></FeatureGate>} />
|
||||
{portal === 'admin' && <Route path={`/${portal}/settings/access`} element={<FeatureGate user={status.user} required={['feature:access', 'access:read', 'roles:manage', 'users:manage', 'audit:manage']}><AccessPage /></FeatureGate>} />}
|
||||
{portal === 'admin' && <Route path={`/${portal}/settings/flags`} element={<FeatureGate user={status.user} required={['feature:flags', 'flags:read', 'ops:manage']}><FeatureFlagsPage /></FeatureGate>} />}
|
||||
<Route path={`/${portal}/digital-humans`} element={<FeatureGate user={status.user} required={['feature:digital-humans', 'digital_humans:read', 'agents:write']}><DigitalHumansPage /></FeatureGate>} />
|
||||
<Route path={`/${portal}/digital-humans/:id`} element={<FeatureGate user={status.user} required={['feature:digital-humans', 'digital_humans:read', 'agents:write']}><DigitalHumansPage /></FeatureGate>} />
|
||||
{/* 记忆库已并入“Agent 与技能”页签,保留旧地址以兼容历史书签。 */}
|
||||
<Route path={`/${portal}/memories`} element={<FeatureGate user={status.user} required={['feature:agents', 'agents:read', 'agents:write']}><Navigate to={`/${portal}/agents?tab=memories`} replace /></FeatureGate>} />
|
||||
<Route path={`/${portal}/ops`} element={<FeatureGate user={status.user} required={['feature:ops', 'ops:read', 'ops:manage']}><OpsPage /></FeatureGate>} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes></Suspense>
|
||||
</AppShell>
|
||||
</DialogProvider></WorkspaceErrorBoundary>
|
||||
}
|
||||
|
||||
function NotFound() {
|
||||
return <div className="empty-state" style={{ padding: 60 }}>页面不存在。<a href="#/admin/overview">回工作台</a></div>
|
||||
}
|
||||
|
||||
function expectedPortal(user: AuthUser | null): Portal {
|
||||
if (!user) return 'user'
|
||||
return user.role === 'admin' || user.permissions?.includes('*') || user.permissions?.includes('admin:manage') ? 'admin' : 'user'
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Wallet, RefreshCw } from 'lucide-react'
|
||||
import { api, localizeError } from './api'
|
||||
import type { PointState } from './types'
|
||||
import './balance-menu.css'
|
||||
|
||||
export function BalanceMenu({ userId }: { userId: number }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [balance, setBalance] = useState<PointState | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const container = useRef<HTMLDivElement>(null)
|
||||
const pending = useRef(false), alive = useRef(false)
|
||||
const refresh = useCallback(async () => {
|
||||
if (pending.current || document.visibilityState === 'hidden') return
|
||||
pending.current = true; setLoading(true)
|
||||
try {
|
||||
const value = await api.get<PointState>('/points/me')
|
||||
if (alive.current) { setBalance(value); setError('') }
|
||||
} catch (reason) { if (alive.current) setError(localizeError(reason)) }
|
||||
finally { pending.current = false; if (alive.current) setLoading(false) }
|
||||
}, [userId])
|
||||
useEffect(() => {
|
||||
alive.current = true; setBalance(null); void refresh()
|
||||
const update = () => void refresh()
|
||||
const timer = window.setInterval(update, 30000)
|
||||
window.addEventListener('focus', update)
|
||||
document.addEventListener('visibilitychange', update)
|
||||
window.addEventListener('points-updated', update)
|
||||
return () => {
|
||||
alive.current = false; window.clearInterval(timer)
|
||||
window.removeEventListener('focus', update)
|
||||
document.removeEventListener('visibilitychange', update)
|
||||
window.removeEventListener('points-updated', update)
|
||||
}
|
||||
}, [refresh])
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const closeOutside = (event: PointerEvent) => {
|
||||
if (!container.current?.contains(event.target as Node)) setOpen(false)
|
||||
}
|
||||
const closeEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') { setOpen(false); container.current?.querySelector('button')?.focus() }
|
||||
}
|
||||
document.addEventListener('pointerdown', closeOutside)
|
||||
document.addEventListener('keydown', closeEscape)
|
||||
return () => { document.removeEventListener('pointerdown', closeOutside); document.removeEventListener('keydown', closeEscape) }
|
||||
}, [open])
|
||||
const number = (value: number) => value.toLocaleString('zh-CN')
|
||||
return <div className="balance-menu" ref={container}>
|
||||
<button className="balance-trigger" aria-expanded={open} aria-controls="my-balance-panel" onClick={() => { setOpen(value => !value); if (!open) void refresh() }}>
|
||||
<Wallet size={16}/><span>余额</span><strong>{error ? '待刷新' : balance ? number(balance.points) : '…'}</strong><span className="balance-unit">点数</span>
|
||||
</button>
|
||||
{open && <section className="balance-panel" id="my-balance-panel" aria-label="我的点数余额">
|
||||
<div><strong>我的余额</strong><button className="mini" aria-label="刷新我的余额" disabled={loading} onClick={() => void refresh()}><RefreshCw size={14}/>{loading ? '刷新中' : '刷新'}</button></div>
|
||||
{error && <p role="alert">余额刷新失败:{error}{balance ? '。下方为上次成功读取的余额。' : ''}</p>}
|
||||
{balance && <><p className="balance-amount">{number(balance.points)} <small>点数</small></p><p className="balance-help">需要补充余额时,请联系管理员。</p></>}
|
||||
{!balance && !error && <p role="status">正在读取余额…</p>}
|
||||
</section>}
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
ReactFlow, Background, Controls, addEdge, useNodesState, useEdgesState,
|
||||
Handle, Position, MarkerType,
|
||||
} from '@xyflow/react'
|
||||
import type { Connection, Edge, Node, NodeProps } from '@xyflow/react'
|
||||
import '@xyflow/react/dist/style.css'
|
||||
import { api, localizeError } from './api'
|
||||
import { useDialog } from './dialog'
|
||||
import { OwnerLabel } from './workbench-ui'
|
||||
import { forkResource, canManageResource, isGlobalResource } from './resourceActions'
|
||||
|
||||
type AgentSummary = {
|
||||
id: number
|
||||
name: string
|
||||
role: string
|
||||
content_tags: string[]
|
||||
model_label?: string
|
||||
fusion_agent_names?: string[]
|
||||
status?: string
|
||||
}
|
||||
|
||||
type CanvasNodeRecord = { node_id: string; agent_id: number; enabled?: boolean; x?: number; y?: number }
|
||||
type CanvasEdgeRecord = { from: string; to: string }
|
||||
type CanvasSummary = { id: number; name: string; nodes: CanvasNodeRecord[]; edges: CanvasEdgeRecord[]; created_at?: string; owner_display_name?: string; owner_username?: string; owner_name?: string; owner_user_id?: number; visibility?: string; can_manage?: boolean }
|
||||
type AgentNodeData = {
|
||||
agentId: number
|
||||
label: string
|
||||
tags: string[]
|
||||
model: string
|
||||
fusion: string[]
|
||||
enabled: boolean
|
||||
onToggle: () => void
|
||||
onDelete: () => void
|
||||
}
|
||||
type AgentFlowNode = Node<AgentNodeData>
|
||||
// Older API responses may still include supervisor fields; the canvas UI ignores them.
|
||||
type CanvasDetail = Pick<CanvasSummary, 'id' | 'name' | 'nodes' | 'edges'>
|
||||
|
||||
const ROLE_LABEL: Record<string, string> = {
|
||||
coordinator: '主控', production: '生产', qc: '质检', service: '服务', composer: '组合',
|
||||
}
|
||||
|
||||
function AgentNode({ data }: NodeProps<AgentFlowNode>) {
|
||||
return (
|
||||
<div className={`rf-node ${data.enabled ? '' : 'rf-node-off'}`}>
|
||||
<Handle type="target" position={Position.Left} />
|
||||
<button className="node-delete" aria-label={`删除 ${data.label} 节点`} title="从画布删除" onClick={(event) => { event.stopPropagation(); data.onDelete() }}>×</button>
|
||||
<div className="rf-node-title">{data.label}</div>
|
||||
<div className="rf-node-sub">{data.tags.join('/') || '未设置标签'} · {data.model || '跟随全局默认'}</div>
|
||||
{data.fusion.length > 0 && <div className="rf-node-fusion">融合 {data.fusion.join(' + ')}</div>}
|
||||
<button className="mini" onClick={(event) => { event.stopPropagation(); data.onToggle() }}>
|
||||
{data.enabled ? '● 已启用' : '○ 已停用'}
|
||||
</button>
|
||||
<Handle type="source" position={Position.Right} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const nodeTypes = { agent: AgentNode }
|
||||
|
||||
export default function CanvasPage() {
|
||||
const dialog = useDialog()
|
||||
const [canvases, setCanvases] = useState<CanvasSummary[]>([])
|
||||
const [curId, setCurId] = useState<number | null>(null)
|
||||
const [templateToCopy, setTemplateToCopy] = useState('')
|
||||
const [agents, setAgents] = useState<AgentSummary[]>([])
|
||||
const [agentsLoading, setAgentsLoading] = useState(true)
|
||||
const [agentsError, setAgentsError] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<AgentFlowNode>([])
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([])
|
||||
const [error, setError] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const savingRef = useRef(false)
|
||||
const loadRequestRef = useRef(0)
|
||||
// Ignore list responses that started before a delete. Without this guard a
|
||||
// late GET can put a deleted canvas back into the selector.
|
||||
const listRequestRef = useRef(0)
|
||||
const testRunningRef = useRef(false)
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
|
||||
const [testRunning, setTestRunning] = useState(false)
|
||||
const [testResult, setTestResult] = useState<{ nodeId: string; text: string; ms: number } | null>(null)
|
||||
const [testError, setTestError] = useState('')
|
||||
const [me, setMe] = useState<any>(null)
|
||||
const [forkBusy, setForkBusy] = useState(false)
|
||||
const toggleNode = useCallback((nodeId: string) => {
|
||||
setNodes(current => current.map(node => node.id === nodeId
|
||||
? { ...node, data: { ...node.data, enabled: !node.data.enabled } }
|
||||
: node))
|
||||
}, [setNodes])
|
||||
|
||||
const deleteNode = useCallback((nodeId: string) => {
|
||||
setNodes(current => current.filter(node => node.id !== nodeId))
|
||||
setEdges(current => current.filter(edge => edge.source !== nodeId && edge.target !== nodeId))
|
||||
}, [setEdges, setNodes])
|
||||
|
||||
const createNode = useCallback((nodeId: string, agentId: number, enabled: boolean, index: number, x?: number, y?: number): AgentFlowNode => {
|
||||
const agent = agents.find(item => item.id === agentId)
|
||||
return {
|
||||
id: nodeId,
|
||||
type: 'agent',
|
||||
position: { x: x ?? 60 + (index % 4) * 220, y: y ?? 70 + Math.floor(index / 4) * 160 },
|
||||
data: {
|
||||
agentId,
|
||||
label: agent?.name || `Agent#${agentId}`,
|
||||
tags: agent?.content_tags || [],
|
||||
model: agent?.model_label || '',
|
||||
fusion: agent?.fusion_agent_names || [],
|
||||
enabled,
|
||||
onToggle: () => toggleNode(nodeId),
|
||||
onDelete: () => deleteNode(nodeId),
|
||||
},
|
||||
}
|
||||
}, [agents, deleteNode, toggleNode])
|
||||
|
||||
// 组合 Agent 由内容任务在成稿阶段自动选择,不属于用户画布编排的一部分。
|
||||
// 旧画布可能仍保存过 composer 节点,加载时将其视为历史数据并清理掉。
|
||||
const composerAgentIds = useMemo(() => new Set(agents.filter(agent => agent.role === 'composer').map(agent => agent.id)), [agents])
|
||||
|
||||
const refresh = useCallback(async (preferredId?: number | null) => {
|
||||
const requestId = ++listRequestRef.current
|
||||
const list = await api.get<CanvasSummary[]>('/canvases')
|
||||
if (requestId !== listRequestRef.current) return
|
||||
setCanvases(list)
|
||||
if (preferredId !== undefined) setCurId(preferredId !== null && list.some(canvas => canvas.id === preferredId) ? preferredId : null)
|
||||
else setCurId(current => list.some(canvas => canvas.id === current) ? current : list[0]?.id ?? null)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setError('')
|
||||
const requestId = ++listRequestRef.current
|
||||
// These are independent resources. A transient Agent request must not
|
||||
// prevent the canvas itself from loading (and vice versa), otherwise the
|
||||
// palette looks empty even though the account owns usable Agents.
|
||||
setAgentsLoading(true)
|
||||
setAgentsError('')
|
||||
Promise.allSettled([
|
||||
api.get<CanvasSummary[]>('/canvases'),
|
||||
api.get<AgentSummary[]>('/agents'),
|
||||
]).then(([canvasResult, agentResult]) => {
|
||||
const isCurrent = requestId === listRequestRef.current
|
||||
// A later canvas refresh must win for the selector, while a successful
|
||||
// Agent response is still useful even if that refresh raced this load.
|
||||
if (isCurrent && canvasResult.status === 'fulfilled') {
|
||||
const canvasList = Array.isArray(canvasResult.value) ? canvasResult.value : []
|
||||
setCanvases(canvasList)
|
||||
setCurId(current => current !== null && canvasList.some(canvas => canvas.id === current)
|
||||
? current : canvasList[0]?.id ?? null)
|
||||
setTemplateToCopy('')
|
||||
} else if (isCurrent && canvasResult.status === 'rejected') {
|
||||
setError(localizeError(canvasResult.reason))
|
||||
}
|
||||
if (agentResult.status === 'fulfilled') {
|
||||
setAgents(Array.isArray(agentResult.value) ? agentResult.value : [])
|
||||
} else {
|
||||
setAgentsError(localizeError(agentResult.reason))
|
||||
}
|
||||
setAgentsLoading(false)
|
||||
})
|
||||
}, [])
|
||||
useEffect(() => { api.get('/auth/me').then(setMe).catch(() => {}) }, [])
|
||||
|
||||
const load = useCallback(async (id: number) => {
|
||||
const requestId = ++loadRequestRef.current
|
||||
try {
|
||||
const canvas = await api.get<CanvasDetail>(`/canvases/${id}`)
|
||||
if (requestId !== loadRequestRef.current) return
|
||||
setName(canvas.name)
|
||||
const visibleNodes = canvas.nodes.filter(node => !composerAgentIds.has(node.agent_id))
|
||||
const visibleNodeIds = new Set(visibleNodes.map(node => node.node_id))
|
||||
setNodes(visibleNodes.map((node, index) => createNode(node.node_id, node.agent_id, node.enabled !== false, index, node.x, node.y)))
|
||||
setEdges(canvas.edges.filter(edge => visibleNodeIds.has(edge.from) && visibleNodeIds.has(edge.to)).map(edge => ({
|
||||
id: `${edge.from}->${edge.to}`,
|
||||
source: edge.from,
|
||||
target: edge.to,
|
||||
markerEnd: { type: MarkerType.ArrowClosed },
|
||||
})))
|
||||
} catch (reason: unknown) {
|
||||
if (requestId !== loadRequestRef.current) return
|
||||
setError(localizeError(reason))
|
||||
}
|
||||
}, [composerAgentIds, createNode, setEdges, setNodes])
|
||||
|
||||
useEffect(() => {
|
||||
// Canvas data is useful even while the Agent list is still loading. The
|
||||
// node labels are filled in by the metadata effect once Agents arrive.
|
||||
if (curId !== null) void load(curId)
|
||||
else { loadRequestRef.current += 1; setNodes([]); setEdges([]); setName('') }
|
||||
}, [curId, load])
|
||||
// 保险:agents 晚于画布加载时补渲染一次节点元数据
|
||||
useEffect(() => {
|
||||
if (agents.length > 0 && nodes.length > 0) {
|
||||
setNodes(current => current.map(node => {
|
||||
const agent = agents.find(item => item.id === node.data.agentId)
|
||||
if (!agent) return node
|
||||
return { ...node, data: { ...node.data, label: agent.name, tags: agent.content_tags || [], model: agent.model_label || '', fusion: agent.fusion_agent_names || [] } }
|
||||
}))
|
||||
}
|
||||
}, [agents])
|
||||
|
||||
// Agent 列表通常与画布并行加载。若画布先返回,待 Agent 元数据到达后再
|
||||
// 清理旧 composer 节点,避免它出现在画布中或被保存回服务端。
|
||||
useEffect(() => {
|
||||
if (!composerAgentIds.size) return
|
||||
const removedNodeIds = new Set(nodes.filter(node => composerAgentIds.has(node.data.agentId)).map(node => node.id))
|
||||
if (!removedNodeIds.size) return
|
||||
setNodes(current => current.filter(node => !removedNodeIds.has(node.id)))
|
||||
setEdges(current => current.filter(edge => !removedNodeIds.has(edge.source) && !removedNodeIds.has(edge.target)))
|
||||
}, [composerAgentIds, nodes, setEdges, setNodes])
|
||||
|
||||
const onConnect = useCallback((connection: Connection) => {
|
||||
if (!connection.source || !connection.target) return
|
||||
setEdges(current => addEdge({ ...connection, markerEnd: { type: MarkerType.ArrowClosed } }, current))
|
||||
}, [setEdges])
|
||||
|
||||
const onNodesDelete = useCallback((deleted: AgentFlowNode[]) => {
|
||||
const deletedIds = new Set(deleted.map(node => node.id))
|
||||
setEdges(current => current.filter(edge => !deletedIds.has(edge.source) && !deletedIds.has(edge.target)))
|
||||
}, [setEdges])
|
||||
|
||||
const dropAgent = (agentId: number) => {
|
||||
if (composerAgentIds.has(agentId)) return
|
||||
setNodes(current => {
|
||||
const index = current.length
|
||||
const nodeId = `n-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
|
||||
return [...current, createNode(nodeId, agentId, true, index)]
|
||||
})
|
||||
}
|
||||
|
||||
const addAllAgents = () => {
|
||||
const available = agents.filter(agent => agent.status !== 'disabled' && agent.role !== 'composer')
|
||||
setNodes(current => {
|
||||
const existingAgentIds = new Set(current.map(node => node.data.agentId))
|
||||
const additions = available.filter(agent => !existingAgentIds.has(agent.id))
|
||||
return [...current, ...additions.map((agent, offset) => createNode(`n-${Date.now()}-${agent.id}`, agent.id, true, current.length + offset))]
|
||||
})
|
||||
}
|
||||
|
||||
const testRun = async () => {
|
||||
if (!selectedNodeId || testRunningRef.current) return
|
||||
const node = nodes.find(n => n.id === selectedNodeId)
|
||||
if (!node) return
|
||||
testRunningRef.current = true
|
||||
setTestRunning(true); setTestError(''); setTestResult(null)
|
||||
try {
|
||||
const r = await api.post<any>(`/agents/${node.data.agentId}/test-run`, { sample: '开场:欢迎姐妹们来到今晚的直播,今天我们讲盆底肌训练,三分钟带你们入门凯格尔。' })
|
||||
setTestResult({ nodeId: selectedNodeId, text: r.text, ms: r.duration_ms || 0 })
|
||||
} catch (e: any) { setTestError(String(e.message || e)) } finally { testRunningRef.current = false; setTestRunning(false) }
|
||||
}
|
||||
const validateCanvas = () => {
|
||||
const ids = new Set(nodes.map(node => node.id))
|
||||
if (!name.trim()) return '请填写画布名称'
|
||||
if (nodes.some(node => !node.data.agentId || !agents.some(agent => agent.id === node.data.agentId))) return '存在未绑定 Agent 的节点,请先修正'
|
||||
if (edges.some(edge => !ids.has(edge.source) || !ids.has(edge.target))) return '存在悬空连线,请删除后再保存'
|
||||
if (edges.some(edge => edge.source === edge.target)) return '不允许节点连接自身'
|
||||
const unique = new Set(edges.map(edge => `${edge.source}->${edge.target}`))
|
||||
if (unique.size !== edges.length) return '存在重复连线,请删除后再保存'
|
||||
return ''
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (savingRef.current) return
|
||||
const validationError = validateCanvas()
|
||||
if (validationError) { setError(validationError); return }
|
||||
savingRef.current = true
|
||||
setSaving(true)
|
||||
setError('')
|
||||
const persistedNodes = nodes.filter(node => !composerAgentIds.has(node.data.agentId))
|
||||
const persistedNodeIds = new Set(persistedNodes.map(node => node.id))
|
||||
const payload = {
|
||||
name: name.trim() || '未命名画布',
|
||||
nodes: persistedNodes.map(node => ({ node_id: node.id, agent_id: node.data.agentId, enabled: node.data.enabled, x: Math.round(node.position.x), y: Math.round(node.position.y) })),
|
||||
edges: edges.filter(edge => persistedNodeIds.has(edge.source) && persistedNodeIds.has(edge.target)).map(edge => ({ from: edge.source, to: edge.target })),
|
||||
}
|
||||
try {
|
||||
if (curId) {
|
||||
await api.put(`/canvases/${curId}`, payload)
|
||||
} else {
|
||||
const created = await api.post<{ id: number }>('/canvases', payload)
|
||||
setCurId(created.id)
|
||||
}
|
||||
await refresh()
|
||||
} catch (reason: unknown) {
|
||||
setError(localizeError(reason))
|
||||
} finally {
|
||||
savingRef.current = false
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const create = async () => {
|
||||
setError('')
|
||||
try {
|
||||
const created = await api.post<{ id: number }>('/canvases', { name: '新画布', nodes: [], edges: [] })
|
||||
await refresh(created.id)
|
||||
} catch (reason: unknown) {
|
||||
setError(localizeError(reason))
|
||||
}
|
||||
}
|
||||
|
||||
const duplicateTemplate = async (templateId: number) => {
|
||||
const template = canvases.find(canvas => canvas.id === templateId)
|
||||
if (!template) return
|
||||
const templateNodes = template.nodes.filter(node => !composerAgentIds.has(node.agent_id))
|
||||
const templateNodeIds = new Set(templateNodes.map(node => node.node_id))
|
||||
setError('')
|
||||
try {
|
||||
const created = await api.post<{ id: number }>('/canvases', {
|
||||
name: `${template.name} 副本`,
|
||||
nodes: templateNodes,
|
||||
edges: template.edges.filter(edge => templateNodeIds.has(edge.from) && templateNodeIds.has(edge.to)),
|
||||
})
|
||||
await refresh(created.id)
|
||||
setTemplateToCopy('')
|
||||
} catch (reason: unknown) {
|
||||
setError(localizeError(reason))
|
||||
}
|
||||
}
|
||||
|
||||
const removeCanvas = async () => {
|
||||
const current = canvases.find(canvas => canvas.id === curId)
|
||||
const global = Boolean(current && isGlobalResource(current) && !canvasEditable)
|
||||
if (!current || !await dialog.confirm({ title: `${global ? '从我的列表移除' : '删除'} 画布「${current.name}」?`, description: global ? '仅从当前账号隐藏,不影响其他用户和管理员使用。' : '画布编排会被删除;已经创建的任务仍会保留自己的流程快照。', confirmLabel: global ? '确认移除' : '确认删除', tone: 'danger' })) return
|
||||
try {
|
||||
await api.delete(`/canvases/${current.id}`)
|
||||
loadRequestRef.current += 1
|
||||
// Invalidate all list requests that may still contain the deleted row.
|
||||
++listRequestRef.current
|
||||
setCanvases(items => items.filter(canvas => canvas.id !== current.id))
|
||||
setCurId(null); setNodes([]); setEdges([]); setName('')
|
||||
setTemplateToCopy('')
|
||||
await refresh(null)
|
||||
} catch (reason: unknown) {
|
||||
setError(localizeError(reason))
|
||||
}
|
||||
}
|
||||
const forkCanvas = async () => {
|
||||
const current = canvases.find(canvas => canvas.id === curId)
|
||||
if (!current || forkBusy) return
|
||||
setForkBusy(true); setError('')
|
||||
try {
|
||||
const result = await forkResource('canvases', Number(current.id))
|
||||
await refresh(result.id)
|
||||
void dialog.notice({ title: '已创建我的画布副本', description: '副本可以独立修改和删除;原全局画布与其他用户不受影响。', tone: 'success' })
|
||||
} catch (e: any) { setError(localizeError(e)) } finally { setForkBusy(false) }
|
||||
}
|
||||
|
||||
const activeAgents = agents.filter(agent => agent.status !== 'disabled' && agent.role !== 'composer')
|
||||
const currentCanvas = canvases.find(canvas => canvas.id === curId)
|
||||
const canvasEditable = canManageResource(currentCanvas, me)
|
||||
return (
|
||||
<div className="canvas-page">
|
||||
<div className="card canvas-toolbar">
|
||||
<div className="canvas-toolbar-main">
|
||||
<select value={curId || ''} onChange={event => setCurId(event.target.value ? Number(event.target.value) : null)} aria-label="选择画布">
|
||||
<option value="">— 选择画布 —</option>
|
||||
{canvases.map(canvas => <option key={canvas.id} value={canvas.id}>{canvas.name}</option>)}
|
||||
</select>
|
||||
<select value={templateToCopy} onChange={event => { const value = event.target.value; setTemplateToCopy(value); if (value) void duplicateTemplate(Number(value)) }} aria-label="复制画布模板">
|
||||
<option value="">📋 复制画布模板…</option>
|
||||
{canvases.filter(canvas => canvas.name.includes('【模板】')).map(canvas => <option key={canvas.id} value={canvas.id}>{canvas.name}</option>)}
|
||||
</select>
|
||||
<input placeholder="画布名称" value={name} onChange={event => setName(event.target.value)} />
|
||||
<button className="ok" onClick={save} disabled={saving || curId === null || !canvasEditable}>{saving ? '保存中…' : '💾 保存画布'}</button>
|
||||
<button onClick={create}>+ 新画布</button>
|
||||
{canvasEditable ? <button className="danger-button" onClick={removeCanvas} disabled={curId === null}>删除画布</button> : currentCanvas && isGlobalResource(currentCanvas) ? <><button className="primary-action" onClick={() => void forkCanvas()} disabled={forkBusy}>{forkBusy ? '正在创建副本…' : '创建我的副本并编辑'}</button><button className="mini danger-text" onClick={removeCanvas} disabled={forkBusy}>从我的列表移除</button></> : null}
|
||||
</div>
|
||||
{selectedNodeId && <button className="mini" onClick={testRun} disabled={testRunning}>▶ 试运行此节点</button>}<div className="canvas-toolbar-meta"><span className="canvas-stat"><strong>{nodes.length}</strong> 个节点</span><span className="canvas-stat"><strong>{activeAgents.length}</strong> 个可用 Agent</span>{curId !== null && <OwnerLabel resource={canvases.find(canvas => canvas.id === curId)} />}<span className="dim">拖动节点调整顺序 · 连接 Handle 建立上下游 · 点击节点右上角 × 删除</span></div>
|
||||
</div>
|
||||
{error && <div className="err">画布操作失败:{error}</div>}
|
||||
{agentsError && <div className="err" role="alert">Agent 列表加载失败:{agentsError}<button className="mini" onClick={() => { setAgentsLoading(true); setAgentsError(''); api.get<AgentSummary[]>('/agents').then(setAgents).catch(reason => setAgentsError(localizeError(reason))).finally(() => setAgentsLoading(false)) }}>重试</button></div>}
|
||||
<div className="canvas-wrap">
|
||||
<aside className="agent-palette">
|
||||
<div className="palette-heading"><div><h3>Agent 列表</h3><p>点击添加到当前画布</p><p className="dim">组合 Agent 由系统默认提供,无需添加</p></div><button className="mini" onClick={addAllAgents} disabled={!activeAgents.length}>全部添加</button></div>
|
||||
{agentsLoading && !agents.length && <div className="empty-state">正在加载 Agent…</div>}
|
||||
{activeAgents.map(agent => <button key={agent.id} className="palette-item" onClick={() => dropAgent(agent.id)}>
|
||||
<span className="palette-role">{ROLE_LABEL[agent.role] || agent.role}</span><b>{agent.name}</b><span className="dim">{agent.content_tags.join(' / ') || '未设置标签'}</span>
|
||||
</button>)}
|
||||
{!agentsLoading && !activeAgents.length && !agentsError && <div className="empty-state">暂无可用 Agent,请先在 Agent 管理中启用。</div>}
|
||||
</aside>
|
||||
<div className="flow-area">
|
||||
{!nodes.length && <div className="canvas-empty"><strong>画布还是空的</strong><span>从左侧点击 Agent,或使用“全部添加”开始编排。</span></div>}
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodesDelete={onNodesDelete}
|
||||
onConnect={onConnect}
|
||||
onNodeClick={(_: any, node: any) => setSelectedNodeId(node.id)}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
deleteKeyCode={['Backspace', 'Delete']}
|
||||
>
|
||||
<Background color="#d8dee9" gap={24} size={1} /><Controls />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
</div>
|
||||
{testError && <div className="err">试运行失败:{testError}</div>}
|
||||
{testResult && <div className="card" style={{ marginTop: 10 }}>
|
||||
<strong>试运行结果({testResult.ms}ms)</strong>
|
||||
<pre className="art" style={{ maxHeight: 220, overflow: 'auto' }}>{testResult.text}</pre>
|
||||
</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { RuntimeDetail } from './types'
|
||||
|
||||
type RecallItem = { id: number; title?: string; source_ref?: string; reason?: string }
|
||||
type RecallGroup = { agent_id: number; agent_name: string; items: RecallItem[] }
|
||||
|
||||
/** 展示持久会话实际生成时记录的召回报告,兼容尚未结束的 model_started 事件。 */
|
||||
export default function MemoryRecall({ runtime }: { runtime: RuntimeDetail | null }) {
|
||||
if (!runtime) return null
|
||||
const rows = runtime.sessions.flatMap(session => {
|
||||
const completed = session.result_meta?.prompt?.memory_recall
|
||||
const started = [...runtime.events].reverse().find(event =>
|
||||
event.event_type === 'model_started' && event.payload?.session_id === session.id)
|
||||
const groups: RecallGroup[] | undefined = completed || started?.payload?.prompt?.memory_recall
|
||||
return groups ? [{ id: session.id, name: session.agent_name, groups }] : []
|
||||
})
|
||||
if (!rows.length) return null
|
||||
return <details className="card" style={{ marginBlock: 12 }}>
|
||||
<summary>本次采用的长期记忆 · {rows.reduce((count, row) => count + row.groups.reduce((sum, group) => sum + group.items.length, 0), 0)} 条会话记录</summary>
|
||||
<p className="dim">记录送入智能体上下文的记忆及召回原因,便于核对内容受哪些经验影响。采用记录不代表模型一定正确执行。</p>
|
||||
{rows.map(row => <div key={row.id}>
|
||||
<h4>{row.name} · 会话 {row.id}</h4>
|
||||
{row.groups.map(group => <div key={group.agent_id}>
|
||||
{group.items.length ? group.items.map(item => <div key={item.id} style={{ marginBlock: 10 }}>
|
||||
<strong>{group.agent_name} · {item.title || `记忆 ${item.id}`}</strong>
|
||||
<p>{item.reason || '已确认的长期经验'}</p>
|
||||
<small className="dim">记忆 #{item.id} · 来源:{item.source_ref || '未记录'}</small>
|
||||
</div>) : <p className="dim">{group.agent_name}:本次没有采用长期记忆。</p>}
|
||||
</div>)}
|
||||
</div>)}
|
||||
</details>
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { api, localizeError } from './api'
|
||||
import { useDialog } from './dialog'
|
||||
import type { Seg, SegmentAgentOption } from './types'
|
||||
|
||||
type PreviousVersion = { agent_name: string; round: number; archived_at: string; reason: string; artifact: string }
|
||||
|
||||
export default function SegmentAgentPicker({ campaignId, segment, options, canEdit, disabled, onBusyChange, onSaved }: {
|
||||
campaignId: number; segment: Seg; options: SegmentAgentOption[]; canEdit: boolean; disabled: boolean; onBusyChange: (busy: boolean) => void; onSaved: () => Promise<void>
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [selection, setSelection] = useState<string | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [feedback, setFeedback] = useState('')
|
||||
const [versions, setVersions] = useState<PreviousVersion[] | null>(null)
|
||||
const [loadingVersions, setLoadingVersions] = useState(false)
|
||||
const inFlight = useRef(false)
|
||||
const editable = canEdit && !['skipped', 'running', 'qc'].includes(segment.status)
|
||||
const change = async (value: string) => {
|
||||
if (!editable || inFlight.current || Number(value) === segment.agent_id) return
|
||||
const target = options.find(option => option.id === Number(value))
|
||||
if (!target) return
|
||||
inFlight.current = true
|
||||
const affected = segment.agent_change_affected || []
|
||||
try {
|
||||
if (affected.length && !await dialog.confirm({
|
||||
title: `更换为「${target.name}」并重置生成进度?`,
|
||||
description: `以下 ${affected.length} 个已出稿内容段将恢复为待生成,需要重新生成并确认:${affected.slice(0, 5).map(item => item.title).join('、')}${affected.length > 5 ? '等' : ''}。旧正文会保留在历史版本中,本段及后续内容重新按顺序生成,以承接新的内容。`,
|
||||
confirmLabel: '保留旧稿并更换',
|
||||
})) return
|
||||
setSelection(value); setSaving(true); onBusyChange(true); setError(''); setFeedback('')
|
||||
await api.put(`/campaigns/${campaignId}/segments/${segment.id}`, { agent_id: Number(value), reset_generated: affected.length > 0 })
|
||||
await onSaved()
|
||||
setVersions(null)
|
||||
setFeedback(`已改用${target.name}。请核对新的知识库分类,再按顺序生成。`)
|
||||
} catch (reason: unknown) { setError(localizeError(reason)) }
|
||||
finally { inFlight.current = false; setSaving(false); onBusyChange(false); setSelection(null) }
|
||||
}
|
||||
const loadVersions = async () => {
|
||||
if (loadingVersions) return
|
||||
setLoadingVersions(true); setError('')
|
||||
try { setVersions(await api.get<PreviousVersion[]>(`/campaigns/${campaignId}/segments/${segment.id}/versions`)) }
|
||||
catch (reason: unknown) { setError(localizeError(reason)) }
|
||||
finally { setLoadingVersions(false) }
|
||||
}
|
||||
return <div className="seg-section">
|
||||
<label>执行智能体 {segment.agent_assignment_mode === 'manual' && <span className="badge gold">手动指定</span>}
|
||||
<select aria-label={`更换「${segment.title}」的执行智能体`} value={selection ?? String(segment.agent_id)} disabled={!editable || disabled || saving} onChange={event => void change(event.target.value)}>
|
||||
{!options.some(option => option.id === segment.agent_id) && <option value={segment.agent_id} disabled>{segment.agent_name || '未分配智能体'}(当前不可选)</option>}
|
||||
{options.map(option => <option key={option.id} value={option.id}>{option.name}{option.content_tags?.length ? ` · ${option.content_tags.join(' / ')}` : ''}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<p className="field-help">{saving ? '正在更换智能体…' : editable ? '可以直接更换。手动选择后,修改标签不会覆盖你的选择;生成仍沿用本段导图并承接前文。' : '运行中、已交付或已跳过的内容段不可更换智能体。'}</p>
|
||||
{feedback && <p role="status">{feedback}</p>}
|
||||
{error && <p className="err" role="alert">{error}</p>}
|
||||
{!!segment.previous_version_count && <details onToggle={event => { if (event.currentTarget.open && versions === null) void loadVersions() }}>
|
||||
<summary>历史正文 · {segment.previous_version_count} 个版本</summary>
|
||||
<p className="dim">用于对比修改,不参与当前正文的衔接或最终成稿。</p>
|
||||
{loadingVersions ? <p role="status">正在读取历史正文…</p> : versions?.map((version, index) => <details key={`${version.round}-${index}`}>
|
||||
<summary>{version.agent_name} · 第 {version.round} 轮 · {version.archived_at}</summary>
|
||||
<p>{version.reason}</p><pre className="art">{version.artifact || '该版本尚未生成正文。'}</pre>
|
||||
</details>)}
|
||||
</details>}
|
||||
</div>
|
||||
}
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
const BASE = '/api'
|
||||
const TOKEN_KEY = 'yawan-workbench-access-token'
|
||||
|
||||
type JsonBody = Record<string, unknown> | unknown[] | string | number | boolean | null
|
||||
type RequestOptions = Omit<RequestInit, 'body'> & { body?: JsonBody }
|
||||
|
||||
function errorMessage(payload: unknown, fallback: string) {
|
||||
if (typeof payload === 'string' && payload.trim()) return localizeError(payload)
|
||||
if (payload && typeof payload === 'object') {
|
||||
const detail = (payload as { detail?: unknown }).detail
|
||||
if (typeof detail === 'string' && detail.trim()) return localizeError(detail)
|
||||
const message = (payload as { message?: unknown }).message
|
||||
if (typeof message === 'string' && message.trim()) return localizeError(message)
|
||||
}
|
||||
return localizeError(fallback)
|
||||
}
|
||||
|
||||
export function localizeError(value: unknown): string {
|
||||
const raw = typeof value === 'string' ? value : value instanceof Error ? value.message : ''
|
||||
const text = raw.trim()
|
||||
if (!text) return '请求失败,请稍后重试'
|
||||
if (/[\u3400-\u9fff]/.test(text)) {
|
||||
return text
|
||||
.replace(/Internal Server Error/gi, '服务器内部错误')
|
||||
.replace(/Bad Gateway/gi, '上游服务暂时不可用')
|
||||
.replace(/Service Unavailable/gi, '服务暂时不可用')
|
||||
.replace(/Gateway Timeout/gi, '上游服务响应超时')
|
||||
.replace(/Connection refused/gi, '连接被拒绝')
|
||||
.replace(/IP_REGION_RESTRICTED/gi, '该服务拒绝了当前网络地区')
|
||||
.replace(/Connection error/gi, '连接失败')
|
||||
.replace(/APIConnectionError/gi, '连接失败')
|
||||
.replace(/timed? ?out/gi, '请求超时')
|
||||
.replace(/request failed/gi, '请求失败')
|
||||
.replace(/model not found/gi, '模型不存在')
|
||||
.replace(/not found/gi, '资源不存在')
|
||||
.replace(/invalid parameter/gi, '参数不正确')
|
||||
.replace(/unsupported/gi, '暂不支持')
|
||||
.replace(/rejected/gi, '被拒绝')
|
||||
.replace(/failed/gi, '失败')
|
||||
.replace(/error/gi, '错误')
|
||||
}
|
||||
const normalized = text.toLowerCase()
|
||||
if (normalized.includes('failed to fetch') || normalized.includes('networkerror')) return '网络连接失败,请确认服务已启动'
|
||||
if (normalized.includes('internal server error') || normalized === '500') return '服务器内部错误,请查看运行日志'
|
||||
if (normalized.includes('bad gateway')) return '上游服务暂时不可用,请稍后重试'
|
||||
if (normalized.includes('service unavailable')) return '服务暂时不可用,请稍后重试'
|
||||
if (normalized.includes('gateway timeout') || normalized.includes('timeout')) return '请求超时,请稍后重试'
|
||||
if (normalized.includes('not found') || normalized.includes('does not exist')) return '请求的资源不存在或已下线'
|
||||
if (normalized.includes('unauthorized') || normalized.includes('authentication')) return '登录状态已失效,请重新登录'
|
||||
if (normalized.includes('forbidden') || normalized.includes('permission')) return '当前账号没有执行此操作的权限'
|
||||
if (normalized.includes('rate limit') || normalized.includes('too many requests')) return '请求过于频繁,请稍后重试'
|
||||
if (normalized.includes('model')) return '模型调用失败,请检查模型配置和余额'
|
||||
if (normalized.includes('provider')) return '模型服务连接失败,请检查 Provider 配置'
|
||||
if (normalized.includes('rejected')) return '模型拒绝了本次请求,请检查输入内容'
|
||||
if (normalized.includes('invalid') || normalized.includes('bad request')) return '请求参数不正确,请检查填写内容'
|
||||
if (normalized.includes('failed') || normalized.includes('error')) return '操作失败,请查看运行日志'
|
||||
return `操作失败:${text}`
|
||||
}
|
||||
|
||||
async function j<T = unknown>(url: string, options: RequestOptions = {}): Promise<T> {
|
||||
const { body, headers, ...rest } = options
|
||||
const requestBody = body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body)
|
||||
const token = localStorage.getItem(TOKEN_KEY)
|
||||
const response = await fetch(BASE + url, {
|
||||
...rest,
|
||||
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...headers },
|
||||
body: requestBody,
|
||||
})
|
||||
|
||||
let payload: unknown = null
|
||||
try { payload = await response.json() } catch { /* 非 JSON 响应交给统一错误处理 */ }
|
||||
if (!response.ok) throw new Error(errorMessage(payload, `${response.status} ${response.statusText}`))
|
||||
return payload as T
|
||||
}
|
||||
|
||||
/** 分块上传大文件:>thresholdMB 时切成 chunkSizeMB 块逐块传,服务端拼装。 */
|
||||
export async function chunkedUpload(urlBase: string, file: File,
|
||||
opts: { purpose?: string; fields?: Record<string, string>;
|
||||
thresholdMB?: number; chunkSizeMB?: number;
|
||||
onProgress?: (pct: number, phase: 'upload' | 'process') => void } = {}) {
|
||||
const threshold = (opts.thresholdMB ?? 80) * 1024 * 1024
|
||||
if (file.size <= threshold) {
|
||||
const fd = new FormData()
|
||||
fd.append('files', file, file.name)
|
||||
for (const [k, v] of Object.entries(opts.fields || {})) fd.append(k, v)
|
||||
const token2 = localStorage.getItem(TOKEN_KEY)
|
||||
const r = await fetch(`${BASE}${urlBase}`, { method: 'POST', headers: token2 ? { Authorization: `Bearer ${token2}` } : {}, body: fd })
|
||||
.then(async resp => { const d = await resp.json(); if (!resp.ok) throw new Error(d.detail || '上传失败'); return d })
|
||||
return r
|
||||
}
|
||||
const token = localStorage.getItem(TOKEN_KEY)
|
||||
const headers = token ? { Authorization: `Bearer ${token}` } : {}
|
||||
// BASE already contains the /api prefix; avoid generating /api/api/... URLs.
|
||||
const init = await fetch(`${BASE}/uploads/init`, {
|
||||
method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ filename: file.name, size: file.size, purpose: opts.purpose || 'kb',
|
||||
// The server needs the target KB and distill form fields at finish time.
|
||||
fields: opts.fields || {},
|
||||
kb_id: opts.fields?.kb_id || undefined }),
|
||||
}).then(async r => {
|
||||
const d = await r.json()
|
||||
if (!r.ok) throw new Error(d.detail || '初始化上传失败')
|
||||
return d
|
||||
})
|
||||
const { upload_id: uploadId, chunk_size: chunkSize } = init
|
||||
const total = Math.ceil(file.size / chunkSize)
|
||||
for (let i = 0; i < total; i++) {
|
||||
const blob = file.slice(i * chunkSize, (i + 1) * chunkSize)
|
||||
let ok = false
|
||||
for (let attempt = 1; attempt <= 3 && !ok; attempt++) {
|
||||
const fd = new FormData()
|
||||
fd.append('data', blob, `${file.name}.part${i}`)
|
||||
const resp = await fetch(`${BASE}/uploads/${uploadId}/chunk/${i}`, {
|
||||
method: 'POST', headers, body: fd,
|
||||
})
|
||||
ok = resp.ok
|
||||
if (!ok && attempt === 3) throw new Error(`第 ${i + 1}/${total} 块上传失败(已重试 3 次)`)
|
||||
if (!ok) await new Promise(r => setTimeout(r, 1500 * attempt))
|
||||
}
|
||||
opts.onProgress?.(Math.round(((i + 1) / total) * 100), 'upload')
|
||||
}
|
||||
const fin = await fetch(`${BASE}/uploads/${uploadId}/finish`, {
|
||||
method: 'POST', headers,
|
||||
}).then(async r => {
|
||||
const d = await r.json()
|
||||
if (!r.ok) throw new Error(d.detail || '完成上传失败')
|
||||
return d
|
||||
})
|
||||
opts.onProgress?.(100, 'process')
|
||||
return fin
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T = unknown>(url: string) => j<T>(url),
|
||||
post: <T = unknown>(url: string, body?: JsonBody) => j<T>(url, { method: 'POST', body: body ?? {} }),
|
||||
put: <T = unknown>(url: string, body: JsonBody) => j<T>(url, { method: 'PUT', body }),
|
||||
patch: <T = unknown>(url: string, body: JsonBody) => j<T>(url, { method: 'PATCH', body }),
|
||||
delete: <T = unknown>(url: string) => j<T>(url, { method: 'DELETE' }),
|
||||
download: async (url: string) => {
|
||||
const token = localStorage.getItem(TOKEN_KEY)
|
||||
const response = await fetch(BASE + url, { headers: token ? { Authorization: `Bearer ${token}` } : {} })
|
||||
if (!response.ok) {
|
||||
const payload: unknown = await response.json().catch(() => null)
|
||||
throw new Error(errorMessage(payload, `${response.status} ${response.statusText}`))
|
||||
}
|
||||
const blob = await response.blob()
|
||||
const disposition = response.headers.get('Content-Disposition') || ''
|
||||
const filename = disposition.match(/filename="?([^";]+)"?/i)?.[1] || 'export'
|
||||
const href = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = href; anchor.download = filename; anchor.click()
|
||||
URL.revokeObjectURL(href)
|
||||
},
|
||||
upload: (url: string, files: FileList, fields: Record<string, string> = {}, onProgress?: (pct: number) => void) => {
|
||||
const form = new FormData()
|
||||
Array.from(files).forEach(file => form.append('files', file))
|
||||
Object.entries(fields).forEach(([key, value]) => form.append(key, value))
|
||||
const token = localStorage.getItem(TOKEN_KEY)
|
||||
if (!onProgress) {
|
||||
return fetch(BASE + url, { method: 'POST', headers: token ? { Authorization: `Bearer ${token}` } : {}, body: form }).then(async response => {
|
||||
const payload: unknown = await response.json().catch(() => null)
|
||||
if (!response.ok) throw new Error(errorMessage(payload, `${response.status} ${response.statusText}`))
|
||||
return payload
|
||||
})
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.open('POST', BASE + url)
|
||||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
|
||||
xhr.upload.onprogress = e => { if (e.lengthComputable) onProgress(Math.round(e.loaded * 100 / e.total)) }
|
||||
xhr.onload = () => {
|
||||
const payload: unknown = JSON.parse(xhr.responseText || 'null')
|
||||
if (xhr.status >= 200 && xhr.status < 300) resolve(payload)
|
||||
else reject(new Error(errorMessage(payload, `${xhr.status} ${xhr.statusText}`)))
|
||||
}
|
||||
xhr.onerror = () => reject(new Error('网络错误'))
|
||||
xhr.send(form)
|
||||
})
|
||||
},
|
||||
uploadBlob: (url: string, blob: Blob, filename: string, fields: Record<string, string> = {}, onProgress?: (pct: number) => void) => {
|
||||
const form = new FormData()
|
||||
form.append('file', blob, filename)
|
||||
Object.entries(fields).forEach(([key, value]) => form.append(key, value))
|
||||
const token = localStorage.getItem(TOKEN_KEY)
|
||||
if (!onProgress) {
|
||||
return fetch(BASE + url, { method: 'POST', headers: token ? { Authorization: `Bearer ${token}` } : {}, body: form }).then(async response => {
|
||||
const payload: unknown = await response.json().catch(() => null)
|
||||
if (!response.ok) throw new Error(errorMessage(payload, `${response.status} ${response.statusText}`))
|
||||
return payload
|
||||
})
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.open('POST', BASE + url)
|
||||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
|
||||
xhr.upload.onprogress = e => { if (e.lengthComputable) onProgress(Math.round(e.loaded * 100 / e.total)) }
|
||||
xhr.onload = () => {
|
||||
const payload: unknown = JSON.parse(xhr.responseText || 'null')
|
||||
if (xhr.status >= 200 && xhr.status < 300) resolve(payload)
|
||||
else reject(new Error(errorMessage(payload, `${xhr.status} ${xhr.statusText}`)))
|
||||
}
|
||||
xhr.onerror = () => reject(new Error('网络错误'))
|
||||
xhr.send(form)
|
||||
})
|
||||
},
|
||||
setToken: (token: string) => localStorage.setItem(TOKEN_KEY, token),
|
||||
clearToken: () => localStorage.removeItem(TOKEN_KEY),
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
#root .balance-menu{position:relative;flex-shrink:0}
|
||||
#root .balance-trigger{display:flex;align-items:center;gap:7px;min-height:36px;margin:0;padding:7px 10px;border:1px solid #b9cde1;border-radius:8px;background:#edf5ff;color:#174777;font-size:13px;white-space:nowrap}
|
||||
#root .balance-trigger strong{font-variant-numeric:tabular-nums;max-width:135px;overflow:hidden;text-overflow:ellipsis}
|
||||
#root .balance-trigger:hover,#root .balance-trigger[aria-expanded=true]{background:#dfedfd;border-color:#2868a8}
|
||||
#root .balance-trigger:focus-visible{outline:2px solid #2868a8;outline-offset:3px}
|
||||
#root .balance-panel{position:absolute;right:0;top:calc(100% + 10px);width:320px;max-width:calc(100vw - 32px);z-index:100;padding:20px;background:#fff;border:1px solid #cdd9e5;border-radius:12px;box-shadow:0 12px 36px #233b5624;color:#263748}
|
||||
#root .balance-panel>div{display:flex;align-items:center;justify-content:space-between;gap:12px}
|
||||
#root .balance-panel .mini{display:flex;align-items:center;gap:5px;margin:0}
|
||||
#root .balance-panel p{margin:12px 0 0;font-size:13px;line-height:1.7}
|
||||
#root .balance-panel .balance-amount{font-size:28px;font-weight:650;color:#174777;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}
|
||||
#root .balance-panel .balance-amount small{font-size:13px;font-weight:400}
|
||||
#root .balance-help{color:#607082}
|
||||
@media(max-width:600px){#root .balance-trigger{gap:4px;padding:7px;font-size:12px}#root .balance-unit{display:none}#root .balance-trigger strong{max-width:95px}#root .topbar .environment-badge{display:none}}
|
||||
@@ -0,0 +1,13 @@
|
||||
/* Batch 3 management UX: readable controls, consistent states, brand-safe geometry */
|
||||
:root { --r: 8px; --r-sm: 6px; }
|
||||
#root { font-size: 14px; }
|
||||
#root :where(p,span,label,button,input,select,textarea,td,th,li,a) { font-size: max(14px, 1em); }
|
||||
#root :where(.dim,.field-help,.form-kicker,.badge,.chip,.mini,.err,.warn,.toolbar-search input) { font-size: 14px; }
|
||||
#root :where(button,input,select,textarea,.card,.row,.panel,.workspace-onboarding,.side-drawer,.drawer-mask) { border-radius: 8px; }
|
||||
#root :where(.badge,.chip,.mini) { border-radius: 6px; }
|
||||
#root :where(input,select,textarea) { min-height: 40px; }
|
||||
#root :where(button) { min-height: 40px; }
|
||||
#root .mini { min-height: 34px; padding: 6px 10px; }
|
||||
#root .empty-workspace,.empty-workspace.compact { min-height: 120px; }
|
||||
#root .err,#root .warn { line-height: 1.55; }
|
||||
@media (max-width: 600px) { #root .grid2 { grid-template-columns: minmax(0,1fr); } #root .page-toolbar { flex-wrap: wrap; } }
|
||||
@@ -0,0 +1,131 @@
|
||||
/* 全局常量:状态色、状态文案、运行时标签、导航元数据、规则模板(从 App.tsx 抽出) */
|
||||
|
||||
export const STATUS_COLOR: Record<string, string> = {
|
||||
pending: '#8a8f98', running: '#3b82f6', qc: '#eab308', human_review: '#a855f7',
|
||||
approved: '#22c55e', rework: '#f97316', failed: '#ef4444', skipped: '#6b7280',
|
||||
draft: '#8a8f98', parsed: '#4568cf', composing_ready: '#a855f7', done: '#238763',
|
||||
parsing: '#eab308', parse_failed: '#ef4444', planning: '#eab308', plan_review: '#a855f7', plan_failed: '#ef4444', waiting_review: '#a855f7', superseded: '#8a8f98', composing: '#eab308',
|
||||
queued: '#64748b', awaiting_review: '#a855f7', completed: '#238763', cancelled: '#64748b',
|
||||
interrupted: '#f97316', waiting_approval: '#eab308',
|
||||
active: '#238763', disabled: '#8a8f98', enabled: '#238763', void: '#8a8f98', indexed: '#238763',
|
||||
untested: '#8a8f98', supported: '#238763', partial: '#eab308',
|
||||
blocked: '#d75555', review: '#a855f7', rejected: '#d75555',
|
||||
PASS: '#238763', FAIL: '#d75555', UNKNOWN: '#8a8f98',
|
||||
}
|
||||
export const STATUS_LABEL: Record<string, string> = {
|
||||
pending: '待派发', running: '生成中', qc: '质检中', human_review: '待审查',
|
||||
approved: '已通过', rework: '重做', failed: '失败', skipped: '跳过',
|
||||
draft: '草稿', parsed: '待生成', composing_ready: '可合成', done: '已交付',
|
||||
parsing: '拆解中', parse_failed: '拆解失败', planning: 'DSH 规划中', plan_review: '待计划审批', plan_failed: '计划校验失败', waiting_review: '待人工审批', superseded: '已被新版本替代', composing: '成稿组合中',
|
||||
PASS: '通过', FAIL: '存在问题', UNKNOWN: '待检查', ok: '成功', error: '错误', rejected: '被拒绝', passed: '通过',
|
||||
queued: '排队中', awaiting_review: '等待审查', completed: '已完成', cancelled: '已取消',
|
||||
interrupted: '等待恢复', waiting_approval: '等待审批',
|
||||
active: '启用', disabled: '停用', enabled: '已启用', void: '已作废', indexed: '已索引',
|
||||
untested: '未测试', supported: '已支持', partial: '部分支持',
|
||||
blocked: '已阻止', review: '待复核',
|
||||
measured: '已计量', estimated: '估算', settled: '已结算', unbilled: '未计费',
|
||||
indexing: '索引中', ready: '已就绪', needs_reindex: '待重建索引', empty: '空库',
|
||||
unlinked: '未关联任务', cancelling: '停止中',
|
||||
}
|
||||
export const KB_STATUS_LABEL: Record<string, string> = {
|
||||
empty: '空库', indexing: '索引中', ready: '已就绪', needs_reindex: '待重建索引', error: '索引异常',
|
||||
}
|
||||
export const CLASSIFICATION_STATUS_LABEL: Record<string, string> = {
|
||||
unclassified: '未分类', ai_suggested: 'AI 已建议', human_confirmed: '人工已确认',
|
||||
}
|
||||
|
||||
export const RUNTIME_EVENT_LABEL: Record<string, string> = {
|
||||
runtime_started: '会话启动', runtime_finished: '会话结束', guardrail_input: '输入护栏', guardrail_output: '输出护栏',
|
||||
model_started: '模型请求', model_finished: '模型返回', tool_started: '工具开始', tool_finished: '工具完成',
|
||||
tool_approval_requested: '等待工具审批', segment_finished: '内容段完成', run_started: '任务启动', run_finished: '任务结束', run_queued: '已排队', run_failed: '任务异常',
|
||||
}
|
||||
export const RUNTIME_PHASE_LABEL: Record<string, string> = {
|
||||
coordinator: '任务拆解', production: '内容生产', qc: '质量检查', qc_structure: '结构质检', qc_style: '风格质检',
|
||||
composer: '成稿组合', handoff: '融合协作',
|
||||
}
|
||||
export const RUNTIME_TOOL_LABEL: Record<string, string> = {
|
||||
knowledge_search: '知识检索', read_upstream_anchors: '读取上游锚点', read_agent_memory: '读取 Agent 记忆',
|
||||
request_handoff: '融合 Agent 协作', web_search: '联网搜索', web_collect: '网页采集',
|
||||
}
|
||||
|
||||
export const PAGE_META: Record<string, { eyebrow: string; subtitle: string }> = {
|
||||
overview: { eyebrow: 'YAWEN CONTENT OPS', subtitle: '统一查看内容任务、资源状态与待处理事项' },
|
||||
tasks: { eyebrow: 'CONTENT DELIVERY', subtitle: '查看生成进度,审查每段内容并领取最终成稿' },
|
||||
canvas: { eyebrow: 'AGENT ORCHESTRATION', subtitle: '配置 Agent 的协作顺序与依赖关系' },
|
||||
agents: { eyebrow: 'AGENT REGISTRY', subtitle: '用规则模板建立生产、质检与组合 Agent' },
|
||||
skills: { eyebrow: 'AGENT SKILLS', subtitle: '沉淀可复用执行方法,并按需挂接到 Agent' },
|
||||
tags: { eyebrow: 'CONTENT TAXONOMY', subtitle: '定义 Agent 可以接收和处理的内容类型' },
|
||||
models: { eyebrow: 'MODEL PROVIDERS', subtitle: '管理模型连接与默认调用配置' },
|
||||
kbs: { eyebrow: 'KNOWLEDGE SOURCES', subtitle: '导入素材、预览原文并验证检索内容' },
|
||||
memories: { eyebrow: 'AGENT MEMORY', subtitle: '按智能体管理已生效和待确认记忆' },
|
||||
'prompt-templates': { eyebrow: 'PROMPT OPERATIONS', subtitle: '用版本化模板组织角色、任务、示例与结构化输出' },
|
||||
logs: { eyebrow: 'RUN HISTORY', subtitle: '追踪模型调用、异常通知与待确认记忆' },
|
||||
usage: { eyebrow: 'USAGE & BILLING', subtitle: '查看 AI 调用、知识检索与 OCR 用量及账单明细' },
|
||||
ops: { eyebrow: 'OPERATIONS', subtitle: '队列、成本、知识库质量与系统告警一屏总览' },
|
||||
access: { eyebrow: 'ACCESS CONTROL', subtitle: '管理本地成员、角色与访问边界' },
|
||||
}
|
||||
|
||||
export const RULE_TEMPLATES = [
|
||||
{
|
||||
id: 'production', label: '内容生产', role: 'production', desc: '根据任务单生成可直接播出的内容段。', rules: `# 内容生产规则
|
||||
|
||||
## 目标
|
||||
- 只围绕任务单中的标题、时间范围和导图节点写作。
|
||||
- 输出可直接播出的自然口语稿,不输出分析过程。
|
||||
|
||||
## 写作要求
|
||||
- 先说明为什么要讲,再给方法、案例或互动。
|
||||
- 用大白话写成一段到底的口语稿,不空行、不分小标题;必要的表演提示用括号内联。
|
||||
- 只引用已挂接知识库中与任务相关的素材;没有资料时不得编造。
|
||||
- 系统会把本 Agent 已成功使用的知识片段标记为已消费;同一 Agent 后续不会再取得该片段。需要复用时,由管理员在知识库内逐条重新开放。
|
||||
|
||||
## 收尾
|
||||
- 只衔接任务单中的下一段;没有下一场主题时写“待确认:下一场主题”。
|
||||
- 正文后按系统要求输出完整锚点卡。`,
|
||||
},
|
||||
{
|
||||
id: 'structure-qc', label: '结构质检', role: 'qc', desc: '检查内容是否完整、顺序是否正确。', rules: `# 结构质检规则
|
||||
|
||||
## 检查项
|
||||
- 时间范围、标题和正文是否对应。
|
||||
- 是否残留提纲、说明文字或无关结构。
|
||||
- 是否有明显断句、重复、遗漏或逻辑跳跃。
|
||||
|
||||
## 输出
|
||||
- 逐项给出 PASS 或 FAIL。
|
||||
- FAIL 必须说明位置、问题和可执行的修改建议。`,
|
||||
},
|
||||
{
|
||||
id: 'style-qc', label: '风格质检', role: 'qc', desc: '检查语气、口播感和品牌表达。', rules: `# 风格质检规则
|
||||
|
||||
## 检查项
|
||||
- 语言是否自然、口语化,适合直播朗读。
|
||||
- 语气是否与当前任务和受众一致。
|
||||
- 是否出现生硬术语、无关人设或不一致的表达。
|
||||
|
||||
## 输出
|
||||
- 只列出需要处理的问题;没有问题时明确 PASS。`,
|
||||
},
|
||||
{
|
||||
id: 'coordinator', label: '任务拆解', role: 'coordinator', desc: '将导图拆成可派发、可审查的内容任务。', rules: `# 任务拆解规则
|
||||
|
||||
## 目标
|
||||
- 忠实转录导图,不补写导图未提供的主题、产品或日期。
|
||||
- 每段只承担一个清楚的内容目标,并给出时间范围和内容标签。
|
||||
|
||||
## 输出
|
||||
- 标注不确定信息为待确认项,不猜测。
|
||||
- 保证所有任务段覆盖原导图,且顺序可执行。`,
|
||||
},
|
||||
{
|
||||
id: 'composer', label: '成稿组合', role: 'composer', desc: '拼接已通过段落并检查全文一致性。', rules: `# 成稿组合规则
|
||||
|
||||
## 目标
|
||||
- 只组合已经人工通过的内容段。
|
||||
- 检查跨段概念、时间线、回指和情绪曲线是否一致。
|
||||
|
||||
## 输出
|
||||
- 有问题时定位到具体内容段并给出修改建议。
|
||||
- 全部通过后输出适合交付的完整文稿。`,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,114 @@
|
||||
import { createContext, useCallback, useContext, useState, useEffect, useRef } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
type DialogTone = 'default' | 'danger' | 'success'
|
||||
|
||||
type BaseOptions = {
|
||||
title: string
|
||||
description?: string
|
||||
confirmLabel?: string
|
||||
tone?: DialogTone
|
||||
}
|
||||
|
||||
type PromptOptions = BaseOptions & {
|
||||
inputLabel?: string
|
||||
placeholder?: string
|
||||
defaultValue?: string
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
type DialogKind = 'confirm' | 'prompt' | 'notice'
|
||||
type ActiveDialog = PromptOptions & {
|
||||
kind: DialogKind
|
||||
resolve: (value: string | null) => void
|
||||
}
|
||||
|
||||
type DialogApi = {
|
||||
confirm: (options: BaseOptions) => Promise<boolean>
|
||||
prompt: (options: PromptOptions) => Promise<string | null>
|
||||
notice: (options: BaseOptions) => Promise<void>
|
||||
}
|
||||
|
||||
const DialogContext = createContext<DialogApi | null>(null)
|
||||
|
||||
export function DialogProvider({ children }: { children: ReactNode }) {
|
||||
const [active, setActive] = useState<ActiveDialog | null>(null)
|
||||
const [value, setValue] = useState('')
|
||||
|
||||
const open = useCallback((kind: DialogKind, options: PromptOptions) => new Promise<string | null>(resolve => {
|
||||
setValue(options.defaultValue || '')
|
||||
setActive({ ...options, kind, resolve })
|
||||
}), [])
|
||||
|
||||
const close = useCallback((result: string | null) => {
|
||||
setActive(current => {
|
||||
current?.resolve(result)
|
||||
return null
|
||||
})
|
||||
}, [])
|
||||
|
||||
const dialogRef = useRef<HTMLElement>(null)
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
const previous = document.activeElement as HTMLElement | null
|
||||
const panel = dialogRef.current
|
||||
const focusables = () => Array.from(panel?.querySelectorAll<HTMLElement>('button:not(:disabled), textarea:not(:disabled), input:not(:disabled), [tabindex="0"]') || [])
|
||||
;(focusables()[0] || panel)?.focus()
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); close(null); return }
|
||||
if (event.key !== 'Tab') return
|
||||
const items = focusables()
|
||||
if (!items.length) { event.preventDefault(); panel?.focus(); return }
|
||||
const first = items[0], last = items[items.length - 1]
|
||||
if (event.shiftKey && (document.activeElement === first || !panel?.contains(document.activeElement))) { event.preventDefault(); last.focus() }
|
||||
else if (!event.shiftKey && (document.activeElement === last || !panel?.contains(document.activeElement))) { event.preventDefault(); first.focus() }
|
||||
}
|
||||
document.addEventListener('keydown', onKey, true)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey, true)
|
||||
if (previous?.isConnected) previous.focus()
|
||||
}
|
||||
}, [active, close])
|
||||
|
||||
const api: DialogApi = {
|
||||
confirm: options => open('confirm', options).then(result => result !== null),
|
||||
prompt: options => open('prompt', options),
|
||||
notice: options => open('notice', options).then(() => undefined),
|
||||
}
|
||||
|
||||
const confirmDisabled = active?.kind === 'prompt' && Boolean(active.required) && !value.trim()
|
||||
const confirmLabel = active?.confirmLabel || (active?.kind === 'notice' ? '知道了' : '确认')
|
||||
|
||||
return (
|
||||
<DialogContext.Provider value={api}>
|
||||
{children}
|
||||
{active && (
|
||||
<div className="dialog-backdrop" role="presentation" onMouseDown={event => {
|
||||
if (event.target === event.currentTarget) close(null)
|
||||
}}>
|
||||
<section ref={dialogRef} tabIndex={-1} className="app-dialog" role="dialog" aria-modal="true" aria-labelledby="app-dialog-title">
|
||||
<div className={`dialog-icon ${active.tone || 'default'}`}>{active.tone === 'danger' ? '!' : active.tone === 'success' ? '✓' : '?'}</div>
|
||||
<div className="dialog-copy">
|
||||
<h2 id="app-dialog-title">{active.title}</h2>
|
||||
{active.description && <p>{active.description}</p>}
|
||||
</div>
|
||||
{active.kind === 'prompt' && <label className="dialog-input-label">{active.inputLabel || '补充说明'}
|
||||
<textarea autoFocus rows={4} placeholder={active.placeholder} value={value} onChange={event => setValue(event.target.value)} />
|
||||
</label>}
|
||||
<div className="dialog-actions">
|
||||
{active.kind !== 'notice' && <button onClick={() => close(null)}>取消</button>}
|
||||
<button className={active.tone === 'danger' ? 'danger-button' : active.tone === 'success' ? 'ok' : ''}
|
||||
onClick={() => close(active.kind === 'prompt' ? value.trim() : 'confirmed')} disabled={confirmDisabled}>{confirmLabel}</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</DialogContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useDialog(): DialogApi {
|
||||
const dialog = useContext(DialogContext)
|
||||
if (!dialog) throw new Error('useDialog 必须在 DialogProvider 内使用')
|
||||
return dialog
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { lazy, Suspense, useEffect, useState, type ComponentType } from 'react'
|
||||
import { WorkspaceErrorBoundary } from './ui'
|
||||
|
||||
const DYNAMIC_IMPORT_RETRY_KEY = 'yawan.dynamic-import-retry'
|
||||
|
||||
/** Shared feedback for route/chunk waits. The second message only appears
|
||||
* after a short delay so fast navigation stays quiet while slower public
|
||||
* connections clearly show that the click was received. */
|
||||
export function PageLoading({ label = '正在打开页面…' }: { label?: string }) {
|
||||
const [slow, setSlow] = useState(false)
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => setSlow(true), 800)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [])
|
||||
return <div className="route-loading" role="status" aria-live="polite" aria-busy="true">
|
||||
<span className="route-loading-spinner" aria-hidden="true" />
|
||||
<strong>{slow ? '正在加载页面数据,请稍候…' : label}</strong>
|
||||
<span className="route-loading-hint">{slow ? '公网连接较慢,页面会自动继续加载,请不要重复点击。' : '正在准备页面资源'}</span>
|
||||
<span className="route-loading-bar" aria-hidden="true"><i /></span>
|
||||
</div>
|
||||
}
|
||||
|
||||
function loadWithRecovery<P extends object>(load: () => Promise<{ default: ComponentType<P> }>) {
|
||||
return load().catch(error => {
|
||||
// 发布切换时浏览器可能拿到旧入口并请求已经更新的 chunk。让当前标签页
|
||||
// 自动重新协商一次入口,避免用户在每个懒加载页面上手动刷新。
|
||||
const message = String(error?.message || error || '')
|
||||
if (/dynamically imported module|imported module|加载页面/i.test(message)) {
|
||||
try {
|
||||
if (!sessionStorage.getItem(DYNAMIC_IMPORT_RETRY_KEY)) {
|
||||
sessionStorage.setItem(DYNAMIC_IMPORT_RETRY_KEY, '1')
|
||||
const hash = window.location.hash
|
||||
const query = `?asset_retry=${Date.now()}`
|
||||
window.location.replace(`${window.location.pathname}${query}${hash}`)
|
||||
return new Promise<{ default: ComponentType<P> }>(() => {})
|
||||
}
|
||||
sessionStorage.removeItem(DYNAMIC_IMPORT_RETRY_KEY)
|
||||
} catch { /* 隐私模式或受限存储时交给错误边界处理 */ }
|
||||
}
|
||||
throw error
|
||||
}).then(module => {
|
||||
try { sessionStorage.removeItem(DYNAMIC_IMPORT_RETRY_KEY) } catch { /* ignore */ }
|
||||
return module
|
||||
})
|
||||
}
|
||||
|
||||
export function lazyRoute<P extends object>(load: () => Promise<{ default: ComponentType<P> }>) {
|
||||
return function LazyRoute(props: P) {
|
||||
const [attempt, setAttempt] = useState(() => ({ Page: lazy(() => loadWithRecovery(load)), key: 0 }))
|
||||
const Page = attempt.Page as ComponentType<P>
|
||||
return <WorkspaceErrorBoundary key={attempt.key} onRetry={() => setAttempt(a => ({ Page: lazy(() => loadWithRecovery(load)), key: a.key + 1 }))}>
|
||||
<Suspense fallback={<PageLoading />}><Page {...props} /></Suspense>
|
||||
</WorkspaceErrorBoundary>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './AppRouter'
|
||||
import './style.css'
|
||||
import './workbench.css'
|
||||
import './batch3-overrides.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode><App /></React.StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
// 画布节点注册表(借鉴 Coze Studio:节点类型注册 + 表单元数据)
|
||||
// 每种节点角色一份 FormMeta:属性检查器按 meta 渲染表单,节点卡只显示摘要。
|
||||
export type NodeField = {
|
||||
key: string
|
||||
label: string
|
||||
type: 'text' | 'select' | 'switch' | 'tags'
|
||||
options?: { value: string; label: string }[]
|
||||
help?: string
|
||||
}
|
||||
|
||||
export interface NodeRegistryItem {
|
||||
type: string
|
||||
label: string
|
||||
summary: (agent: { name: string; content_tags?: string[]; model_label?: string }) => string
|
||||
fields: NodeField[]
|
||||
}
|
||||
|
||||
export const NODE_REGISTRY: Record<string, NodeRegistryItem> = {
|
||||
production: {
|
||||
type: 'production', label: '生产节点',
|
||||
summary: a => `${a.content_tags?.join('/') || '未设标签'} · ${a.model_label || '默认模型'}`,
|
||||
fields: [
|
||||
{ key: 'enabled', label: '启用节点', type: 'switch', help: '停用后任务跳过此节点' },
|
||||
{ key: 'agent_id', label: '执行 Agent', type: 'select', help: '决定这一段由谁生成' },
|
||||
],
|
||||
},
|
||||
qc: {
|
||||
type: 'qc', label: '质检节点',
|
||||
summary: a => `${a.name} · 每段自动执行`,
|
||||
fields: [{ key: 'enabled', label: '参与质检', type: 'switch' }],
|
||||
},
|
||||
coordinator: {
|
||||
type: 'coordinator', label: '主控节点',
|
||||
summary: a => `${a.name} · 导图拆解入口`,
|
||||
fields: [{ key: 'enabled', label: '启用', type: 'switch' }],
|
||||
},
|
||||
composer: {
|
||||
type: 'composer', label: '组合节点',
|
||||
summary: a => `${a.name} · 成稿合成`,
|
||||
fields: [{ key: 'enabled', label: '启用', type: 'switch' }],
|
||||
},
|
||||
}
|
||||
|
||||
export function registryFor(role: string): NodeRegistryItem {
|
||||
return NODE_REGISTRY[role] || NODE_REGISTRY.production
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
/* 页面组件:从 App.tsx 拆出(Rose Clay 重构) */
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api, localizeError } from '../api'
|
||||
import { useDialog } from '../dialog'
|
||||
import { Tag } from '../ui'
|
||||
import { OwnerLabel } from '../workbench-ui'
|
||||
import ResourceRevisionReview from './ResourceRevisionReview'
|
||||
import { STATUS_LABEL, KB_STATUS_LABEL, CLASSIFICATION_STATUS_LABEL, RUNTIME_EVENT_LABEL,
|
||||
RUNTIME_PHASE_LABEL, RUNTIME_TOOL_LABEL, PAGE_META, RULE_TEMPLATES } from '../constants'
|
||||
import type { Seg, Camp, CampaignState, RuntimeDetail, CampaignCreateResponse, ComposeResponse,
|
||||
AgentCreateResponse, AgentSummary, KnowledgeBaseSummary, NotificationSummary, AuthUser, AuthStatus,
|
||||
TraceSummary, TraceDetail, UsageSummary, UsageEventRow, PointState, PointAccountRow, UsageResponse,
|
||||
AdminUsageResponse, AgentListRow, MemoryListRow, KbFileRow, KbCategoryRow, KbDetailResponse, KbUsageResponse } from '../types'
|
||||
|
||||
|
||||
function errorText(error: unknown) {
|
||||
return localizeError(error)
|
||||
}
|
||||
|
||||
/* ---------------- 访问控制 ---------------- */
|
||||
export default function AccessPage() {
|
||||
const dialog = useDialog()
|
||||
const [users, setUsers] = useState<AuthUser[]>([])
|
||||
const [adminKbs, setAdminKbs] = useState<any[]>([])
|
||||
const [adminCanvases, setAdminCanvases] = useState<any[]>([])
|
||||
const [adminMemories, setAdminMemories] = useState<any[]>([])
|
||||
const [adminDigitalHumans, setAdminDigitalHumans] = useState<any[]>([])
|
||||
const [adminSkills, setAdminSkills] = useState<any[]>([])
|
||||
const [adminAgents, setAdminAgents] = useState<any[]>([])
|
||||
const [roles, setRoles] = useState<any[]>([])
|
||||
const [newRoleLabel, setNewRoleLabel] = useState('')
|
||||
const [newRoleDescription, setNewRoleDescription] = useState('')
|
||||
const [newRolePermissions, setNewRolePermissions] = useState<string[]>(['read'])
|
||||
// 资源授权批处理选择:键由资源类型与 id 组成,跨知识库、画布、记忆和数字人统一管理。
|
||||
const [selectedResources, setSelectedResources] = useState<string[]>([])
|
||||
const [selectedGrantUsers, setSelectedGrantUsers] = useState<string[]>([])
|
||||
const [bulkPermission, setBulkPermission] = useState('use')
|
||||
const [bulkBusy, setBulkBusy] = useState(false)
|
||||
const [bulkProgress, setBulkProgress] = useState<{ done: number; total: number; success: number; failure: number } | null>(null)
|
||||
|
||||
const [resetPasswords, setResetPasswords] = useState<Record<number, string>>({})
|
||||
const [busy, setBusy] = useState(false)
|
||||
const busyRef = useRef(false)
|
||||
const [tab, setTab] = useState<'members' | 'roles' | 'grants' | 'security'>('members')
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [createDraft, setCreateDraft] = useState({ username: '', displayName: '', password: '', role: 'viewer' })
|
||||
const [error, setError] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const userRows = await api.get<AuthUser[]>('/auth/users')
|
||||
setUsers(Array.isArray(userRows) ? userRows : [])
|
||||
}
|
||||
catch (requestError: unknown) { setError(errorText(requestError)) }
|
||||
try {
|
||||
const [kbs, canvases, memories, digitalHumans, skills, agents, roleRows] = await Promise.all([
|
||||
api.get<any[]>('/admin/knowledge-bases'), api.get<any[]>('/admin/canvases'),
|
||||
api.get<any[]>('/admin/memories'), api.get<any[]>('/admin/digital-humans'), api.get<any[]>('/admin/skills'), api.get<any[]>('/admin/agents'), api.get<any[]>('/auth/roles'),
|
||||
])
|
||||
setAdminKbs(Array.isArray(kbs) ? kbs.map(item => ({ ...item, grants: Array.isArray(item.grants) ? item.grants : [] })) : [])
|
||||
setAdminCanvases(Array.isArray(canvases) ? canvases.map(item => ({ ...item, nodes: Array.isArray(item.nodes) ? item.nodes : [], grants: Array.isArray(item.grants) ? item.grants : [] })) : [])
|
||||
setAdminMemories(Array.isArray(memories) ? memories : [])
|
||||
setAdminDigitalHumans(Array.isArray(digitalHumans) ? digitalHumans.map(item => ({ ...item, grants: Array.isArray(item.grants) ? item.grants : [] })) : [])
|
||||
setAdminSkills(Array.isArray(skills) ? skills.map(item => ({ ...item, grants: Array.isArray(item.grants) ? item.grants : [] })) : [])
|
||||
setAdminAgents(Array.isArray(agents) ? agents.map(item => ({ ...item, grants: Array.isArray(item.grants) ? item.grants : [] })) : [])
|
||||
setRoles(Array.isArray(roleRows) ? roleRows.map(item => ({ ...item, permissions: Array.isArray(item.permissions) ? item.permissions : [] })) : [])
|
||||
} catch (requestError: unknown) { setError(errorText(requestError)) }
|
||||
}
|
||||
useEffect(() => { refresh() }, [])
|
||||
const createRole = async () => {
|
||||
if (!newRoleLabel.trim()) { setError('请填写角色名称'); return }
|
||||
if (busyRef.current) return
|
||||
busyRef.current = true
|
||||
setBusy(true); setError('')
|
||||
try {
|
||||
await api.post('/auth/roles', { label: newRoleLabel.trim(), description: newRoleDescription.trim(), permissions: newRolePermissions })
|
||||
setNewRoleLabel(''); setNewRoleDescription(''); setNewRolePermissions(['read']); await refresh()
|
||||
} catch (requestError: unknown) { setError(errorText(requestError)) }
|
||||
finally { busyRef.current = false; setBusy(false) }
|
||||
}
|
||||
const deleteRole = async (roleRow: any) => {
|
||||
if (roleRow.builtin) { setError('系统内置角色不能删除'); return }
|
||||
if (busyRef.current) return
|
||||
busyRef.current = true
|
||||
setBusy(true); setError('')
|
||||
try { await api.delete(`/auth/roles/${encodeURIComponent(roleRow.role)}`); await refresh() }
|
||||
catch (requestError: unknown) { setError(errorText(requestError)) }
|
||||
finally { busyRef.current = false; setBusy(false) }
|
||||
}
|
||||
|
||||
const update = async (user: AuthUser, patch: Record<string, unknown>) => {
|
||||
if (busyRef.current) return
|
||||
busyRef.current = true
|
||||
setBusy(true); setError('')
|
||||
try { await api.put(`/auth/users/${user.id}`, patch); await refresh() }
|
||||
catch (requestError: unknown) { setError(errorText(requestError)) }
|
||||
finally { busyRef.current = false; setBusy(false) }
|
||||
}
|
||||
const reset = async (user: AuthUser) => {
|
||||
const nextPassword = resetPasswords[user.id] || ''
|
||||
if (nextPassword.length < 10) { setError('新密码至少需要 10 个字符'); return }
|
||||
if (busyRef.current) return
|
||||
busyRef.current = true
|
||||
setBusy(true); setError('')
|
||||
try { await api.post(`/auth/users/${user.id}/reset-password`, { password: nextPassword }); setResetPasswords(current => ({ ...current, [user.id]: '' })) }
|
||||
catch (requestError: unknown) { setError(errorText(requestError)) }
|
||||
finally { busyRef.current = false; setBusy(false) }
|
||||
}
|
||||
const deleteUser = async (user: AuthUser) => {
|
||||
if (user.username.startsWith('deleted_')) return
|
||||
if (!await dialog.confirm({
|
||||
title: `删除成员「${user.display_name || user.username}」?`,
|
||||
description: '账号将永久删除,现有会话和资源授权会立即失效;历史操作与用量记录仍会保留。',
|
||||
confirmLabel: '确认删除',
|
||||
tone: 'danger',
|
||||
})) return
|
||||
if (busyRef.current) return
|
||||
busyRef.current = true
|
||||
setBusy(true); setError('')
|
||||
try { await api.delete(`/auth/users/${user.id}`); await refresh() }
|
||||
catch (requestError: unknown) { setError(errorText(requestError)) }
|
||||
finally { busyRef.current = false; setBusy(false) }
|
||||
}
|
||||
const grant = async (kind: GrantKind, id: number, userId: string, permission: string) => {
|
||||
if (!userId) { setError('请选择要授权的成员'); return }
|
||||
if (busyRef.current) return
|
||||
busyRef.current = true
|
||||
setBusy(true); setError('')
|
||||
try { await api.post(`/admin/${kind}/${id}/grants`, { user_id: Number(userId), permission }); await refresh() }
|
||||
catch (requestError: unknown) { setError(errorText(requestError)) }
|
||||
finally { busyRef.current = false; setBusy(false) }
|
||||
}
|
||||
const revoke = async (kind: GrantKind, id: number, grantId: number) => {
|
||||
if (busyRef.current) return
|
||||
busyRef.current = true
|
||||
setBusy(true); setError('')
|
||||
try { await api.delete(`/admin/${kind}/${id}/grants/${grantId}`); await refresh() }
|
||||
catch (requestError: unknown) { setError(errorText(requestError)) }
|
||||
finally { busyRef.current = false; setBusy(false) }
|
||||
}
|
||||
const publishGlobal = async (kind: GrantKind, id: number) => {
|
||||
if (busyRef.current) return
|
||||
busyRef.current = true
|
||||
setBusy(true); setError('')
|
||||
try { await api.post(`/admin/${kind}/${id}/publish-global`, {}); await refresh() }
|
||||
catch (requestError: unknown) { setError(errorText(requestError)) }
|
||||
finally { busyRef.current = false; setBusy(false) }
|
||||
}
|
||||
const resourceKey = (kind: string, id: number) => `${kind}:${id}`
|
||||
const toggleResource = (kind: string, id: number) => {
|
||||
const key = resourceKey(kind, id)
|
||||
setSelectedResources(current => current.includes(key) ? current.filter(item => item !== key) : [...current, key])
|
||||
}
|
||||
const allResourceKeys = [
|
||||
...adminKbs.map(item => resourceKey('knowledge-bases', item.id)),
|
||||
...adminCanvases.map(item => resourceKey('canvases', item.id)),
|
||||
...adminMemories.map(item => resourceKey('memories', item.id)),
|
||||
...adminDigitalHumans.map(item => resourceKey('digital-humans', item.id)),
|
||||
...adminSkills.map(item => resourceKey('skills', item.id)),
|
||||
...adminAgents.map(item => resourceKey('agents', item.id)),
|
||||
]
|
||||
const toggleAllResources = () => setSelectedResources(current => current.length === allResourceKeys.length ? [] : allResourceKeys)
|
||||
const toggleResourceGroup = (kind: string, rows: any[]) => {
|
||||
const keys = rows.map(item => resourceKey(kind, item.id))
|
||||
setSelectedResources(current => keys.length && keys.every(key => current.includes(key))
|
||||
? current.filter(key => !keys.includes(key))
|
||||
: [...new Set([...current, ...keys])])
|
||||
}
|
||||
const toggleGrantUser = (id: string) => setSelectedGrantUsers(current => current.includes(id) ? current.filter(item => item !== id) : [...current, id])
|
||||
const grantableUsers = users.filter(user => user.enabled && user.role !== 'admin')
|
||||
const toggleAllGrantUsers = () => setSelectedGrantUsers(current => current.length === grantableUsers.length ? [] : grantableUsers.map(user => String(user.id)))
|
||||
const bulkGrant = async () => {
|
||||
if (!selectedResources.length || !selectedGrantUsers.length) { setError('请先选择资源和至少一位成员'); return }
|
||||
if ((bulkPermission === 'manage' || bulkPermission === 'export') && selectedResources.some(key => key.startsWith('digital-humans:'))) {
|
||||
setError('数字人仅支持“只读”或“可使用”;请调整权限后再批量授权'); return
|
||||
}
|
||||
if (busyRef.current) return
|
||||
busyRef.current = true; setBusy(true); setBulkBusy(true); setError(''); setNotice('')
|
||||
const failures: { key: string; detail: string }[] = []
|
||||
const names = new Map<string, string>()
|
||||
for (const item of [...adminKbs.map(item => ['knowledge-bases', item] as const), ...adminCanvases.map(item => ['canvases', item] as const), ...adminMemories.map(item => ['memories', item] as const), ...adminDigitalHumans.map(item => ['digital-humans', item] as const), ...adminSkills.map(item => ['skills', item] as const), ...adminAgents.map(item => ['agents', item] as const)]) names.set(resourceKey(item[0], item[1].id), item[1].name || item[1].agent_name || `#${item[1].id}`)
|
||||
const total = selectedResources.length * selectedGrantUsers.length; let done = 0; let success = 0
|
||||
setBulkProgress({ done, total, success, failure: 0 })
|
||||
// 串行提交,避免 SQLite 写锁争用;每个资源/成员失败会保留在选择中,便于重试。
|
||||
for (const key of selectedResources) {
|
||||
const [kind, rawId] = key.split(':'); const id = Number(rawId)
|
||||
for (const userId of selectedGrantUsers) {
|
||||
try { await api.post(`/admin/${kind}/${id}/grants`, { user_id: Number(userId), permission: bulkPermission }); success++ }
|
||||
catch (requestError: unknown) { failures.push({ key, detail: `${names.get(key) || `${kind} #${id}`} / 用户 ${users.find(user => String(user.id) === userId)?.display_name || userId}:${errorText(requestError)}` }) }
|
||||
done++; setBulkProgress({ done, total, success, failure: failures.length })
|
||||
}
|
||||
}
|
||||
busyRef.current = false; setBulkBusy(false); setBusy(false); await refresh()
|
||||
setSelectedResources(failures.length ? [...new Set(failures.map(item => item.key))] : []); if (!failures.length) setSelectedGrantUsers([])
|
||||
if (failures.length) setError(`批量授权完成 ${success}/${total} 项,${failures.length} 项失败:${failures.slice(0, 3).map(item => item.detail).join(';')}${failures.length > 3 ? ';…' : ''}`)
|
||||
else setNotice(`批量授权完成:${success} 项`)
|
||||
}
|
||||
const bulkPublish = async () => {
|
||||
if (!selectedResources.length) { setError('请先选择要发布的资源'); return }
|
||||
if (busyRef.current) return
|
||||
busyRef.current = true; setBusy(true); setBulkBusy(true); setError(''); setNotice(''); const failures: { key: string; detail: string }[] = []
|
||||
const names = new Map<string, string>()
|
||||
for (const item of [...adminKbs.map(item => ['knowledge-bases', item] as const), ...adminCanvases.map(item => ['canvases', item] as const), ...adminMemories.map(item => ['memories', item] as const), ...adminDigitalHumans.map(item => ['digital-humans', item] as const), ...adminSkills.map(item => ['skills', item] as const), ...adminAgents.map(item => ['agents', item] as const)]) names.set(resourceKey(item[0], item[1].id), item[1].name || item[1].agent_name || `#${item[1].id}`)
|
||||
const total = selectedResources.length; let done = 0; let success = 0; setBulkProgress({ done, total, success, failure: 0 })
|
||||
for (const key of selectedResources) {
|
||||
const [kind, rawId] = key.split(':'); const id = Number(rawId)
|
||||
try { await api.post(`/admin/${kind}/${id}/publish-global`, {}); success++ }
|
||||
catch (requestError: unknown) { failures.push({ key, detail: `${names.get(key) || `${kind} #${id}`}:${errorText(requestError)}` }) }
|
||||
done++; setBulkProgress({ done, total, success, failure: failures.length })
|
||||
}
|
||||
busyRef.current = false; setBulkBusy(false); setBusy(false); await refresh(); setSelectedResources(failures.length ? [...new Set(failures.map(item => item.key))] : [])
|
||||
if (failures.length) setError(`批量发布完成 ${success}/${total} 项,${failures.length} 项失败:${failures.slice(0, 3).map(item => item.detail).join(';')}${failures.length > 3 ? ';…' : ''}`)
|
||||
else setNotice(`批量发布完成:${success} 项`)
|
||||
}
|
||||
const exportResource = async (kind: GrantKind, id: number) => {
|
||||
if (busyRef.current) return
|
||||
busyRef.current = true
|
||||
setBusy(true); setError('')
|
||||
try { await api.download(`/admin/${kind}/${id}/export`) }
|
||||
catch (requestError: unknown) { setError(errorText(requestError)) }
|
||||
finally { busyRef.current = false; setBusy(false) }
|
||||
}
|
||||
const grantControls = (kind: GrantKind, resource: any) => {
|
||||
return <GrantControls kind={kind} resource={resource} users={users} busy={busy} onGrant={grant} onPublish={publishGlobal} />
|
||||
}
|
||||
const submitCreate = async () => {
|
||||
if (busyRef.current) return
|
||||
busyRef.current = true
|
||||
setBusy(true); setError('')
|
||||
try {
|
||||
const path = '/auth/users'
|
||||
await api.post(path, { username: createDraft.username, display_name: createDraft.displayName, password: createDraft.password, role: createDraft.role })
|
||||
setShowCreate(false); setCreateDraft({ username: '', displayName: '', password: '', role: 'viewer' })
|
||||
await refresh()
|
||||
} catch (e: unknown) { setError(errorText(e)) } finally { busyRef.current = false; setBusy(false) }
|
||||
}
|
||||
const tabs: { key: 'members' | 'roles' | 'grants' | 'security'; label: string; badge?: number }[] = [
|
||||
{ key: 'members', label: '成员', badge: users.length },
|
||||
{ key: 'roles', label: '角色与权限' },
|
||||
{ key: 'grants', label: '资源授权' },
|
||||
{ key: 'security', label: '登录与安全' },
|
||||
]
|
||||
return <div className="access-page">
|
||||
<div className="detail-tabs" role="tablist">
|
||||
{tabs.map(t => <button key={t.key} role="tab" aria-selected={tab === t.key} className={`detail-tab ${tab === t.key ? 'active' : ''}`} onClick={() => setTab(t.key)}>{t.label}{t.badge ? <span className="tab-badge">{t.badge}</span> : null}</button>)}
|
||||
</div>
|
||||
{error && <div className="err">{error}</div>}
|
||||
{notice && <div className="success-note" role="status">{notice}</div>}
|
||||
|
||||
{tab === 'members' && <>
|
||||
<div className="page-toolbar">
|
||||
<button className="primary-action" onClick={() => setShowCreate(true)}>+ 新增成员</button>
|
||||
<button className="mini" onClick={refresh} disabled={busy}>刷新</button>
|
||||
</div>
|
||||
<div className="access-user-list">{users.map(user => <div className="card access-user" key={user.id}>
|
||||
<div className="access-user-identity"><span className="avatar">{user.display_name.slice(0, 1) || user.username.slice(0, 1)}</span><div><b>{user.display_name}</b><span>{user.username} · 创建于 {user.created_at}</span></div></div>
|
||||
<select value={user.role} onChange={event => update(user, { role: event.target.value, enabled: user.enabled })} disabled={busy || user.username.startsWith('deleted_')}>{roles.map(roleRow => <option key={roleRow.role} value={roleRow.role}>{roleRow.label}</option>)}</select>
|
||||
<label className="inline-check"><input type="checkbox" checked={user.enabled} onChange={event => update(user, { role: user.role, enabled: event.target.checked })} disabled={busy || user.username.startsWith('deleted_')} />启用</label>
|
||||
<div className="access-reset"><input type="password" placeholder="重设密码" value={resetPasswords[user.id] || ''} onChange={event => setResetPasswords(current => ({ ...current, [user.id]: event.target.value }))} disabled={user.username.startsWith('deleted_')} /><button className="mini" onClick={() => reset(user)} disabled={busy || user.username.startsWith('deleted_') || !(resetPasswords[user.id] || '')}>重设</button></div>
|
||||
<button className="mini danger-text" onClick={() => void deleteUser(user)} disabled={busy || user.username.startsWith('deleted_')}>删除</button>
|
||||
</div>)}</div>
|
||||
<p className="field-help">停用成员会立即令其现有会话失效;删除成员会永久移除账号和资源授权,历史审计与用量记录仍会保留。系统始终保留至少一个启用管理员。重设密码请输入新密码后点「重设」(≥10 字符)。</p>
|
||||
</>}
|
||||
|
||||
{tab === 'roles' && <section className="role-permission-section">
|
||||
<div className="role-permission-grid">{roles.map(roleRow => <RolePermissionEditor key={roleRow.role} roleRow={roleRow} busy={busy} onSaved={refresh} onDelete={() => deleteRole(roleRow)} />)}</div>
|
||||
<div className="card role-create-card"><div><h3>新建自定义角色</h3><p>先按左侧功能栏选择功能,再勾选该功能允许的操作。</p></div><div className="role-create-fields"><input placeholder="角色名称,例如:内容主编" value={newRoleLabel} onChange={event => setNewRoleLabel(event.target.value)} /><input placeholder="角色说明(可选)" value={newRoleDescription} onChange={event => setNewRoleDescription(event.target.value)} /><PermissionCheckList selected={newRolePermissions} onToggle={permission => setNewRolePermissions(current => current.includes(permission) ? current.filter(item => item !== permission) : [...current, permission])} /><button className="ok" onClick={createRole} disabled={busy || !newRoleLabel.trim()}>创建角色</button></div></div>
|
||||
</section>}
|
||||
|
||||
{tab === 'grants' && <section className="resource-access-section">
|
||||
<div className="bulk-grant-toolbar" role="region" aria-label="批量资源授权">
|
||||
<div className="bulk-grant-heading"><b>批量授权与发布</b><span>跨分类选择资源后,一次授权给多位成员或发布全局</span>{bulkProgress && <small className="bulk-progress-text">处理中 {bulkProgress.done}/{bulkProgress.total} · 成功 {bulkProgress.success} · 失败 {bulkProgress.failure}</small>}</div>
|
||||
<label className="bulk-select-all"><input type="checkbox" checked={allResourceKeys.length > 0 && selectedResources.length === allResourceKeys.length} onChange={toggleAllResources} /> <span>全选全部资源</span> <em>{selectedResources.length} / {allResourceKeys.length} 已选</em></label>
|
||||
<div className="bulk-grant-members"><span>授权成员</span><button type="button" className="bulk-link" onClick={toggleAllGrantUsers} disabled={!grantableUsers.length}>{selectedGrantUsers.length === grantableUsers.length ? '清空成员' : '全选成员'}</button>{grantableUsers.map(user => <label key={user.id}><input type="checkbox" checked={selectedGrantUsers.includes(String(user.id))} onChange={() => toggleGrantUser(String(user.id))} />{user.display_name || user.username}</label>)}{grantableUsers.length === 0 && <small>暂无可授权成员</small>}</div>
|
||||
<div className="bulk-grant-actions"><select value={bulkPermission} onChange={event => setBulkPermission(event.target.value)} aria-label="批量授权权限"><option value="read">只读</option><option value="use">可使用</option><option value="manage">可管理</option></select><span className="field-help">全局资源允许成员修改自己的副本,成员不提供导出权限。</span><button className="mini" onClick={() => void bulkGrant()} disabled={bulkBusy || busy || !selectedResources.length || !selectedGrantUsers.length}>批量授权</button><button className="mini" onClick={() => void bulkPublish()} disabled={bulkBusy || busy || !selectedResources.length}>发布全局</button></div>
|
||||
</div>
|
||||
<GrantResourceDrawer title="知识库" kind="knowledge-bases" rows={adminKbs} selectedResources={selectedResources} resourceKey={resourceKey} onToggleGroup={toggleResourceGroup}>
|
||||
<div className="access-resource-list">{adminKbs.map(resource => <ResourceCard key={`kb-${resource.id}`} kind="knowledge-bases" resource={resource} selected={selectedResources.includes(resourceKey('knowledge-bases', resource.id))} onToggle={toggleResource} busy={busy} grantControls={grantControls} exportResource={exportResource} revoke={revoke} publishGlobal={publishGlobal} />)}</div>
|
||||
</GrantResourceDrawer>
|
||||
<GrantResourceDrawer title="Agent" kind="agents" rows={adminAgents} selectedResources={selectedResources} resourceKey={resourceKey} onToggleGroup={toggleResourceGroup}>
|
||||
<div className="access-resource-list">{adminAgents.map(resource => <ResourceCard key={`agent-${resource.id}`} kind="agents" resource={resource} selected={selectedResources.includes(resourceKey('agents', resource.id))} onToggle={toggleResource} busy={busy} grantControls={grantControls} exportResource={exportResource} revoke={revoke} publishGlobal={publishGlobal} />)}</div>
|
||||
</GrantResourceDrawer>
|
||||
<GrantResourceDrawer title="Agent 画布" kind="canvases" rows={adminCanvases} selectedResources={selectedResources} resourceKey={resourceKey} onToggleGroup={toggleResourceGroup}>
|
||||
<div className="access-resource-list">{adminCanvases.map(resource => <ResourceCard key={`canvas-${resource.id}`} kind="canvases" resource={resource} selected={selectedResources.includes(resourceKey('canvases', resource.id))} onToggle={toggleResource} busy={busy} grantControls={grantControls} exportResource={exportResource} revoke={revoke} publishGlobal={publishGlobal} />)}</div>
|
||||
</GrantResourceDrawer>
|
||||
<GrantResourceDrawer title="Agent 记忆(随 Agent 与技能)" kind="memories" rows={adminMemories} selectedResources={selectedResources} resourceKey={resourceKey} onToggleGroup={toggleResourceGroup}>
|
||||
<div className="access-resource-list">{adminMemories.map(resource => <ResourceCard key={`memory-${resource.id}`} kind="memories" resource={resource} selected={selectedResources.includes(resourceKey('memories', resource.id))} onToggle={toggleResource} busy={busy} grantControls={grantControls} exportResource={exportResource} revoke={revoke} publishGlobal={publishGlobal} />)}</div>
|
||||
</GrantResourceDrawer>
|
||||
<GrantResourceDrawer title="技能" kind="skills" rows={adminSkills} selectedResources={selectedResources} resourceKey={resourceKey} onToggleGroup={toggleResourceGroup}>
|
||||
<div className="access-resource-list">{adminSkills.map(resource => <ResourceCard key={`skill-${resource.id}`} kind="skills" resource={resource} selected={selectedResources.includes(resourceKey('skills', resource.id))} onToggle={toggleResource} busy={busy} grantControls={grantControls} exportResource={exportResource} revoke={revoke} publishGlobal={publishGlobal} />)}</div>
|
||||
</GrantResourceDrawer>
|
||||
<GrantResourceDrawer title="数字人" kind="digital-humans" rows={adminDigitalHumans} selectedResources={selectedResources} resourceKey={resourceKey} onToggleGroup={toggleResourceGroup}>
|
||||
<div className="access-resource-list">{adminDigitalHumans.map(resource => <ResourceCard key={`digital-human-${resource.id}`} kind="digital-humans" resource={resource} selected={selectedResources.includes(resourceKey('digital-humans', resource.id))} onToggle={toggleResource} busy={busy} grantControls={grantControls} exportResource={exportResource} revoke={revoke} publishGlobal={publishGlobal} />)}</div>
|
||||
</GrantResourceDrawer>
|
||||
<ResourceRevisionReview names={Object.fromEntries([
|
||||
...adminKbs.map(item => [`knowledge_base:${item.id}`, item.name]),
|
||||
...adminCanvases.map(item => [`canvas:${item.id}`, item.name]),
|
||||
...adminMemories.map(item => [`memory:${item.id}`, item.name || item.agent_name || `记忆 #${item.id}`]),
|
||||
...adminSkills.map(item => [`skill:${item.id}`, item.name]),
|
||||
...adminDigitalHumans.map(item => [`digital_human:${item.id}`, item.name]),
|
||||
...adminAgents.map(item => [`agent:${item.id}`, item.name]),
|
||||
])} />
|
||||
</section>}
|
||||
|
||||
{tab === 'security' && <section className="card security-card">
|
||||
<h3>登录与安全策略(当前生效)</h3>
|
||||
<div className="kv-list">
|
||||
<div><span>会话有效期</span><p>12 小时,过期自动要求重新登录</p></div>
|
||||
<div><span>防爆破</span><p>同一(IP + 用户名)5 次失败锁定 15 分钟;单 IP 30 次失败锁定 15 分钟</p></div>
|
||||
<div><span>密码规则</span><p>长度 ≥10 字符;成员密码仅管理员可重设</p></div>
|
||||
<div><span>传输安全</span><p>全站 HTTPS(Cloudflare 边缘证书);API 凭据仅存服务端钥匙串</p></div>
|
||||
<div><span>响应安全头</span><p>X-Frame-Options / nosniff / CSP frame-ancestors 已启用</p></div>
|
||||
</div>
|
||||
</section>}
|
||||
|
||||
{/* 新增成员弹窗 */}
|
||||
{showCreate && <div className="drawer-mask" onClick={() => setShowCreate(false)} />}
|
||||
{showCreate && <div className="modal-card" role="dialog" aria-label="新增成员">
|
||||
<div className="modal-head"><strong>新增成员</strong><button className="icon-button" onClick={() => setShowCreate(false)} aria-label="关闭">✕</button></div>
|
||||
<input placeholder="用户名(至少 3 个字符)" value={createDraft.username} onChange={event => setCreateDraft(d => ({ ...d, username: event.target.value }))} />
|
||||
<input placeholder="显示名称(选填)" value={createDraft.displayName} onChange={event => setCreateDraft(d => ({ ...d, displayName: event.target.value }))} />
|
||||
<input type="password" placeholder="初始密码(至少 10 个字符)" value={createDraft.password} onChange={event => setCreateDraft(d => ({ ...d, password: event.target.value }))} />
|
||||
<select value={createDraft.role} onChange={event => setCreateDraft(d => ({ ...d, role: event.target.value }))}>{roles.map(roleRow => <option key={roleRow.role} value={roleRow.role}>{roleRow.label}</option>)}</select>
|
||||
<div className="btns"><span className="flex-spacer" /><button className="mini" onClick={() => setShowCreate(false)}>取消</button><button className="ok" onClick={submitCreate} disabled={busy || createDraft.username.trim().length < 3 || createDraft.password.length < 10}>添加成员</button></div>
|
||||
</div>}
|
||||
</div>
|
||||
}
|
||||
|
||||
const VISIBILITY_LABEL: Record<string, string> = { private: '私有', shared: '指定成员', global: '全局' }
|
||||
const GRANT_LABEL: Record<string, string> = { read: '只读', use: '可使用', manage: '可管理', export: '可导出' }
|
||||
|
||||
type GrantKind = 'knowledge-bases' | 'canvases' | 'memories' | 'digital-humans' | 'skills' | 'agents'
|
||||
|
||||
function GrantResourceDrawer({ title, kind, rows, selectedResources, resourceKey, onToggleGroup, children }: any) {
|
||||
const keys = rows.map((item: any) => resourceKey(kind, item.id))
|
||||
const selected = keys.filter((key: string) => selectedResources.includes(key)).length
|
||||
return <details className="grant-resource-drawer" open={rows.length > 0}>
|
||||
<summary>
|
||||
<span className="grant-drawer-chevron" aria-hidden="true">›</span>
|
||||
<span className="grant-drawer-title"><b>{title}</b><small>{selected ? `已选 ${selected} 项` : '点击展开资源列表'}</small></span>
|
||||
<span className="grant-drawer-count">{rows.length}</span>
|
||||
<label className="grant-group-select" onClick={event => event.stopPropagation()}><input type="checkbox" checked={rows.length > 0 && selected === rows.length} onChange={() => onToggleGroup(kind, rows)} aria-label={`全选${title}`} /><span>全选</span></label>
|
||||
</summary>
|
||||
{children}
|
||||
</details>
|
||||
}
|
||||
|
||||
/** 资源卡片内的授权选择器:采用消费记录相同的抽屉、卡片和选中态,避免原生 select 难以辨认。 */
|
||||
function GrantControls({ kind, resource, users, busy, onGrant, onPublish }: any) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [userId, setUserId] = useState('')
|
||||
const [permission, setPermission] = useState('use')
|
||||
const currentGrant = resource.grants?.[0]
|
||||
const grantable = users.filter((user: any) => user.enabled && user.role !== 'admin')
|
||||
const selectedUser = grantable.find((user: any) => String(user.id) === userId)
|
||||
const submit = () => {
|
||||
if (!userId) return
|
||||
void onGrant(kind, resource.id, userId, permission)
|
||||
setOpen(false)
|
||||
setUserId('')
|
||||
}
|
||||
return <div className="resource-grant-controls">
|
||||
<details className="grant-permission-drawer" open={open} onToggle={event => setOpen(event.currentTarget.open)}>
|
||||
<summary><span className="grant-drawer-chevron" aria-hidden="true">›</span><span className="grant-permission-title"><b>{selectedUser ? selectedUser.display_name || selectedUser.username : '选择授权成员'}</b><small>{selectedUser ? (GRANT_LABEL[permission] || '可使用') : '点击展开成员与权限'}</small></span><span className="grant-drawer-count">{grantable.length}</span></summary>
|
||||
<div className="grant-permission-body">
|
||||
<p className="field-help">选择一个成员,再设置他在此资源上的权限。</p>
|
||||
<div className="grant-permission-members">
|
||||
{grantable.map((user: any) => {
|
||||
const active = String(user.id) === userId
|
||||
return <label key={user.id} className={`grant-permission-card${active ? ' is-selected' : ''}`}><input type="radio" name={`grant-user-${kind}-${resource.id}`} checked={active} onChange={() => setUserId(String(user.id))} /><span><b>{user.display_name || user.username}</b><small>{user.username}</small></span><i>{active ? '已选择' : '选择'}</i></label>
|
||||
})}
|
||||
{!grantable.length && <span className="usage-empty">暂无可授权成员</span>}
|
||||
</div>
|
||||
<div className="grant-permission-levels" role="group" aria-label="选择授权权限">
|
||||
{(['read', 'use', ...(kind !== 'digital-humans' ? ['manage'] : [])] as string[]).map(level => <button type="button" key={level} className={permission === level ? 'active' : ''} onClick={() => setPermission(level)}>{GRANT_LABEL[level]}</button>)}
|
||||
</div>
|
||||
<div className="grant-permission-actions"><button className="mini" type="button" onClick={() => setOpen(false)}>取消</button><button className="mini primary-action" type="button" disabled={busy || !userId} onClick={submit}>授权</button></div>
|
||||
</div>
|
||||
</details>
|
||||
{currentGrant && <span className="field-help">当前首条授权:{currentGrant.display_name || currentGrant.username} · {GRANT_LABEL[currentGrant.permission] || '可使用'}</span>}
|
||||
</div>
|
||||
}
|
||||
|
||||
function ResourceCard({ kind, resource, selected, onToggle, busy, grantControls, exportResource, revoke, publishGlobal }: any) {
|
||||
const resourceName = resource.name || resource.agent_name || `#${resource.id}`
|
||||
const detail = kind === 'knowledge-bases' ? `${resource.file_count || 0} 文件`
|
||||
: kind === 'canvases' ? `${resource.nodes?.length || 0} 个节点`
|
||||
: kind === 'digital-humans' ? `${resource.source_count || 0} 个来源`
|
||||
: kind === 'skills' ? `${resource.agent_count || 0} 个 Agent 使用`
|
||||
: kind === 'agents' ? `${resource.role || '生产'} · ${resource.status === 'active' ? '已启用' : '已停用'}`
|
||||
: (resource.content?.slice(0, 60) || '空记忆')
|
||||
return <div className={`card access-resource ${selected ? 'selected' : ''}`}>
|
||||
<div className="row">
|
||||
<label className="resource-select"><input type="checkbox" checked={selected} onChange={() => onToggle(kind, resource.id)} aria-label={`选择${resourceName}`} /><b>{resourceName}</b><OwnerLabel resource={resource} /></label>
|
||||
{kind === 'memories' && <span className="badge gold">{resource.layer}</span>}
|
||||
{kind === 'digital-humans' && <span className="badge">{resource.status === 'active' ? '已生效' : '待确认'}</span>}
|
||||
<span className="badge">{VISIBILITY_LABEL[resource.visibility] || '私有'}</span><span className="dim">{detail} · {resource.grants?.length || 0} 条授权</span>
|
||||
<button className="mini" onClick={() => exportResource(kind, resource.id)} disabled={busy}>导出</button>
|
||||
{resource.visibility !== 'global' && <button className="mini" onClick={() => publishGlobal(kind, resource.id)} disabled={busy}>发布全局</button>}
|
||||
</div>
|
||||
{grantControls(kind, resource)}
|
||||
{resource.grants?.map((item: any) => <div className="grant-row" key={item.id}><span>{item.display_name || item.username} · {GRANT_LABEL[item.permission] || '可使用'}</span><button className="mini danger-text" onClick={() => revoke(kind, resource.id, item.id)} disabled={busy}>撤销</button></div>)}
|
||||
</div>
|
||||
}
|
||||
|
||||
/* 权限目录和左侧功能栏保持一一对应。每组就是侧栏中的一个分组,组内先控制入口,再控制具体操作。 */
|
||||
const PERMISSION_GROUPS: { key: string; title: string; hint: string; items: [string, string, string][] }[] = [
|
||||
{ key: 'workspace', title: '工作台', hint: '进入工作台并查看账号可见的首页信息',
|
||||
items: [
|
||||
['read', '进入工作台', '登录后查看总览和当前账号已开通的功能'],
|
||||
] },
|
||||
{ key: 'production', title: '生产', hint: '侧栏中的内容任务(含训练营导图)、运行记录和操作日志',
|
||||
items: [
|
||||
['feature:tasks', '内容任务(含训练营导图)', '显示内容任务入口;训练营导图在内容任务内使用'],
|
||||
['tasks:read', '查看内容任务', '查看任务列表、详情和生成进度'],
|
||||
['tasks:write', '创建与运行任务', '新建任务、上传导图、触发拆解和 Agent 生成'],
|
||||
['tasks:review', '审查内容', '对内容段通过、打回或跳过,处理待确认项'],
|
||||
['tasks:deliver', '交付成稿', '确认最终稿并下载或导出成稿'],
|
||||
['feature:runs', '运行记录', '显示运行记录入口和任务运行状态'],
|
||||
['runs:read', '查看运行记录', '查看运行事件、通知和执行详情'],
|
||||
['logs:read', '查看 AI 追踪', '查看运行中的 AI 调用、Trace 和用量明细'],
|
||||
['feature:audit', '操作日志', '显示用户操作审计入口'],
|
||||
['audit:read', '查看操作日志', '查看用户对系统执行的操作记录'],
|
||||
] },
|
||||
{ key: 'agents', title: '智能体', hint: '侧栏中的 Agent 与技能、数字人和画布编排;记忆随 Agent 管理',
|
||||
items: [
|
||||
['feature:agents', 'Agent 与技能', '显示 Agent 工作区;技能已集成在这里,无需单独授权'],
|
||||
['agents:read', '查看 Agent 与技能', '查看 Agent、技能和运行配置'],
|
||||
['agents:write', '管理 Agent 与技能', '新建、编辑和绑定 Agent、技能、画布及数字人规则'],
|
||||
['feature:digital-humans', '数字人', '显示数字人管理和蒸馏入口'],
|
||||
['digital_humans:read', '查看数字人', '查看数字人档案、版本和绑定状态'],
|
||||
['feature:flows', '画布编排', '显示画布流程编排入口'],
|
||||
['flows:read', '查看画布编排', '查看画布流程及节点配置'],
|
||||
['memories:read', '查看 Agent 记忆', '在 Agent 与技能中查看长期记忆及其来源'],
|
||||
['memories:write', '管理 Agent 记忆', '新增、修改或删除 Agent 长期记忆'],
|
||||
['memories:review', '审批记忆沉淀', '审批 Agent 记忆沉淀申请(批准或拒绝)'],
|
||||
] },
|
||||
{ key: 'knowledge', title: '知识资产', hint: '侧栏中的知识库;分类和标签都在知识库内管理',
|
||||
items: [
|
||||
['feature:knowledge', '知识库', '显示知识库入口,分类和标签随知识库一起受控'],
|
||||
['kb:read', '查看知识库', '浏览知识库、文件与检索测试'],
|
||||
['kb:write', '管理知识库', '建库、导入删除文件、重建索引、分类整理'],
|
||||
] },
|
||||
{ key: 'system', title: '系统管理', hint: '侧栏中的模型、用量、日志、访问控制、开关和运营看板',
|
||||
items: [
|
||||
['feature:models', '模型管理', '显示模型与 Provider 配置入口'],
|
||||
['providers:write', '管理模型与密钥', '新增删除 Provider、换模型、设全局默认(密钥入钥匙串)'],
|
||||
['providers:read', '查看模型管理', '查看已配置模型和连接状态'],
|
||||
['feature:usage', '用量分析', '显示用量和余额入口'],
|
||||
['usage:read', '查看用量分析', '查看个人或组织的用量与余额'],
|
||||
['billing:manage', '管理计费', '发放点数、调整用量配额'],
|
||||
['feature:system-logs', '系统日志', '显示系统服务日志入口'],
|
||||
['system_logs:read', '查看系统日志', '查看服务日志和错误详情'],
|
||||
['feature:access', '访问控制', '显示成员、角色和资源授权入口'],
|
||||
['access:read', '查看访问控制', '查看成员、角色和资源授权页面'],
|
||||
['users:manage', '管理成员', '添加、停用或永久删除成员,重置密码'],
|
||||
['roles:manage', '管理角色', '调整角色权限矩阵、新建删除角色'],
|
||||
['audit:manage', '资源授权', '把知识库/画布授权给成员、发布全局;成员修改通过个人副本隔离'],
|
||||
['feature:flags', '功能开关', '显示系统功能开关入口'],
|
||||
['flags:read', '查看功能开关', '查看当前系统功能开关状态'],
|
||||
['feature:ops', '运营看板', '显示运营数据和告警入口'],
|
||||
['ops:read', '查看运营数据', '查看运营看板、指标和告警'],
|
||||
['ops:manage', '运营操作', '手动巡检告警、备份、恢复演练和死信重放'],
|
||||
] },
|
||||
]
|
||||
|
||||
const VISIBLE_PERMISSION_KEYS = new Set(PERMISSION_GROUPS.flatMap(group => group.items.map(([permission]) => permission)))
|
||||
|
||||
function PermissionCheckList({ selected, onToggle, locked }: { selected: string[]; onToggle: (permission: string) => void; locked?: boolean }) {
|
||||
const hasAll = selected.includes('*')
|
||||
const [activeGroup, setActiveGroup] = useState(PERMISSION_GROUPS[0]?.key || '')
|
||||
const group = PERMISSION_GROUPS.find(item => item.key === activeGroup) || PERMISSION_GROUPS[0]
|
||||
const toggleAll = (items: [string, string, string][]) => {
|
||||
const complete = items.every(([permission]) => hasAll || selected.includes(permission))
|
||||
items.forEach(([permission]) => {
|
||||
const enabled = hasAll || selected.includes(permission)
|
||||
if (enabled !== !complete) onToggle(permission)
|
||||
})
|
||||
}
|
||||
return <div className="perm-drawer">
|
||||
<nav className="perm-drawer-nav" aria-label="权限功能栏">
|
||||
{PERMISSION_GROUPS.map(item => {
|
||||
const enabledCount = item.items.filter(([permission]) => hasAll || selected.includes(permission)).length
|
||||
return <button type="button" key={item.key} className={`perm-drawer-nav-item ${item.key === group?.key ? 'active' : ''}`} onClick={() => setActiveGroup(item.key)}>
|
||||
<span><b>{item.title}</b><small>{item.hint}</small></span><em>{enabledCount}/{item.items.length}</em><i aria-hidden="true">›</i>
|
||||
</button>
|
||||
})}
|
||||
</nav>
|
||||
{group && <section className="perm-drawer-panel" aria-label={`${group.title}权限`}>
|
||||
<div className="perm-drawer-panel-head"><div><b>{group.title}</b><span>{group.hint}</span><small>先勾选“功能入口”让用户看到侧栏;再按需开放查看、编辑、审核或交付操作。</small></div><button type="button" className="perm-group-select" onClick={() => toggleAll(group.items)} disabled={locked}>{group.items.every(([permission]) => hasAll || selected.includes(permission)) ? '清空本组' : '全选本组'}</button></div>
|
||||
<div className="perm-group-items">{group.items.map(([permission, label, help]) => (
|
||||
<label key={permission} className={`perm-item ${selected.includes(permission) || hasAll ? 'on' : ''}`}>
|
||||
<input type="checkbox" checked={hasAll || selected.includes(permission)} disabled={locked} onChange={() => onToggle(permission)} />
|
||||
<span className="perm-item-copy"><b>{label}{permission.startsWith('feature:') ? <em>功能入口</em> : null}</b><small>{help}</small></span><code>{permission}</code>
|
||||
</label>
|
||||
))}</div>
|
||||
</section>}
|
||||
</div>
|
||||
}
|
||||
|
||||
function RolePermissionEditor({ roleRow, busy, onSaved, onDelete }: { roleRow: any; busy: boolean; onSaved: () => void; onDelete: () => void }) {
|
||||
const [permissions, setPermissions] = useState<string[]>(Array.isArray(roleRow.permissions) ? roleRow.permissions : [])
|
||||
const [saving, setSaving] = useState(false)
|
||||
// 只在切换角色时同步本地勾选;父组件轮询刷新不覆盖未保存的编辑。
|
||||
useEffect(() => { setPermissions(Array.isArray(roleRow.permissions) ? roleRow.permissions : []) }, [roleRow.role])
|
||||
const isAdmin = roleRow.role === 'admin'
|
||||
const count = permissions.includes('*') ? VISIBLE_PERMISSION_KEYS.size : permissions.filter(permission => VISIBLE_PERMISSION_KEYS.has(permission)).length
|
||||
const save = async () => {
|
||||
setSaving(true)
|
||||
try { await api.put(`/auth/roles/${roleRow.role}`, { permissions }); onSaved() }
|
||||
finally { setSaving(false) }
|
||||
}
|
||||
return <div className="card role-permission-card">
|
||||
<div className="section-title-row"><div><h3>{roleRow.label}</h3><p>{roleRow.description || '未填写角色说明'} · {count} 项权限</p></div>
|
||||
<div className="btns"><button className="mini" onClick={save} disabled={busy || saving}>{saving ? '保存中…' : '保存权限'}</button>{!roleRow.builtin && <button className="mini danger-text" onClick={onDelete} disabled={busy}>删除角色</button>}</div></div>
|
||||
<PermissionCheckList selected={permissions} locked={isAdmin}
|
||||
onToggle={permission => setPermissions(current => current.includes(permission) ? current.filter(item => item !== permission) : [...current, permission])} />
|
||||
{isAdmin && <span className="field-help">管理员保留全部权限,避免锁死管理入口。</span>}
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
// AgentWorkbench:320px 列表 + 详情区(五页签:基本信息/知识与技能/工具与模型/数字人身份/版本记录)
|
||||
// 业务逻辑沿用旧 AgentsPage(save/create/remove/AI 创建),仅重排视图 + 路由 :id
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useAgentEditSafety } from './AgentsPage'
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { Plus, Search, Sparkles, ChevronRight } from 'lucide-react'
|
||||
import { api, localizeError } from '../api'
|
||||
import { useDialog } from '../dialog'
|
||||
import { AiAssistDialog } from './aiAssist'
|
||||
import { PageHeader, EmptyWorkspace, ResourceSummary, OwnerLabel } from '../workbench-ui'
|
||||
import SkillsPage from './SkillsPage'
|
||||
import MemoriesPage from './MemoriesPage'
|
||||
import PersonaProfile from './PersonaProfile'
|
||||
import { forkResource, isGlobalResource } from '../resourceActions'
|
||||
|
||||
export default function AgentWorkbench() {
|
||||
const { id } = useParams()
|
||||
const nav = useNavigate()
|
||||
const dialog = useDialog()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
// 模块内页签:agent=Agent 列表 skills=技能库 memories=记忆库
|
||||
const moduleTab = (searchParams.get('tab') || 'agent') as 'agent' | 'skills' | 'memories'
|
||||
const [agents, setAgents] = useState<any[]>([])
|
||||
const [tags, setTags] = useState<any[]>([])
|
||||
const [providers, setProviders] = useState<any[]>([])
|
||||
const [kbs, setKbs] = useState<any[]>([])
|
||||
const [kbCategories, setKbCategories] = useState<Record<number, any[]>>({})
|
||||
const [skills, setSkills] = useState<any[]>([])
|
||||
const [digitalHumans, setDigitalHumans] = useState<any[]>([])
|
||||
const [toolCatalog, setToolCatalog] = useState<any[]>([])
|
||||
const [cur, setCur] = useState<any>(null)
|
||||
const [me, setMe] = useState<any>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [aiOpen, setAiOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [detailTab, setDetailTab] = useState<'basic' | 'knowledge' | 'tools' | 'persona' | 'versions'>('basic')
|
||||
const [tagFilter, setTagFilter] = useState('')
|
||||
const [humanDetail, setHumanDetail] = useState<any>(null)
|
||||
const [humanLoading, setHumanLoading] = useState(false)
|
||||
const [humanError, setHumanError] = useState('')
|
||||
const [forkBusy, setForkBusy] = useState(false)
|
||||
const [baseline, setBaseline] = useState('null')
|
||||
const dirty = Boolean(cur) && JSON.stringify(cur) !== baseline
|
||||
const inFlight = useRef(false)
|
||||
const allowLeave = useAgentEditSafety(moduleTab === 'agent' && dirty, busy)
|
||||
const accept = (value: any) => { setCur(value); setBaseline(JSON.stringify(value)) }
|
||||
|
||||
const loadAvailableModels = async () => {
|
||||
const rows = await api.get<any[]>('/models/available')
|
||||
const grouped: Record<number, any> = {}
|
||||
rows.forEach(row => {
|
||||
const current = grouped[row.provider_id] || { id: row.provider_id, name: row.provider_name, models: [], catalogs: [] }
|
||||
current.models.push(row.model_name)
|
||||
current.catalogs.push(row)
|
||||
grouped[row.provider_id] = current
|
||||
})
|
||||
setProviders(Object.values(grouped))
|
||||
}
|
||||
const refresh = () => Promise.all([
|
||||
api.get('/agents').then(setAgents), api.get('/tags').then(setTags),
|
||||
loadAvailableModels(), api.get('/kbs').then(setKbs),
|
||||
api.get('/skills').then(setSkills), api.get('/digital-humans').then(setDigitalHumans),
|
||||
api.get('/agent-tools').then(setToolCatalog),
|
||||
])
|
||||
useEffect(() => { refresh(); api.get('/auth/me').then(setMe).catch(() => {}) }, [])
|
||||
// 路由 :id → 选中
|
||||
const draftRef = useRef(cur)
|
||||
draftRef.current = cur
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
if (id) {
|
||||
// history 返回取消后恢复相同 id 时,不让异步 GET 覆盖仍在编辑的草稿。
|
||||
if (String(draftRef.current?.id) === id) return
|
||||
api.get(`/agents/${id}`).then(a => { if (active) { accept(a); setDetailTab('basic') } }).catch(() => { if (active) setError('Agent 加载失败,请重试') })
|
||||
} else if (!dirty) setCur(null)
|
||||
return () => { active = false }
|
||||
}, [id])
|
||||
|
||||
useEffect(() => { if (moduleTab !== 'agent') setCur(JSON.parse(baseline)) }, [moduleTab])
|
||||
|
||||
const editable = Boolean(cur && me && (cur.owner_user_id === me.id || me.role === 'admin'))
|
||||
const globalReadonly = Boolean(cur && isGlobalResource(cur) && !editable)
|
||||
const boundHuman = digitalHumans.find(human => human.id === cur?.digital_human_id)
|
||||
useEffect(() => {
|
||||
let current = true
|
||||
setHumanDetail(null); setHumanError('')
|
||||
if (!cur?.digital_human_id || detailTab !== 'persona') { setHumanLoading(false); return }
|
||||
setHumanLoading(true)
|
||||
api.get(`/digital-humans/${cur.digital_human_id}`).then(human => { if (current) setHumanDetail(human) })
|
||||
.catch(e => { if (current) setHumanError(localizeError(e)) }).finally(() => { if (current) setHumanLoading(false) })
|
||||
return () => { current = false }
|
||||
}, [cur?.digital_human_id, detailTab])
|
||||
const filtered = useMemo(() => agents.filter(a =>
|
||||
!query.trim() || a.name.includes(query.trim()) || (a.content_tags || []).some((t: string) => t.includes(query.trim()))
|
||||
), [agents, query])
|
||||
|
||||
const applyAiDraft = async (draft: any) => {
|
||||
if (inFlight.current) return
|
||||
inFlight.current = true; setBusy(true); setError('')
|
||||
setAiOpen(false)
|
||||
try {
|
||||
const r = await api.post<any>('/agents', {
|
||||
name: draft.name || '新Agent', role: draft.role || 'production',
|
||||
desc: draft.desc || '', status: 'disabled',
|
||||
rules: (draft.rules || []).length ? draft.rules.map((item: string) => `- ${item}`).join('\n') : undefined,
|
||||
content_tags: draft.content_tags || [],
|
||||
})
|
||||
await refresh(); accept(await api.get(`/agents/${r.id}`)); allowLeave.release()
|
||||
nav(`/admin/agents/${r.id}`)
|
||||
void dialog.notice({ title: '草稿已填入', description: draft.advice || '请补齐知识库挂接、绑定模型后保存并启用。' })
|
||||
} catch (e: any) { setError(localizeError(e)) } finally { inFlight.current = false; setBusy(false) }
|
||||
}
|
||||
const publishAgent = async () => {
|
||||
if (!cur || inFlight.current) return
|
||||
inFlight.current = true; setBusy(true); setError('')
|
||||
try {
|
||||
await api.put(`/agents/${cur.id}`, {
|
||||
name: cur.name, role: cur.role, desc: cur.desc, content_tags: cur.content_tags, kb_ids: cur.kb_ids,
|
||||
skill_ids: cur.skill_ids || [], fusion_agent_ids: cur.fusion_agent_ids || [],
|
||||
allowed_tools: cur.allowed_tools || [],
|
||||
model_provider_id: cur.model_provider_id, model_name: cur.model_name,
|
||||
model_catalog_id: cur.model_catalog_id || null,
|
||||
prompt_template_id: cur.prompt_template_id || null,
|
||||
status: cur.status, rules: cur.rules, digital_human_id: cur.digital_human_id || null,
|
||||
})
|
||||
const r = await api.post<{ message: string }>(`/agents/${cur.id}/publish`, {})
|
||||
accept(await api.get(`/agents/${cur.id}`)); await refresh()
|
||||
void dialog.notice({ title: '已发布', description: r.message, tone: 'success' })
|
||||
} catch (e: any) { setError(String(e.message || e)) } finally { inFlight.current = false; setBusy(false) }
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (!cur || inFlight.current) return
|
||||
inFlight.current = true; setBusy(true); setError('')
|
||||
try {
|
||||
await api.put(`/agents/${cur.id}`, {
|
||||
name: cur.name, role: cur.role, desc: cur.desc, content_tags: cur.content_tags, kb_ids: cur.kb_ids,
|
||||
skill_ids: cur.skill_ids || [], fusion_agent_ids: cur.fusion_agent_ids || [],
|
||||
allowed_tools: cur.allowed_tools || [],
|
||||
model_provider_id: cur.model_provider_id, model_name: cur.model_name,
|
||||
model_catalog_id: cur.model_catalog_id || null,
|
||||
prompt_template_id: cur.prompt_template_id || null,
|
||||
status: cur.status, rules: cur.rules, digital_human_id: cur.digital_human_id || null,
|
||||
})
|
||||
accept(await api.get(`/agents/${cur.id}`)); await refresh()
|
||||
void dialog.notice({ title: '已保存', description: '草稿已保存;发布快照未改变。需要冻结配置时请单独点击发布。', tone: 'success' })
|
||||
} catch (e: any) { setError(localizeError(e)) } finally { inFlight.current = false; setBusy(false) }
|
||||
}
|
||||
const create = async () => {
|
||||
if (inFlight.current || !await allowLeave()) return
|
||||
inFlight.current = true
|
||||
setBusy(true); setError('')
|
||||
try {
|
||||
const r = await api.post<any>('/agents', { name: '新Agent', status: 'disabled' })
|
||||
await refresh(); accept(await api.get(`/agents/${r.id}`)); allowLeave.release(); nav(`/admin/agents/${r.id}`)
|
||||
} catch (e: any) { setError(localizeError(e)) } finally { inFlight.current = false; setBusy(false) }
|
||||
}
|
||||
const remove = async () => {
|
||||
if (!cur || inFlight.current || !await dialog.confirm({ title: `${globalReadonly ? '从我的列表移除' : '删除'} Agent「${cur.name}」?`, description: globalReadonly ? '仅从当前账号的列表隐藏,不影响其他用户和管理员使用。' : '系统会先自动解除画布、融合、技能、知识库和记忆绑定,再永久删除 Agent。已参与历史任务的 Agent 仍需保留。', confirmLabel: globalReadonly ? '确认移除' : '确认删除', tone: 'danger' })) return
|
||||
inFlight.current = true; setBusy(true); setError('')
|
||||
try { await api.delete(`/agents/${cur.id}`); accept(null); allowLeave.release(); nav('/admin/agents'); await refresh() }
|
||||
catch (e: any) { setError(localizeError(e)) } finally { inFlight.current = false; setBusy(false) }
|
||||
}
|
||||
const fork = async () => {
|
||||
if (!cur || forkBusy) return
|
||||
setForkBusy(true); setError('')
|
||||
try {
|
||||
const result = await forkResource('agents', Number(cur.id))
|
||||
await refresh()
|
||||
nav(`/admin/agents/${result.id}`)
|
||||
void dialog.notice({ title: '已创建我的副本', description: '你可以编辑副本并保存新版本;原全局 Agent 和其他用户不受影响。', tone: 'success' })
|
||||
} catch (e: any) { setError(localizeError(e)) } finally { setForkBusy(false) }
|
||||
}
|
||||
const toggleTool = (toolName: string) => {
|
||||
if (!cur) return
|
||||
const selected = Array.isArray(cur.allowed_tools) ? cur.allowed_tools : toolCatalog.filter(tool => tool.default_enabled).map(tool => tool.name)
|
||||
setCur({ ...cur, allowed_tools: selected.includes(toolName) ? selected.filter((name: string) => name !== toolName) : [...selected, toolName] })
|
||||
}
|
||||
const toggleKb = async (kid: number) => {
|
||||
if (!cur) return
|
||||
const selected = (cur.kb_ids || []).includes(kid)
|
||||
const nextIds = selected ? cur.kb_ids.filter((x: number) => x !== kid) : [...(cur.kb_ids || []), kid]
|
||||
if (!selected && !kbCategories[kid]) { try { const d = await api.get<any>(`/kbs/${kid}`); setKbCategories(prev => ({ ...prev, [kid]: d.categories || [] })) } catch {} }
|
||||
setCur({ ...cur, kb_ids: nextIds })
|
||||
}
|
||||
// 可搜索分组标签多选器
|
||||
const tagGroups = useMemo(() => {
|
||||
const groups: Record<string, any[]> = {}
|
||||
tags.filter(t => t.enabled).forEach(t => {
|
||||
const g = t.group || t.category || '通用'
|
||||
if (!groups[g]) groups[g] = []
|
||||
groups[g].push(t)
|
||||
})
|
||||
return Object.entries(groups).filter(([g]) => !tagFilter || g.includes(tagFilter) || groups[g].some(t => t.name.includes(tagFilter)))
|
||||
}, [tags, tagFilter])
|
||||
|
||||
const moduleTabs = [
|
||||
{ key: 'agent', label: 'Agent' },
|
||||
{ key: 'skills', label: '技能库' },
|
||||
{ key: 'memories', label: '记忆库' },
|
||||
]
|
||||
|
||||
return <div className="agent-workbench">
|
||||
<PageHeader title="Agent 与技能" description="配置职责、知识和工具;人物风格与表达规则统一从绑定的数字人继承。" meta={<span className="scope-chip">配置中心</span>} />
|
||||
{/* 模块内页签 */}
|
||||
<div className="detail-tabs" role="tablist">
|
||||
{moduleTabs.map(t => (
|
||||
<button key={t.key} role="tab" aria-selected={moduleTab === t.key} className={`detail-tab ${moduleTab === t.key ? 'active' : ''}`}
|
||||
onClick={() => setSearchParams(t.key === 'agent' ? {} : { tab: t.key })}>{t.label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{moduleTab === 'agent' && <fieldset disabled={busy} style={{ border: 0, padding: 0, margin: 0, minWidth: 0 }}><div className="agent-split">
|
||||
<aside className="agent-list-panel">
|
||||
<div className="collection-heading"><h2>Agent 目录</h2><span>{filtered.length} 个</span></div>
|
||||
<div className="page-toolbar">
|
||||
<div className="toolbar-search"><Search size={14} /><input placeholder="搜索 Agent 或标签…" value={query} onChange={e => setQuery(e.target.value)} /></div>
|
||||
</div>
|
||||
<div className="heading-actions-inline" style={{ marginBottom: 10 }}>
|
||||
<button className="primary-action" onClick={create} disabled={busy}><Plus size={14} /> 新建</button>
|
||||
<button className="ai-entry" onClick={async () => { if (await allowLeave()) setAiOpen(true) }} disabled={busy}><Sparkles size={14} /> AI 帮我创建</button>
|
||||
</div>
|
||||
{filtered.map(a => (
|
||||
<button key={a.id} className={`agent-row ${String(a.id) === id ? 'active' : ''} ${a.status === 'disabled' ? 'dim' : ''}`}
|
||||
onClick={() => nav(`/admin/agents/${a.id}`)}>
|
||||
<b>{a.name}</b>
|
||||
<span className="badge">{{ production: '内容生产', training_map: '导图生成', qc: '质检', service: '服务', coordinator: '主控', composer: '组合' }[a.role] || a.role}</span>
|
||||
<span className="dim">{a.content_tags?.join('/') || '—'}</span>
|
||||
<span className="dim">{a.model_label}</span>
|
||||
<OwnerLabel resource={a} />
|
||||
{a.builtin && <span className="badge gold">内置</span>}
|
||||
<ChevronRight size={13} className="row-chevron" />
|
||||
</button>
|
||||
))}
|
||||
{!filtered.length && <div className="empty-state">没有匹配的 Agent</div>}
|
||||
</aside>
|
||||
|
||||
<section className="agent-detail-panel">
|
||||
{error && <div className="err">{error}</div>}
|
||||
{!cur && <EmptyWorkspace title="搭建可复用的生产团队" description="选择一个 Agent 查看职责与配置。修改不会自动触发任务。" steps={["基本信息:定义职责与启用状态", "数字人身份:继承人物风格与表达规则", "知识与工具:挂接资料和模型并保存"]} />}
|
||||
{cur && <>
|
||||
<ResourceSummary items={[{label:"当前状态",value:cur.status === "active" ? "启用" : "停用"},{label:"知识来源",value:`${cur.kb_ids?.length || 0} 个知识库`},{label:"数字人身份",value:boundHuman ? `${boundHuman.name} · v${boundHuman.version}` : cur.digital_human_name || "未绑定"}]} />
|
||||
<div className="detail-header">
|
||||
<div className="detail-title">
|
||||
<h1>{cur.name}</h1>
|
||||
<OwnerLabel resource={cur} />
|
||||
<div className="detail-meta">
|
||||
<span className="badge">{{ production: '内容生产', training_map: '导图生成', qc: '质检', service: '服务', coordinator: '主控', composer: '组合' }[cur.role] || cur.role}</span>
|
||||
<span className="tag">{cur.status === 'active' ? '启用' : '停用'}</span>
|
||||
<span className="dim">v{cur.rules_version || 1}</span>
|
||||
<span className="dim">{cur.model_label}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="detail-tabs" role="tablist">
|
||||
{([['basic', '基本信息'], ['knowledge', '知识与技能'], ['tools', '工具与模型'], ['persona', '数字人身份'], ['versions', '版本记录']] as const).map(([k, label]) => (
|
||||
<button key={k} role="tab" aria-selected={detailTab === k} className={`detail-tab ${detailTab === k ? 'active' : ''}`} onClick={() => setDetailTab(k)}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{detailTab === 'basic' && <div className="card agent-form">
|
||||
<label>名称 <input value={cur.name} onChange={e => setCur({ ...cur, name: e.target.value })} /></label>
|
||||
<label>职责类型
|
||||
<select value={cur.role} onChange={e => setCur({ ...cur, role: e.target.value })}>
|
||||
<option value="production">生产</option><option value="qc">质检</option>
|
||||
<option value="service">服务</option><option value="coordinator">主控</option>
|
||||
<option value="composer">组合</option><option value="training_map">导图生成</option>
|
||||
</select></label>
|
||||
<label>描述<textarea rows={2} value={cur.desc} onChange={e => setCur({ ...cur, desc: e.target.value })} /></label>
|
||||
<label>状态<span className="field-help">{cur.role === 'training_map' ? '导图生成 Agent 使用训练营导图页面选择的知识库,Agent 本身无需挂接知识库。' : '生产 Agent 必须先挂接知识库才能启用。'}</span>
|
||||
<select value={cur.status} onChange={e => setCur({ ...cur, status: e.target.value })}>
|
||||
<option value="active">启用</option><option value="disabled">停用</option>
|
||||
</select></label>
|
||||
</div>}
|
||||
|
||||
|
||||
{detailTab === 'knowledge' && <div className="card agent-form">
|
||||
<label>内容标签(可搜索分组多选)
|
||||
<input className="tag-search" placeholder="搜索标签…" value={tagFilter} onChange={e => setTagFilter(e.target.value)} />
|
||||
{tagGroups.map(([group, items]) => <div key={group} className="tag-group">
|
||||
<div className="tag-group-label">{group}</div>
|
||||
<div className="chips">{items.map(t => (
|
||||
<button key={t.id} className={`chip ${cur.content_tags.includes(t.name) ? 'on' : ''}`}
|
||||
onClick={() => setCur({ ...cur, content_tags: cur.content_tags.includes(t.name) ? cur.content_tags.filter((x: string) => x !== t.name) : [...cur.content_tags, t.name] })}>{t.name}</button>
|
||||
))}</div>
|
||||
</div>)}
|
||||
</label>
|
||||
<label>知识库挂接(多选)<span className="field-help">{cur.role === 'training_map' ? '导图生成 Agent 的知识范围由训练营导图页面单独选择,这里无需挂接。' : '生产 Agent 必须挂接;用过的片段会锁定防重复。'}</span>
|
||||
<div className="chips">{kbs.map(k => (
|
||||
<button key={k.id} className={`chip ${cur.kb_ids.includes(k.id) ? 'on' : ''}`}
|
||||
onClick={() => void toggleKb(k.id)}>{k.name}</button>
|
||||
))}</div>
|
||||
</label>
|
||||
<label>技能挂接(多选)
|
||||
<div className="chips">{skills.filter(skill => skill.enabled).map(skill => (
|
||||
<button key={skill.id} className={`chip ${(cur.skill_ids || []).includes(skill.id) ? 'on' : ''}`}
|
||||
onClick={() => setCur({ ...cur, skill_ids: (cur.skill_ids || []).includes(skill.id) ? cur.skill_ids.filter((x: number) => x !== skill.id) : [...(cur.skill_ids || []), skill.id] })}>{skill.name}</button>
|
||||
))}</div>
|
||||
{!skills.length && <span className="field-help">暂无技能,先到技能库新建。</span>}
|
||||
</label>
|
||||
<label>融合 Agent(多选)<span className="field-help">主笔 Agent 融合勾选 Agent 的写法与记忆,知识仍只从主笔的知识库读取。</span>
|
||||
{cur.role === 'production' ? <div className="chips">{agents.filter(agent => agent.id !== cur.id && agent.role === 'production' && agent.status === 'active').map(agent => (
|
||||
<button key={agent.id} className={`chip ${(cur.fusion_agent_ids || []).includes(agent.id) ? 'on' : ''}`}
|
||||
onClick={() => setCur({ ...cur, fusion_agent_ids: (cur.fusion_agent_ids || []).includes(agent.id) ? cur.fusion_agent_ids.filter((x: number) => x !== agent.id) : [...(cur.fusion_agent_ids || []), agent.id] })}>{agent.name}</button>
|
||||
))}</div> : <span className="field-help">只有生产 Agent 可配置融合。</span>}
|
||||
</label>
|
||||
</div>}
|
||||
|
||||
{detailTab === 'tools' && <div className="card agent-form">
|
||||
<label>工具权限<span className="field-help">联网工具每次调用都需人工审批。</span>
|
||||
<div className="tool-permission-list">
|
||||
{toolCatalog.map(tool => {
|
||||
const selected = (Array.isArray(cur.allowed_tools) ? cur.allowed_tools : toolCatalog.filter(item => item.default_enabled).map(item => item.name)).includes(tool.name)
|
||||
return <label key={tool.name} className={`tool-permission-row ${tool.configurable ? '' : 'locked'}`}>
|
||||
<input type="checkbox" checked={selected} disabled={!tool.configurable} onChange={() => toggleTool(tool.name)} />
|
||||
<span><b>{tool.label}</b><small>{tool.description}</small></span>
|
||||
<em>{tool.requires_approval ? '需人工审批' : tool.category}</em>
|
||||
</label>
|
||||
})}
|
||||
</div>
|
||||
</label>
|
||||
<label>模型
|
||||
<select value={cur.model_provider_id || ''} onChange={e => setCur({ ...cur, model_provider_id: e.target.value ? Number(e.target.value) : null, model_catalog_id: null, model_name: '' })}>
|
||||
<option value="">跟随全局默认</option>
|
||||
{providers.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
{cur.model_provider_id && (
|
||||
<select value={cur.model_name} onChange={e => { const item = providers.find(p => p.id === cur.model_provider_id)?.catalogs.find((candidate: any) => candidate.model_name === e.target.value); setCur({ ...cur, model_name: e.target.value, model_catalog_id: item?.id || null }) }}>
|
||||
{(providers.find(p => p.id === cur.model_provider_id)?.catalogs || []).map((item: any) => <option key={item.id} value={item.model_name}>{item.display_name || item.model_name}</option>)}
|
||||
</select>)}
|
||||
</label>
|
||||
</div>}
|
||||
|
||||
{detailTab === 'persona' && <div className="card agent-form persona-binding">
|
||||
<label>选择数字人身份<span className="field-help">绑定后直接继承这个人的文稿、情感、叙事和思考方式。表达提示与规则在数字人中统一维护。</span>
|
||||
<select disabled={!editable} value={cur.digital_human_id || ''} onChange={e => setCur({ ...cur, digital_human_id: e.target.value ? Number(e.target.value) : null })}>
|
||||
<option value="">暂不绑定数字人</option>
|
||||
{cur.digital_human_id && !digitalHumans.some(human => human.id === cur.digital_human_id && human.can_use) && <option value={cur.digital_human_id} disabled>{boundHuman?.name || cur.digital_human_name || '原绑定数字人'}({boundHuman?.status === 'testing' ? '新版本待审核' : '暂不可用'})</option>}
|
||||
{digitalHumans.filter(human => human.status === 'active' && human.can_use).map(human => <option key={human.id} value={human.id}>{human.name} · v{human.version}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<p className="persona-intro">任务分工、导图节点和前后文衔接要求继续生效;数字人负责统一表达方式。保存或发布 Agent 后,后续任务会使用选定的人格版本。</p>
|
||||
<div className="persona-actions">
|
||||
{cur.digital_human_id && <button className="mini" onClick={() => nav(`/admin/digital-humans/${cur.digital_human_id}`)}>{boundHuman?.can_manage ? '前往数字人编辑表达设定' : '查看数字人档案'}</button>}
|
||||
<button className="mini" onClick={() => nav('/admin/digital-humans')}>蒸馏或管理数字人</button>
|
||||
</div>
|
||||
{humanLoading && <p role="status" className="field-help">正在读取人格档案…</p>}
|
||||
{humanError && <p role="alert" className="err">{humanError}</p>}
|
||||
{humanDetail && <>
|
||||
<p className="persona-intro"><strong>{humanDetail.name} · v{humanDetail.version}</strong> · {humanDetail.status === 'active' ? '已审核生效' : '新版本待试写与审核'}。{humanDetail.status !== 'active' ? '已保存任务沿用原人格版本;新绑定需先审核。' : '下方为此数字人的统一表达设定。'}</p>
|
||||
<PersonaProfile key={humanDetail.id} persona={humanDetail.persona || {}} />
|
||||
</>}
|
||||
{!cur.digital_human_id && <p className="field-help">先到数字人上传本人的资料,完成蒸馏、试写与审核,再回来选择身份。</p>}
|
||||
</div>}
|
||||
|
||||
{detailTab === 'versions' && <div className="card agent-form">
|
||||
<div className="kv-list">
|
||||
<div><span>Agent 配置版本</span><p>v{cur.rules_version || 1}</p></div>
|
||||
<div><span>原任务模板</span><p>{cur.prompt_template_name || '系统默认'} · v{cur.prompt_template_version || 1}</p></div>
|
||||
<div><span>融合成员</span><p>{cur.fusion_agent_names?.join(' + ') || '—'}</p></div>
|
||||
<div><span>发布状态</span><p>{cur.runtime_snapshot?.published_at ? `已发布 v${cur.runtime_snapshot.published_version} · ${cur.runtime_snapshot.published_at}` : '未发布(使用实时配置)'}</p></div>
|
||||
<div><span>创建信息</span><p>{cur.created_at || '—'}</p></div>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* sticky 保存栏:仅所有者/管理员可改 */}
|
||||
<div className="sticky-review save-bar">
|
||||
<span role="status">{busy ? '正在提交,请勿重复操作…' : dirty ? '有未保存的修改' : '配置已同步'}</span>
|
||||
{editable ? <>
|
||||
<button className="ok" onClick={save} disabled={busy}>保存草稿(v{cur.rules_version + 1})</button>
|
||||
<button className="primary-action" onClick={publishAgent} disabled={busy}>发布(冻结配置)</button>
|
||||
<button className="danger-button" onClick={remove} disabled={busy}>删除</button>
|
||||
</> : globalReadonly ? <><button className="primary-action" onClick={fork} disabled={busy || forkBusy}>{forkBusy ? '正在创建副本…' : '创建我的副本并编辑'}</button><button className="mini danger-text" onClick={remove} disabled={busy}>从我的列表移除</button><span className="field-help">全局资源不能直接修改或导出;创建副本后,你的编辑、删除和版本只影响自己。移除只对当前账号生效。</span></> : <span className="field-help">该 Agent 为只读资源,你只有使用权;如需修改请创建自己的副本。</span>}
|
||||
</div>
|
||||
</>}
|
||||
</section>
|
||||
</div></fieldset>}
|
||||
|
||||
{moduleTab === 'skills' && <SkillsPage />}
|
||||
{moduleTab === 'memories' && <MemoriesPage />}
|
||||
{aiOpen && <AiAssistDialog kind="agent" onApply={applyAiDraft} onClose={() => setAiOpen(false)} />}
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
/* 页面组件:从 App.tsx 拆出(Rose Clay 重构) */
|
||||
import { useContext, useEffect, useRef, useState } from 'react'
|
||||
import { UNSAFE_NavigationContext } from 'react-router-dom'
|
||||
|
||||
/** Agent 模块专用:HashRouter 下保护路由切换、退出与浏览器刷新。 */
|
||||
export function useAgentEditSafety(dirty: boolean, busy: boolean) {
|
||||
const dialog = useDialog()
|
||||
const { navigator } = useContext(UNSAFE_NavigationContext)
|
||||
const live = useRef({ dirty, busy, dialog })
|
||||
live.current = { dirty, busy, dialog }
|
||||
const asking = useRef(false)
|
||||
const allow = async () => {
|
||||
if (live.current.busy || asking.current) return false
|
||||
if (!live.current.dirty) return true
|
||||
asking.current = true
|
||||
try { return await live.current.dialog.confirm({ title: '放弃未保存的修改?', description: '继续将丢失本次输入;取消可返回编辑并保存。', confirmLabel: '放弃修改', tone: 'danger' }) }
|
||||
finally { asking.current = false }
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!dirty && !busy) return
|
||||
const push = navigator.push.bind(navigator), replace = navigator.replace.bind(navigator)
|
||||
navigator.push = (...args) => { void allow().then(ok => { if (ok) push(...args) }) }
|
||||
navigator.replace = (...args) => { void allow().then(ok => { if (ok) replace(...args) }) }
|
||||
const unload = (event: BeforeUnloadEvent) => { if (live.current.dirty || live.current.busy) { event.preventDefault(); event.returnValue = '' } }
|
||||
const logout = (event: MouseEvent) => {
|
||||
const target = (event.target as Element).closest('button')
|
||||
if (!target || !(target.classList.contains('user-logout') || target.getAttribute('aria-label') === '退出')) return
|
||||
if (!live.current.dirty && !live.current.busy) return
|
||||
event.preventDefault(); event.stopImmediatePropagation()
|
||||
void allow().then(ok => { if (ok) { live.current.dirty = false; target.click() } })
|
||||
}
|
||||
// HashRouter 的返回/前进在 React 处理前回到原 history entry,再经确认重放。
|
||||
let index = window.history.state?.idx ?? 0
|
||||
let restoring = false, replaying = false
|
||||
let blockedHashChanges = 0
|
||||
const hash = (event: HashChangeEvent) => { if (blockedHashChanges > 0) { blockedHashChanges--; event.stopImmediatePropagation() } }
|
||||
const pop = (event: PopStateEvent) => {
|
||||
const next = event.state?.idx ?? index
|
||||
if (restoring) { restoring = false; event.stopImmediatePropagation(); return }
|
||||
if (replaying) { replaying = false; index = next; return }
|
||||
if ((!live.current.dirty && !live.current.busy) || next === index) { index = next; return }
|
||||
event.stopImmediatePropagation()
|
||||
const delta = next - index
|
||||
restoring = true
|
||||
blockedHashChanges += 2
|
||||
window.history.go(-delta)
|
||||
void allow().then(ok => { if (ok) { replaying = true; window.history.go(delta) } })
|
||||
}
|
||||
// push/replace route updates change idx without a popstate event.
|
||||
const syncIndex = () => { if (!restoring && !replaying) index = window.history.state?.idx ?? index }
|
||||
const timer = window.setInterval(syncIndex, 100)
|
||||
const browserNavigation = (window as any).navigation
|
||||
let permittedTraversal = false
|
||||
const traverse = (event: any) => {
|
||||
if (event.navigationType !== 'traverse' || !event.cancelable || (!live.current.dirty && !live.current.busy)) return
|
||||
if (permittedTraversal) { permittedTraversal = false; return }
|
||||
event.preventDefault()
|
||||
void allow().then(ok => { if (ok) { permittedTraversal = true; replaying = true; browserNavigation.traverseTo(event.destination.key).finished.catch(() => { permittedTraversal = false; replaying = false }) } })
|
||||
}
|
||||
browserNavigation?.addEventListener('navigate', traverse)
|
||||
window.addEventListener('beforeunload', unload)
|
||||
window.addEventListener('popstate', pop, true)
|
||||
window.addEventListener('hashchange', hash, true)
|
||||
document.addEventListener('click', logout, true)
|
||||
return () => { browserNavigation?.removeEventListener('navigate', traverse); navigator.push = push; navigator.replace = replace; window.clearInterval(timer); window.removeEventListener('beforeunload', unload); window.removeEventListener('popstate', pop, true); window.removeEventListener('hashchange', hash, true); document.removeEventListener('click', logout, true) }
|
||||
}, [navigator, dirty, busy])
|
||||
return Object.assign(allow, { release: () => { live.current.dirty = false; live.current.busy = false } })
|
||||
}
|
||||
import { api, localizeError } from '../api'
|
||||
import { useDialog } from '../dialog'
|
||||
import { Tag } from '../ui'
|
||||
import { OwnerLabel } from '../workbench-ui'
|
||||
import { AiAssistDialog, type AiDraft } from './aiAssist'
|
||||
import { STATUS_LABEL, KB_STATUS_LABEL, CLASSIFICATION_STATUS_LABEL, RUNTIME_EVENT_LABEL,
|
||||
RUNTIME_PHASE_LABEL, RUNTIME_TOOL_LABEL, PAGE_META, RULE_TEMPLATES } from '../constants'
|
||||
import type { Seg, Camp, CampaignState, RuntimeDetail, CampaignCreateResponse, ComposeResponse,
|
||||
AgentCreateResponse, AgentSummary, KnowledgeBaseSummary, NotificationSummary, AuthUser, AuthStatus,
|
||||
TraceSummary, TraceDetail, UsageSummary, UsageEventRow, PointState, PointAccountRow, UsageResponse,
|
||||
AdminUsageResponse, AgentListRow, MemoryListRow, KbFileRow, KbCategoryRow, KbDetailResponse, KbUsageResponse } from '../types'
|
||||
|
||||
/* ---------------- Agent 管理 ---------------- */
|
||||
export default function AgentsPage() {
|
||||
const dialog = useDialog()
|
||||
const [agents, setAgents] = useState<any[]>([])
|
||||
const [tags, setTags] = useState<any[]>([])
|
||||
const [providers, setProviders] = useState<any[]>([])
|
||||
const [kbs, setKbs] = useState<any[]>([])
|
||||
const [kbCategories, setKbCategories] = useState<Record<number, KbCategoryRow[]>>({})
|
||||
const [skills, setSkills] = useState<any[]>([])
|
||||
const [promptTemplates, setPromptTemplates] = useState<any[]>([])
|
||||
const [toolCatalog, setToolCatalog] = useState<any[]>([])
|
||||
const [cur, setCur] = useState<any>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [aiOpen, setAiOpen] = useState(false)
|
||||
const applyAiDraft = async (draft: AiDraft) => {
|
||||
setAiOpen(false)
|
||||
try {
|
||||
// 先创建占位 Agent,再把草稿字段填进去(Agent 编辑是保存式的)
|
||||
const r = await api.post<AgentCreateResponse>('/agents', {
|
||||
name: draft.name || '新Agent', role: draft.role || 'production',
|
||||
desc: draft.desc || '', status: 'disabled',
|
||||
rules: (draft.rules || []).length ? draft.rules.map(item => `- ${item}`).join('\n') : undefined,
|
||||
content_tags: draft.content_tags || [],
|
||||
})
|
||||
setCur(await api.get(`/agents/${r.id}`)); await refresh()
|
||||
void dialog.notice({ title: '草稿已填入', description: draft.advice || '请补齐知识库挂接、绑定模型后保存并启用。' })
|
||||
} catch (e: any) { setError(String(e.message || e)) }
|
||||
}
|
||||
const loadAvailableModels = async () => {
|
||||
const rows = await api.get<any[]>('/models/available')
|
||||
const grouped: Record<number, any> = {}
|
||||
rows.forEach(row => {
|
||||
const current = grouped[row.provider_id] || { id: row.provider_id, name: row.provider_name, models: [], catalogs: [] }
|
||||
current.models.push(row.model_name)
|
||||
current.catalogs.push(row)
|
||||
grouped[row.provider_id] = current
|
||||
})
|
||||
setProviders(Object.values(grouped))
|
||||
}
|
||||
const refresh = () => Promise.all([
|
||||
api.get('/agents').then(setAgents), api.get('/tags').then(setTags),
|
||||
loadAvailableModels(), api.get('/kbs').then(setKbs),
|
||||
api.get('/skills').then(setSkills), api.get('/prompt-templates').then(setPromptTemplates),
|
||||
api.get('/agent-tools').then(setToolCatalog),
|
||||
])
|
||||
useEffect(() => { refresh() }, [])
|
||||
const applyTemplate = (templateId: string) => {
|
||||
const template = RULE_TEMPLATES.find(item => item.id === templateId)
|
||||
if (!template || !cur) return
|
||||
setCur({ ...cur, role: template.role, desc: cur.desc || template.desc, rules: template.rules })
|
||||
}
|
||||
const selectAgent = async (id: number) => {
|
||||
const agent = await api.get<any>(`/agents/${id}`)
|
||||
const selected = agent.kb_ids || []
|
||||
const entries = await Promise.all(selected.map(async (kid: number) => [kid, (await api.get<KbDetailResponse>(`/kbs/${kid}`)).categories || []] as const))
|
||||
setKbCategories(Object.fromEntries(entries)); setCur({ ...agent, kb_category_ids: agent.kb_category_ids || {} })
|
||||
}
|
||||
const save = async () => {
|
||||
if (!cur) return
|
||||
setBusy(true); setError('')
|
||||
try {
|
||||
await api.put(`/agents/${cur.id}`, {
|
||||
name: cur.name, role: cur.role, desc: cur.desc, content_tags: cur.content_tags, kb_ids: cur.kb_ids, kb_category_ids: cur.kb_category_ids || {},
|
||||
skill_ids: cur.skill_ids || [], fusion_agent_ids: cur.fusion_agent_ids || [],
|
||||
allowed_tools: cur.allowed_tools || [],
|
||||
model_provider_id: cur.model_provider_id, model_name: cur.model_name,
|
||||
model_catalog_id: cur.model_catalog_id || null,
|
||||
prompt_template_id: cur.prompt_template_id || null,
|
||||
status: cur.status, rules: cur.rules,
|
||||
})
|
||||
setCur(null); await refresh()
|
||||
} catch (e: any) { setError(String(e.message || e)) } finally { setBusy(false) }
|
||||
}
|
||||
const create = async () => {
|
||||
setBusy(true); setError('')
|
||||
try {
|
||||
const r = await api.post<AgentCreateResponse>('/agents', { name: '新Agent', status: 'disabled' })
|
||||
setCur(await api.get(`/agents/${r.id}`)); await refresh()
|
||||
} catch (e: any) { setError(String(e.message || e)) } finally { setBusy(false) }
|
||||
}
|
||||
const remove = async () => {
|
||||
if (!cur || cur.builtin || !await dialog.confirm({ title: `删除 Agent「${cur.name}」?`, description: '系统会先自动解除画布、融合、技能、知识库和记忆绑定,再永久删除 Agent。已参与历史任务的 Agent 仍需保留。', confirmLabel: '确认删除', tone: 'danger' })) return
|
||||
setBusy(true); setError('')
|
||||
try { await api.delete(`/agents/${cur.id}`); setCur(null); await refresh() }
|
||||
catch (e: any) { setError(String(e.message || e)) } finally { setBusy(false) }
|
||||
}
|
||||
const toggleTool = (toolName: string) => {
|
||||
if (!cur) return
|
||||
const selected = Array.isArray(cur.allowed_tools) ? cur.allowed_tools : toolCatalog.filter(tool => tool.default_enabled).map(tool => tool.name)
|
||||
setCur({ ...cur, allowed_tools: selected.includes(toolName)
|
||||
? selected.filter((name: string) => name !== toolName)
|
||||
: [...selected, toolName] })
|
||||
}
|
||||
return (
|
||||
<div className="grid2">
|
||||
<section>
|
||||
<div className="section-title-row"><div><h2>Agent 列表</h2><p>每个 Agent 负责一种内容节点或质量检查。</p></div>
|
||||
<div className="heading-actions-inline"><button className="primary-action ai-entry" onClick={() => setAiOpen(true)}><span>✦</span> AI 帮我创建</button><button className="mini ok" onClick={create} disabled={busy}>+ 新建</button></div></div>
|
||||
{agents.map(a => (
|
||||
<div key={a.id} className={`row click ${a.status === 'disabled' ? 'dim' : ''}`} onClick={() => void selectAgent(a.id)}>
|
||||
<b>{a.name}</b>
|
||||
<OwnerLabel resource={a} />
|
||||
<span className="badge">{{ production: '内容生产', training_map: '导图生成', qc: '质检', service: '服务', coordinator: '主控', composer: '组合' }[a.role] || a.role}</span>
|
||||
<span className="dim">{a.content_tags.join('/') || '—'}</span>
|
||||
{a.fusion_agent_names?.length > 0 && <span className="dim">融合:{a.fusion_agent_names.join(' + ')}</span>}
|
||||
<span className="dim">提示:{a.prompt_template_name || '系统默认'} · v{a.prompt_template_version || 1}</span>
|
||||
<span className="dim">{a.model_label}</span>
|
||||
{a.status === 'disabled' && <span className="badge">停用</span>}
|
||||
{a.builtin && <span className="badge gold">内置</span>}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
<section>
|
||||
<div className="section-title-row"><div><h2>{cur ? `编辑:${cur.name}` : '选择一个 Agent'}</h2><p>{cur ? '先套用模板,再按实际业务补充少量规则。' : '从左侧选择 Agent,即可查看配置。'}</p></div></div>
|
||||
{error && <div className="err">{error}</div>}
|
||||
{cur && (
|
||||
<div className="card">
|
||||
<div className="rule-template-bar"><div><strong>不知道怎么写规则?</strong><span>选择模板会填入基础规则,之后仍可自由修改。</span></div><select value="" onChange={e => applyTemplate(e.target.value)}><option value="">套用规则模板…</option>{RULE_TEMPLATES.map(template => <option key={template.id} value={template.id}>{template.label}</option>)}</select></div>
|
||||
<label>名称 <input value={cur.name} onChange={e => setCur({ ...cur, name: e.target.value })} /></label>
|
||||
<label>职责类型
|
||||
<select value={cur.role} onChange={e => setCur({ ...cur, role: e.target.value })}>
|
||||
<option value="production">生产</option><option value="training_map">导图生成</option><option value="qc">质检</option>
|
||||
<option value="service">服务</option><option value="coordinator">主控</option>
|
||||
<option value="composer">组合</option>
|
||||
</select></label>
|
||||
<label>内容标签(多选)<span className="field-help">决定此 Agent 可以处理哪些任务段。任务拆解后,系统会按标签把段落派给匹配的 Agent。</span>
|
||||
<div className="chips">{tags.filter(t => t.enabled).map(t => (
|
||||
<button key={t.id} className={`chip ${cur.content_tags.includes(t.name) ? 'on' : ''}`}
|
||||
onClick={() => setCur({ ...cur, content_tags: cur.content_tags.includes(t.name)
|
||||
? cur.content_tags.filter((x: string) => x !== t.name) : [...cur.content_tags, t.name] })}>
|
||||
{t.name}</button>))}
|
||||
</div></label>
|
||||
<label>融合 Agent(多选)<span className="field-help">主笔 Agent 会融合勾选 Agent 的写法、技能和已确认记忆,最终仍输出一段完整正文;知识只从主笔已挂接的知识库读取,因此不会越过素材边界。</span>
|
||||
{cur.role === 'production' ? <div className="chips fusion-chips">{agents.filter(agent => agent.id !== cur.id && agent.role === 'production' && agent.status === 'active').map(agent => (
|
||||
<button key={agent.id} className={`chip ${(cur.fusion_agent_ids || []).includes(agent.id) ? 'on' : ''}`}
|
||||
onClick={() => setCur({ ...cur, fusion_agent_ids: (cur.fusion_agent_ids || []).includes(agent.id)
|
||||
? cur.fusion_agent_ids.filter((id: number) => id !== agent.id) : [...(cur.fusion_agent_ids || []), agent.id] })}>
|
||||
{agent.name}
|
||||
</button>))}</div> : <span className="field-help">只有生产 Agent 可以配置融合能力。</span>}
|
||||
{cur.role === 'production' && !agents.some(agent => agent.id !== cur.id && agent.role === 'production' && agent.status === 'active') && <span className="field-help">暂无其他启用的生产 Agent 可融合。</span>}
|
||||
</label>
|
||||
<label>知识库挂接(多选)<span className="field-help">内容生产 Agent 必须挂接知识库。导图生成 Agent 使用训练营导图页面单独选择的知识库,可不挂接知识库,避免与导图资料范围冲突。</span>
|
||||
<div className="chips">{kbs.map(k => (
|
||||
<button key={k.id} className={`chip ${cur.kb_ids.includes(k.id) ? 'on' : ''}`}
|
||||
onClick={() => setCur({ ...cur, kb_ids: cur.kb_ids.includes(k.id)
|
||||
? cur.kb_ids.filter((x: number) => x !== k.id) : [...cur.kb_ids, k.id] })}>
|
||||
{k.name}</button>))}
|
||||
</div></label>
|
||||
<label>知识库分类(按已选知识库)
|
||||
<div className="chips">{Object.entries(kbCategories).flatMap(([kid, nodes]) => nodes.flatMap((node: KbCategoryRow) => [node, ...(node.children || [])]).map((c: KbCategoryRow) => {
|
||||
const selected = (cur.kb_category_ids?.[kid] || []).includes(c.id)
|
||||
return <button key={`${kid}-${c.id}`} className={`chip ${selected ? 'on' : ''}`} onClick={() => setCur({ ...cur, kb_category_ids: { ...(cur.kb_category_ids || {}), [kid]: selected ? (cur.kb_category_ids[kid] || []).filter((id: number) => id !== c.id) : [...(cur.kb_category_ids?.[kid] || []), c.id] } })}>{c.name}</button>
|
||||
}))}</div>
|
||||
</label>
|
||||
<label>技能挂接(多选)<span className="field-help">技能用于补充执行方法;它不能覆盖“只用知识库、同 Agent 片段不可重复使用”的系统规则。</span>
|
||||
<div className="chips">{skills.filter(skill => skill.enabled).map(skill => (
|
||||
<button key={skill.id} className={`chip ${(cur.skill_ids || []).includes(skill.id) ? 'on' : ''}`}
|
||||
onClick={() => setCur({ ...cur, skill_ids: (cur.skill_ids || []).includes(skill.id)
|
||||
? cur.skill_ids.filter((id: number) => id !== skill.id) : [...(cur.skill_ids || []), skill.id] })}>
|
||||
{skill.name}</button>))}
|
||||
</div>
|
||||
{!skills.length && <span className="field-help">暂无技能。请先到“技能库”新建可复用方法。</span>}
|
||||
</label>
|
||||
<label>工具权限<span className="field-help">系统工具用于知识、记忆和画布衔接;联网工具只访问公开网页,并且每次调用都要人工审批。没有勾选的工具不会注入模型。</span>
|
||||
<div className="tool-permission-list">
|
||||
{toolCatalog.map(tool => {
|
||||
const selected = (Array.isArray(cur.allowed_tools) ? cur.allowed_tools : toolCatalog.filter(item => item.default_enabled).map(item => item.name)).includes(tool.name)
|
||||
return <label key={tool.name} className={`tool-permission-row ${tool.configurable ? '' : 'locked'}`}>
|
||||
<input type="checkbox" checked={selected} disabled={!tool.configurable} onChange={() => toggleTool(tool.name)} />
|
||||
<span><b>{tool.label}</b><small>{tool.description}</small></span>
|
||||
<em>{tool.requires_approval ? '需人工审批' : tool.category}</em>
|
||||
</label>
|
||||
})}
|
||||
</div>
|
||||
{!toolCatalog.length && <span className="field-help">工具目录加载中…</span>}
|
||||
</label>
|
||||
<label>模型
|
||||
<select value={cur.model_provider_id || ''} onChange={e => setCur({ ...cur, model_provider_id: e.target.value ? Number(e.target.value) : null, model_catalog_id: null, model_name: '' })}>
|
||||
<option value="">跟随全局默认(DeepSeek)</option>
|
||||
{providers.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
{cur.model_provider_id && (
|
||||
<select value={cur.model_name} onChange={e => { const item = providers.find(p => p.id === cur.model_provider_id)?.catalogs.find((candidate: any) => candidate.model_name === e.target.value); setCur({ ...cur, model_name: e.target.value, model_catalog_id: item?.id || null }) }}>
|
||||
{(providers.find(p => p.id === cur.model_provider_id)?.catalogs || []).map((item: any) => <option key={item.id} value={item.model_name}>{item.display_name || item.model_name}</option>)}
|
||||
</select>)}
|
||||
</label>
|
||||
<label>提示模板<span className="field-help">模板负责角色、任务、示例和输出协议;保存任务时会冻结当前版本。</span>
|
||||
<select value={cur.prompt_template_id || ''} onChange={e => setCur({ ...cur, prompt_template_id: e.target.value ? Number(e.target.value) : null })}>
|
||||
<option value="">使用系统默认模板</option>
|
||||
{promptTemplates.filter(template => template.enabled).map(template => <option key={template.id} value={template.id}>{template.name} · v{template.version}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>状态<span className="field-help">内容生产 Agent 必须先挂接知识库,导图生成 Agent 的资料范围由训练营导图单独选择。</span>
|
||||
<select value={cur.status} onChange={e => setCur({ ...cur, status: e.target.value })}>
|
||||
<option value="active">启用</option><option value="disabled">停用</option>
|
||||
</select></label>
|
||||
<label>规则(rules.md)<span className="field-help">用来约束这个 Agent 的职责、输出方式和禁止事项。系统会把规则连同当前任务一起发送给模型。</span><textarea rows={12} value={cur.rules} onChange={e => setCur({ ...cur, rules: e.target.value })} /></label>
|
||||
<div className="btns">
|
||||
<button className="ok" onClick={save} disabled={busy}>💾 保存(版本 v{cur.rules_version + 1})</button>
|
||||
<button className="danger-button" onClick={remove} disabled={busy}>删除 Agent</button>
|
||||
<button onClick={() => setCur(null)}>取消</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{aiOpen && <AiAssistDialog kind="agent" onApply={applyAiDraft} onClose={() => setAiOpen(false)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---------------- 技能库 ---------------- */
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
|
||||
type Row = { id:number; display_name:string; action:string; resource:string; result:string; created_at:string }
|
||||
|
||||
// 审计记录由后端统一记录 HTTP 操作。这里仅翻译已知枚举,后端新增值仍显示原文,避免丢失排查信息。
|
||||
const ACTION_LABEL: Record<string, string> = {
|
||||
'http.get': '查看',
|
||||
'http.post': '创建 / 提交',
|
||||
'http.put': '更新',
|
||||
'http.patch': '更新',
|
||||
'http.delete': '删除',
|
||||
login: '登录',
|
||||
logout: '退出登录',
|
||||
'points.grant': '发放点数',
|
||||
'feature_flags.update': '更新功能开关',
|
||||
'tags.update': '更新标签',
|
||||
}
|
||||
const RESULT_LABEL: Record<string, string> = {
|
||||
success: '成功',
|
||||
ok: '成功',
|
||||
failure: '失败',
|
||||
failed: '失败',
|
||||
error: '错误',
|
||||
}
|
||||
const RESOURCE_LABEL: Record<string, string> = {
|
||||
'/api/ops': '运营运维',
|
||||
'/api/auth/login': '登录',
|
||||
'/api/auth/logout': '退出登录',
|
||||
'/api/auth/users': '用户',
|
||||
'/api/auth': '访问控制',
|
||||
'/api/tags': '标签',
|
||||
'/api/agent-tools': '智能体工具',
|
||||
'/api/agents': '智能体',
|
||||
'/api/settings': '系统设置',
|
||||
'/api/digital-humans': '数字人',
|
||||
'/api/skills': '技能',
|
||||
'/api/prompt-templates': '提示词模板',
|
||||
'/api/models': '模型',
|
||||
'/api/providers': '模型服务商',
|
||||
'/api/kbs': '知识库',
|
||||
'/api/kb-metrics': '知识库指标',
|
||||
'/api/kb-usages': '知识库用量',
|
||||
'/api/admin/knowledge-bases': '知识库授权',
|
||||
'/api/admin/canvases': '画布模板管理',
|
||||
'/api/notifications': '通知',
|
||||
'/api/pending_memories': '待确认记忆',
|
||||
'/api/audit-logs': '操作日志',
|
||||
'/api/system-logs': '系统日志',
|
||||
'/api/campaigns': '内容任务',
|
||||
'/api/training-maps': '训练营导图',
|
||||
'/api/canvases': '画布模板',
|
||||
'/api/runs': '运行记录',
|
||||
'/api/segments': '任务段',
|
||||
'/api/artifacts': '成稿',
|
||||
'/api/usage': '用量分析',
|
||||
'/api/admin/usage': '用量分析',
|
||||
'/api/me': '个人中心',
|
||||
'/api/points': '点数',
|
||||
'/api/admin/points': '点数管理',
|
||||
'/api/admin/billing': '账单结算',
|
||||
'/api/admin/points/grant': '点数发放',
|
||||
}
|
||||
|
||||
function labelAction(value: string, resource = '') {
|
||||
const raw = String(value || '').trim()
|
||||
const method = raw.toLowerCase()
|
||||
if (raw.startsWith('http.')) {
|
||||
const method = raw.slice(5).toLowerCase()
|
||||
const path = resource.split('?')[0]
|
||||
if (/^\/api\/campaigns\/\d+\/segments(?:\/\d+)?$/.test(path)) {
|
||||
return ({ post: '创建内容段', put: '修改内容段', patch: '修改内容段', delete: '删除内容段' } as Record<string, string>)[method] || '查看内容段'
|
||||
}
|
||||
if (method === 'post' && /^\/api\/campaigns\/\d+\/parse$/.test(path)) return '拆解内容任务'
|
||||
if (method === 'post' && /^\/api\/campaigns\/\d+\/reparse$/.test(path)) return '重新拆解内容任务'
|
||||
if (method === 'post' && /^\/api\/campaigns\/\d+\/run-step$/.test(path)) return '生成下一内容段'
|
||||
if (method === 'post' && /^\/api\/campaigns\/\d+\/run$/.test(path)) return '启动内容任务'
|
||||
if (method === 'post' && /^\/api\/campaigns\/\d+\/compose$/.test(path)) return '合成内容成稿'
|
||||
if (method === 'post' && /^\/api\/segments\/\d+\/(review|fix)$/.test(path)) return '审核或修复内容段'
|
||||
const specific: Record<string, string> = {
|
||||
'post:/api/segments': '创建内容段',
|
||||
'post:/api/segments/': '处理内容段',
|
||||
'post:/api/campaigns': '创建内容任务',
|
||||
'post:/api/campaigns/parse': '拆解内容任务',
|
||||
'post:/api/campaigns/reparse': '重新拆解内容任务',
|
||||
'post:/api/campaigns/run-step': '生成下一内容段',
|
||||
'post:/api/campaigns/run': '启动内容任务',
|
||||
'post:/api/campaigns/compose': '合成内容成稿',
|
||||
'post:/api/campaigns/': '操作内容任务',
|
||||
'post:/api/training-maps': '生成训练营导图',
|
||||
'post:/api/digital-humans': '创建数字人',
|
||||
'post:/api/kbs': '创建知识库',
|
||||
'post:/api/kbs/': '更新知识库',
|
||||
'post:/api/providers': '配置模型服务商',
|
||||
}
|
||||
const key = `${method}:${resource}`
|
||||
const exact = specific[key] || Object.entries(specific).find(([prefix]) => prefix.endsWith('/') && key.startsWith(prefix))?.[1]
|
||||
if (exact) return exact
|
||||
const methodLabel: Record<string, string> = { get: '查看', post: '创建 / 提交', put: '更新', patch: '更新', delete: '删除' }
|
||||
return methodLabel[method] || `访问(${method.toUpperCase()})`
|
||||
}
|
||||
if (ACTION_LABEL[raw]) return ACTION_LABEL[raw]
|
||||
if (['get', 'post', 'put', 'patch', 'delete'].includes(method)) {
|
||||
return ({ get: '查看', post: '创建 / 提交', put: '更新', patch: '更新', delete: '删除' } as Record<string, string>)[method]
|
||||
}
|
||||
return raw || '—'
|
||||
}
|
||||
|
||||
function labelResource(value: string) {
|
||||
const raw = String(value || '')
|
||||
const segment = raw.match(/^\/api\/campaigns\/(\d+)\/segments(?:\/(\d+))?$/)
|
||||
if (segment) return `内容任务 #${segment[1]}${segment[2] ? ` / 内容段 #${segment[2]}` : ' / 内容段'}`
|
||||
const task = raw.match(/^\/api\/campaigns\/(\d+)/)
|
||||
if (task) return `内容任务 #${task[1]}`
|
||||
const standaloneSegment = raw.match(/^\/api\/segments\/(\d+)/)
|
||||
if (standaloneSegment) return `内容段 #${standaloneSegment[1]}`
|
||||
if (RESOURCE_LABEL[raw]) return RESOURCE_LABEL[raw]
|
||||
// 记录中的资源通常带有 ID 或查询参数,先匹配已知 API 前缀。
|
||||
const prefix = Object.keys(RESOURCE_LABEL).find(item => raw.startsWith(`${item}/`) || raw.startsWith(`${item}?`))
|
||||
return prefix ? RESOURCE_LABEL[prefix] : raw || '—'
|
||||
}
|
||||
|
||||
function labelResult(value: string) {
|
||||
const raw = String(value || '')
|
||||
return RESULT_LABEL[raw.toLowerCase()] || raw || '—'
|
||||
}
|
||||
|
||||
export default function AuditLogsPage() {
|
||||
const [rows, setRows] = useState<Row[]>([]); const [page, setPage] = useState(1); const [total, setTotal] = useState(0)
|
||||
useEffect(() => { api.get<{items:Row[]; total:number}>(`/audit-logs?page=${page}&page_size=50`).then(x => { setRows(x.items); setTotal(x.total) }) }, [page])
|
||||
return <section><h2>操作日志</h2><p>管理员业务变更审计(不记录密钥、令牌或请求正文)。共 {total} 条。</p>
|
||||
<div className="card"><div className="row dim"><b>操作人</b><span>动作</span><span>资源</span><span>结果</span><span>时间</span></div>{rows.map(r => <div className="row" key={r.id}><b>{r.display_name || '系统'}</b><span>{labelAction(r.action, r.resource)}</span><span>{labelResource(r.resource)}</span><span>{labelResult(r.result)}</span><span className="dim">{r.created_at}</span></div>)}{!rows.length && <div className="empty-workspace compact">暂无操作日志</div>}</div>
|
||||
<div className="btns"><button disabled={page <= 1} onClick={() => setPage(page-1)}>上一页</button><button disabled={page*50 >= total} onClick={() => setPage(page+1)}>下一页</button></div>
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import './training-map.css'
|
||||
export function ContentTabs({ map = false }: { map?: boolean }) {
|
||||
return <nav className="tm-tabs" aria-label="内容生成标签"><Link aria-current={!map ? 'page' : undefined} to="/admin/tasks">内容任务</Link><Link aria-current={map ? 'page' : undefined} to="/admin/training-maps">训练营导图</Link></nav>
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
// 数字人工作台(/digital-humans):列表 + 详情抽屉
|
||||
// 流程:创建(上传资料蒸馏)→ 测试(生成文稿试写)→ 人工审核(通过=生效 / 填建议重蒸馏)
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { Sparkles, RefreshCw, Check, X, FlaskConical, MessageCircle, QrCode, Link2, Unplug, RotateCw } from 'lucide-react'
|
||||
import { api, chunkedUpload } from '../api'
|
||||
import { useDialog } from '../dialog'
|
||||
import { PageHeader, ResourceSummary, OwnerLabel } from '../workbench-ui'
|
||||
import PersonaProfile from './PersonaProfile'
|
||||
import { useAgentEditSafety } from './AgentsPage'
|
||||
import { forkResource, isGlobalResource } from '../resourceActions'
|
||||
import QRCode from 'qrcode'
|
||||
|
||||
const STATUS_BADGE: Record<string, { label: string; cls: string }> = {
|
||||
distilling: { label: '蒸馏中', cls: '' },
|
||||
testing: { label: '待人工测试', cls: 'gold' },
|
||||
active: { label: '已生效', cls: 'okbg' },
|
||||
}
|
||||
|
||||
export default function DigitalHumansPage() {
|
||||
const dialog = useDialog()
|
||||
const nav = useNavigate()
|
||||
const { id } = useParams()
|
||||
const [list, setList] = useState<any[]>([])
|
||||
const [sel, setSel] = useState<any>(null)
|
||||
const [grantOpen, setGrantOpen] = useState(false)
|
||||
const [grantList, setGrantList] = useState<any[]>([])
|
||||
const [grantUserId, setGrantUserId] = useState('')
|
||||
const [grantPermission, setGrantPermission] = useState('use')
|
||||
const [members, setMembers] = useState<any[]>([])
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
// 蒸馏向导
|
||||
const [wizardOpen, setWizardOpen] = useState(false)
|
||||
const [wName, setWName] = useState('')
|
||||
const [wDesc, setWDesc] = useState('')
|
||||
const [wText, setWText] = useState('')
|
||||
const [wVisibility, setWVisibility] = useState<'private' | 'shared' | 'global'>('private')
|
||||
const [wBusy, setWBusy] = useState(false)
|
||||
// 测试
|
||||
const [testTopic, setTestTopic] = useState('开场:欢迎姐妹们来到今晚的直播,今天讲盆底肌训练入门。')
|
||||
const [testResult, setTestResult] = useState('')
|
||||
const [testBusy, setTestBusy] = useState(false)
|
||||
const [advice, setAdvice] = useState('')
|
||||
const [redoFiles, setRedoFiles] = useState<File[]>([])
|
||||
const [redoBusy, setRedoBusy] = useState(false)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const [editingPersona, setEditingPersona] = useState(false)
|
||||
const [personaDraft, setPersonaDraft] = useState<Record<string, string>>({})
|
||||
const [personaBusy, setPersonaBusy] = useState(false)
|
||||
const [forkBusy, setForkBusy] = useState<number | null>(null)
|
||||
const [testVersion, setTestVersion] = useState<number | null>(null)
|
||||
// 微信 Bot 连接(每个登录用户独立,可绑定多个微信账号)
|
||||
const [wechatOpen, setWechatOpen] = useState(false)
|
||||
const [wechatAccounts, setWechatAccounts] = useState<any[]>([])
|
||||
const [wechatAgents, setWechatAgents] = useState<any[]>([])
|
||||
const [wechatLoading, setWechatLoading] = useState(false)
|
||||
const [wechatAction, setWechatAction] = useState<string | null>(null)
|
||||
const [wechatError, setWechatError] = useState('')
|
||||
const [wechatAgentId, setWechatAgentId] = useState('')
|
||||
const [wechatQr, setWechatQr] = useState<any>(null)
|
||||
const [wechatQrImage, setWechatQrImage] = useState('')
|
||||
|
||||
// iLink 的 qrcode_img_content 是扫码链接(HTML 页面),不是可直接作为
|
||||
// <img src> 的图片地址;在浏览器本地生成二维码,避免把登录链接转发给第三方。
|
||||
useEffect(() => {
|
||||
const value = String(wechatQr?.qr_code || wechatQr?.qr_url || wechatQr?.qrcode || '').trim()
|
||||
if (!value) { setWechatQrImage(''); return }
|
||||
let cancelled = false
|
||||
QRCode.toDataURL(value, { width: 360, margin: 2, errorCorrectionLevel: 'M' })
|
||||
.then(url => { if (!cancelled) setWechatQrImage(url) })
|
||||
.catch(() => { if (!cancelled) setWechatQrImage('') })
|
||||
return () => { cancelled = true }
|
||||
}, [wechatQr])
|
||||
useEffect(() => {
|
||||
if (!wechatQr?.id) return
|
||||
const current = wechatAccounts.find(item => String(item.id) === String(wechatQr.id))
|
||||
if (current && ['connected', 'online', 'ready'].includes(String(current.status || '').toLowerCase())) setWechatQr(null)
|
||||
}, [wechatAccounts, wechatQr])
|
||||
const personaDirty = editingPersona && JSON.stringify(personaDraft) !== JSON.stringify(sel?.persona || {})
|
||||
const leaveSafety = useAgentEditSafety(personaDirty, personaBusy || testBusy || redoBusy || busy)
|
||||
const canManage = Boolean(sel?.can_manage)
|
||||
const personaLocked = personaBusy || testBusy || redoBusy || busy
|
||||
useEffect(() => {
|
||||
setEditingPersona(false); setPersonaDraft(sel?.persona || {}); setTestResult(''); setTestVersion(null)
|
||||
setAdvice(''); setRedoFiles([])
|
||||
|
||||
}, [sel?.id, sel?.version])
|
||||
const savePersona = async () => {
|
||||
if (!sel || !canManage || personaLocked) return
|
||||
setPersonaBusy(true); setError('')
|
||||
try {
|
||||
await api.put(`/digital-humans/${sel.id}`, { persona: personaDraft, version: sel.version })
|
||||
setSel(await api.get(`/digital-humans/${sel.id}`)); setEditingPersona(false); setTestResult(''); setTestVersion(null)
|
||||
await refresh()
|
||||
void dialog.notice({ title: '人格已保存', description: '请试写并审核新版本,之后绑定的 Agent 会继承这份表达设定。', tone: 'success' })
|
||||
} catch (e: any) { setError(String(e.message || e)) } finally { setPersonaBusy(false) }
|
||||
}
|
||||
|
||||
const readResp = async (resp: Response) => {
|
||||
const text = await resp.text()
|
||||
try { return JSON.parse(text) } catch {
|
||||
throw new Error(text.includes('<!DOCTYPE') || text.includes('<html')
|
||||
? '服务返回异常(页面版本可能过期,请按 Cmd+Shift+R 强刷后重试)'
|
||||
: text.slice(0, 120) || `服务错误 ${resp.status}`)
|
||||
}
|
||||
}
|
||||
useEffect(() => { api.get<any>('/auth/users').then((r: any) => setMembers(Array.isArray(r) ? r : (r.users || []))).catch(() => {}) }, [])
|
||||
|
||||
const refresh = () => api.get<any[]>('/digital-humans').then(setList).catch(e => setError(String(e.message || e)))
|
||||
useEffect(() => { refresh() }, [])
|
||||
useEffect(() => {
|
||||
let current = true
|
||||
if (id) api.get(`/digital-humans/${id}`).then(value => { if (current) setSel(value) }).catch(e => { if (current) { setSel(null); setError(String(e.message || e)) } })
|
||||
else setSel(null)
|
||||
return () => { current = false }
|
||||
}, [id])
|
||||
|
||||
const select = (row: any) => { nav(`/admin/digital-humans/${row.id}`) }
|
||||
|
||||
// ---------- 蒸馏(创建/重蒸共用) ----------
|
||||
const distill = async (adviceText = '', withTest = false) => {
|
||||
if (!wizardOpen && !adviceText) { setWizardOpen(true); return }
|
||||
setWBusy(true); setError('')
|
||||
try {
|
||||
let r: any
|
||||
if (sel && adviceText) {
|
||||
// 重蒸馏
|
||||
const fd = new FormData()
|
||||
fd.append('advice', adviceText)
|
||||
if (wText.trim()) fd.append('text', wText)
|
||||
const token0 = localStorage.getItem('yawan-workbench-access-token')
|
||||
const resp0 = await fetch(`/api/digital-humans/${sel.id}/redistill`, { method: 'POST', headers: token0 ? { Authorization: `Bearer ${token0}` } : {}, body: fd })
|
||||
const d0 = await readResp(resp0)
|
||||
if (!resp0.ok) throw new Error(d0.detail || '重蒸馏失败')
|
||||
r = d0
|
||||
} else {
|
||||
const fd = new FormData()
|
||||
fd.append('name', wName)
|
||||
fd.append('desc', wDesc)
|
||||
fd.append('text', wText)
|
||||
|
||||
const token1 = localStorage.getItem('yawan-workbench-access-token')
|
||||
const resp1 = await fetch('/api/digital-humans/distill', { method: 'POST', headers: token1 ? { Authorization: `Bearer ${token1}` } : {}, body: fd })
|
||||
r = await resp1.json()
|
||||
if (!resp1.ok) throw new Error(r.detail || '蒸馏失败')
|
||||
await refresh()
|
||||
nav(`/admin/digital-humans/${r.id}`)
|
||||
}
|
||||
setSel(await api.get(`/digital-humans/${r.id}`))
|
||||
setWizardOpen(false); setWText(''); setWName(''); setWDesc(''); setWVisibility('private')
|
||||
void dialog.notice({ title: '蒸馏完成', description: `已生成 v${r.version} 人格档案,并自动创建同名 Agent;请测试并审核。`, tone: 'success' })
|
||||
} catch (e: any) { setError(String(e.message || e)) } finally { setWBusy(false) }
|
||||
}
|
||||
|
||||
const handleFiles = async (files: FileList | null) => {
|
||||
if (!files?.length) return
|
||||
const texts: string[] = []
|
||||
for (const f of Array.from(files)) {
|
||||
if (/\.(docx|pdf)$/i.test(f.name)) { void dialog.notice({ title: '提示', description: `${f.name} 是文档格式,请用「上传文档蒸馏」按钮直接上传。`, tone: 'success' }); continue }
|
||||
texts.push(await f.text())
|
||||
}
|
||||
if (texts.length) setWText(prev => (prev ? prev + '\n\n' : '') + texts.join('\n\n'))
|
||||
}
|
||||
|
||||
// 上传文档蒸馏(docx/pdf 走 distill 端点的 files 字段)
|
||||
const distillWithDocs = async (files: FileList | null) => {
|
||||
if (!files?.length) return
|
||||
const arr = Array.from(files)
|
||||
const badType = arr.filter(f => !/\.(txt|md|srt|docx|doc|pdf)$/i.test(f.name))
|
||||
const maxChunkedBytes = 20 * 1024 * 1024 * 1024
|
||||
const tooBig = arr.filter(f => f.size > maxChunkedBytes)
|
||||
const totalMB = arr.reduce((sum, f) => sum + f.size, 0) / 1024 / 1024
|
||||
if (badType.length) { setError(`格式不支持:${badType.map(f => f.name).join('、')}(支持 docx/doc/pdf/txt/md/srt)`); return }
|
||||
if (tooBig.length) { setError(`文件过大(单文件限 20GB):${tooBig.map(f => f.name).join('、')}`); return }
|
||||
setWBusy(true); setError('')
|
||||
const big = arr.filter(f => f.size > 80 * 1024 * 1024)
|
||||
if (big.length === 1 && arr.length === 1) {
|
||||
try {
|
||||
const r = await chunkedUpload('/digital-humans/distill', arr[0], {
|
||||
purpose: 'distill',
|
||||
fields: { name: wName, desc: wDesc, text: wText, visibility: wVisibility },
|
||||
onProgress: pct => setError(pct < 100 ? `上传中 ${pct}%…` : ''),
|
||||
})
|
||||
await refresh(); setSel(await api.get(`/digital-humans/${r.id}`))
|
||||
nav(`/admin/digital-humans/${r.id}`)
|
||||
setWizardOpen(false); setWText(''); setWName(''); setWDesc(''); setWVisibility('private')
|
||||
void dialog.notice({ title: '蒸馏完成', description: '已自动创建同名 Agent,请测试并审核这份人格。', tone: 'success' })
|
||||
} catch (e: any) { setError(String(e.message || e)) } finally { setWBusy(false) }
|
||||
return
|
||||
}
|
||||
if (big.length) { setError('一次只能分片蒸馏一个大文件;其余文件请分批用「按建议重新蒸馏」追加'); setWBusy(false); return }
|
||||
if (totalMB > 90) { setError(`文件总量 ${Math.round(totalMB)}MB 超过单次 90MB 上限,请分批蒸馏`); setWBusy(false); return }
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('name', wName); fd.append('desc', wDesc); fd.append('text', wText)
|
||||
Array.from(files).forEach(f => fd.append('files', f))
|
||||
const token = localStorage.getItem('yawan-workbench-access-token')
|
||||
const resp = await fetch('/api/digital-humans/distill', { method: 'POST', headers: token ? { Authorization: `Bearer ${token}` } : {}, body: fd })
|
||||
const data = await readResp(resp)
|
||||
if (!resp.ok) throw new Error(data.detail || '蒸馏失败')
|
||||
await refresh(); setSel(await api.get(`/digital-humans/${data.id}`))
|
||||
nav(`/admin/digital-humans/${data.id}`)
|
||||
setWizardOpen(false); setWText(''); setWName(''); setWDesc(''); setWVisibility('private')
|
||||
void dialog.notice({ title: '蒸馏完成', description: '已自动创建同名 Agent,请测试并审核这份人格。', tone: 'success' })
|
||||
} catch (e: any) { setError(String(e.message || e)) } finally { setWBusy(false) }
|
||||
}
|
||||
|
||||
// ---------- 测试:用数字人试写一篇 ----------
|
||||
const runTest = async () => {
|
||||
if (!sel) return
|
||||
setTestBusy(true); setTestResult(''); setError('')
|
||||
try {
|
||||
const r = await api.post<{ text: string }>('/digital-humans/' + sel.id + '/test-write', { topic: testTopic })
|
||||
setTestResult(r.text); setTestVersion(sel.version)
|
||||
} catch (e: any) { setError(String(e.message || e)) } finally { setTestBusy(false) }
|
||||
}
|
||||
|
||||
const approve = async () => {
|
||||
if (!sel) return
|
||||
setBusy(true)
|
||||
try { await api.post(`/digital-humans/${sel.id}/approve`, {}); setSel(await api.get(`/digital-humans/${sel.id}`)); await refresh(); void dialog.notice({ title: '已生效', description: '该数字人现在可以被 Agent 绑定使用了。', tone: 'success' }) }
|
||||
catch (e: any) { setError(String(e.message || e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const remove = async (row: any) => {
|
||||
const global = isGlobalResource(row) && !row.can_manage
|
||||
if (!await dialog.confirm({ title: `${global ? '从我的列表移除' : '删除'} 数字人「${row.name}」?`, description: global ? '仅从当前账号隐藏,不影响其他用户和管理员使用。' : '系统会先自动解除 Agent 绑定和成员授权,再永久删除蒸馏档案。', confirmLabel: global ? '确认移除' : '确认删除', tone: 'danger' })) return
|
||||
try { await api.delete(`/digital-humans/${row.id}`); if (sel?.id === row.id) nav('/admin/digital-humans'); await refresh() }
|
||||
catch (e: any) { setError(String(e.message || e)) }
|
||||
}
|
||||
const fork = async (row: any) => {
|
||||
if (forkBusy) return
|
||||
setForkBusy(row.id); setError('')
|
||||
try {
|
||||
const result = await forkResource('digital-humans', Number(row.id))
|
||||
await refresh(); nav(`/admin/digital-humans/${result.id}`)
|
||||
void dialog.notice({ title: '已创建我的数字人副本', description: '你可以继续蒸馏、修改和删除副本;原全局数字人与其他用户不受影响。', tone: 'success' })
|
||||
} catch (e: any) { setError(String(e.message || e)) } finally { setForkBusy(null) }
|
||||
}
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
const filtered = list.filter(row => !query.trim() || `${row.name} ${row.persona_name || ''}`.includes(query.trim()))
|
||||
|
||||
const refreshWechat = async () => {
|
||||
setWechatLoading(true); setWechatError('')
|
||||
try {
|
||||
const [accounts, agents] = await Promise.all([
|
||||
api.get<any>('/wechat/bot/accounts'),
|
||||
api.get<any>('/agents'),
|
||||
])
|
||||
const accountRows = Array.isArray(accounts) ? accounts : (accounts.accounts || [])
|
||||
setWechatAccounts(accountRows)
|
||||
// 二维码通常由 sidecar 异步生成;列表轮询拿到后同步到扫码面板。
|
||||
const pending = accountRows.find((item: any) => item.status === 'pending_qr' && (item.qr_code || item.qr_url || item.qrcode))
|
||||
if (pending) setWechatQr(pending)
|
||||
// 扫码成功后清掉当前账号的二维码,避免已连接列表和扫码面板同时显示。
|
||||
if (wechatQr?.id && accountRows.some((item: any) => String(item.id) === String(wechatQr.id) && ['connected', 'online', 'ready'].includes(String(item.status || '').toLowerCase()))) {
|
||||
setWechatQr(null)
|
||||
}
|
||||
const rows = Array.isArray(agents) ? agents : (agents.agents || [])
|
||||
// 微信对话必须落到已绑定数字人的 Agent,蒸馏专用 Agent 不作为聊天入口。
|
||||
const usableAgents = rows.filter((a: any) => a.enabled !== false && a.status !== 'disabled' && a.name !== '数字人蒸馏 Agent' && ['production', 'service'].includes(a.role) && a.digital_human_id && a.digital_human_binding?.status === 'active')
|
||||
setWechatAgents(usableAgents)
|
||||
if (!wechatAgentId && usableAgents.length) setWechatAgentId(String(usableAgents[0].id))
|
||||
} catch (e: any) { setWechatError(String(e.message || e)) } finally { setWechatLoading(false) }
|
||||
}
|
||||
const openWechat = () => { setWechatOpen(true); void refreshWechat() }
|
||||
const connectWechat = async () => {
|
||||
if (!wechatAgentId || wechatAction) return
|
||||
setWechatAction('connect'); setWechatError(''); setWechatQr(null)
|
||||
try {
|
||||
let result: any
|
||||
try { result = await api.post<any>('/wechat/bot/accounts/connect', { agent_id: Number(wechatAgentId) }) }
|
||||
catch (first: any) {
|
||||
// 兼容旧版服务端:连接接口也可能直接挂在集合路径
|
||||
if (!String(first.message || '').includes('不存在') && !String(first.message || '').includes('404')) throw first
|
||||
result = await api.post<any>('/wechat/bot/accounts', { agent_id: Number(wechatAgentId) })
|
||||
}
|
||||
if (result?.qr_code || result?.qr_url || result?.qrcode) setWechatQr(result)
|
||||
await refreshWechat()
|
||||
void dialog.notice({ title: '扫码连接已创建', description: '请使用微信扫描二维码;扫码完成后本页会显示在线状态。', tone: 'success' })
|
||||
} catch (e: any) { setWechatError(String(e.message || e)) } finally { setWechatAction(null) }
|
||||
}
|
||||
const bindWechatAgent = async (account: any, agentId: string) => {
|
||||
if (!agentId || wechatAction) return
|
||||
setWechatAction(`bind-${account.id}`); setWechatError('')
|
||||
try {
|
||||
await api.patch(`/wechat/bot/accounts/${account.id}`, { agent_id: Number(agentId) })
|
||||
setWechatAccounts(current => current.map(item => item.id === account.id ? { ...item, agent_id: Number(agentId), agent_name: wechatAgents.find(a => String(a.id) === agentId)?.name } : item))
|
||||
void dialog.notice({ title: 'Agent 已绑定', description: '该微信账号的对话将使用所选 Agent,并继承其数字人和技能权限。', tone: 'success' })
|
||||
} catch (e: any) { setWechatError(String(e.message || e)) } finally { setWechatAction(null) }
|
||||
}
|
||||
const disconnectWechat = async (account: any) => {
|
||||
if (wechatAction) return
|
||||
if (!await dialog.confirm({ title: `删除微信「${account.account_name || account.nickname || account.name || '账号'}」?`, description: '将删除该微信连接及其扫码凭据,不会删除绑定的 Agent 或数字人。', confirmLabel: '确认删除', tone: 'danger' })) return
|
||||
setWechatAction(`remove-${account.id}`); setWechatError('')
|
||||
try { await api.delete(`/wechat/bot/accounts/${account.id}`); setWechatAccounts(current => current.filter(item => item.id !== account.id)) }
|
||||
catch (e: any) { setWechatError(String(e.message || e)) } finally { setWechatAction(null) }
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!wechatOpen) return
|
||||
const timer = window.setInterval(() => { if (!wechatAction) void refreshWechat() }, 5000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [wechatOpen, wechatAction])
|
||||
return (
|
||||
<>
|
||||
<div className="list-page digital-human-page">
|
||||
<PageHeader title="数字人" description="从本人资料提取文稿风格、情感、叙事与思考方式;创建后自动生成同名 Agent,也可将同一数字人绑定到多个 Agent。" meta={<span className="scope-chip">人格资产</span>} />
|
||||
<ResourceSummary items={[{label:"人格档案",value:list.length},{label:"已生效",value:list.filter(row=>row.status === "active").length},{label:"使用流程",value:"素材 → 蒸馏 → 审核"}]} />
|
||||
<div className="page-toolbar">
|
||||
<div className="toolbar-search"><input aria-label="搜索数字人" placeholder="搜索名称或人格…" value={query} onChange={e=>setQuery(e.target.value)} /></div>
|
||||
<button className="mini wechat-connect-trigger" onClick={openWechat}><MessageCircle size={14} /> 微信 Bot 连接</button>
|
||||
<button className="primary-action" onClick={async () => { if (await leaveSafety()) { setEditingPersona(false); setWizardOpen(true); setWName(''); setWDesc(''); setWText('') } }}><FlaskConical size={14} /> 蒸馏新数字人</button>
|
||||
</div>
|
||||
{error && <div className="err">{error}</div>}
|
||||
{(wBusy || testBusy || redoBusy) && <div className="ai-operation-progress" role="status" aria-live="polite"><div><strong>{wBusy ? 'AI 正在蒸馏数字人' : redoBusy ? 'AI 正在按建议重新蒸馏' : 'AI 正在生成测试文稿'}</strong><span>正在处理资料,预计需要 1–2 分钟,请不要关闭页面</span></div><div className="ai-progress-track"><div className="ai-progress-indeterminate" /></div><small>读取资料 → 分析表达风格 → 保存新版本</small></div>}
|
||||
{filtered.map(row => {
|
||||
const st = STATUS_BADGE[row.status] || STATUS_BADGE.distilling
|
||||
return (
|
||||
<div key={row.id} className="row persona-row" onClick={() => select(row)} role="button" tabIndex={0} onKeyDown={e => { if (e.key === 'Enter') select(row) }} style={{ cursor: 'pointer' }}>
|
||||
<b>{row.name}</b>
|
||||
<OwnerLabel resource={row} />
|
||||
<span className={`badge ${st.cls}`}>{st.label}</span>
|
||||
<span className="dim">v{row.version} · {row.persona_name || '未命名人格'} · 来源 {row.source_count} 份 · {row.visibility === 'private' ? '私有' : row.visibility === 'shared' ? '共享' : '全局'}</span>
|
||||
<span className="flex-spacer" />
|
||||
<button className="mini" onClick={e => { e.stopPropagation(); select(row) }}>查看</button>
|
||||
{row.can_manage && <button className="mini danger-text" onClick={e => { e.stopPropagation(); remove(row) }}>删除</button>}
|
||||
{!row.can_manage && isGlobalResource(row) && <><button className="mini" onClick={e => { e.stopPropagation(); void fork(row) }} disabled={forkBusy === row.id}>{forkBusy === row.id ? '创建中…' : '创建我的副本'}</button><button className="mini danger-text" onClick={e => { e.stopPropagation(); void remove(row) }}>从我的列表移除</button></>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{!filtered.length && <div className="empty-workspace compact"><strong>还没有数字人</strong><span>点「蒸馏新数字人」,上传这个人的直播稿/聊天记录,AI 提取文稿风格、情感表达、叙事与思考方式,蒸馏出可绑定 Agent 的人格档案。</span></div>}
|
||||
|
||||
{/* ---------- 蒸馏向导 ---------- */}
|
||||
{wizardOpen && <div className="dialog-backdrop" onClick={() => !wBusy && setWizardOpen(false)}>
|
||||
<div className="app-dialog ai-assist-dialog" onClick={e => e.stopPropagation()}>
|
||||
<div className="dialog-icon">✦</div>
|
||||
<div className="dialog-copy">
|
||||
<h2>蒸馏新数字人</h2>
|
||||
<p>上传或粘贴这个人的直播稿、聊天记录,提取本人文稿风格、情感变化、叙事结构和思考方式。也可在蒸馏后补充表达提示与规则。支持 <b>docx / doc / pdf / txt / md / srt</b>,单个大文件可通过分片上传至 20GB;多个普通文件单次总量 ≤ 90MB。扫描版 PDF / 图片不含文字,无法识别。</p>
|
||||
</div>
|
||||
<div className="ai-assist-body" style={{ width: '100%' }}>
|
||||
<input placeholder="数字人名称(如:雅文老师)" value={wName} onChange={e => setWName(e.target.value)} />
|
||||
<input placeholder="一句话描述(可选)" value={wDesc} onChange={e => setWDesc(e.target.value)} />
|
||||
|
||||
<textarea rows={7} placeholder="粘贴直播稿 / 聊天记录…(建议 500 字以上,越多段越像)" value={wText} onChange={e => setWText(e.target.value)} />
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<label className="mini file-import-label">📎 上传文档直接蒸馏(docx/doc/pdf/txt/md/srt,可多选)
|
||||
<input type="file" accept=".txt,.md,.srt,.docx,.doc,.pdf" multiple style={{ display: 'none' }} onChange={e => distillWithDocs(e.target.files)} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="ai-assist-actions">
|
||||
<button className="ok ai-assist-go" onClick={() => distill()} disabled={wBusy || (!wText.trim() && !wName.trim())}>
|
||||
{wBusy ? <><span className="auth-spinner light" /> 蒸馏中…(约 1-2 分钟)</> : '✦ 开始蒸馏'}
|
||||
</button>
|
||||
<button className="mini" onClick={() => setWizardOpen(false)} disabled={wBusy}>取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* ---------- 详情 ---------- */}
|
||||
{sel && <div className="card" style={{ marginTop: 12 }}>
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<h2 style={{ fontSize: 16, margin: 0 }}>{sel.name} <span className={`badge ${(STATUS_BADGE[sel.status] || {}).cls}`}>{(STATUS_BADGE[sel.status] || {}).label} · v{sel.version}</span></h2>
|
||||
<OwnerLabel resource={sel} />
|
||||
<p style={{ margin: '4px 0 0', fontSize: 12, color: 'var(--ink-2)' }}>{sel.desc || '—'} · 来源:{(sel.source_files || []).join('、') || '—'}</p>
|
||||
</div>
|
||||
{sel.status === 'active' && <span className="tag g">可被 Agent 绑定</span>}
|
||||
</div>
|
||||
|
||||
<p className="persona-intro">人物表达统一在这份档案维护。绑定此数字人的 Agent 会继承其口吻、情感、文稿与叙事方式,并按任务导图和上文衔接生成。</p>
|
||||
{sel.persona_meta && <details className="persona-group" style={{ marginBottom: 10 }}>
|
||||
<summary><span><strong>蒸馏质量与证据说明</strong><small>查看素材覆盖、证据边界和人工审核要求</small></span></summary>
|
||||
<div className="persona-fields">
|
||||
<p className="persona-intro">已累计投喂 {sel.persona_meta.source_history?.length || 1} 批 · {sel.persona_meta.sampled_chars || 0} 字 · {sel.persona_meta.source_count || (sel.source_files || []).length} 份去重素材。{sel.persona_meta.sampling || ''}</p>
|
||||
{sel.persona_meta.source_history?.length > 1 && <p className="persona-intro">最近一批新增:{(sel.persona_meta.source_history.at(-1)?.source_names || []).join('、') || '仅按建议修订'}。每次投喂都会生成新版本,需重新测试并审核。</p>}
|
||||
<p className="persona-intro">证据规则:{sel.persona_meta.evidence_policy || '每个结论需有原文依据;原话逐字引用。'}</p>
|
||||
<p className="persona-intro">不确定性:{sel.persona_meta.uncertainty || '证据不足内容需人工确认。'}</p>
|
||||
<p className="persona-intro">使用边界:{sel.persona_meta.context_rules || '人格只影响表达,不改变任务和知识范围。'}</p>
|
||||
</div>
|
||||
</details>}
|
||||
{sel.bound_agents?.length > 0 && <p className="persona-intro">已绑定:{sel.bound_agents.map((agent: any) => agent.name).join('、')}。新版本审核生效后用于后续配置;已保存任务继续使用当时的版本。</p>}
|
||||
<div className="persona-actions">
|
||||
{canManage && !editingPersona && <button className="mini" disabled={personaLocked} onClick={() => { setPersonaDraft({ ...(sel.persona || {}) }); setEditingPersona(true) }}>编辑人格与表达规则</button>}
|
||||
{editingPersona && <>
|
||||
<button className="primary-action" disabled={personaLocked || !personaDirty} onClick={savePersona}>{personaBusy ? '保存中…' : '保存并进入测试'}</button>
|
||||
<button className="mini" disabled={personaLocked} onClick={async () => { if (await leaveSafety()) { setEditingPersona(false); setPersonaDraft(sel.persona || {}) } }}>取消编辑</button>
|
||||
</>}
|
||||
{!canManage && isGlobalResource(sel) ? <><button className="primary-action" onClick={() => void fork(sel)} disabled={forkBusy === sel.id}>{forkBusy === sel.id ? '正在创建副本…' : '创建我的副本并编辑'}</button><button className="mini danger-text" onClick={() => void remove(sel)}>从我的列表移除</button><span className="field-help">全局数字人不能直接修改;副本的蒸馏、删除和版本只影响你的账号。</span></> : !canManage && <span className="field-help">当前为只读档案,表达设定由创建者或管理员维护。</span>}
|
||||
</div>
|
||||
<PersonaProfile key={`${sel.id}-${editingPersona ? 'edit' : 'view'}`} persona={editingPersona ? personaDraft : sel.persona || {}}
|
||||
onChange={editingPersona ? setPersonaDraft : undefined} disabled={personaLocked} />
|
||||
|
||||
{canManage && <fieldset disabled={personaLocked || editingPersona} className="card" style={{ marginTop: 12, background: 'var(--card)', minWidth: 0 }}>
|
||||
<b style={{ fontSize: 13 }}>② 人工测试:用这个数字人试写一段</b>
|
||||
<input placeholder="测试话题(模拟任务目标)" value={testTopic} onChange={e => setTestTopic(e.target.value)} style={{ marginTop: 6 }} />
|
||||
<div className="btns">
|
||||
<button className="ok" onClick={runTest} disabled={testBusy || !testTopic.trim()}>{testBusy ? '试写中…(1-2 分钟)' : '✦ 生成测试文稿'}</button>
|
||||
{sel.status !== 'active' && <button className="primary-action" onClick={approve} disabled={busy || testVersion !== sel.version || !testResult}><Check size={13} /> 审核通过,让 TA 生效</button>}
|
||||
</div>
|
||||
{sel.status !== 'active' && !testResult && <p className="field-help">先生成本版本的测试文稿,确认表达效果后即可审核生效。</p>}
|
||||
{editingPersona && <p className="field-help">请先保存人格修改,再进行试写和审核。</p>}
|
||||
{testResult && <pre className="art" style={{ maxHeight: 260, overflow: 'auto', whiteSpace: 'pre-wrap', marginTop: 8 }}>{testResult}</pre>}
|
||||
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<b style={{ fontSize: 13 }}>③ 持续投喂资料 / 按建议重新蒸馏</b>
|
||||
<p className="field-help">可以只上传新的资料持续补充这个数字人的知识,也可以同时填写建议;每次都会生成新版本,审核通过后再被绑定的 Agent 使用。</p>
|
||||
<textarea rows={2} placeholder="例如:口头禅再土一点;开场要先唠两句家常;不要说书面语…" value={advice} onChange={e => setAdvice(e.target.value)} style={{ marginTop: 6 }} />
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginTop: 6, flexWrap: 'wrap' }}>
|
||||
<label className="mini file-import-label">📎 上传补充素材(支持大文件分片,单文件 ≤ 20GB)
|
||||
<input type="file" accept=".txt,.md,.srt,.docx,.doc,.pdf" multiple style={{ display: 'none' }}
|
||||
onChange={e => { setRedoFiles(Array.from(e.target.files || [])); e.target.value = '' }} />
|
||||
</label>
|
||||
{redoFiles.length > 0 && <span className="field-help">已选:{redoFiles.map(f => f.name).join('、')}</span>}
|
||||
</div>
|
||||
<div className="btns">
|
||||
<button onClick={async () => {
|
||||
if (!advice.trim() && !redoFiles.length) return
|
||||
setRedoBusy(true); setError('')
|
||||
try {
|
||||
if (redoFiles.some(file => !/\.(txt|md|srt|docx|doc|pdf)$/i.test(file.name))) throw new Error('请选择 docx/doc/pdf/txt/md/srt 格式的资料')
|
||||
if (redoFiles.some(file => file.size > 20 * 1024 ** 3)) throw new Error('补充素材单文件不能超过 20GB')
|
||||
const largeFiles = redoFiles.filter(file => file.size > 80 * 1024 ** 2)
|
||||
let r: any
|
||||
if (largeFiles.length) {
|
||||
if (redoFiles.length !== 1) throw new Error('大文件请单独分片上传,其余素材可分批追加')
|
||||
r = await chunkedUpload('/digital-humans/distill', redoFiles[0], {
|
||||
purpose: 'distill', fields: { digital_human_id: String(sel.id), advice },
|
||||
onProgress: percentage => setError(percentage < 100 ? `补充素材上传中 ${percentage}%…` : ''),
|
||||
})
|
||||
} else {
|
||||
if (redoFiles.reduce((sum, file) => sum + file.size, 0) > 90 * 1024 ** 2) throw new Error('普通补充素材单次总量不能超过 90MB,请分批追加')
|
||||
const fd = new FormData(); fd.append('advice', advice)
|
||||
redoFiles.forEach(file => fd.append('files', file, file.name))
|
||||
const tokenR = localStorage.getItem('yawan-workbench-access-token')
|
||||
const respR = await fetch(`/api/digital-humans/${sel.id}/redistill`, { method: 'POST', headers: tokenR ? { Authorization: `Bearer ${tokenR}` } : {}, body: fd })
|
||||
r = await readResp(respR)
|
||||
if (!respR.ok) throw new Error(r.detail || '重蒸馏失败')
|
||||
}
|
||||
setSel(await api.get(`/digital-humans/${sel.id}`)); setAdvice(''); setRedoFiles([]); setTestResult('')
|
||||
await refresh(); void dialog.notice({ title: '重蒸馏完成', description: `已生成 v${r.version},请重新测试。`, tone: 'success' })
|
||||
} catch (e: any) { setError(String(e.message || e)) } finally { setRedoBusy(false) }
|
||||
}} disabled={redoBusy || (!advice.trim() && !redoFiles.length)}><RefreshCw size={13} /> {redoBusy ? '持续蒸馏中…(1-2 分钟)' : '继续投喂并生成新版本'}</button>
|
||||
{sel.last_feedback && <span className="field-help">上轮意见:{sel.last_feedback}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>}
|
||||
</div>}
|
||||
</div>
|
||||
{wechatOpen && <div className="drawer-mask wechat-drawer-mask" onClick={() => !wechatAction && setWechatOpen(false)}>
|
||||
<aside className="side-drawer wechat-drawer" role="dialog" aria-modal="true" aria-labelledby="wechat-drawer-title" onClick={e => e.stopPropagation()}>
|
||||
<div className="side-drawer-head wechat-drawer-head">
|
||||
<div><span className="wechat-drawer-kicker"><MessageCircle size={14} /> 微信 Bot</span><h2 id="wechat-drawer-title">连接微信账号</h2><p>扫码后,微信消息会交给绑定的 Agent 处理并回复。每个账号仅属于当前用户,互相隔离。</p></div>
|
||||
<button className="icon-button" aria-label="关闭微信连接" onClick={() => setWechatOpen(false)} disabled={Boolean(wechatAction)}><X size={18} /></button>
|
||||
</div>
|
||||
<div className="wechat-drawer-body">
|
||||
{wechatError && <div className="err wechat-error" role="alert">{wechatError}</div>}
|
||||
<section className="wechat-connect-panel">
|
||||
<div className="wechat-connect-title"><div><strong>添加新的微信账号</strong><span>创建数字人时会自动生成同名 Agent;可重复添加多个账号,分别绑定不同数字人 Agent</span></div><Link2 size={18} /></div>
|
||||
<label className="field"><span>绑定 Agent</span><select className="input" value={wechatAgentId} onChange={e => setWechatAgentId(e.target.value)} disabled={wechatLoading || Boolean(wechatAction)}><option value="">请选择 Agent</option>{wechatAgents.map(agent => <option key={agent.id} value={agent.id}>{agent.name}{agent.digital_human_name ? ` · ${agent.digital_human_name}` : ''}</option>)}</select></label>
|
||||
<button className="primary-action wechat-connect-button" onClick={() => void connectWechat()} disabled={!wechatAgentId || Boolean(wechatAction) || wechatLoading}><QrCode size={15} /> {wechatAction === 'connect' ? '正在创建扫码连接…' : '生成扫码二维码'}</button>
|
||||
{wechatQr && <div className="wechat-qr-panel"><div className="wechat-qr-title"><strong>请用微信扫码</strong><button className="mini" onClick={() => setWechatQr(null)}>隐藏</button></div>{(wechatQrImage || wechatQr.qr_code || wechatQr.qrcode || wechatQr.qr_url) && <img className="wechat-qr" src={wechatQrImage || wechatQr.qr_code || wechatQr.qrcode || wechatQr.qr_url} alt="微信 Bot 登录二维码" />}{wechatQr.expires_at && <small>二维码有效期至 {wechatQr.expires_at}</small>}<span>扫码成功后保持本页打开,状态会自动刷新。</span></div>}
|
||||
</section>
|
||||
<section className="wechat-account-section"><div className="wechat-section-heading"><strong>已连接账号</strong><span>{wechatLoading ? '同步中…' : `${wechatAccounts.length} 个`}</span><button className="icon-button" aria-label="刷新微信账号" onClick={() => void refreshWechat()} disabled={wechatLoading || Boolean(wechatAction)}><RotateCw size={15} className={wechatLoading ? 'spin' : ''} /></button></div>
|
||||
{!wechatLoading && !wechatAccounts.length && <div className="wechat-empty"><MessageCircle size={22} /><strong>还没有连接微信</strong><span>选择 Agent 后生成二维码,扫码即可开始。</span></div>}
|
||||
<div className="wechat-account-list">{wechatAccounts.map(account => {
|
||||
const status = String(account.status || '').toLowerCase();
|
||||
const online = ['connected', 'online', 'ready'].includes(status);
|
||||
const pending = ['pending', 'pending_qr', 'pending_scan', 'scanning', 'connecting'].includes(status);
|
||||
const statusText = online ? '已连接' : pending ? '等待扫码' : status === 'error' ? '连接异常' : status === 'offline' ? '已离线' : (account.status_label || '未连接');
|
||||
return <article className="wechat-account-row" key={account.id || account.account_id}>
|
||||
<div className="wechat-account-main"><span className={`wechat-status-dot ${online ? 'is-online' : pending ? 'is-pending' : 'is-offline'}`} /><div><strong>{account.account_name || account.nickname || account.name || `微信账号 ${account.id}`}</strong><span>{statusText}{account.last_seen_at ? ` · 最近活动 ${account.last_seen_at}` : ''}</span></div></div>
|
||||
<label className="wechat-agent-binding"><span>对话 Agent</span><select value={String(account.agent_id || '')} onChange={e => void bindWechatAgent(account, e.target.value)} disabled={Boolean(wechatAction) || !wechatAgents.length}><option value="">未绑定</option>{wechatAgents.map(agent => <option key={agent.id} value={agent.id}>{agent.name}</option>)}</select></label>
|
||||
{account.last_error && <p className="wechat-account-error">{account.last_error}</p>}
|
||||
<div className="wechat-account-foot"><span>{account.agent_name ? `已绑定:${account.agent_name}${account.digital_human_name ? ` · 数字人:${account.digital_human_name}` : ''}` : '尚未绑定 Agent'}</span><button className="mini danger-text" onClick={() => void disconnectWechat(account)} disabled={Boolean(wechatAction)}><Unplug size={13} /> {wechatAction === `remove-${account.id}` ? '删除中…' : '删除账号'}</button></div>
|
||||
</article>
|
||||
})}</div>
|
||||
</section>
|
||||
<div className="wechat-isolation-note"><strong>权限隔离</strong><span>微信只可使用绑定 Agent 已授权的知识库、技能和数字人。账号标识不会展示给其他用户,也不会跨账号共享会话。</span></div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>}
|
||||
{grantOpen && sel && (
|
||||
<div className="drawer-mask" onClick={() => setGrantOpen(false)}>
|
||||
<div className="drawer" onClick={e => e.stopPropagation()}>
|
||||
<h3>授权成员 · {sel.name}</h3>
|
||||
<p className="muted">当前已授权:{grantList.map((g: any) => g.display_name || g.username).join('、') || '(无)'}</p>
|
||||
<label className="field"><span>选择成员</span>
|
||||
<select className="input" value={grantUserId} onChange={e => setGrantUserId(e.target.value)}>
|
||||
<option value="">选择要授权的成员</option>
|
||||
{members.filter((m: any) => m.enabled).map((m: any) => (
|
||||
<option key={m.id} value={m.id}>{m.display_name || m.username}(@{m.username} · {m.role})</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field"><span>权限</span>
|
||||
<select className="input" value={grantPermission} onChange={e => setGrantPermission(e.target.value)}>
|
||||
<option value="use">可使用</option>
|
||||
<option value="read">只读</option>
|
||||
</select>
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
||||
<button className="primary" disabled={!grantUserId || busy} onClick={async () => {
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.post(`/digital-humans/${sel.id}/grants`, { user_id: Number(grantUserId), permission: grantPermission })
|
||||
const r = await api.get<any>(`/digital-humans/${sel.id}/grants`)
|
||||
setGrantList(r.grants || [])
|
||||
setGrantUserId('')
|
||||
void dialog.notice({ title: '已授权', description: '成员现在可以按权限使用这个数字人。', tone: 'success' })
|
||||
} catch (err: any) { setError(String(err.message || err)) } finally { setBusy(false) }
|
||||
}}>添加授权</button>
|
||||
<button className="mini" onClick={() => setGrantOpen(false)}>关闭</button>
|
||||
</div>
|
||||
{grantList.length > 0 && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
{grantList.map((g: any) => (
|
||||
<div key={g.id || g.user_id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '6px 0', borderBottom: '1px solid var(--border)' }}>
|
||||
<span>{g.display_name || g.username} · {g.permission === 'read' ? '只读' : '可使用'}</span>
|
||||
<button className="mini" disabled={busy} onClick={async () => {
|
||||
setBusy(true)
|
||||
try { await api.delete(`/digital-humans/${sel.id}/grants/${g.id}`); setGrantList((cur: any[]) => cur.filter(x => x.id !== g.id)) }
|
||||
catch (err: any) { setError(String(err.message || err)) } finally { setBusy(false) }
|
||||
}}>移除</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// 全局功能开关(借鉴 Coze Feature Flags)
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api, localizeError } from '../api'
|
||||
|
||||
const FLAG_META: { key: string; label: string; hint: string }[] = [
|
||||
{ key: 'cross_qc', label: '跨稿质检', hint: '生成时对比上一天任务,检测重复与衔接问题' },
|
||||
{ key: 'ai_assist', label: 'AI 辅助创建', hint: '任务导图生成与 Agent/技能/模板的 AI 起草入口' },
|
||||
{ key: 'lazy_qc_auto_redo', label: '后台质检自动重做', hint: '先出稿模式下质检发现问题自动重做一轮' },
|
||||
{ key: 'kb_heading_split', label: '知识库按标题切分', hint: '建库时可选「按标题切分」分段策略' },
|
||||
]
|
||||
|
||||
export default function FeatureFlagsPage() {
|
||||
const [flags, setFlags] = useState<Record<string, boolean>>({})
|
||||
const [busy, setBusy] = useState(false)
|
||||
const busyRef = useRef(false)
|
||||
const [error, setError] = useState('')
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [status, setStatus] = useState('')
|
||||
|
||||
useEffect(() => { api.get('/settings/feature-flags').then(setFlags).catch(e => setError(localizeError(e))) }, [])
|
||||
const toggle = (key: string) => { if (busyRef.current) return; setStatus(''); setError(''); setDirty(true); setSaved(false); setFlags(f => ({ ...f, [key]: !f[key] })) }
|
||||
const save = async () => {
|
||||
if (busyRef.current || !dirty) return
|
||||
busyRef.current = true
|
||||
setBusy(true); setError(''); setSaved(false); setStatus('正在保存…')
|
||||
try { const r = await api.put<{ flags: Record<string, boolean> }>('/settings/feature-flags', flags); setFlags(r.flags); setDirty(false); setSaved(true); setStatus('已保存'); setTimeout(() => setSaved(false), 2500) }
|
||||
catch { setError('功能开关保存失败,请稍后重试(不显示服务端原始错误)。'); setStatus('保存失败,已保留当前修改') } finally { busyRef.current = false; setBusy(false) }
|
||||
}
|
||||
|
||||
return <div className="list-page" style={{ maxWidth: 720 }}>
|
||||
<div className="page-toolbar">
|
||||
<div><h2 style={{ fontSize: 17, margin: 0 }}>功能开关</h2></div>
|
||||
<span className="flex-spacer" />
|
||||
{dirty && <span className="tag">未保存</span>}{status && <span role="status" className="field-help">{status}</span>}{saved && <span className="tag g">已保存</span>}
|
||||
<button className="primary-action" onClick={save} disabled={busy}>保存</button>
|
||||
</div>
|
||||
{error && <div className="err">{error}</div>}
|
||||
{FLAG_META.map(meta => (
|
||||
<div key={meta.key} className="row card" style={{ justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<span><b>{meta.label}</b><br /><span className="dim" style={{ fontSize: 12 }}>{meta.hint}</span></span>
|
||||
<label className="inline-check" style={{ gap: 6 }}>
|
||||
<input type="checkbox" checked={flags[meta.key] ?? true} onChange={() => toggle(meta.key)} disabled={busy} />
|
||||
<span style={{ fontSize: 12 }}>{(flags[meta.key] ?? true) ? '开启' : '关闭'}</span>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
<p className="field-help">开关立即生效(保存后下一次请求起应用);关闭的功能仅隐藏入口或跳过对应环节,不影响历史数据。</p>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
/* 页面组件:从 App.tsx 拆出(墨韵金重构) */
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api, localizeError } from '../api'
|
||||
import { useDialog } from '../dialog'
|
||||
import { Tag } from '../ui'
|
||||
import { STATUS_LABEL, KB_STATUS_LABEL, CLASSIFICATION_STATUS_LABEL, RUNTIME_EVENT_LABEL,
|
||||
RUNTIME_PHASE_LABEL, RUNTIME_TOOL_LABEL, PAGE_META, RULE_TEMPLATES } from '../constants'
|
||||
import type { Seg, Camp, CampaignState, RuntimeDetail, CampaignCreateResponse, ComposeResponse,
|
||||
AgentCreateResponse, AgentSummary, KnowledgeBaseSummary, NotificationSummary, AuthUser, AuthStatus,
|
||||
TraceSummary, TraceDetail, UsageSummary, UsageEventRow, PointState, PointAccountRow, UsageResponse,
|
||||
AdminUsageResponse, AgentListRow, MemoryListRow, KbFileRow, KbCategoryRow, KbDetailResponse, KbUsageResponse } from '../types'
|
||||
|
||||
|
||||
/* ---------------- zip 打包(store 模式,无外部依赖) ---------------- */
|
||||
let _crcTable: number[] | null = null
|
||||
function crc32Table(): number[] {
|
||||
if (_crcTable) return _crcTable
|
||||
const t: number[] = []
|
||||
for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0 }
|
||||
return _crcTable = t
|
||||
}
|
||||
async function crc32(buf: ArrayBuffer): Promise<number> {
|
||||
const table = crc32Table()
|
||||
const bytes = new Uint8Array(buf)
|
||||
let crc = -1
|
||||
for (let i = 0; i < bytes.length; i++) crc = (crc >>> 8) ^ table[(crc ^ bytes[i]) & 0xff]
|
||||
return (crc ^ -1) >>> 0
|
||||
}
|
||||
async function buildZip(files: File[]): Promise<Blob> {
|
||||
const enc = new TextEncoder()
|
||||
const chunks: BlobPart[] = []
|
||||
const central: BlobPart[] = []
|
||||
let offset = 0
|
||||
for (const f of files) {
|
||||
const rel = ((f as any).webkitRelativePath || f.name) as string
|
||||
const nameBytes = enc.encode(rel)
|
||||
const data = await f.arrayBuffer()
|
||||
const crc = await crc32(data)
|
||||
const local = new DataView(new ArrayBuffer(30))
|
||||
local.setUint32(0, 0x04034b50, true); local.setUint16(4, 20, true); local.setUint16(6, 0x0800, true)
|
||||
local.setUint16(8, 0, true); local.setUint16(10, 0, true); local.setUint16(12, 0, true)
|
||||
local.setUint32(14, crc, true); local.setUint32(18, data.byteLength, true); local.setUint32(22, data.byteLength, true)
|
||||
local.setUint16(26, nameBytes.length, true); local.setUint16(28, 0, true)
|
||||
chunks.push(local.buffer, nameBytes, data)
|
||||
const cv = new DataView(new ArrayBuffer(46))
|
||||
cv.setUint32(0, 0x02014b50, true); cv.setUint16(4, 20, true); cv.setUint16(6, 20, true)
|
||||
cv.setUint16(8, 0x0800, true); cv.setUint16(10, 0, true); cv.setUint16(12, 0, true)
|
||||
cv.setUint16(14, 0, true); cv.setUint32(16, crc, true)
|
||||
cv.setUint32(20, data.byteLength, true); cv.setUint32(24, data.byteLength, true)
|
||||
cv.setUint16(28, nameBytes.length, true)
|
||||
cv.setUint32(42, offset, true)
|
||||
central.push(cv.buffer, nameBytes)
|
||||
offset += 30 + nameBytes.length + data.byteLength
|
||||
}
|
||||
const centralSize = central.reduce((s: number, c: BlobPart) => s + (c as ArrayBuffer).byteLength, 0)
|
||||
const end = new DataView(new ArrayBuffer(22))
|
||||
end.setUint32(0, 0x06054b50, true); end.setUint16(8, files.length, true); end.setUint16(10, files.length, true)
|
||||
end.setUint32(12, centralSize, true); end.setUint32(16, offset, true)
|
||||
return new Blob([...chunks, ...central, end.buffer], { type: 'application/zip' })
|
||||
}
|
||||
|
||||
/* ---------------- 知识库 ---------------- */
|
||||
export default function KBsPage() {
|
||||
const dialog = useDialog()
|
||||
const [kbs, setKbs] = useState<any[]>([])
|
||||
const [kbMetrics, setKbMetrics] = useState<any>(null)
|
||||
const [name, setName] = useState(''); const [desc, setDesc] = useState('')
|
||||
const [q, setQ] = useState(''); const [hits, setHits] = useState<any[]>([])
|
||||
const [sel, setSel] = useState<number | null>(null)
|
||||
const [editing, setEditing] = useState<any>(null)
|
||||
const [detail, setDetail] = useState<any>(null)
|
||||
const [preview, setPreview] = useState<any>(null)
|
||||
const [usages, setUsages] = useState<KbUsageResponse[]>([])
|
||||
const [categoryId, setCategoryId] = useState<number | ''>('')
|
||||
const [categoryParentId, setCategoryParentId] = useState<number | ''>('')
|
||||
const [categoryName, setCategoryName] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [uploadPct, setUploadPct] = useState<number | null>(null)
|
||||
const [folderJob, setFolderJob] = useState<any>(null)
|
||||
const [folderInfo, setFolderInfo] = useState('')
|
||||
const dragRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
const uploadFolder = async (fileList: FileList | File[]) => {
|
||||
const all = Array.from(fileList as any as File[])
|
||||
const files = all.filter(f => (f as any).webkitRelativePath || f.size > 0 || f.name)
|
||||
if (!files.length) return
|
||||
if (!sel) { setError('先在左侧选中一个知识库,再上传文件夹'); return }
|
||||
setBusy(true); setError(''); setFolderInfo('')
|
||||
try {
|
||||
const media = files.filter(f => /\.(mp4|mov|m4a|mp3|wav|flv|mkv|avi|webm|aac|ogg|flac)$/i.test(f.name))
|
||||
const docs = files.filter(f => /\.(md|txt|docx|srt|json|jsonl|pdf)$/i.test(f.name))
|
||||
const skipped = files.length - media.length - docs.length
|
||||
const bits = [docs.length ? `文档 ${docs.length} 个` : '', media.length ? `音视频 ${media.length} 个(本地转写,耗时较久)` : '', skipped ? `其他 ${skipped} 个(自动跳过)` : ''].filter(Boolean)
|
||||
setFolderInfo(`已选 ${files.length} 个文件:${bits.join(',')}`)
|
||||
const zipBlob = await buildZip(files)
|
||||
setUploadPct(0)
|
||||
const r = await api.uploadBlob(`/kbs/${sel}/import-folder`, zipBlob, 'folder.zip', categoryId ? { category_id: String(categoryId) } : {}, setUploadPct)
|
||||
setFolderJob({ job_id: (r as any).job_id, status: 'running', current: '上传完成,开始处理…', total: files.length, files_done: 0 })
|
||||
} catch (e: any) { setError(String(e.message || e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!folderJob || folderJob.status !== 'running' || !sel) return
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
const j = await api.get<any>(`/kbs/${sel}/import-progress/${folderJob.job_id}`)
|
||||
setFolderJob((prev: any) => ({ ...(prev || {}), ...j }))
|
||||
if (j.status === 'done') { await refresh(); await selectKb(sel); setFolderInfo('') }
|
||||
} catch { /* 轮询失败保持现状 */ }
|
||||
}, 2000)
|
||||
return () => clearInterval(timer)
|
||||
}, [folderJob?.job_id, folderJob?.status, sel])
|
||||
const refresh = () => Promise.all([
|
||||
api.get('/kbs').then(setKbs),
|
||||
api.get('/kb-metrics').then(setKbMetrics).catch(() => setKbMetrics(null)),
|
||||
])
|
||||
useEffect(() => { refresh() }, [])
|
||||
const selectKb = async (id: number) => {
|
||||
setSel(id); setPreview(null); setHits([]); setError('')
|
||||
try {
|
||||
const [kbDetail, usageRows] = await Promise.all([
|
||||
api.get<KbDetailResponse>(`/kbs/${id}`),
|
||||
api.get<KbUsageResponse[]>(`/kbs/${id}/usage`),
|
||||
])
|
||||
setDetail(kbDetail); setUsages(usageRows); setCategoryId(''); setCategoryParentId('')
|
||||
} catch (e: any) { setError(String(e.message || e)) }
|
||||
}
|
||||
const reset = () => { setName(''); setDesc(''); setEditing(null) }
|
||||
const add = async () => { setBusy(true); setError(''); try { await api.post('/kbs', { name, desc }); reset(); await refresh() } catch (e: any) { setError(String(e.message || e)) } finally { setBusy(false) } }
|
||||
const edit = (kb: any) => { setEditing(kb); setName(kb.name); setDesc(kb.desc || ''); setError('') }
|
||||
const save = async () => { if (!editing) return; setBusy(true); setError(''); try { await api.put(`/kbs/${editing.id}`, { name, desc }); reset(); await refresh(); if (sel === editing.id) await selectKb(editing.id) } catch (e: any) { setError(String(e.message || e)) } finally { setBusy(false) } }
|
||||
const remove = async (kb: any) => {
|
||||
if (!await dialog.confirm({ title: `删除知识库「${kb.name}」?`, description: '源文件、分类记录和向量索引都会一并删除,且无法恢复。', confirmLabel: '确认删除', tone: 'danger' })) return
|
||||
setBusy(true); setError('')
|
||||
try { await api.delete(`/kbs/${kb.id}`); if (sel === kb.id) { setSel(null); setDetail(null); setHits([]); setUsages([]) }; await refresh() }
|
||||
catch (e: any) { setError(String(e.message || e)) } finally { setBusy(false) }
|
||||
}
|
||||
const search = async () => { if (!sel || !q.trim()) return; setBusy(true); setError(''); try { setHits(await api.get(`/kbs/${sel}/search?q=${encodeURIComponent(q)}`)) } catch (e: any) { setError(String(e.message || e)) } finally { setBusy(false) } }
|
||||
const previewFile = async (file: string) => {
|
||||
if (!sel) return
|
||||
setBusy(true); setError('')
|
||||
try { setPreview(await api.get(`/kbs/${sel}/files/${encodeURIComponent(file)}`)) }
|
||||
catch (e: any) { setError(String(e.message || e)) } finally { setBusy(false) }
|
||||
}
|
||||
const releaseUsage = async (usage: KbUsageResponse) => {
|
||||
if (!sel || !await dialog.confirm({ title: '重新开放知识片段', description: `这条片段将再次提供给「${usage.agent_name}」,其他 Agent 的使用记录不受影响。`, confirmLabel: '重新开放' })) return
|
||||
setBusy(true); setError('')
|
||||
try { await api.post(`/kb-usages/${usage.id}/release`); await selectKb(sel) }
|
||||
catch (e: any) { setError(String(e.message || e)) } finally { setBusy(false) }
|
||||
}
|
||||
const flattenCategories = (nodes: KbCategoryRow[], depth = 0): KbCategoryRow[] => nodes.flatMap(node => [
|
||||
{ ...node, depth }, ...flattenCategories(node.children || [], depth + 1),
|
||||
])
|
||||
const categories = detail ? flattenCategories(detail.categories || []) : []
|
||||
const addCategory = async () => {
|
||||
if (!sel || !categoryName.trim()) return
|
||||
setBusy(true); setError('')
|
||||
try {
|
||||
await api.post(`/kbs/${sel}/categories`, { name: categoryName, parent_id: categoryParentId || null })
|
||||
setCategoryName(''); setCategoryParentId(''); await selectKb(sel)
|
||||
} catch (e: any) { setError(String(e.message || e)) } finally { setBusy(false) }
|
||||
}
|
||||
const moveFile = async (file: KbFileRow, nextCategoryId: string) => {
|
||||
if (!sel) return
|
||||
setBusy(true); setError('')
|
||||
try { await api.put(`/kbs/${sel}/files/${encodeURIComponent(file.name)}/category`, { category_id: nextCategoryId ? Number(nextCategoryId) : null }); await selectKb(sel) }
|
||||
catch (e: any) { setError(String(e.message || e)) } finally { setBusy(false) }
|
||||
}
|
||||
const deleteFile = async (file: KbFileRow) => {
|
||||
if (!sel) return
|
||||
if (!await dialog.confirm({ title: `删除知识文件「${file.name}」?`, description: '该文件的向量索引会同步清理,Agent 将检索不到此内容。', confirmLabel: '确认删除', tone: 'danger' })) return
|
||||
setBusy(true); setError('')
|
||||
try { await api.delete(`/kbs/${sel}/files/${encodeURIComponent(file.name)}`); await selectKb(sel) }
|
||||
catch (e: any) { setError(String(e.message || e)) } finally { setBusy(false) }
|
||||
}
|
||||
const moveFileToKb = async (file: KbFileRow, targetKbId: string) => {
|
||||
if (!sel || !targetKbId) return
|
||||
setBusy(true); setError('')
|
||||
try {
|
||||
await api.put(`/kbs/${sel}/files/${encodeURIComponent(file.name)}/move`, { target_kb_id: Number(targetKbId) })
|
||||
await refresh(); await selectKb(sel)
|
||||
await dialog.notice({ title: '迁移完成', description: `「${file.name}」已移动到目标知识库并重建索引。` })
|
||||
} catch (e: any) { setError(String(e.message || e)) } finally { setBusy(false) }
|
||||
}
|
||||
const saveFileEdit = async (file: KbFileRow, content: string) => {
|
||||
if (!sel) return
|
||||
setBusy(true); setError('')
|
||||
try { await api.put(`/kbs/${sel}/files/${encodeURIComponent(file.name)}/content`, { content }); await selectKb(sel); await dialog.notice({ title: '已保存并更新索引' }) }
|
||||
catch (e: any) { setError(String(e.message || e)) } finally { setBusy(false) }
|
||||
}
|
||||
const classifyFiles = async () => {
|
||||
if (!sel) return
|
||||
setBusy(true); setError('')
|
||||
try { await api.post(`/kbs/${sel}/classify`, {}); await selectKb(sel) }
|
||||
catch (e: any) { setError(String(e.message || e)) } finally { setBusy(false) }
|
||||
}
|
||||
const confirmClassification = async (file: KbFileRow) => {
|
||||
if (!sel) return
|
||||
setBusy(true); setError('')
|
||||
try { await api.post(`/kbs/${sel}/files/${encodeURIComponent(file.name)}/classification/confirm`); await selectKb(sel) }
|
||||
catch (e: any) { setError(String(e.message || e)) } finally { setBusy(false) }
|
||||
}
|
||||
return (
|
||||
<div className="kb-layout">
|
||||
{/* 左:窄导航(库列表 + 建库),也是拖拽上传的落点 */}
|
||||
<section
|
||||
ref={dragRef as any}
|
||||
onDragOver={e => { e.preventDefault(); if (dragRef.current) (dragRef.current as HTMLElement).classList.add('drag-over') }}
|
||||
onDragLeave={() => { dragRef.current?.classList.remove('drag-over') }}
|
||||
onDrop={async e => {
|
||||
e.preventDefault(); dragRef.current?.classList.remove('drag-over')
|
||||
const items = e.dataTransfer?.files
|
||||
if (items?.length && sel) await uploadFolder(items)
|
||||
else if (items?.length) setError('先在左侧选中一个知识库,再拖入文件夹')
|
||||
}}>
|
||||
<div className="section-title-row"><div><h2>知识库</h2><p>选中一个库开始管理。</p></div></div>
|
||||
<div className="card">
|
||||
{editing && <div className="form-kicker">编辑知识库:{editing.name}</div>}
|
||||
<input placeholder="库名(如:青蛇白蛇素材库)" value={name} onChange={e => setName(e.target.value)} />
|
||||
<input placeholder="描述" value={desc} onChange={e => setDesc(e.target.value)} />
|
||||
{error && <div className="err">{error}</div>}
|
||||
<div className="btns">{editing
|
||||
? <><button className="ok" onClick={save} disabled={!name || busy}>保存修改</button><button onClick={reset}>取消编辑</button></>
|
||||
: <button onClick={add} disabled={!name || busy}>+ 建库</button>}</div>
|
||||
</div>
|
||||
<div className="kb-nav-list">
|
||||
{kbs.map(k => (
|
||||
<div key={k.id} className={`kb-nav-item ${sel === k.id ? 'sel' : ''}`} onClick={() => selectKb(k.id)}>
|
||||
<b>{k.name}</b>
|
||||
<span className="kb-nav-meta"><span>{KB_STATUS_LABEL[k.index_status] || k.index_status}</span><span>{k.file_count} 文件 / {k.chunk_count} 块</span></span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 右:宽工作区 */}
|
||||
<section className="kb-workspace">
|
||||
<div className="section-title-row"><div><h2>{sel ? `${kbs.find(k => k.id === sel)?.name || ''}工作区` : '知识库工作区'}</h2><p>{preview ? `预览:${preview.name} · ${preview.characters} 字${preview.truncated ? '(仅前 50,000 字)' : ''}` : '上传素材、管理分类、测试检索——Agent 生成时自动引用这里的内容。'}</p></div>{preview && <button className="mini" onClick={() => setPreview(null)}>返回检索</button>}</div>
|
||||
|
||||
{!sel && (
|
||||
<div className="empty-workspace compact"><strong>先在左侧选择一个知识库</strong><span>选中后即可上传文件或整个文件夹(支持音视频自动转写)、管理分类并测试检索。</span></div>
|
||||
)}
|
||||
|
||||
{sel && preview && (
|
||||
<div className="card kb-preview"><pre className="art">{preview.text || '该文件没有可解析的文字内容。'}</pre></div>
|
||||
)}
|
||||
|
||||
{sel && !preview && (
|
||||
<>
|
||||
{/* 工具行:上传组 | 分类组 | 维护组 */}
|
||||
<div className="kb-toolbar">
|
||||
<div className="kb-toolbar-group">
|
||||
<span className="group-label">上传到</span>
|
||||
<select value={categoryId} onChange={e => setCategoryId(e.target.value ? Number(e.target.value) : '')} aria-label="上传目标分类">
|
||||
<option value="">未分类</option>{categories.map(category => <option key={category.id} value={category.id}>{' '.repeat(category.depth)}{category.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<span className="kb-toolbar-divider" />
|
||||
<div className="kb-toolbar-group">
|
||||
<label className="file-btn">📁 整个文件夹
|
||||
<input type="file" multiple {...({ webkitdirectory: '', directory: '' } as any)}
|
||||
onChange={async e => { if (e.target.files?.length) await uploadFolder(e.target.files) }} /></label>
|
||||
<label className="file-btn">📤 文件
|
||||
<input type="file" multiple accept=".docx,.srt,.md,.txt,.json,.jsonl,.pdf,.mp4,.mov,.m4a,.mp3,.wav,.flv,.mkv,.avi,.webm,.aac,.ogg,.flac"
|
||||
onChange={async e => { if (e.target.files?.length) { setBusy(true); setError(''); try { await api.upload(`/kbs/${sel}/import`, e.target.files, categoryId ? { category_id: String(categoryId) } : {}); await refresh(); await selectKb(sel) } catch (e: any) { setError(String(e.message || e)) } finally { setBusy(false) } } }} /></label>
|
||||
</div>
|
||||
<span className="kb-toolbar-divider" />
|
||||
<div className="kb-toolbar-group">
|
||||
<button className="mini" onClick={classifyFiles} disabled={busy}>✦ AI 自动分类</button>
|
||||
<button className="mini" onClick={async () => { setBusy(true); setError(''); try { await api.post(`/kbs/${sel}/rebuild`); await refresh(); await selectKb(sel) } catch (e: any) { setError(String(e.message || e)) } finally { setBusy(false) } }}>↻ 重建索引</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{folderInfo && <div className="field-help">{folderInfo}</div>}
|
||||
|
||||
{uploadPct !== null && !folderJob && (
|
||||
<div className="folder-job-card">
|
||||
<div className="folder-job-line">📤 上传中 {uploadPct}%</div>
|
||||
<div className="task-progress-track"><div className="task-progress-fill" style={{ width: `${uploadPct}%` }} /></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{folderJob && (
|
||||
<div className="folder-job-card">
|
||||
{folderJob.status === 'running' && <div className="folder-job-line">⏳ 正在处理:{folderJob.current || '准备中…'}{folderJob.total ? `(${folderJob.files_done}/${folderJob.total})` : ''}</div>}
|
||||
{folderJob.status === 'done' && <div className="folder-job-line ok">✓ 导入完成:{folderJob.result?.files ?? '?'} 个文件 / {folderJob.result?.chunks ?? '?'} 个知识块</div>}
|
||||
{folderJob.status === 'error' && <div className="folder-job-line err">导入失败:{folderJob.error}</div>}
|
||||
<button className="mini" onClick={() => setFolderJob(null)}>关闭</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分类管理 */}
|
||||
<div className="card">
|
||||
<strong style={{ display: 'block', marginBottom: 8, fontSize: 11 }}>分类管理</strong>
|
||||
<div className="category-create-line" style={{ margin: 0, paddingTop: 0, borderTop: 0 }}>
|
||||
<input placeholder="新建分类名称" value={categoryName} onChange={e => setCategoryName(e.target.value)} />
|
||||
<select value={categoryParentId} onChange={e => setCategoryParentId(e.target.value ? Number(e.target.value) : '')}><option value="">作为一级分类</option>{categories.map(category => <option key={category.id} value={category.id}>{' '.repeat(category.depth)}{category.name}</option>)}</select>
|
||||
<button className="mini" onClick={addCategory} disabled={!categoryName.trim() || busy}>+ 新建分类</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 已导入文件 */}
|
||||
{detail?.files?.length > 0 && (
|
||||
<div className="file-list">
|
||||
<strong>已导入文件({detail.files.length})</strong>
|
||||
{detail.files.map((file: any) => (
|
||||
<div key={file.name} className="file-row">
|
||||
<button className="file-preview-button" onClick={() => previewFile(file.name)}>
|
||||
<b>{file.name}</b>
|
||||
<span className={file.classification_status === 'ai_suggested' ? 'ai-category-hint' : undefined}
|
||||
title={file.classification_status === 'ai_suggested' ? (file.ai_reason || 'AI 根据文件内容推荐,待人工确认') : undefined}>
|
||||
{file.category_path || '未分类'}{file.classification_status === 'ai_suggested' ? ` · AI 推荐 ${(file.ai_confidence * 100).toFixed(0)}%${file.ai_reason ? ` · ${file.ai_reason}` : ''}` : ''}
|
||||
</span>
|
||||
</button>
|
||||
<select value={file.category_id || ''} onChange={e => moveFile(file, e.target.value)} aria-label={`移动 ${file.name} 到分类`}>
|
||||
<option value="">未分类</option>{categories.map(category => <option key={category.id} value={category.id}>{' '.repeat(category.depth)}{category.name}</option>)}
|
||||
</select>
|
||||
<div className="file-actions">
|
||||
{file.classification_status === 'ai_suggested' && <button className="mini ok" onClick={() => confirmClassification(file)} disabled={busy}>确认分类</button>}
|
||||
{(file.name.toLowerCase().endsWith('.txt') || file.name.toLowerCase().endsWith('.md')) && <button className="mini" onClick={async () => {
|
||||
const r = await api.get<{ text: string }>(`/kbs/${sel}/files/${encodeURIComponent(file.name)}`)
|
||||
const next = window.prompt(`编辑「${file.name}」内容(保存后自动重建索引)`, r.text)
|
||||
if (next !== null && next.trim()) await saveFileEdit(file, next)
|
||||
}} disabled={busy}>编辑</button>}
|
||||
{kbs.length > 1 && (
|
||||
<select value="" onChange={e => moveFileToKb(file, e.target.value)} aria-label={`移动 ${file.name} 到其他知识库`} disabled={busy}>
|
||||
<option value="">移到其他库…</option>
|
||||
{kbs.filter(kb => kb.id !== sel).map(kb => <option key={kb.id} value={kb.id}>{kb.name}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<button className="mini danger-text" onClick={() => deleteFile(file)} disabled={busy}>删除</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agent 已消费知识 */}
|
||||
<div className="file-list">
|
||||
<strong>Agent 已消费知识({usages.filter(usage => usage.status === 'consumed').length} 条锁定)</strong>
|
||||
<span className="field-help">锁定仅针对已使用它的 Agent;其他 Agent 仍可检索。重新开放后,该 Agent 才能再次使用。</span>
|
||||
{usages.length ? usages.map(usage => (
|
||||
<div className="usage-row" key={usage.id}>
|
||||
<div><b>{usage.file_name || '未记录来源文件'}</b><span>{usage.heading ? ` / ${usage.heading}` : ''} · {usage.agent_name} · {usage.used_at}</span></div>
|
||||
{usage.status === 'consumed' ? <button className="mini" onClick={() => releaseUsage(usage)} disabled={busy}>重新开放</button> : <span className="badge okbg">已开放</span>}
|
||||
</div>
|
||||
)) : <span className="dim">尚未有 Agent 消费本知识库的片段。</span>}
|
||||
</div>
|
||||
|
||||
{busy && <div className="dim">处理中(分块+向量化可能需要几分钟)…</div>}
|
||||
|
||||
{/* 检索测试 */}
|
||||
<div className="card kb-toolbar">
|
||||
<input placeholder="输入一个与素材相关的问题(如:凯格尔怎么练)" value={q} onChange={e => setQ(e.target.value)} />
|
||||
<button onClick={search} disabled={!sel || !q}>🔍 检索测试</button>
|
||||
</div>
|
||||
{hits.map((h, i) => (
|
||||
<div key={i} className="card">
|
||||
<div className="row"><span className="dim">来源:{h.meta?.file || '?'}{h.meta?.heading ? ' / ' + h.meta.heading : ''}</span>
|
||||
<span className="dim">score {(h.score || 0).toFixed(2)}</span></div>
|
||||
<pre className="art">{h.text}</pre>
|
||||
</div>
|
||||
))}
|
||||
{!hits.length && q && <div className="empty-workspace compact"><strong>没有检索到内容</strong><span>换个问法试试,或确认素材已导入本库。</span></div>}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---------------- 记忆库 ---------------- */
|
||||
@@ -0,0 +1,665 @@
|
||||
import { KnowledgeUsageList } from './KnowledgeUsageList'
|
||||
// KnowledgePage(/knowledge):列表 + 选中 → 右侧五页签(文件/分类/检索测试/消费记录/设置)
|
||||
// 复用 KBsPage 的全部业务函数;detail 中已有 files/categories/usage 数据
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { Plus, Search, Trash2 } from 'lucide-react'
|
||||
import { api, chunkedUpload } from '../api'
|
||||
import { useDialog } from '../dialog'
|
||||
import { Tag } from '../ui'
|
||||
import { PageHeader, EmptyWorkspace, OwnerLabel } from '../workbench-ui'
|
||||
import { forkResource, canManageResource, canWriteResource, isGlobalResource } from '../resourceActions'
|
||||
import TagsPage from './TagsPage'
|
||||
import type { KbFileRow, KbCategoryRow, KbDetailResponse, KbUsageResponse } from '../types'
|
||||
|
||||
// zip 打包(从 KBsPage 抽出的工具,保持一致)
|
||||
let _crcTable: number[] | null = null
|
||||
function crc32Table(): number[] {
|
||||
if (_crcTable) return _crcTable
|
||||
const t: number[] = []
|
||||
for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0 }
|
||||
return _crcTable = t
|
||||
}
|
||||
async function crc32(buf: ArrayBuffer): Promise<number> {
|
||||
const table = crc32Table()
|
||||
const bytes = new Uint8Array(buf)
|
||||
let crc = -1
|
||||
for (let i = 0; i < bytes.length; i++) crc = (crc >>> 8) ^ table[(crc ^ bytes[i]) & 0xff]
|
||||
return (crc ^ -1) >>> 0
|
||||
}
|
||||
async function buildZip(files: File[]): Promise<Blob> {
|
||||
const enc = new TextEncoder()
|
||||
const chunks: BlobPart[] = []
|
||||
const central: BlobPart[] = []
|
||||
let offset = 0
|
||||
for (const f of files) {
|
||||
const rel = ((f as any).webkitRelativePath || f.name) as string
|
||||
const nameBytes = enc.encode(rel)
|
||||
const data = await f.arrayBuffer()
|
||||
const crc = await crc32(data)
|
||||
const local = new DataView(new ArrayBuffer(30))
|
||||
local.setUint32(0, 0x04034b50, true); local.setUint16(4, 20, true); local.setUint16(6, 0x0800, true)
|
||||
local.setUint16(8, 0, true); local.setUint16(10, 0, true); local.setUint16(12, 0, true)
|
||||
local.setUint32(14, crc, true); local.setUint32(18, data.byteLength, true); local.setUint32(22, data.byteLength, true)
|
||||
local.setUint16(26, nameBytes.length, true); local.setUint16(28, 0, true)
|
||||
chunks.push(local.buffer, nameBytes, data)
|
||||
const cv = new DataView(new ArrayBuffer(46))
|
||||
cv.setUint32(0, 0x02014b50, true); cv.setUint16(4, 20, true); cv.setUint16(6, 20, true)
|
||||
cv.setUint16(8, 0x0800, true); cv.setUint16(10, 0, true); cv.setUint16(12, 0, true)
|
||||
cv.setUint16(14, 0, true); cv.setUint32(16, crc, true)
|
||||
cv.setUint32(20, data.byteLength, true); cv.setUint32(24, data.byteLength, true)
|
||||
cv.setUint16(28, nameBytes.length, true)
|
||||
cv.setUint32(42, offset, true)
|
||||
central.push(cv.buffer, nameBytes)
|
||||
offset += 30 + nameBytes.length + data.byteLength
|
||||
}
|
||||
const centralSize = central.reduce((s: number, c: BlobPart) => s + (c as ArrayBuffer).byteLength, 0)
|
||||
const end = new DataView(new ArrayBuffer(22))
|
||||
end.setUint32(0, 0x06054b50, true); end.setUint16(8, files.length, true); end.setUint16(10, files.length, true)
|
||||
end.setUint32(12, centralSize, true); end.setUint32(16, offset, true)
|
||||
return new Blob([...chunks, ...central, end.buffer], { type: 'application/zip' })
|
||||
}
|
||||
|
||||
type TabKey = 'files' | 'categories' | 'search' | 'usage' | 'settings'
|
||||
|
||||
type ImportResult = { imported_files?: number; skipped_duplicates?: number; removed_duplicate_chunks?: number; duplicate_files?: { file_name: string; duplicate_of: string }[] }
|
||||
function describeImportResult(result: ImportResult, label: string): string {
|
||||
const imported = Number(result.imported_files || 0), skipped = Number(result.skipped_duplicates || 0)
|
||||
const removed = Number(result.removed_duplicate_chunks || 0)
|
||||
let text = `${label}:新增或更新 ${imported} 个文件,跳过 ${skipped} 个重复文件。`
|
||||
if (removed) text += ` ${removed.toLocaleString('zh-CN')} 个完全重复片段已合并索引,原文保留。`
|
||||
const duplicates = result.duplicate_files || []
|
||||
if (duplicates.length) text += ' ' + duplicates.slice(0, 5).map(item => `「${item.file_name}」与「${item.duplicate_of}」内容相同`).join(';') + (duplicates.length > 5 ? `等 ${duplicates.length} 个文件。` : '。')
|
||||
return text
|
||||
}
|
||||
|
||||
export default function KnowledgePage() {
|
||||
const { id } = useParams()
|
||||
const nav = useNavigate()
|
||||
const dialog = useDialog()
|
||||
const [searchParams] = useSearchParams()
|
||||
const moduleTab = searchParams.get('tab') === 'tags' ? 'tags' : searchParams.get('tab') === 'usage' ? 'usage' : 'kbs'
|
||||
const [kbs, setKbs] = useState<any[]>([])
|
||||
const [me, setMe] = useState<any>(null)
|
||||
const [forkBusy, setForkBusy] = useState<number | null>(null)
|
||||
const [sel, setSel] = useState<number | null>(id ? Number(id) : null)
|
||||
const selRef = useRef<number | null>(id ? Number(id) : null)
|
||||
const [detail, setDetail] = useState<any>(null)
|
||||
const [detailLoading, setDetailLoading] = useState(false)
|
||||
const [usages, setUsages] = useState<any[]>([])
|
||||
const [allUsages, setAllUsages] = useState<any[]>([])
|
||||
const [allUsageLoading, setAllUsageLoading] = useState(false)
|
||||
const [allUsageError, setAllUsageError] = useState('')
|
||||
const [usageSummary, setUsageSummary] = useState<any>(null)
|
||||
const [selectedUsageIds, setSelectedUsageIds] = useState<Set<number>>(new Set())
|
||||
const [openCategoryIds, setOpenCategoryIds] = useState<Set<number>>(new Set())
|
||||
const [tab, setTab] = useState<TabKey>('files')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [query, setQuery] = useState('')
|
||||
// 新建/上传
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [desc, setDesc] = useState('')
|
||||
const [chunkTarget, setChunkTarget] = useState<number | ''>('')
|
||||
const [chunkMode, setChunkMode] = useState<'auto' | 'heading'>('auto')
|
||||
const [uploadPct, setUploadPct] = useState<number | null>(null)
|
||||
const [folderJob, setFolderJob] = useState<any>(null)
|
||||
const [importMessages, setImportMessages] = useState<string[]>([])
|
||||
const uploadAlive = useRef(false)
|
||||
useEffect(() => { uploadAlive.current = true; return () => { uploadAlive.current = false } }, [])
|
||||
const [fileInputRef, setFileInputRef] = useState<HTMLInputElement | null>(null)
|
||||
const [folderInputRef, setFolderInputRef] = useState<HTMLInputElement | null>(null)
|
||||
// 检索
|
||||
const [q, setQ] = useState('')
|
||||
const [hits, setHits] = useState<any[]>([])
|
||||
// 分类
|
||||
const [categoryName, setCategoryName] = useState('')
|
||||
const [categoryParentId, setCategoryParentId] = useState('')
|
||||
const [classifyBusy, setClassifyBusy] = useState(false)
|
||||
const [classifyNotice, setClassifyNotice] = useState('')
|
||||
const [classifyError, setClassifyError] = useState('')
|
||||
// 文件预览
|
||||
const [preview, setPreview] = useState<any>(null)
|
||||
const [previewLoading, setPreviewLoading] = useState(false)
|
||||
const [previewTarget, setPreviewTarget] = useState('')
|
||||
// 路由切换或开发模式 StrictMode 可能让详情请求重叠;仅允许最后一次请求更新视图。
|
||||
const selectRequestRef = useRef(0)
|
||||
|
||||
const refresh = () => api.get<any[]>('/kbs').then(setKbs).catch(e => setError(String(e.message || e)))
|
||||
useEffect(() => { refresh() }, [])
|
||||
useEffect(() => { api.get('/auth/me').then(setMe).catch(() => {}) }, [])
|
||||
useEffect(() => {
|
||||
if (moduleTab !== 'usage') return
|
||||
let alive = true
|
||||
setAllUsageLoading(true)
|
||||
setAllUsageError('')
|
||||
api.get<any[]>('/kb-usages').then(rows => { if (alive) setAllUsages(Array.isArray(rows) ? rows : []) })
|
||||
.catch((e: any) => { if (alive) setAllUsageError(String(e.message || e)) })
|
||||
.finally(() => { if (alive) setAllUsageLoading(false) })
|
||||
return () => { alive = false }
|
||||
}, [moduleTab])
|
||||
useEffect(() => {
|
||||
if (id) { const nextId = Number(id); void selectKb(nextId) }
|
||||
else {
|
||||
// 离开详情路由时也要使进行中的请求失效,避免返回列表后旧响应又把详情写回来。
|
||||
selectRequestRef.current += 1
|
||||
selRef.current = null
|
||||
setSel(null); setDetail(null); setUsages([]); setUsageSummary(null); setDetailLoading(false)
|
||||
setSelectedUsageIds(new Set())
|
||||
}
|
||||
}, [id])
|
||||
|
||||
const selectKb = async (id: number) => {
|
||||
const requestId = ++selectRequestRef.current
|
||||
// 切换到另一个知识库时先清空旧详情,避免新库请求完成前仍显示旧库文件。
|
||||
// 同一个知识库刷新(上传、分类操作等)保留旧详情,避免无意义闪烁。
|
||||
if (selRef.current !== id) {
|
||||
setImportMessages([]); setFolderJob(null)
|
||||
setDetail(null)
|
||||
setUsages([])
|
||||
setUsageSummary(null)
|
||||
setTab('files')
|
||||
setQuery('')
|
||||
}
|
||||
selRef.current = id
|
||||
setSel(id); setPreview(null); setHits([]); setError(''); setDetailLoading(true)
|
||||
try {
|
||||
// 先展示知识库详情;消费统计不阻塞首次打开,后台补齐即可。
|
||||
const kbDetail = await api.get<KbDetailResponse>(`/kbs/${id}`)
|
||||
if (requestId !== selectRequestRef.current) return
|
||||
setDetail(kbDetail)
|
||||
setSelectedUsageIds(new Set())
|
||||
void Promise.all([
|
||||
api.get<KbUsageResponse[]>(`/kbs/${id}/usage`),
|
||||
api.get<any>(`/kbs/${id}/usage/summary`).catch(() => null),
|
||||
]).then(([usageRows, usageSum]) => {
|
||||
if (requestId !== selectRequestRef.current) return
|
||||
setUsages(usageRows); setUsageSummary(usageSum)
|
||||
}).catch(() => {})
|
||||
} catch (e: any) {
|
||||
if (requestId !== selectRequestRef.current) return
|
||||
setDetail(null); setError(String(e.message || e))
|
||||
} finally {
|
||||
if (requestId === selectRequestRef.current) setDetailLoading(false)
|
||||
}
|
||||
}
|
||||
const add = async () => {
|
||||
if (!name.trim()) return
|
||||
setBusy(true); setError('')
|
||||
try { const r = await api.post<any>('/kbs', { name, desc, chunk_target: chunkTarget ? Number(chunkTarget) : 0, chunk_mode: chunkMode }); setName(''); setDesc(''); setShowCreate(false); await refresh(); nav(`/admin/knowledge/${r.id}`) }
|
||||
catch (e: any) { setError(String(e.message || e)) } finally { setBusy(false) }
|
||||
}
|
||||
const remove = async (kb: any) => {
|
||||
const global = isGlobalResource(kb) && !canManageResource(kb, me)
|
||||
if (!await dialog.confirm({ title: `${global ? '从我的列表移除' : '删除'} 知识库「${kb.name}」?`, description: global ? '仅从当前账号隐藏,不影响其他用户和管理员使用。' : '系统会先自动解除所有 Agent 的知识库挂接,再删除源文件、分类和向量索引。此操作无法恢复。', confirmLabel: global ? '确认移除' : '确认删除', tone: 'danger' })) return
|
||||
setBusy(true); setError('')
|
||||
try { await api.delete(`/kbs/${kb.id}`); if (sel === kb.id) { setSel(null); setDetail(null) }; await refresh() }
|
||||
catch (e: any) { setError(String(e.message || e)) } finally { setBusy(false) }
|
||||
}
|
||||
const fork = async (kb: any) => {
|
||||
if (forkBusy) return
|
||||
setForkBusy(kb.id); setError('')
|
||||
try {
|
||||
const result = await forkResource('kbs', Number(kb.id))
|
||||
await refresh(); nav(`/admin/knowledge/${result.id}`)
|
||||
void dialog.notice({ title: '已创建我的知识库副本', description: '副本中的文件、分类和删除操作只影响你的账号;管理员可在版本审核中查看你的修改。', tone: 'success' })
|
||||
} catch (e: any) { setError(String(e.message || e)) } finally { setForkBusy(null) }
|
||||
}
|
||||
const waitForImport = async (kbId: number, jobId: string): Promise<ImportResult | null> => {
|
||||
while (uploadAlive.current) {
|
||||
const job = await api.get<any>(`/kbs/${kbId}/import-progress/${jobId}`)
|
||||
if (!uploadAlive.current) return null
|
||||
if (selRef.current === kbId) setFolderJob(job)
|
||||
if (job.status === 'done') return job.result || {}
|
||||
if (job.status === 'error') throw new Error(job.error || '后台入库失败,请重试')
|
||||
await new Promise(resolve => window.setTimeout(resolve, 1500))
|
||||
}
|
||||
return null
|
||||
}
|
||||
const uploadFolder = async (files: FileList) => {
|
||||
if (!sel || !files.length) return
|
||||
const kbId = sel
|
||||
setBusy(true); setError(''); setUploadPct(0); setImportMessages([]); setFolderJob(null)
|
||||
try {
|
||||
const zipBlob = await buildZip(Array.from(files))
|
||||
const response = await api.uploadBlob(`/kbs/${kbId}/import-folder`, zipBlob, 'folder.zip', {}, pct => { if (uploadAlive.current) setUploadPct(pct) })
|
||||
if (!uploadAlive.current) return
|
||||
setUploadPct(null)
|
||||
const result = await waitForImport(kbId, (response as any).job_id)
|
||||
if (result && uploadAlive.current && selRef.current === kbId) {
|
||||
setImportMessages([describeImportResult(result, '文件夹处理完成')]); await selectKb(kbId)
|
||||
}
|
||||
} catch (e: any) { if (uploadAlive.current) setError(String(e.message || e)) }
|
||||
finally { if (uploadAlive.current) { setUploadPct(null); setBusy(false) } }
|
||||
}
|
||||
const uploadOneWithRetry = async (kbId: number, file: File, attempt = 1): Promise<any> => {
|
||||
const dt = new DataTransfer(); dt.items.add(file)
|
||||
try { return await api.upload(`/kbs/${kbId}/import`, dt.files) }
|
||||
catch (e: any) {
|
||||
const msg = String(e.message || e)
|
||||
if (/网络|network|failed to fetch|timeout|timed out|负载|524|502|503/i.test(msg) && attempt < 3 && uploadAlive.current) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, 1500 * attempt))
|
||||
if (uploadAlive.current) return uploadOneWithRetry(kbId, file, attempt + 1)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
const uploadFiles = async (files: FileList) => {
|
||||
if (!sel || !files.length) return
|
||||
setBusy(true); setError(''); setImportMessages([]); setFolderJob(null)
|
||||
const kbId = sel, failed: string[] = [], messages: string[] = []
|
||||
try {
|
||||
const all = Array.from(files)
|
||||
for (let idx = 0; idx < all.length && uploadAlive.current; idx++) {
|
||||
const file = all[idx]
|
||||
try {
|
||||
setUploadPct(Math.round((idx / all.length) * 100))
|
||||
let result: any
|
||||
if (file.size > 80 * 1024 * 1024) {
|
||||
result = await chunkedUpload(`/kbs/${kbId}/import`, file, {
|
||||
purpose: 'kb', fields: { kb_id: String(kbId) },
|
||||
onProgress: pct => { if (uploadAlive.current) setUploadPct(Math.round(((idx + pct / 100) / all.length) * 100)) },
|
||||
})
|
||||
} else result = await uploadOneWithRetry(kbId, file)
|
||||
if (!uploadAlive.current) return
|
||||
if (result?.background && result.job_id) {
|
||||
setUploadPct(null)
|
||||
result = await waitForImport(kbId, result.job_id)
|
||||
}
|
||||
if (!uploadAlive.current) return
|
||||
if (result) messages.push(describeImportResult(result, file.name))
|
||||
if (selRef.current === kbId) setImportMessages([...messages])
|
||||
} catch (reason: any) { failed.push(`${file.name}:${String(reason.message || reason).slice(0, 160)}`) }
|
||||
}
|
||||
if (uploadAlive.current && selRef.current === kbId) {
|
||||
await selectKb(kbId)
|
||||
if (failed.length) setError('以下文件处理失败:\n' + failed.join('\n'))
|
||||
}
|
||||
} finally { if (uploadAlive.current) { setUploadPct(null); setBusy(false); if (fileInputRef) fileInputRef.value = '' } }
|
||||
}
|
||||
const search = async () => {
|
||||
if (!sel || !q.trim()) return
|
||||
setBusy(true); setError('')
|
||||
try { setHits(await api.get(`/kbs/${sel}/search?q=${encodeURIComponent(q)}`)) }
|
||||
catch (e: any) { setError(String(e.message || e)) } finally { setBusy(false) }
|
||||
}
|
||||
const classifyFiles = async () => {
|
||||
if (!sel || classifyBusy) return
|
||||
setClassifyBusy(true); setClassifyNotice(''); setClassifyError('')
|
||||
try {
|
||||
const result = await api.post<any>(`/kbs/${sel}/classify`, {})
|
||||
const suggestions = Array.isArray(result?.suggestions) ? result.suggestions.length : 0
|
||||
const errors = Array.isArray(result?.errors) ? result.errors.length : 0
|
||||
setClassifyNotice(result?.status === 'empty' ? '知识库暂无文件,上传文件后再生成推荐。' : `已完成分类推荐:${suggestions} 个文件有推荐${errors ? `,${errors} 个文件处理失败` : ''}。请逐条确认或采用全部建议。`)
|
||||
if (errors) setClassifyError(result.errors.slice(0, 3).join(';'))
|
||||
await selectKb(sel)
|
||||
} catch (e: any) { setClassifyError(String(e.message || e)) }
|
||||
finally { setClassifyBusy(false) }
|
||||
}
|
||||
const confirmAiCategory = async (file: any) => {
|
||||
if (!sel || !file.ai_category_id || classifyBusy) return
|
||||
setClassifyBusy(true); setClassifyError('')
|
||||
try { await api.post(`/kbs/${sel}/files/${encodeURIComponent(file.name)}/classification/confirm`, {}); setClassifyNotice(`已采用「${file.name}」的推荐分类:${file.category_path || '推荐分类'}`); await selectKb(sel) }
|
||||
catch (e: any) { setClassifyError(String(e.message || e)) }
|
||||
finally { setClassifyBusy(false) }
|
||||
}
|
||||
const confirmAllAiCategories = async () => {
|
||||
if (!sel || classifyBusy) return
|
||||
const pending = files.filter(file => file.classification_status === 'ai_suggested' && (file as any).ai_category_id)
|
||||
if (!pending.length) { setClassifyNotice('当前没有待确认的 AI 分类建议。'); return }
|
||||
setClassifyBusy(true); setClassifyError('')
|
||||
try {
|
||||
let done = 0
|
||||
for (const file of pending) { await api.post(`/kbs/${sel}/files/${encodeURIComponent(file.name)}/classification/confirm`, {}); done++ }
|
||||
setClassifyNotice(`已采用全部推荐分类(${done} 个文件)。`); await selectKb(sel)
|
||||
} catch (e: any) { setClassifyError(String(e.message || e)) }
|
||||
finally { setClassifyBusy(false) }
|
||||
}
|
||||
const previewFile = async (file: string) => {
|
||||
const kbId = sel
|
||||
if (!kbId) return
|
||||
const requestId = selectRequestRef.current
|
||||
setBusy(true); setPreviewLoading(true); setPreviewTarget(file); setError('')
|
||||
try {
|
||||
const result = await api.get(`/kbs/${kbId}/files/${encodeURIComponent(file)}`)
|
||||
// 页面已切换知识库时,丢弃旧文件预览响应,避免首击后出现“文件不存在”误导。
|
||||
if (requestId === selectRequestRef.current) setPreview(result)
|
||||
}
|
||||
catch (e: any) {
|
||||
if (requestId === selectRequestRef.current) setError(String(e.message || e))
|
||||
} finally { setBusy(false); setPreviewLoading(false); setPreviewTarget('') }
|
||||
}
|
||||
const releaseUsage = async (usage: KbUsageResponse) => {
|
||||
if (!sel || !await dialog.confirm({ title: '重新开放知识片段', description: `这条片段将再次提供给「${usage.agent_name}」。`, confirmLabel: '重新开放' })) return
|
||||
setBusy(true); setError('')
|
||||
try { await api.post(`/kb-usages/${usage.id}/release`); await selectKb(sel) }
|
||||
catch (e: any) { setError(String(e.message || e)) } finally { setBusy(false) }
|
||||
}
|
||||
const releaseSelectedUsages = async (all = false) => {
|
||||
if (!sel) return
|
||||
const ids = all ? [] : Array.from(selectedUsageIds)
|
||||
if (!all && !ids.length) return
|
||||
if (!await dialog.confirm({ title: all ? '一键重新开放全部消费记录?' : `重新开放选中的 ${ids.length} 条记录?`, description: '开放后这些知识片段会重新回到可用池。', confirmLabel: '确认开放' })) return
|
||||
setBusy(true); setError('')
|
||||
try { await api.post(`/kbs/${sel}/usage/release-batch`, { usage_ids: ids }); setSelectedUsageIds(new Set()); await selectKb(sel) }
|
||||
catch (e: any) { setError(String(e.message || e)) } finally { setBusy(false) }
|
||||
}
|
||||
const flattenCategories = (nodes: KbCategoryRow[], depth = 0): KbCategoryRow[] => nodes.flatMap(node => [
|
||||
{ ...node, depth }, ...flattenCategories(node.children || [], depth + 1),
|
||||
])
|
||||
const categories = detail ? flattenCategories(detail.categories || []) : []
|
||||
const toggleCategory = (id: number) => setOpenCategoryIds(current => {
|
||||
const next = new Set(current); next.has(id) ? next.delete(id) : next.add(id); return next
|
||||
})
|
||||
const renderCategoryTree = (nodes: KbCategoryRow[], depth = 0): any => nodes.map((category: any) => {
|
||||
const children = category.children || []
|
||||
const open = openCategoryIds.has(category.id)
|
||||
return <div key={category.id} className="category-drawer-row" style={{ marginLeft: depth * 16 }}>
|
||||
<div className="category-drawer-head">
|
||||
{children.length ? <button className="mini category-chevron" onClick={() => toggleCategory(category.id)} aria-label={open ? '收起分类' : '展开分类'}>{open ? '▾' : '▸'}</button> : <span className="category-chevron-spacer" />}
|
||||
<span className="category-drawer-name">{category.name}</span><span className="dim">{String(category.file_count ?? 0)} 个文件{children.length > 0 ? '(含子分类)' : ''}</span>
|
||||
<button className="mini" onClick={() => editCategory(category)}>重命名</button><button className="mini danger-text" onClick={() => deleteCategory(category)}>删除</button>
|
||||
</div>
|
||||
{open && children.length ? <div className="category-drawer-children">{renderCategoryTree(children, depth + 1)}</div> : null}
|
||||
</div>
|
||||
})
|
||||
const addCategory = async () => {
|
||||
if (!sel || !categoryName.trim()) return
|
||||
setBusy(true); setError('')
|
||||
try { await api.post(`/kbs/${sel}/categories`, { name: categoryName, parent_id: categoryParentId || null }); setCategoryName(''); setCategoryParentId(''); await selectKb(sel) }
|
||||
catch (e: any) { setError(String(e.message || e)) } finally { setBusy(false) }
|
||||
}
|
||||
const editCategory = async (c: any) => {
|
||||
if (!sel) return
|
||||
const next = window.prompt('分类名称', c.name)
|
||||
if (!next || next.trim() === c.name) return
|
||||
setBusy(true); setError('')
|
||||
try { await api.put(`/kbs/${sel}/categories/${c.id}`, { name: next.trim() }); await selectKb(sel) }
|
||||
catch (e: any) { setError(String(e.message || e)) } finally { setBusy(false) }
|
||||
}
|
||||
const moveCategory = async (c: any, parent: string) => {
|
||||
if (!sel) return
|
||||
setBusy(true); setError('')
|
||||
try { await api.put(`/kbs/${sel}/categories/${c.id}`, { parent_id: parent ? Number(parent) : null }); await selectKb(sel) }
|
||||
catch (e: any) { setError(String(e.message || e)) } finally { setBusy(false) }
|
||||
}
|
||||
const deleteCategory = async (c: any) => {
|
||||
if (!sel || !await dialog.confirm({ title: `删除分类「${c.name}」?`, description: c.children?.length ? '请先删除或移动子分类。' : ((c.file_count || 0) ? '分类下有文件,删除后文件将变为未分类。' : '此操作不可恢复。'), confirmLabel: '确认删除', tone: 'danger' })) return
|
||||
setBusy(true); setError('')
|
||||
try { await api.delete(`/kbs/${sel}/categories/${c.id}`); await selectKb(sel) }
|
||||
catch (e: any) { setError(String(e.message || e)) } finally { setBusy(false) }
|
||||
}
|
||||
const moveFile = async (file: KbFileRow, nextCategoryId: string) => {
|
||||
if (!sel) return
|
||||
setBusy(true); setError('')
|
||||
try { await api.put(`/kbs/${sel}/files/${encodeURIComponent(file.name)}/category`, { category_id: nextCategoryId ? Number(nextCategoryId) : null }); await selectKb(sel) }
|
||||
catch (e: any) { setError(String(e.message || e)) } finally { setBusy(false) }
|
||||
}
|
||||
const deleteFile = async (file: KbFileRow) => {
|
||||
if (!sel || !await dialog.confirm({ title: `删除「${file.name}」?`, description: '向量索引同步清理。', confirmLabel: '确认删除', tone: 'danger' })) return
|
||||
setBusy(true); setError('')
|
||||
try { await api.delete(`/kbs/${sel}/files/${encodeURIComponent(file.name)}`); await selectKb(sel) }
|
||||
catch (e: any) { setError(String(e.message || e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const files: KbFileRow[] = (detail?.files || []) as KbFileRow[]
|
||||
const detailEditable = canManageResource(detail, me)
|
||||
const canWriteKnowledge = canWriteResource('knowledge_base', me)
|
||||
const filteredFiles = useMemo(() => files.filter(f => !query.trim() || f.name.includes(query.trim())), [files, query])
|
||||
const aiSuggestedFiles = useMemo(() => files.filter(file => file.classification_status === 'ai_suggested' && (file as any).ai_category_id), [files])
|
||||
const tabs: { key: TabKey; label: string; badge?: number }[] = [
|
||||
{ key: 'files', label: '文件', badge: files.length || undefined },
|
||||
{ key: 'categories', label: '分类', badge: categories.length || undefined },
|
||||
{ key: 'search', label: '检索测试' },
|
||||
{ key: 'usage', label: '消费记录', badge: usages.length || undefined },
|
||||
{ key: 'settings', label: '设置' },
|
||||
]
|
||||
|
||||
const refreshAllUsages = async () => {
|
||||
setAllUsageError('')
|
||||
try { const rows = await api.get<any[]>('/kb-usages'); setAllUsages(Array.isArray(rows) ? rows : []) }
|
||||
catch (e: any) { setAllUsageError(String(e.message || e)); throw e }
|
||||
}
|
||||
const releaseAnyUsage = async (usage: any) => {
|
||||
if (!await dialog.confirm({ title: '重新开放知识片段', description: `这条片段将再次提供给「${usage.agent_name || '当前 Agent'}」。`, confirmLabel: '重新开放' })) return
|
||||
setBusy(true); setError('')
|
||||
try { await api.post(`/kb-usages/${usage.id}/release`); await refreshAllUsages() }
|
||||
catch (e: any) { setError(String(e.message || e)) } finally { setBusy(false) }
|
||||
}
|
||||
const releaseAllUsages = async (all = false) => {
|
||||
const actionable = allUsages.filter(row => row.status === 'consumed' && row.can_release !== false)
|
||||
const ids = all ? [] : Array.from(selectedUsageIds).filter(id => actionable.some(row => row.id === id))
|
||||
if (!all && !ids.length) return
|
||||
if (!await dialog.confirm({ title: all ? '一键重新开放全部消费记录?' : `重新开放选中的 ${ids.length} 条记录?`, description: '开放后这些知识片段会重新回到可用池。', confirmLabel: '确认开放' })) return
|
||||
setBusy(true); setError('')
|
||||
let done = 0, failed: string[] = []
|
||||
try {
|
||||
for (const kb of kbs) {
|
||||
const eligible = actionable.filter(row => row.kb_id === kb.id)
|
||||
const kbIds = all ? [] : eligible.filter(row => ids.includes(row.id)).map(row => row.id)
|
||||
if (!all && !kbIds.length) continue
|
||||
if (all && !eligible.length) continue
|
||||
try { const result = await api.post<any>(`/kbs/${kb.id}/usage/release-batch`, { usage_ids: kbIds }); done += Number(result?.released || (all ? eligible.length : kbIds.length)) }
|
||||
catch (e: any) { failed.push(`${kb.name}:${String(e.message || e)}`) }
|
||||
}
|
||||
setSelectedUsageIds(new Set()); await refreshAllUsages()
|
||||
if (failed.length) setError(`已开放 ${done} 条,${failed.length} 个知识库失败:${failed.join(';')}`)
|
||||
} catch (e: any) { setError(String(e.message || e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
if (moduleTab === 'tags') return <TagsPage />
|
||||
if (moduleTab === 'usage') return <div className="all-usage-page">
|
||||
<PageHeader title="知识库消费记录" description="查看所有知识库的引用和消费情况,按状态抽屉分类管理。" meta={<span className="scope-chip">知识资产</span>} />
|
||||
<div className="detail-tabs" role="tablist">
|
||||
<button role="tab" className="detail-tab" onClick={() => nav('/admin/knowledge')}>知识库</button>
|
||||
<button role="tab" className="detail-tab active" aria-selected="true">消费记录{allUsages.length ? <span className="tab-badge">{allUsages.length}</span> : null}</button>
|
||||
<button role="tab" className="detail-tab" onClick={() => nav('/admin/knowledge?tab=tags')}>标签管理</button>
|
||||
</div>
|
||||
{error && <div className="err">{error}</div>}
|
||||
{allUsageError && <div className="err">消费记录加载失败:{allUsageError} <button className="mini" onClick={() => { setAllUsageError(''); void refreshAllUsages() }}>重试</button></div>}
|
||||
{allUsageLoading ? <div className="all-usage-loading">正在读取所有知识库消费记录…</div> : !allUsageError && <KnowledgeUsageList usages={allUsages} selected={selectedUsageIds} busy={busy} onSelect={setSelectedUsageIds} onReleaseAll={() => void releaseAllUsages(true)} onReleaseSelected={() => void releaseAllUsages(false)} onRelease={usage => void releaseAnyUsage(usage)} />}
|
||||
{!allUsageLoading && !allUsageError && !allUsages.length && <div className="empty-state">暂无消费记录——生成内容任务后,这里会显示所有知识库被引用的片段。</div>}
|
||||
</div>
|
||||
|
||||
return <div className="knowledge-page">
|
||||
<PageHeader title="知识库" description="从素材入库到引用追踪,集中维护内容生产的知识来源。" meta={<span className="scope-chip">知识资产</span>} />
|
||||
<div className="detail-tabs" role="tablist">
|
||||
<button role="tab" aria-selected={moduleTab === 'kbs'} className={`detail-tab ${moduleTab === 'kbs' ? 'active' : ''}`} onClick={() => nav('/admin/knowledge')}>知识库</button>
|
||||
<button role="tab" aria-selected={false} className="detail-tab" onClick={() => nav('/admin/knowledge?tab=usage')}>消费记录{allUsages.length ? <span className="tab-badge">{allUsages.length}</span> : null}</button>
|
||||
<button role="tab" aria-selected={false} className="detail-tab" onClick={() => nav('/admin/knowledge?tab=tags')}>标签管理</button>
|
||||
</div>
|
||||
|
||||
<div className="knowledge-split">
|
||||
<aside className="kb-list-panel">
|
||||
<div className="collection-heading"><h2>知识库目录</h2><span>{kbs.length} 个</span></div>
|
||||
<div className="page-toolbar">
|
||||
<div className="toolbar-search"><Search size={14} /><input placeholder="搜索知识库…" onChange={e => setQuery(e.target.value)} /></div>
|
||||
<button className="primary-action" onClick={() => setShowCreate(v => !v)}><Plus size={14} /> 新建</button>
|
||||
</div>
|
||||
{showCreate && <div className="card kb-create-card">
|
||||
<input placeholder="知识库名称" value={name} onChange={e => setName(e.target.value)} />
|
||||
<input placeholder="描述(可选)" value={desc} onChange={e => setDesc(e.target.value)} />
|
||||
<div className="two-select-row">
|
||||
<label className="duration-field">分段策略
|
||||
<select value={chunkMode} onChange={e => setChunkMode(e.target.value as 'auto' | 'heading')}>
|
||||
<option value="auto">按长度聚合(自动)</option>
|
||||
<option value="heading">按标题切分</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="duration-field">分段目标长度
|
||||
<select value={chunkTarget} onChange={e => setChunkTarget(e.target.value ? Number(e.target.value) : '')}>
|
||||
<option value="">自动(小段 500 字)</option>
|
||||
<option value={1200}>1,200 字</option>
|
||||
<option value={2200}>2,200 字(口播长文)</option>
|
||||
<option value={4000}>4,000 字</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="btns"><button className="ok" onClick={add} disabled={busy || !name.trim()}>创建</button><button className="mini" onClick={() => setShowCreate(false)}>取消</button></div>
|
||||
</div>}
|
||||
{kbs.map(kb => (
|
||||
<div key={kb.id} className={`agent-row ${sel === kb.id ? 'active' : ''}`} role="button" tabIndex={0}
|
||||
onClick={() => nav(`/admin/knowledge/${kb.id}`)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); nav(`/admin/knowledge/${kb.id}`) } }}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}><b>{kb.name}</b><span className="dim">{kb.file_count ?? 0} 文件 · {kb.chunk_count ?? 0} 块</span><OwnerLabel resource={kb} /></div>
|
||||
{kb.index_status === 'ready' && <span className="tag g">就绪</span>}
|
||||
{canManageResource(kb, me) ? <button className="icon-button danger-text" aria-label={`删除知识库 ${kb.name}`} title="删除知识库" disabled={busy}
|
||||
onClick={e => { e.stopPropagation(); void remove(kb) }}><Trash2 size={14} /></button> : isGlobalResource(kb) && canWriteKnowledge ? <><button className="mini" onClick={e => { e.stopPropagation(); void fork(kb) }} disabled={busy || forkBusy === kb.id}>{forkBusy === kb.id ? '创建中…' : '创建我的副本'}</button><button className="mini danger-text" onClick={e => { e.stopPropagation(); void remove(kb) }} disabled={busy}>从我的列表移除</button></> : <span className="field-help">只读</span>}
|
||||
</div>
|
||||
))}
|
||||
{!kbs.length && <div className="empty-state">还没有知识库</div>}
|
||||
</aside>
|
||||
|
||||
<section className="kb-detail-panel">
|
||||
{error && <div className="err">{error}</div>}
|
||||
{!detail && detailLoading && <div className="empty-workspace compact"><strong>正在打开知识库…</strong><span>正在读取文件和分类信息,请稍候。</span></div>}
|
||||
{!detail && !detailLoading && <EmptyWorkspace title="让内容有据可查" description="选择知识库管理文件、分类和引用;新建空库不会调用 AI。" steps={["收集:上传文档与素材文件夹", "整理:维护分类和分段策略", "追踪:查看检索片段与消费记录"]} />}
|
||||
{detail && <>
|
||||
<div className="detail-header">
|
||||
<div className="detail-title">
|
||||
<h1>{detail.name}</h1>
|
||||
<OwnerLabel resource={detail} />
|
||||
<div className="detail-meta">
|
||||
<span className="tag g">{detail.index_status === 'ready' ? '索引就绪' : detail.index_status}</span>
|
||||
<span className="dim">{files.length} 文件 · {detail.chunk_count ?? 0} 块</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="detail-actions">
|
||||
{detailEditable ? <><button className="primary-action" onClick={() => folderInputRef?.click()} disabled={busy}>📁 上传文件夹</button>
|
||||
<button onClick={() => fileInputRef?.click()} disabled={busy}>上传文件</button></> : null}
|
||||
<input ref={setFolderInputRef} type="file" multiple style={{ display: 'none' }} onChange={e => e.target.files && uploadFolder(e.target.files)} />
|
||||
<input ref={setFileInputRef} type="file" multiple accept=".md,.txt,.docx,.doc,.pdf,.srt,.json,.jsonl,.mp4,.mov,.flv,.mkv,.avi,.webm,.mp3,.wav,.m4a,.aac,.ogg,.flac" style={{ display: 'none' }} onChange={e => e.target.files && uploadFiles(e.target.files)} />
|
||||
{canManageResource(detail, me) ? <button className="danger-button" onClick={() => remove(detail)} disabled={busy}><Trash2 size={14} /> 删除知识库</button> : isGlobalResource(detail) && canWriteKnowledge ? <><button className="primary-action" onClick={() => void fork(detail)} disabled={busy || forkBusy === detail.id}>{forkBusy === detail.id ? '正在创建副本…' : '创建我的副本并编辑'}</button><button className="mini danger-text" onClick={() => remove(detail)} disabled={busy}>从我的列表移除</button></> : <span className="field-help">当前知识库为只读资源</span>}
|
||||
</div>
|
||||
</div>
|
||||
{/* @ts-ignore webkitdirectory */}
|
||||
<input ref={el => { if (el) { (el as any).webkitdirectory = true; setFolderInputRef(el) } }} type="file" multiple style={{ display: 'none' }} onChange={e => e.target.files && uploadFolder(e.target.files)} />
|
||||
<div className="dim" style={{ margin: '6px 0 10px', fontSize: 12 }}>单个文件超过 80MB 会自动分片上传,公网单文件支持至 20GB;文件夹上传请先压缩并受公网通道限制。同一知识库的重复文件会自动跳过,文件内完全重复的同标题片段只索引一次。</div>
|
||||
|
||||
{importMessages.length > 0 && <div className="card" role="status" aria-live="polite" style={{ padding: '10px 14px', marginBottom: 10 }}><strong>上传处理结果</strong><ul>{importMessages.map((message, index) => <li key={index}>{message}</li>)}</ul></div>}
|
||||
{uploadPct !== null && <div className="task-progress"><div className="task-progress-head"><span>上传中 {uploadPct}%</span></div><div className="task-progress-track"><div className="task-progress-fill" style={{ width: `${uploadPct}%` }} /></div></div>}
|
||||
{folderJob && <div className="card" style={{ padding: '10px 14px', marginBottom: 10 }}>
|
||||
{folderJob.status === 'running' && <span className="folder-job-line">⏳ 正在处理:{folderJob.current || '准备中'}{folderJob.total ? `(${folderJob.files_done}/${folderJob.total})` : ''}</span>}
|
||||
{folderJob.status === 'done' && <span className="folder-job-line ok">✓ 导入完成</span>}
|
||||
{folderJob.status === 'error' && <span className="folder-job-line err">失败:{folderJob.error}</span>}
|
||||
</div>}
|
||||
|
||||
<div className="detail-tabs" role="tablist">
|
||||
{tabs.map(t => <button key={t.key} role="tab" aria-selected={tab === t.key} className={`detail-tab ${tab === t.key ? 'active' : ''}`} onClick={() => setTab(t.key)}>{t.label}{t.badge ? <span className="tab-badge">{t.badge}</span> : null}</button>)}
|
||||
</div>
|
||||
|
||||
{tab === 'files' && <div>
|
||||
<div className="page-toolbar kb-classify-toolbar">
|
||||
<div className="toolbar-search"><Search size={14} /><input placeholder="搜索文件名…" value={query} onChange={e => setQuery(e.target.value)} /></div>
|
||||
<div className="kb-classify-actions">
|
||||
{detailEditable && <><button className="mini" onClick={() => void classifyFiles()} disabled={busy || classifyBusy || !files.length}>{classifyBusy ? 'AI 推荐处理中…' : '✦ AI 推荐分类'}</button>
|
||||
<button className="mini" onClick={() => void confirmAllAiCategories()} disabled={busy || classifyBusy || !aiSuggestedFiles.length}>采用全部建议 <em>{aiSuggestedFiles.length || ''}</em></button></>}
|
||||
</div>
|
||||
</div>
|
||||
{classifyBusy && <div className="inline-ai-progress" role="status" aria-live="polite"><div><strong>正在处理分类建议</strong><span>请等待本次操作完成</span></div><div className="ai-progress-track"><div className="ai-progress-indeterminate" /></div><small>AI 推荐会读取文件并匹配分类;采用建议后会更新文件归属。</small></div>}
|
||||
{(classifyNotice || classifyError) && <div className={`kb-classify-notice ${classifyError ? 'error' : ''}`} role="status" aria-live="polite"><span>{classifyError || classifyNotice}</span>{classifyError && <button className="mini" onClick={() => setClassifyError('')}>关闭</button>}</div>}
|
||||
{!!aiSuggestedFiles.length && <div className="kb-recommendation-summary"><span className="kb-recommendation-icon">✦</span><div><b>待确认的 AI 分类建议({aiSuggestedFiles.length})</b><small>AI 只生成建议,不会自动改变文件归属;请逐条采用或一次采用全部建议。</small></div><button className="mini" onClick={() => void confirmAllAiCategories()} disabled={busy || classifyBusy}>一键采用推荐</button></div>}
|
||||
{filteredFiles.map((f: any) => <div key={f.name} className="file-row card" style={{ padding: '8px 12px', marginBottom: 6 }}>
|
||||
<button className="link-like" onClick={() => previewFile(f.name)}><b>{f.name}</b></button>
|
||||
<span className="dim">{f.chunk_count ?? 0} 块</span>
|
||||
{f.classification_status === 'ai_suggested' && <span className="ai-category-hint" title={f.ai_reason || 'AI 根据文件内容推荐'}>✦ AI 推荐:{f.category_path || '待确认'}{f.ai_confidence ? `(${Math.round(f.ai_confidence * 100)}%)` : ''}{f.ai_reason ? ` · ${f.ai_reason}` : ''}</span>}
|
||||
<select value={f.category_id || ''} onChange={e => moveFile(f, e.target.value)} style={{ maxWidth: 160 }} disabled={!detailEditable}>
|
||||
<option value="">未分类</option>
|
||||
{categories.map(c => <option key={c.id} value={c.id}>{' '.repeat(c.depth)}{c.name}</option>)}
|
||||
</select>
|
||||
<button className="mini kb-view-button" onClick={() => void previewFile(f.name)} disabled={previewLoading || busy} aria-label={`查看知识文件 ${f.name}`}>{previewLoading && previewTarget === f.name ? '读取中…' : '查看'}</button>
|
||||
{f.classification_status === 'ai_suggested' && <button className="mini recommend-accept" onClick={() => void confirmAiCategory(f)} disabled={!detailEditable || busy || classifyBusy}>采用推荐</button>}
|
||||
{detailEditable && <button className="mini danger-text" onClick={() => deleteFile(f)}>删除</button>}
|
||||
</div>)}
|
||||
{!filteredFiles.length && <div className="empty-state">没有文件。用顶部「上传文件夹」导入。</div>}
|
||||
</div>}
|
||||
|
||||
{tab === 'categories' && <div className="card agent-form">
|
||||
<div className="btns">
|
||||
<input placeholder="新分类名" value={categoryName} onChange={e => setCategoryName(e.target.value)} style={{ maxWidth: 200 }} />
|
||||
<select value={categoryParentId} onChange={e => setCategoryParentId(e.target.value)} style={{ maxWidth: 160 }}>
|
||||
<option value="">作为顶级分类</option>
|
||||
{categories.map(c => <option key={c.id} value={c.id}>{' '.repeat(c.depth)}{c.name}</option>)}
|
||||
</select>
|
||||
{detailEditable && <button className="ok" onClick={addCategory} disabled={busy || !categoryName.trim()}>添加分类</button>}
|
||||
</div>
|
||||
<div className="category-drawer">{renderCategoryTree(detail.categories || [])}</div>
|
||||
{!categories.length && <div className="empty-state">暂无分类。</div>}
|
||||
</div>}
|
||||
|
||||
{tab === 'search' && <div className="card agent-form">
|
||||
<div className="btns">
|
||||
<input placeholder="输入查询,验证检索效果…" value={q} onChange={e => setQ(e.target.value)} onKeyDown={e => e.key === 'Enter' && search()} style={{ flex: 1 }} />
|
||||
<button className="ok" onClick={search} disabled={busy || !q.trim()}>检索</button>
|
||||
</div>
|
||||
{hits.map((h: any, i: number) => <div key={i} className="kv-list"><div><span>{`#${i + 1} · ${(h.meta && h.meta.file) || h.file || '—'}`}</span><p>{String(h.text || h).slice(0, 220)}</p></div></div>)}
|
||||
</div>}
|
||||
|
||||
{tab === 'usage' && <div>
|
||||
{usageSummary && (() => {
|
||||
const total = usageSummary.total_chunks || 0
|
||||
const consumed = usageSummary.consumed || 0
|
||||
const pct = total ? Math.round(consumed / total * 100) : 0
|
||||
return <>
|
||||
<div className="card" style={{ marginBottom: 10 }}>
|
||||
<div style={{ display: 'flex', gap: 18, alignItems: 'baseline', flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 13 }}><b>{total}</b> <span className="dim">总块</span></span>
|
||||
<span style={{ fontSize: 13 }}><b style={{ color: '#b04a4a' }}>{consumed}</b> <span className="dim">已被消费</span></span>
|
||||
<span style={{ fontSize: 13 }}><b>{usageSummary.released || 0}</b> <span className="dim">已重新开放</span></span>
|
||||
<span style={{ fontSize: 13 }}><b style={{ color: 'var(--accent)' }}>{usageSummary.remaining ?? 0}</b> <span className="dim">剩余可用</span></span>
|
||||
</div>
|
||||
<div style={{ height: 6, background: 'var(--border)', borderRadius: 3, marginTop: 8, overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', width: pct + '%', background: '#b04a4a' }} />
|
||||
</div>
|
||||
<p className="field-help" style={{ marginTop: 6 }}>同一片段只对消费它的 Agent 锁定;其他 Agent 仍可用。「重新开放」后片段会回到可用池。</p>
|
||||
</div>
|
||||
{!!usageSummary.by_agent?.length && <div className="card" style={{ marginBottom: 10 }}>
|
||||
<b style={{ fontSize: 13 }}>按 Agent 统计</b>
|
||||
{usageSummary.by_agent.map((a: any) => (
|
||||
<div key={a.agent_id} className="row" style={{ marginTop: 6 }}>
|
||||
<b>{a.agent_name}</b>
|
||||
<span className="dim">已消费 {a.used} 段{a.released ? ` · 已重新开放 ${a.released}` : ''}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>}
|
||||
|
||||
</>
|
||||
})()}
|
||||
<KnowledgeUsageList key={sel} usages={usages} selected={selectedUsageIds} busy={busy} onSelect={setSelectedUsageIds} onReleaseAll={() => void releaseSelectedUsages(true)} onReleaseSelected={() => void releaseSelectedUsages(false)} onRelease={usage => void releaseUsage(usage as KbUsageResponse)} />
|
||||
{!usages.length && <div className="empty-state">暂无消费记录——生成任务后这里会显示每个 Agent 用了哪些知识。</div>}
|
||||
</div>}
|
||||
|
||||
{tab === 'settings' && <div className="card agent-form">
|
||||
<div className="kv-list">
|
||||
<div><span>块数</span><p>{String(detail.chunk_count ?? 0)}</p></div>
|
||||
<div><span>索引状态</span><p>{detail.index_status}</p></div>
|
||||
<div><span>描述</span><p>{detail.desc || '—'}</p></div>
|
||||
</div>
|
||||
<div className="btns"><button className="danger-button" onClick={() => remove(detail)}>删除此知识库</button></div>
|
||||
</div>}
|
||||
</>}
|
||||
</section>
|
||||
</div>
|
||||
{preview && <div className="kb-preview-mask" role="presentation" onClick={() => setPreview(null)}>
|
||||
<aside className="kb-preview-drawer" role="dialog" aria-modal="true" aria-label={`知识文件预览:${preview.file || preview.name || '文件'}`} onClick={e => e.stopPropagation()}>
|
||||
<header className="kb-preview-head">
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<strong>知识文件预览</strong>
|
||||
<span title={preview.file || preview.name || ''}>{preview.file || preview.name || '未命名文件'}</span>
|
||||
</div>
|
||||
<button className="drawer-close" onClick={() => setPreview(null)} aria-label="关闭预览">×</button>
|
||||
</header>
|
||||
<div className="kb-preview-meta">
|
||||
{detail?.name && <span>知识库:{detail.name}</span>}
|
||||
{preview.characters != null && <span>{Number(preview.characters).toLocaleString('zh-CN')} 字</span>}
|
||||
{preview.truncated && <span className="tag">仅显示前 50,000 字</span>}
|
||||
{preview.category_path && <span>分类:{preview.category_path}</span>}
|
||||
</div>
|
||||
<div className="kb-preview-content">
|
||||
<pre className="art">{typeof preview === 'string' ? preview : (preview.text ?? preview.content ?? '该文件没有可解析的文字内容。')}</pre>
|
||||
</div>
|
||||
</aside>
|
||||
</div>}
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useState } from 'react'
|
||||
import { ChevronRight, Check, RotateCcw, Clock3 } from 'lucide-react'
|
||||
import './knowledge-usage.css'
|
||||
|
||||
type Usage = { id: number; status?: string; agent_name?: string; heading?: string; file_name?: string; excerpt?: string; chunk_id?: string; used_at?: string; released_at?: string; campaign_name?: string; segment_title?: string; kb_name?: string; kb_id?: number; can_release?: boolean }
|
||||
type Props = { usages: Usage[]; selected: Set<number>; busy: boolean; onSelect: (next: Set<number>) => void; onReleaseAll: () => void; onReleaseSelected: () => void; onRelease: (usage: Usage) => void }
|
||||
|
||||
export function KnowledgeUsageList({ usages, selected, busy, onSelect, onReleaseAll, onReleaseSelected, onRelease }: Props) {
|
||||
const consumed = usages.filter(row => row.status === 'consumed')
|
||||
const released = usages.filter(row => row.status === 'available')
|
||||
const pending = usages.filter(row => !['consumed', 'available'].includes(row.status || ''))
|
||||
const [open, setOpen] = useState<Record<string, boolean>>({ consumed: true })
|
||||
// 每个知识库在状态抽屉内独立折叠,避免消费记录很多时页面过长。
|
||||
const [openKb, setOpenKb] = useState<Record<string, boolean>>({})
|
||||
const actionable = consumed.filter(row => row.can_release !== false)
|
||||
const selectedCount = actionable.filter(row => selected.has(row.id)).length
|
||||
const allSelected = actionable.length > 0 && selectedCount === actionable.length
|
||||
const toggle = (id: number) => { const next = new Set(selected); next.has(id) ? next.delete(id) : next.add(id); onSelect(next) }
|
||||
const groups = [{ id: 'consumed', title: '已消费', rows: consumed, hint: '点击记录或左侧勾选框选择,可批量重新开放。' },
|
||||
{ id: 'available', title: '已开放', rows: released, hint: '这些记录已重新开放,可再次参与内容生成。' },
|
||||
...(pending.length ? [{ id: 'pending', title: '处理中', rows: pending, hint: '正在使用的片段完成后会更新状态。' }] : [])]
|
||||
return <section className="knowledge-usage-list" aria-label="消费记录分类">
|
||||
<div className="knowledge-usage-toolbar"><strong>消费明细 <span>{usages.length}</span></strong><div>
|
||||
<button className="mini" disabled={busy || !actionable.length} onClick={() => onSelect(allSelected ? new Set() : new Set(actionable.map(row => row.id)))}>{allSelected ? '取消全选' : '全选可开放'}</button>
|
||||
<button className="mini" disabled={busy || !selectedCount} onClick={onReleaseSelected}>开放选中({selectedCount})</button>
|
||||
<button className="mini" disabled={busy || !actionable.length} onClick={onReleaseAll}>一键全部开放</button>
|
||||
</div></div>
|
||||
{groups.map(group => <details key={group.id} className={`usage-drawer usage-${group.id}`} open={!!open[group.id]} onToggle={event => { const expanded = event.currentTarget.open; setOpen(current => ({ ...current, [group.id]: expanded })) }}>
|
||||
<summary><ChevronRight size={16}/><strong>{group.title}</strong><span className="usage-drawer-count">{group.rows.length}</span><span className="usage-drawer-hint">{open[group.id] ? '收起' : '展开'}</span></summary>
|
||||
{!!open[group.id] && <div className="usage-drawer-body"><p className="field-help">{group.hint}</p>
|
||||
{Object.entries(group.rows.reduce((acc, row) => { const key = row.kb_id ? `${row.kb_id}:${row.kb_name || '知识库'}` : 'none:未指定知识库'; (acc[key] ||= []).push(row); return acc }, {} as Record<string, Usage[]>)).map(([kbKey, kbRows], kbIndex) => {
|
||||
const drawerKey = `${group.id}:${kbKey}`
|
||||
const kbExpanded = openKb[drawerKey] ?? (group.id === 'consumed' && kbIndex === 0)
|
||||
return <details key={kbKey} className="usage-kb-drawer" open={kbExpanded} onToggle={event => { const expanded = event.currentTarget.open; setOpenKb(current => ({ ...current, [drawerKey]: expanded })) }}><summary><ChevronRight size={14}/><strong>{kbRows[0].kb_name || '未指定知识库'}</strong><span>{kbRows.length}</span><em>{kbExpanded ? '收起' : '展开'}</em></summary><div className="usage-kb-group">{kbRows.map(row => {
|
||||
const selectable = row.status === 'consumed' && row.can_release !== false, active = selectable && selected.has(row.id)
|
||||
const text = <span className="usage-card-text"><span className="usage-card-title">{row.kb_name && <b className="usage-card-kb">{row.kb_name}</b>}<b>{row.agent_name || '未命名 Agent'}</b><span>{row.heading || row.file_name || '知识片段'}</span></span><span className="usage-card-excerpt">{row.excerpt || row.chunk_id}</span>{(row.campaign_name || row.used_at) && <span className="usage-card-meta">{row.campaign_name ? `任务:${row.campaign_name}${row.segment_title ? ` · ${row.segment_title}` : ''}` : ''}{row.used_at ? ` · ${row.used_at}` : ''}</span>}</span>
|
||||
return <article key={row.id} className={`knowledge-usage-card${active ? ' is-selected' : ''}`}>
|
||||
{selectable ? <label className="usage-card-choice"><input type="checkbox" checked={active} disabled={busy} onChange={() => toggle(row.id)} aria-label={`选择记录 ${row.id}:${row.heading || row.file_name || '知识片段'}`} />{text}</label> : <div className="usage-card-choice">{row.status === 'available' ? <Check size={18} className="usage-open-icon"/> : <Clock3 size={18} className="usage-open-icon"/>}{text}</div>}
|
||||
{selectable && row.can_release !== false && <button className="mini usage-release" disabled={busy} onClick={() => onRelease(row)}><RotateCcw size={13}/><span>重新开放</span></button>}
|
||||
</article>
|
||||
})}</div></details>})}
|
||||
{!group.rows.length && <p className="usage-empty">暂无{group.title}记录</p>}
|
||||
</div>}
|
||||
</details>)}
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/* 页面组件:从 App.tsx 拆出(墨韵金重构) */
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api, localizeError } from '../api'
|
||||
import { useDialog } from '../dialog'
|
||||
import { Tag } from '../ui'
|
||||
import { STATUS_LABEL, KB_STATUS_LABEL, CLASSIFICATION_STATUS_LABEL, RUNTIME_EVENT_LABEL,
|
||||
RUNTIME_PHASE_LABEL, RUNTIME_TOOL_LABEL, PAGE_META, RULE_TEMPLATES } from '../constants'
|
||||
import type { Seg, Camp, CampaignState, RuntimeDetail, CampaignCreateResponse, ComposeResponse,
|
||||
AgentCreateResponse, AgentSummary, KnowledgeBaseSummary, NotificationSummary, AuthUser, AuthStatus,
|
||||
TraceSummary, TraceDetail, UsageSummary, UsageEventRow, PointState, PointAccountRow, UsageResponse,
|
||||
AdminUsageResponse, AgentListRow, MemoryListRow, KbFileRow, KbCategoryRow, KbDetailResponse, KbUsageResponse } from '../types'
|
||||
|
||||
const NOTIFICATION_TYPE_LABEL: Record<string, string> = { alert: '告警', failure: '失败', review: '待审查', success: '成功', info: '信息', reminder: '提醒' }
|
||||
|
||||
function errorText(error: unknown) {
|
||||
return localizeError(error)
|
||||
}
|
||||
|
||||
export default function LogsPage() {
|
||||
const [traces, setTraces] = useState<TraceSummary[]>([])
|
||||
const [notifs, setNotifs] = useState<any[]>([])
|
||||
const [mems, setMems] = useState<any[]>([])
|
||||
const [runStatus, setRunStatus] = useState('')
|
||||
const [selectedTrace, setSelectedTrace] = useState('')
|
||||
const [notifBusy, setNotifBusy] = useState(false)
|
||||
const [detail, setDetail] = useState<TraceDetail | null>(null)
|
||||
const [traceError, setTraceError] = useState('')
|
||||
const refresh = () => Promise.all([
|
||||
api.get<TraceSummary[]>(`/traces${runStatus ? `?status=${encodeURIComponent(runStatus)}` : ''}`).then(setTraces), api.get('/notifications').then(setNotifs),
|
||||
api.get('/pending_memories').then(setMems),
|
||||
]).catch((requestError: unknown) => setTraceError(errorText(requestError)))
|
||||
useEffect(() => { refresh(); const t = setInterval(refresh, 5000); return () => clearInterval(t) }, [runStatus])
|
||||
const inspectTrace = async (traceId: string) => {
|
||||
setSelectedTrace(traceId); setTraceError('')
|
||||
try { setDetail(await api.get<TraceDetail>(`/traces/${encodeURIComponent(traceId)}`)) }
|
||||
catch (requestError: unknown) { setTraceError(errorText(requestError)); setDetail(null) }
|
||||
}
|
||||
const readAllNotifications = async () => {
|
||||
if (notifBusy) return
|
||||
setNotifBusy(true); setTraceError('')
|
||||
try {
|
||||
await api.post('/notifications/read-all', {})
|
||||
await refresh()
|
||||
} catch (requestError: unknown) {
|
||||
setTraceError(errorText(requestError))
|
||||
} finally {
|
||||
setNotifBusy(false)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="grid2">
|
||||
<section>
|
||||
<div className="section-title-row"><div><h2>通知</h2><p>任务失败、审查提醒和系统告警。</p></div>
|
||||
<button className="mini" onClick={readAllNotifications} disabled={notifBusy || !notifs.some(n => n.status === 'unread')}>✓ {notifBusy ? '正在处理…' : '一键全部已读'}</button></div>
|
||||
{notifs.map(n => (
|
||||
<div key={n.id} className={`row ${n.status === 'unread' ? 'warn' : 'dim'}`}>
|
||||
<span className="badge">{NOTIFICATION_TYPE_LABEL[n.type] || n.type}</span><span>{n.message}</span>
|
||||
<span className="dim">{n.created_at}</span>
|
||||
{n.status === 'unread' && <button className="mini" onClick={async () => { await api.post(`/notifications/${n.id}/read`, {}); refresh() }}>已读</button>}
|
||||
</div>))}
|
||||
{!notifs.length && <div className="empty-workspace compact"><strong>暂无通知</strong><span>任务失败、审查提醒和系统告警会出现在这里。</span></div>}
|
||||
<h2>记忆待确认池(确认后才沉淀)</h2>
|
||||
{mems.map(m => (
|
||||
<div key={m.id} className="card">
|
||||
<div className="row"><span className="badge gold">{m.type}</span><span className="dim">{m.agent_name || `Agent#${m.target_agent_id}`} · {m.source_ref}</span></div>
|
||||
<div>{m.content}</div>
|
||||
<div className="btns">
|
||||
<button className="ok" onClick={async () => { await api.post(`/pending_memories/${m.id}`, { action: 'approve' }); refresh() }}>✅ 确认沉淀</button>
|
||||
<button onClick={async () => { await api.post(`/pending_memories/${m.id}`, { action: 'reject' }); refresh() }}>❌ 拒绝</button>
|
||||
<button onClick={async () => { await api.post(`/pending_memories/${m.id}`, { action: 'block' }); refresh() }}>🚫 屏蔽同类</button>
|
||||
</div>
|
||||
</div>))}
|
||||
</section>
|
||||
<section>
|
||||
<div className="section-title-row"><div><h2>LLM Trace({traces.length})</h2><p>一次运行的模型调用、工具事件、QC 与成稿配置在同一条 Trace 内回放。</p></div><select className="trace-filter" value={runStatus} onChange={event => setRunStatus(event.target.value)}><option value="">全部运行状态</option><option value="queued">排队中</option><option value="running">运行中</option><option value="awaiting_review">等待审查</option><option value="failed">失败</option><option value="cancelled">已取消</option></select></div>
|
||||
{traceError && <div className="err">{traceError}</div>}
|
||||
<div className="trace-list">{traces.map(trace => <button key={trace.trace_id} className={`trace-row ${selectedTrace === trace.trace_id ? 'selected' : ''}`} onClick={() => inspectTrace(trace.trace_id)}>
|
||||
<span><b>任务 #{trace.campaign_id || '—'}</b><small>{trace.trace_id}</small></span><Tag s={trace.run_status} /><span className="trace-metrics">{trace.call_count} 调用 · {trace.session_count} 会话 · {trace.event_count} 事件</span><span className="trace-metrics">{trace.prompt_tokens}+{trace.completion_tokens} tok · {trace.duration_ms}ms{trace.error_count ? ` · ${trace.error_count} 异常` : ''}</span>
|
||||
</button>)}</div>
|
||||
{!traces.length && <div className="empty-workspace compact"><strong>暂无可追踪的运行</strong><span>创建并运行内容任务后,这里会出现完整的调用链。</span></div>}
|
||||
{detail && <details className="trace-detail card" open><summary>Trace 详情:{detail.trace_id}</summary>
|
||||
<div className="trace-detail-grid"><div><b>运行配置</b><span>{detail.run ? `运行 #${detail.run.id} · ${detail.run.runtime_version} · ${STATUS_LABEL[detail.run.status] || detail.run.status}` : '无关联运行'}</span></div><div><b>质检</b><span>{detail.qc_reports.map(report => `${report.gate}:${STATUS_LABEL[report.verdict] || report.verdict}`).join(' · ') || '尚无'}</span></div><div><b>成稿</b><span>{detail.artifacts.map(artifact => `#${artifact.id} ${STATUS_LABEL[artifact.final_status] || artifact.final_status}`).join(' · ') || '尚无'}</span></div></div>
|
||||
<div className="trace-call-list">{detail.calls.map(call => <div className="trace-call" key={call.id}><Tag s={call.status} /><span><b>{call.agent_name || '系统'}</b><small>{call.provider}/{call.model} · 第 {call.attempt} 次 · 段 {call.segment_id || '—'}</small></span><span>{call.duration_ms}ms<br />{call.prompt_tokens}+{call.completion_tokens} tok</span><small>{call.error ? errorText(call.error) : `输入 ${call.prompt_hash.slice(0, 10)} · 输出 ${call.output_hash.slice(0, 10)}`}</small></div>)}</div>
|
||||
<div className="trace-event-list">{detail.events.slice().reverse().map(event => <div key={event.id}><span>{event.created_at?.slice(11, 19) || '--:--:--'}</span><b>{RUNTIME_EVENT_LABEL[event.event_type] || event.event_type}</b><em>{event.actor_name || '系统'}{event.tool_name ? ` · ${RUNTIME_TOOL_LABEL[event.tool_name] || event.tool_name}` : ''}</em><Tag s={event.status || 'UNKNOWN'} /></div>)}</div>
|
||||
{detail.run && <details className="trace-snapshot"><summary>冻结配置快照</summary><pre className="art">{JSON.stringify(detail.run.config_snapshot, null, 2)}</pre></details>}
|
||||
</details>}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useAgentEditSafety } from './AgentsPage'
|
||||
import { api, localizeError } from '../api'
|
||||
import { useDialog } from '../dialog'
|
||||
import type { AgentListRow, MemoryListRow } from '../types'
|
||||
import { OwnerLabel } from '../workbench-ui'
|
||||
import { forkResource, isGlobalResource } from '../resourceActions'
|
||||
|
||||
const MEMORY_TYPES: Record<string, string> = {
|
||||
L1: 'L1 长期规则', L2: 'L2 纠错经验', L3: 'L3 表达偏好', L4: 'L4 成功经验',
|
||||
}
|
||||
type Editor = { kind: 'metadata' | 'approve' | 'create'; memory?: MemoryListRow }
|
||||
type MemoryForm = { agent_id: string; type: string; title: string; description: string; content: string; source_ref: string; replace_memory_id: string }
|
||||
const emptyForm = (): MemoryForm => ({ agent_id: '', type: 'L1', title: '', description: '', content: '', source_ref: '', replace_memory_id: '' })
|
||||
const titleOf = (memory: MemoryListRow) => memory.title?.trim() || memory.content.split('\n')[0]?.slice(0, 80) || '未命名记忆'
|
||||
const dateLabel = (value?: string | null) => {
|
||||
if (!value) return '尚未使用'
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
export default function MemoriesPage() {
|
||||
const dialog = useDialog()
|
||||
const [agents, setAgents] = useState<AgentListRow[]>([])
|
||||
const [forkBusy, setForkBusy] = useState<number | null>(null)
|
||||
const [memories, setMemories] = useState<MemoryListRow[]>([])
|
||||
const [pending, setPending] = useState<MemoryListRow[]>([])
|
||||
const [agentId, setAgentId] = useState('')
|
||||
const [status, setStatus] = useState('')
|
||||
const [type, setType] = useState('')
|
||||
const [q, setQ] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [feedback, setFeedback] = useState('')
|
||||
const [editor, setEditor] = useState<Editor | null>(null)
|
||||
const [form, setForm] = useState<MemoryForm>(emptyForm)
|
||||
const [baseline, setBaseline] = useState(JSON.stringify(emptyForm()))
|
||||
const [replacementOptions, setReplacementOptions] = useState<MemoryListRow[]>([])
|
||||
const [replacementLoading, setReplacementLoading] = useState(false)
|
||||
const [replacementError, setReplacementError] = useState('')
|
||||
const inFlight = useRef(false)
|
||||
const requestVersion = useRef(0)
|
||||
const editorVersion = useRef(0)
|
||||
const editorElement = useRef<HTMLElement>(null)
|
||||
const filters = useRef({ agentId, status, type, q })
|
||||
filters.current = { agentId, status, type, q }
|
||||
const dirty = editor !== null && JSON.stringify(form) !== baseline
|
||||
const allowLeave = useAgentEditSafety(dirty, busy)
|
||||
|
||||
const refresh = async () => {
|
||||
const version = ++requestVersion.current
|
||||
const active = filters.current
|
||||
const params = new URLSearchParams()
|
||||
if (active.agentId) params.set('agent_id', active.agentId)
|
||||
if (active.status) params.set('status', active.status)
|
||||
if (active.type) params.set('memory_type', active.type)
|
||||
if (active.q.trim()) params.set('q', active.q.trim())
|
||||
const pendingParams = new URLSearchParams()
|
||||
if (active.agentId) pendingParams.set('agent_id', active.agentId)
|
||||
if (active.type) pendingParams.set('memory_type', active.type)
|
||||
setLoading(true)
|
||||
try {
|
||||
const [memoryRows, pendingRows] = await Promise.all([
|
||||
api.get<MemoryListRow[]>(`/memories?${params}`),
|
||||
api.get<MemoryListRow[]>(`/pending_memories?${pendingParams}`),
|
||||
])
|
||||
if (version !== requestVersion.current) return
|
||||
const needle = active.q.trim().toLocaleLowerCase()
|
||||
setMemories(memoryRows)
|
||||
setPending(pendingRows.filter(row => !needle || `${row.title || ''} ${row.description || ''} ${row.content} ${row.source_ref || ''}`.toLocaleLowerCase().includes(needle)))
|
||||
} catch (reason: unknown) {
|
||||
if (version === requestVersion.current) setError(`刷新记忆失败:${localizeError(reason)}`)
|
||||
} finally {
|
||||
if (version === requestVersion.current) setLoading(false)
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
api.get<AgentListRow[]>('/agents').then(rows => { if (!cancelled) setAgents(rows) })
|
||||
.catch(reason => { if (!cancelled) setError(localizeError(reason)) })
|
||||
return () => { cancelled = true; ++requestVersion.current; ++editorVersion.current }
|
||||
}, [])
|
||||
useEffect(() => { void refresh() }, [agentId, status, type])
|
||||
useEffect(() => { if (editor) editorElement.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) }, [editor])
|
||||
|
||||
const closeEditor = async () => {
|
||||
if (!await allowLeave()) return
|
||||
++editorVersion.current
|
||||
setEditor(null)
|
||||
}
|
||||
const openEditor = async (kind: Editor['kind'], memory?: MemoryListRow) => {
|
||||
if (memory && !memory.can_manage || !await allowLeave()) return
|
||||
const version = ++editorVersion.current
|
||||
const value: MemoryForm = memory ? {
|
||||
agent_id: String(memory.target_agent_id ?? memory.agent_id ?? ''), type: memory.type,
|
||||
title: memory.title || (kind === 'approve' ? titleOf(memory) : ''), description: memory.description || '',
|
||||
content: memory.content, source_ref: memory.source_ref || '', replace_memory_id: '',
|
||||
} : { ...emptyForm(), agent_id: agentId }
|
||||
setForm(value); setBaseline(JSON.stringify(value)); setEditor({ kind, memory })
|
||||
setError(''); setFeedback(''); setReplacementOptions([]); setReplacementError('')
|
||||
setReplacementLoading(kind === 'approve')
|
||||
if (kind !== 'approve' || !memory) return
|
||||
try {
|
||||
const rows = await api.get<MemoryListRow[]>(`/memories?${new URLSearchParams({ agent_id: value.agent_id, status: 'active' })}`)
|
||||
if (version !== editorVersion.current) return
|
||||
setReplacementOptions(rows.filter(row => row.can_manage && row.status === 'active'
|
||||
&& row.agent_id === memory.target_agent_id
|
||||
&& memory.owner_user_id !== undefined && row.owner_user_id === memory.owner_user_id
|
||||
&& memory.organization_id !== undefined && row.organization_id === memory.organization_id
|
||||
&& row.visibility === memory.visibility))
|
||||
} catch (reason: unknown) {
|
||||
if (version === editorVersion.current) setReplacementError(`旧记忆加载失败:${localizeError(reason)}。可保留为新增记忆,或关闭后重新打开。`)
|
||||
} finally {
|
||||
if (version === editorVersion.current) setReplacementLoading(false)
|
||||
}
|
||||
}
|
||||
const mutate = async (operation: () => Promise<unknown>, message: string, close = false) => {
|
||||
if (inFlight.current) return
|
||||
inFlight.current = true; setBusy(true); setError(''); setFeedback('')
|
||||
try {
|
||||
await operation()
|
||||
if (close) { ++editorVersion.current; setEditor(null) }
|
||||
setFeedback(message)
|
||||
await refresh()
|
||||
} catch (reason: unknown) { setError(localizeError(reason)) }
|
||||
finally { inFlight.current = false; setBusy(false) }
|
||||
}
|
||||
const save = async () => {
|
||||
if (!editor || inFlight.current) return
|
||||
if (editor.kind === 'metadata') {
|
||||
if (!editor.memory?.can_manage) return
|
||||
await mutate(() => api.put(`/memories/${editor.memory!.id}`, {
|
||||
title: form.title, description: form.description, source_ref: form.source_ref,
|
||||
}), '标题、说明和来源已保存。', true)
|
||||
return
|
||||
}
|
||||
if (!form.content.trim()) { setError('请填写记忆正文'); return }
|
||||
if (editor.kind === 'create') {
|
||||
if (!form.agent_id) { setError('请选择对应的智能体'); return }
|
||||
await mutate(() => api.post('/pending_memories', {
|
||||
agent_id: Number(form.agent_id), type: form.type, content: form.content.trim(), source_ref: form.source_ref.trim(),
|
||||
}), '已添加私有的待确认记忆,请在待确认列表中审核并保存后生效。', true)
|
||||
return
|
||||
}
|
||||
if (!editor.memory?.can_manage) return
|
||||
const replacement = form.replace_memory_id ? replacementOptions.find(row => row.id === Number(form.replace_memory_id)) : null
|
||||
if (form.replace_memory_id && !replacement) { setError('所选旧记忆不可替换,请重新选择'); return }
|
||||
await mutate(() => api.post(`/pending_memories/${editor.memory!.id}`, {
|
||||
action: 'approve', type: form.type, content: form.content.trim(), title: form.title.trim(), description: form.description.trim(),
|
||||
...(replacement ? { replace_memory_id: replacement.id } : {}),
|
||||
}), replacement ? '新记忆已确认保存,旧记忆已过期并保留在历史中。' : '已确认并保存,后续任务可以召回这条长期记忆。', true)
|
||||
}
|
||||
const remove = async (kind: 'memory' | 'pending', memory: MemoryListRow) => {
|
||||
const personalRemoval = isGlobalResource(memory) && !memory.can_manage
|
||||
if ((!memory.can_manage && !personalRemoval) || !await allowLeave()) return
|
||||
if (!await dialog.confirm({ title: `${personalRemoval ? '从我的列表移除' : '删除'}「${titleOf(memory)}」?`, description: personalRemoval ? '仅从当前账号隐藏,不影响其他用户和管理员使用。' : kind === 'memory' ? '将永久删除这条记忆。需要保留历史时,请使用“设为过期”。已生成的任务正文不会受影响。' : '将删除这条尚未生效的候选记忆。', confirmLabel: personalRemoval ? '确认移除' : '确认删除', tone: 'danger' })) return
|
||||
await mutate(() => api.delete(`/${kind === 'memory' ? 'memories' : 'pending_memories'}/${memory.id}`), '记忆已删除。', editor?.memory?.id === memory.id && editor.kind === (kind === 'memory' ? 'metadata' : 'approve'))
|
||||
}
|
||||
const fork = async (memory: MemoryListRow) => {
|
||||
if (forkBusy) return
|
||||
setForkBusy(memory.id); setError('')
|
||||
try { await forkResource('memories', Number(memory.id)); setFeedback('已创建记忆副本,可独立编辑和删除。'); await refresh() }
|
||||
catch (e: any) { setError(localizeError(e)) } finally { setForkBusy(null) }
|
||||
}
|
||||
const toggleStatus = async (memory: MemoryListRow) => {
|
||||
if (!memory.can_manage) return
|
||||
const next = memory.status === 'active' ? 'expired' : 'active'
|
||||
await mutate(() => api.put(`/memories/${memory.id}`, { status: next }), next === 'expired' ? '已设为过期,后续任务不会再召回这条记忆。' : '已恢复生效,后续任务可以重新召回这条记忆。')
|
||||
}
|
||||
const managedMemories = memories.filter(row => row.can_manage)
|
||||
const managedPending = pending.filter(row => row.can_manage)
|
||||
const batchDelete = async (kind: 'memory' | 'pending') => {
|
||||
const rows = kind === 'memory' ? managedMemories : managedPending
|
||||
if (!rows.length || !await allowLeave()) return
|
||||
if (!await dialog.confirm({ title: `删除当前筛选中可管理的 ${rows.length} 条记忆?`, description: '仅删除当前筛选列表中你有管理权限的记录。已生成的任务正文不会受影响。', confirmLabel: '确认删除', tone: 'danger' })) return
|
||||
await mutate(() => api.post(kind === 'memory' ? '/memories/batch-delete' : '/pending_memories/batch-delete', { ids: rows.map(row => row.id) }), '当前筛选中可管理的记忆已删除。', rows.some(row => row.id === editor?.memory?.id) && editor?.kind === (kind === 'memory' ? 'metadata' : 'approve'))
|
||||
}
|
||||
const editorTitle = editor?.kind === 'metadata' ? '编辑标题与说明' : editor?.kind === 'approve' ? '审核并确认长期记忆' : '新增待确认记忆'
|
||||
|
||||
return <div className="memory-page">
|
||||
<div className="card">
|
||||
<div className="section-title-row"><div><h2>长期记忆</h2><p>这里管理人工确认的长期经验。智能体画像和当前任务上下文由各自的配置与任务提供。</p></div><button onClick={() => openEditor('create')} disabled={busy}>+ 新增待确认记忆</button></div>
|
||||
<p className="dim">内容段首次生成时,会按任务召回合适的记忆并冻结快照。同一运行的重试沿用原快照;本页修改供后续首次生成的内容段使用。</p>
|
||||
</div>
|
||||
<div className="card memory-filter-bar">
|
||||
<select aria-label="筛选智能体" value={agentId} onChange={e => setAgentId(e.target.value)}><option value="">全部智能体</option>{agents.map(agent => <option key={agent.id} value={agent.id}>{agent.name}</option>)}</select>
|
||||
<select aria-label="筛选已确认记忆状态" value={status} onChange={e => setStatus(e.target.value)}><option value="">全部状态</option><option value="active">已生效</option><option value="expired">已过期</option></select>
|
||||
<select aria-label="筛选记忆类型" value={type} onChange={e => setType(e.target.value)}><option value="">全部类型</option>{Object.entries(MEMORY_TYPES).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select>
|
||||
<input aria-label="搜索记忆" placeholder="搜索记忆内容或来源" value={q} onChange={e => setQ(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') void refresh() }} />
|
||||
<button onClick={() => refresh()} disabled={busy || loading}>{loading ? '加载中…' : '筛选'}</button>
|
||||
</div>
|
||||
{feedback && <p role="status">{feedback}</p>}
|
||||
{error && <div className="err" role="alert">{error}</div>}
|
||||
{editor && <section className="card" ref={editorElement} aria-labelledby="memory-editor-title">
|
||||
<h2 id="memory-editor-title">{editorTitle}</h2>
|
||||
<p>{editor.kind === 'metadata' ? '已确认的记忆正文保持只读。要纠正正文,请新增候选记忆,审核时选择替换旧记忆以保留历史。' : editor.kind === 'approve' ? '请核对并修改正文、类型、标题和说明,确认后才会成为可召回的长期经验。' : '手动补充的候选记忆默认私有,确认保存后才会生效。'}</p>
|
||||
<form onSubmit={event => { event.preventDefault(); void save() }}>
|
||||
<fieldset disabled={busy} style={{ border: 0, padding: 0, minWidth: 0 }}>
|
||||
{editor.kind === 'create' ? <label>对应智能体<select required value={form.agent_id} onChange={e => setForm({ ...form, agent_id: e.target.value })}><option value="">请选择智能体</option>{agents.map(agent => <option key={agent.id} value={agent.id}>{agent.name}</option>)}</select></label> : <p>对应智能体:<strong>{editor.memory?.agent_name}</strong></p>}
|
||||
{editor.kind !== 'metadata' && <label>记忆类型<select value={form.type} onChange={e => setForm({ ...form, type: e.target.value })}>{Object.entries(MEMORY_TYPES).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label>}
|
||||
{editor.kind !== 'create' && <>
|
||||
<label>记忆标题<input maxLength={500} value={form.title} onChange={e => setForm({ ...form, title: e.target.value })} /></label>
|
||||
<label>记忆说明<textarea maxLength={500} rows={3} placeholder="这条经验适用于什么场景,为什么要保留" value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} /></label>
|
||||
</>}
|
||||
<label>记忆正文{editor.kind === 'metadata' ? <p>{form.content}</p> : <textarea required maxLength={6000} rows={6} placeholder="填写明确、可执行的长期规则或经验(最多 6000 字)" value={form.content} onChange={e => setForm({ ...form, content: e.target.value })} />}</label>
|
||||
{editor.kind === 'approve' ? <>
|
||||
<p className="dim">来源:{form.source_ref || '未记录'}</p>
|
||||
<label>保存方式<select value={form.replace_memory_id} disabled={replacementLoading} onChange={e => setForm({ ...form, replace_memory_id: e.target.value })}><option value="">新增为一条长期记忆</option>{replacementOptions.map(memory => <option key={memory.id} value={memory.id}>替换:{titleOf(memory)}({MEMORY_TYPES[memory.type] || memory.type})</option>)}</select></label>
|
||||
{replacementLoading ? <p role="status">正在查找可替换的旧记忆…</p> : <p className="dim">仅可替换同一智能体、同一所属用户、工作区及可见范围的生效记忆;替换后旧记忆会过期并保留历史。</p>}
|
||||
{replacementError && <p className="err" role="alert">{replacementError}</p>}
|
||||
{form.replace_memory_id && <details open><summary>将被替换的旧记忆</summary><p>{replacementOptions.find(memory => memory.id === Number(form.replace_memory_id))?.content}</p></details>}
|
||||
</> : <label>记忆来源<input maxLength={500} placeholder="例如:任务名称、人工复盘" value={form.source_ref} onChange={e => setForm({ ...form, source_ref: e.target.value })} /></label>}
|
||||
<p role="status">{busy ? '正在保存,请勿重复操作…' : dirty ? '有未保存的修改' : editor.kind === 'approve' ? '请核对后确认保存' : '尚无修改'}</p>
|
||||
<div className="btns"><button className="ok" type="submit" disabled={editor.kind === 'approve' && replacementLoading}>{editor.kind === 'approve' ? '确认并保存' : editor.kind === 'create' ? '保存到待确认' : '保存标题与说明'}</button><button type="button" onClick={closeEditor}>取消</button></div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</section>}
|
||||
<div className="section-title-row"><div><h2>已确认记忆({memories.length})</h2><p>生效记忆供后续任务召回;过期记忆保留历史。已有替代版本生效时,需先停用新版本才能恢复旧版本。</p></div>{managedMemories.length > 0 && <button className="mini danger-text" onClick={() => batchDelete('memory')} disabled={busy || loading}>批量删除可管理记忆({managedMemories.length})</button>}</div>
|
||||
{memories.map(memory => <div className="card memory-row" key={`memory-${memory.id}`} style={{ flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<div className="row"><b>{memory.agent_name}</b><span className="badge gold">{MEMORY_TYPES[memory.type] || memory.type}</span><span className={`badge ${memory.status === 'active' ? 'okbg' : ''}`}>{memory.status === 'active' ? '已生效' : '已过期'}</span>{!memory.can_manage && <span className="badge">只读</span>}</div>
|
||||
<h3>{titleOf(memory)}</h3><OwnerLabel resource={memory} /><p className="dim">{memory.description || '尚未填写说明'}</p><p>{memory.content}</p>
|
||||
<p className="dim">使用 {memory.usage_count ?? 0} 次 · 最近使用:{dateLabel(memory.last_used_at)}</p>
|
||||
<p className="dim">来源:{memory.source_ref || '未记录'} · 创建于 {dateLabel(memory.created_at)}</p>
|
||||
</div>
|
||||
{memory.can_manage ? <div className="btns"><button className="mini" onClick={() => openEditor('metadata', memory)} disabled={busy}>编辑标题与说明</button><button className="mini" onClick={() => toggleStatus(memory)} disabled={busy}>{memory.status === 'active' ? '设为过期' : '恢复生效'}</button><button className="mini danger-text" onClick={() => remove('memory', memory)} disabled={busy}>删除</button></div> : isGlobalResource(memory) && <div className="btns"><button className="mini" onClick={() => void fork(memory)} disabled={busy || forkBusy === memory.id}>{forkBusy === memory.id ? '创建副本中…' : '创建我的副本'}</button><button className="mini" onClick={() => void remove('memory', memory)} disabled={busy}>从我的列表移除</button></div>}
|
||||
</div>)}
|
||||
{!loading && !memories.length && <div className="empty-workspace compact"><strong>当前筛选下没有已确认记忆</strong><span>审核通过的候选记忆会出现在这里,也可以调整筛选条件。</span></div>}
|
||||
<div className="section-title-row memory-section-heading"><div><h2>待确认记忆({pending.length})</h2><p>先审核内容,再确认保存。候选记忆在确认前不会参与内容生成。</p></div>{managedPending.length > 0 && <button className="mini danger-text" onClick={() => batchDelete('pending')} disabled={busy || loading}>批量删除可管理记忆({managedPending.length})</button>}</div>
|
||||
{pending.map(memory => <div className="card memory-row" key={`pending-${memory.id}`} style={{ flexWrap: 'wrap' }}>
|
||||
<div><div className="row"><b>{memory.agent_name}</b><span className="badge gold">{MEMORY_TYPES[memory.type] || memory.type}</span><span className="badge">待确认</span>{!memory.can_manage && <span className="badge">只读</span>}</div><h3>{titleOf(memory)}</h3><OwnerLabel resource={memory} />{memory.description && <p className="dim">{memory.description}</p>}<p>{memory.content}</p><p className="dim">来源:{memory.source_ref || '未记录'} · 创建于 {dateLabel(memory.created_at)}</p></div>
|
||||
{memory.can_manage && <div className="btns"><button className="mini ok" onClick={() => openEditor('approve', memory)} disabled={busy}>审核并确认</button><button className="mini danger-text" onClick={() => remove('pending', memory)} disabled={busy}>删除</button></div>}
|
||||
</div>)}
|
||||
{!loading && !pending.length && <div className="empty-workspace compact"><strong>暂无待确认记忆</strong><span>可以手动补充一条长期经验,再审核确认。</span></div>}
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/* 页面组件:从 App.tsx 拆出(墨韵金重构) */
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api, localizeError } from '../api'
|
||||
import { useDialog } from '../dialog'
|
||||
import { Tag } from '../ui'
|
||||
import { STATUS_LABEL, KB_STATUS_LABEL, CLASSIFICATION_STATUS_LABEL, RUNTIME_EVENT_LABEL,
|
||||
RUNTIME_PHASE_LABEL, RUNTIME_TOOL_LABEL, PAGE_META, RULE_TEMPLATES } from '../constants'
|
||||
import type { Seg, Camp, CampaignState, RuntimeDetail, CampaignCreateResponse, ComposeResponse,
|
||||
AgentCreateResponse, AgentSummary, KnowledgeBaseSummary, NotificationSummary, AuthUser, AuthStatus,
|
||||
TraceSummary, TraceDetail, UsageSummary, UsageEventRow, PointState, PointAccountRow, UsageResponse,
|
||||
AdminUsageResponse, AgentListRow, MemoryListRow, KbFileRow, KbCategoryRow, KbDetailResponse, KbUsageResponse } from '../types'
|
||||
|
||||
/* ---------------- 模型管理 ---------------- */
|
||||
export default function ModelsPage({ readOnly = false }: { readOnly?: boolean }) {
|
||||
const dialog = useDialog()
|
||||
const emptyProvider = { name: '', base_url: '', api_key: '', models: '', default_model: '', note: '', enabled: true }
|
||||
const [ps, setPs] = useState<any[]>([])
|
||||
const [form, setForm] = useState(emptyProvider)
|
||||
const [editing, setEditing] = useState<any>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const busyRef = useRef(false)
|
||||
const [status, setStatus] = useState('')
|
||||
const [defaultModel, setDefaultModel] = useState<any>(null)
|
||||
const providerError = (error: unknown, fallback: string) => {
|
||||
const message = localizeError(error)
|
||||
return message && message !== '请求失败,请稍后重试' ? message : fallback
|
||||
}
|
||||
const refresh = () => readOnly
|
||||
? api.get<any[]>('/models/available').then(rows => {
|
||||
const grouped: Record<number, any> = {}
|
||||
rows.forEach(row => {
|
||||
const current = grouped[row.provider_id] || { id: row.provider_id, name: row.provider_name, models: [], catalogs: [] }
|
||||
current.models.push(row.model_name); current.catalogs.push(row); grouped[row.provider_id] = current
|
||||
})
|
||||
setPs(Object.values(grouped)); setDefaultModel(null)
|
||||
})
|
||||
: Promise.all([api.get('/providers').then(setPs), api.get('/settings/model-default').then(setDefaultModel)])
|
||||
useEffect(() => { void refresh().catch(error => setError(providerError(error, '模型列表读取失败'))) }, [])
|
||||
const reset = () => { setEditing(null); setForm(emptyProvider); setError(''); setStatus('') }
|
||||
const discover = async () => {
|
||||
if (busyRef.current) return
|
||||
busyRef.current = true
|
||||
setBusy(true); setError('')
|
||||
try {
|
||||
const result = await api.post<{ models: string[]; default_model: string }>('/providers/discover-models', {
|
||||
base_url: form.base_url, api_key: form.api_key, ...(editing ? { provider_id: editing.id } : {}),
|
||||
})
|
||||
setForm(current => ({ ...current, models: result.models.join(', '), default_model: result.default_model }))
|
||||
} catch (e: any) { setError(providerError(e, 'Provider 请求失败,请根据具体错误检查配置。')) } finally { busyRef.current = false; setBusy(false) }
|
||||
}
|
||||
const add = async () => {
|
||||
if (busyRef.current) return
|
||||
busyRef.current = true
|
||||
setBusy(true); setError('')
|
||||
try {
|
||||
const result = await api.post<{ model_discovery: string }>('/providers', { ...form, models: form.models.split(',').map(s => s.trim()).filter(Boolean) })
|
||||
reset(); await refresh()
|
||||
if (result.model_discovery === 'manual') setError('Provider 已保存,当前使用的是手动填写的模型列表。')
|
||||
}
|
||||
catch (e: any) { setError(providerError(e, 'Provider 请求失败,请根据具体错误检查配置。')) } finally { busyRef.current = false; setBusy(false) }
|
||||
}
|
||||
const edit = (provider: any) => {
|
||||
if (busyRef.current) return
|
||||
setStatus('')
|
||||
setEditing(provider)
|
||||
setForm({ name: provider.name, base_url: provider.base_url, api_key: '', models: (provider.models || []).join(', '), default_model: provider.default_model || provider.models?.[0] || '', note: provider.note || '', enabled: provider.enabled !== false })
|
||||
setError('')
|
||||
}
|
||||
const save = async () => {
|
||||
if (!editing || busyRef.current) return
|
||||
busyRef.current = true
|
||||
setBusy(true); setError(''); setStatus('正在保存…')
|
||||
try { await api.put(`/providers/${editing.id}`, { ...form, models: form.models.split(',').map(s => s.trim()).filter(Boolean) }); await refresh(); setStatus('已保存'); setEditing(null); setForm(emptyProvider) }
|
||||
catch (e: any) { setError(providerError(e, 'Provider 保存失败,请根据具体错误检查配置。')); setStatus('保存失败,已保留输入') } finally { busyRef.current = false; setBusy(false) }
|
||||
}
|
||||
const refreshModels = async () => {
|
||||
if (editing) await discover()
|
||||
}
|
||||
const remove = async (provider: any) => {
|
||||
if (busyRef.current) return
|
||||
busyRef.current = true
|
||||
setBusy(true); setError('')
|
||||
try {
|
||||
if (!await dialog.confirm({ title: `删除 Provider「${provider.name}」?`, description: '删除后该 Provider 的模型目录同步清除,正在使用它的 Agent 将无法生成内容。此操作不可恢复。', confirmLabel: '确认删除', tone: 'danger' })) return
|
||||
await api.delete(`/providers/${provider.id}`); if (editing?.id === provider.id) reset(); await refresh()
|
||||
}
|
||||
catch (e: any) { setError(providerError(e, 'Provider 请求失败,请根据具体错误检查配置。')) } finally { busyRef.current = false; setBusy(false) }
|
||||
}
|
||||
const test = async (provider: any) => {
|
||||
if (busyRef.current) return
|
||||
busyRef.current = true
|
||||
setBusy(true); setError('')
|
||||
try { const result = await api.post<{ duration_ms: number; model: string }>(`/providers/${provider.id}/test`); await dialog.notice({ title: '模型连接正常', description: `已使用 ${result.model} 完成测试,耗时 ${result.duration_ms}ms。`, tone: 'success' }) }
|
||||
catch (e: any) { setError(providerError(e, 'Provider 请求失败,请根据具体错误检查配置。')) } finally { busyRef.current = false; setBusy(false) }
|
||||
}
|
||||
const setGlobalDefault = async (provider: any, model: string) => {
|
||||
if (busyRef.current) return
|
||||
busyRef.current = true
|
||||
setBusy(true); setError('')
|
||||
try { await api.put('/settings/model-default', { provider_id: provider.id, model_name: model }); await refresh() }
|
||||
catch (e: any) { setError(providerError(e, 'Provider 请求失败,请根据具体错误检查配置。')) } finally { busyRef.current = false; setBusy(false) }
|
||||
}
|
||||
return (
|
||||
<div className="grid2">
|
||||
<section>
|
||||
<h2>{readOnly ? '可用模型目录' : editing ? `编辑 Provider:${editing.name}` : '新增 Provider(OpenAI 兼容)'}</h2>
|
||||
{readOnly && <div className="card"><p>模型连接、发现、能力测试和费率由管理端统一维护。用户端只能选择已发布的模型用于自己的 Agent。</p></div>}
|
||||
{!readOnly && <>
|
||||
<div className="card">
|
||||
{!editing && <div className="form-kicker">保存时系统会自动从 Provider 的模型接口同步全部可用模型。手动列表仅作为同步失败时的备用方案。要用于整张导图生图,请填支持 OpenAI Images API 的 Provider,并确保模型列表包含 image、flux、dall、sdxl 等生图模型。</div>}
|
||||
<input placeholder="名称(如:硅基流动)" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} />
|
||||
<input placeholder="base_url(如 https://api.siliconflow.cn/v1)" value={form.base_url} onChange={e => setForm({ ...form, base_url: e.target.value })} />
|
||||
<input type="password" placeholder={editing ? 'API Key(留空保持原值)' : 'API Key'} value={form.api_key} onChange={e => setForm({ ...form, api_key: e.target.value })} />
|
||||
{!editing && <div className="btns"><button className="mini" onClick={discover} disabled={!form.base_url || busy}>获取 Provider 模型</button><span className="field-help">获取后会填充下方模型列表。</span></div>}
|
||||
{editing && <div className="btns"><button className="mini" onClick={refreshModels} disabled={busy}>同步最新模型</button><span className="field-help">优先使用当前填写的新密钥;留空使用已保存密钥。同步后点击“保存修改”生效。</span></div>}
|
||||
<input placeholder="模型列表(自动同步;失败时可逗号分隔手填)" value={form.models} onChange={e => setForm({ ...form, models: e.target.value })} />
|
||||
<input placeholder="默认模型(必须在模型列表中)" value={form.default_model} onChange={e => setForm({ ...form, default_model: e.target.value })} />
|
||||
<input placeholder="备注(可选)" value={form.note} onChange={e => setForm({ ...form, note: e.target.value })} />
|
||||
{editing && <label className="inline-check"><input type="checkbox" checked={form.enabled} onChange={e => setForm({ ...form, enabled: e.target.checked })} />启用 Provider</label>}
|
||||
{status && <div className={status.includes('失败') ? 'err' : 'field-help'} role="status">{status}</div>}{error && <div className="err">{error}</div>}
|
||||
<div className="btns">{editing
|
||||
? <><button className="ok" onClick={save} disabled={busy}>保存修改</button><button onClick={reset} disabled={busy}>取消编辑</button></>
|
||||
: <button onClick={add} disabled={!form.name || !form.base_url || busy}>+ 添加 Provider</button>}</div>
|
||||
</div>
|
||||
</>}
|
||||
</section>
|
||||
<section>
|
||||
<h2>{readOnly ? '已发布模型' : '已注册 Provider'}</h2>
|
||||
{defaultModel && <div className="card model-default-card"><strong>全局默认模型</strong><span className="dim">{ps.find(p => p.id === defaultModel.provider_id)?.name || '—'} / {defaultModel.model_name || '—'}</span></div>}
|
||||
{ps.map(p => (
|
||||
<div key={p.id} className="row">
|
||||
<b>{p.name}</b>{p.builtin && <span className="badge gold">内置</span>}<span className={`badge ${p.enabled ? 'okbg' : ''}`}>{p.enabled ? '启用' : '停用'}</span>
|
||||
<span className="dim">{p.base_url} · {p.api_key || '未配置 Key'}</span>
|
||||
<span className="dim">默认:{p.default_model || '—'} · {p.models.join(', ')}</span>
|
||||
{!readOnly && <><button className="mini" onClick={() => edit(p)}>编辑</button>
|
||||
<button className="mini" onClick={() => test(p)} disabled={busy || !p.enabled || editing?.id === p.id} title={editing?.id === p.id ? '请先保存当前修改,再测试连通' : '使用已保存配置测试'}>测试连通</button>
|
||||
{p.enabled && <select className="inline-model-select" value={p.id === defaultModel?.provider_id ? defaultModel.model_name : ''} onChange={event => { if (event.target.value) setGlobalDefault(p, event.target.value) }} aria-label={`设置 ${p.name} 默认模型`}><option value="">设为全局默认…</option>{p.models.map((m: string) => <option key={m} value={m}>{m}</option>)}</select>}
|
||||
<button className="mini danger-text" onClick={() => remove(p)}>删除</button></>}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---------------- 知识库 ---------------- */
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api, localizeError } from '../api'
|
||||
import { Tag } from '../ui'
|
||||
|
||||
/* 运营看板(P2-2):队列 / 任务 / 成本 / 知识库 / Provider 健康 / 死信 / 告警 一屏 */
|
||||
|
||||
type OpsDashboard = {
|
||||
scope?: 'org' | 'self'
|
||||
owner_user_id?: number
|
||||
queue: { total: number; pending: number; running: number; done: number; dead: number }
|
||||
campaigns: { active: number; failed: number; done: number; total: number }
|
||||
cost_7d: { amount_minor: number; tokens: number }
|
||||
knowledge: { hit_rate: number; repeat_rate: number; reference_coverage: number; by_kb: any[] }
|
||||
providers: { id: number; name: string; enabled: boolean; backend: string; models: number }[]
|
||||
dead_letter: { id: number; task_key: string; error: string; attempt: number; updated_at: string }[]
|
||||
backup: { count: number; keep_count: number; latest: string; latest_at: string; latest_size_kb: number; last_drill: { ok: boolean; detail: string; backup: string; checked_at: string } | null; directory: string }
|
||||
recent_alerts: { id: number; message: string; created_at: string }[]
|
||||
generated_at: string
|
||||
}
|
||||
type Performance = { samples: number; api: { p50_ms: number; p95_ms: number; p99_ms: number; status_counts: Record<string, number> }; rag: { samples: number; p50_ms: number; p95_ms: number; cache_hit_rate: number; embedding?: { embedding_encode_ms?: { p50_ms: number; p95_ms: number }; embedding_model_load_ms?: { p50_ms: number; p95_ms: number }; embedding_ipc_ms?: { p50_ms: number; p95_ms: number }; cache_hit_rate?: number; pids?: number[] } } }
|
||||
|
||||
const number = (value: number) => new Intl.NumberFormat('zh-CN').format(value || 0)
|
||||
|
||||
export default function OpsPage() {
|
||||
const [data, setData] = useState<OpsDashboard | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [performance, setPerformance] = useState<Performance | null>(null)
|
||||
const load = () => api.get<OpsDashboard>('/ops/dashboard').then(setData).catch(e => setError(localizeError(e)))
|
||||
useEffect(() => {
|
||||
load()
|
||||
const t = setInterval(load, 15000)
|
||||
return () => clearInterval(t)
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
if (!data || data.scope === 'self') return
|
||||
const loadPerformance = () => api.get<Performance>('/ops/performance').then(setPerformance).catch(() => {})
|
||||
loadPerformance()
|
||||
const t = setInterval(loadPerformance, 15000)
|
||||
return () => clearInterval(t)
|
||||
}, [data?.scope])
|
||||
const replay = async (id: number) => {
|
||||
setBusy(true); setError('')
|
||||
try { await api.post(`/ops/dead-letter/${id}/requeue`); await load() }
|
||||
catch (e: unknown) { setError(localizeError(e)) } finally { setBusy(false) }
|
||||
}
|
||||
const sweep = async () => {
|
||||
setBusy(true)
|
||||
try { await api.post('/ops/alerts/sweep'); await load() } finally { setBusy(false) }
|
||||
}
|
||||
const backupNow = async () => {
|
||||
setBusy(true); setError('')
|
||||
try { await api.post('/ops/backup'); await load() }
|
||||
catch (e: unknown) { setError(localizeError(e)) } finally { setBusy(false) }
|
||||
}
|
||||
const runDrill = async () => {
|
||||
setBusy(true); setError('')
|
||||
try { await api.post('/ops/backup/drill'); await load() }
|
||||
catch (e: unknown) { setError(localizeError(e)) } finally { setBusy(false) }
|
||||
}
|
||||
if (error && !data) return <div className="err">{error}</div>
|
||||
if (!data) return <div className="empty-workspace compact"><strong>看板加载中…</strong></div>
|
||||
const money = (minor: number) => new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format((minor || 0) / 100)
|
||||
const isAdmin = data?.scope !== 'self'
|
||||
return (
|
||||
<div className="ops-page">
|
||||
{error && <div className="err">{error}</div>}
|
||||
<div className="metric-grid">
|
||||
<div className="metric-card metric-accent"><div className="metric-top"><span>{isAdmin ? '队列执行中' : '我的任务进行中'}</span><span className="metric-icon">▶</span></div><strong>{number(data.campaigns.active)}</strong><small>{isAdmin ? `待执行 ${number(data.queue.pending)} · 已完成 ${number(data.queue.done)}` : `已交付 ${number(data.campaigns.done)} · 有失败 ${number(data.campaigns.failed)}`}</small></div>
|
||||
{isAdmin && <div className={`metric-card ${data.queue.dead ? 'metric-warning' : ''}`}><div className="metric-top"><span>死信任务</span><span className="metric-icon orange">☠</span></div><strong>{number(data.queue.dead)}</strong><small>{data.queue.dead ? '存在需要处理的重试失败任务' : '队列健康,无死信'}</small></div>}
|
||||
<div className="metric-card"><div className="metric-top"><span>近 7 天 Token</span><span className="metric-icon purple">∑</span></div><strong>{number(data.cost_7d.tokens)}</strong><small>{isAdmin ? `费用 ${money(data.cost_7d.amount_minor)}` : '我的用量(全部服务合计)'}</small></div>
|
||||
<div className="metric-card"><div className="metric-top"><span>{isAdmin ? '任务总量' : '我的任务'}</span><span className="metric-icon teal">▦</span></div><strong>{number(data.campaigns.total)}</strong><small>{isAdmin ? '组织内全部任务' : '只统计你自己创建的任务'}</small></div>
|
||||
</div>
|
||||
<div className="overview-grid">
|
||||
<section className="panel">
|
||||
<div className="panel-header"><div><h2>{isAdmin ? '任务与质量' : '我的任务概览'}</h2><p>{isAdmin ? '内容生产概览与知识库消费质量' : '只显示你自己创建的任务'}</p></div>{isAdmin && <button className="text-action" onClick={sweep} disabled={busy}>↻ 立即巡检</button>}</div>
|
||||
<div className="ops-row-grid">
|
||||
<div className="ops-stat"><span>进行中任务</span><b>{number(data.campaigns.active)}</b></div>
|
||||
<div className="ops-stat"><span>已交付</span><b>{number(data.campaigns.done)}</b></div>
|
||||
<div className="ops-stat"><span>存在失败</span><b className={data.campaigns.failed ? 'warn-text' : ''}>{number(data.campaigns.failed)}</b></div>
|
||||
<div className="ops-stat"><span>检索命中率</span><b>{(data.knowledge.hit_rate * 100).toFixed(0)}%</b></div>
|
||||
<div className="ops-stat"><span>重复消费率</span><b>{(data.knowledge.repeat_rate * 100).toFixed(0)}%</b></div>
|
||||
<div className="ops-stat"><span>引用覆盖率</span><b>{(data.knowledge.reference_coverage * 100).toFixed(0)}%</b></div>
|
||||
</div>
|
||||
{data.knowledge.by_kb.length > 0 && (
|
||||
<div className="ops-kb-list">{data.knowledge.by_kb.map(kb => (
|
||||
<div className="ops-kb-row" key={kb.kb_id}>
|
||||
<span className="ops-kb-name">{kb.name}</span>
|
||||
<div className="ops-kb-bar"><i style={{ width: `${Math.min(100, (kb.consumption_ratio || 0) * 100)}%` }} /></div>
|
||||
<span className="ops-kb-num">{number(kb.consumed)}/{number(kb.chunk_count)} 块</span>
|
||||
</div>
|
||||
))}</div>
|
||||
)}
|
||||
</section>
|
||||
{isAdmin && <section className="panel">
|
||||
<div className="panel-header"><div><h2>Provider 健康</h2><p>密钥后端与模型数量</p></div></div>
|
||||
<div className="ops-provider-list">{data.providers.map(p => (
|
||||
<div className="ops-provider-row" key={p.id}>
|
||||
<b>{p.name}</b>
|
||||
<span className={`badge ${p.enabled ? 'okbg' : ''}`}>{p.enabled ? '启用' : '停用'}</span>
|
||||
<span className="badge gold">{p.backend === 'keychain' ? '钥匙串' : '数据库'}</span>
|
||||
<span className="dim">{p.models} 个模型</span>
|
||||
</div>
|
||||
))}</div>
|
||||
<div className="panel-header ops-sub-header"><div><h2>最近告警</h2><p>10 分钟窗口去重</p></div></div>
|
||||
{data.recent_alerts.length ? <div className="ops-alert-list">{data.recent_alerts.map(a => (
|
||||
<div className="ops-alert-row" key={a.id}><span className="warn-text">⚠</span><span>{a.message}</span><small>{a.created_at}</small></div>
|
||||
))}</div> : <div className="empty-state">暂无告警,一切正常。</div>}
|
||||
</section>}
|
||||
</div>
|
||||
{isAdmin && performance && <section className="panel">
|
||||
<div className="panel-header"><div><h2>性能基线</h2><p>最近 {number(performance.samples)} 次 API 请求;重启后重新采样</p></div></div>
|
||||
<div className="ops-row-grid">
|
||||
<div className="ops-stat"><span>API P50</span><b>{performance.api.p50_ms.toFixed(0)} ms</b></div>
|
||||
<div className="ops-stat"><span>API P95</span><b>{performance.api.p95_ms.toFixed(0)} ms</b></div>
|
||||
<div className="ops-stat"><span>API P99</span><b>{performance.api.p99_ms.toFixed(0)} ms</b></div>
|
||||
<div className="ops-stat"><span>RAG P95</span><b>{performance.rag.p95_ms.toFixed(0)} ms</b></div>
|
||||
<div className="ops-stat"><span>RAG 缓存命中</span><b>{(performance.rag.cache_hit_rate * 100).toFixed(0)}%</b></div>
|
||||
{performance.rag.embedding?.embedding_encode_ms && <div className="ops-stat"><span>Embedding 推理 P95</span><b>{performance.rag.embedding.embedding_encode_ms.p95_ms.toFixed(0)} ms</b></div>}
|
||||
{performance.rag.embedding?.embedding_model_load_ms && <div className="ops-stat"><span>模型加载 P95</span><b>{performance.rag.embedding.embedding_model_load_ms.p95_ms.toFixed(0)} ms</b></div>}
|
||||
{performance.rag.embedding?.embedding_ipc_ms && <div className="ops-stat"><span>进程通信 P95</span><b>{performance.rag.embedding.embedding_ipc_ms.p95_ms.toFixed(0)} ms</b></div>}
|
||||
</div>
|
||||
</section>}
|
||||
{data.dead_letter.length > 0 && (
|
||||
<section className="panel">
|
||||
<div className="panel-header"><div><h2>死信队列({data.dead_letter.length})</h2><p>重试耗尽的任务,可手动重放</p></div></div>
|
||||
<div className="ops-dead-list">{data.dead_letter.map(item => (
|
||||
<div className="ops-dead-row" key={item.id}>
|
||||
<div><b>{item.task_key}</b><small>{item.error || '无错误信息'} · 第 {item.attempt} 次尝试 · {item.updated_at}</small></div>
|
||||
<button className="mini" onClick={() => replay(item.id)} disabled={busy}>重放</button>
|
||||
</div>
|
||||
))}</div>
|
||||
</section>
|
||||
)}
|
||||
{isAdmin && <section className="panel">
|
||||
<div className="panel-header"><div><h2>备份与恢复</h2><p>启动与每 6 小时自动备份,保留最近 {data.backup.keep_count} 份</p></div>
|
||||
<div className="heading-actions-inline">
|
||||
<button className="text-action" onClick={backupNow} disabled={busy}>⤓ 立即备份</button>
|
||||
<button className="text-action" onClick={runDrill} disabled={busy}>✓ 恢复演练</button>
|
||||
</div></div>
|
||||
{data.backup.latest ? (
|
||||
<div className="ops-backup-grid">
|
||||
<div className="ops-stat"><span>备份份数</span><b>{data.backup.count}</b></div>
|
||||
<div className="ops-stat"><span>最近备份</span><b className="ops-backup-time">{data.backup.latest_at.replace('T', ' ').slice(0, 16)}</b></div>
|
||||
<div className="ops-stat"><span>大小</span><b>{data.backup.latest_size_kb} KB</b></div>
|
||||
<div className="ops-stat"><span>恢复演练</span>
|
||||
{data.backup.last_drill
|
||||
? <b className={data.backup.last_drill.ok ? '' : 'warn-text'}>{data.backup.last_drill.ok ? '✓ 通过' : '⚠ 未通过'}</b>
|
||||
: <b className="ops-backup-time">未执行</b>}
|
||||
</div>
|
||||
{data.backup.last_drill && <div className="ops-backup-note">演练详情:{data.backup.last_drill.detail}({data.backup.last_drill.checked_at.replace('T', ' ').slice(0, 16)})</div>}
|
||||
</div>
|
||||
) : <div className="empty-state">尚无备份,点击「立即备份」创建第一份。</div>}
|
||||
</section>}
|
||||
<div className="overview-footer-note"><span className="note-mark">i</span><span>{isAdmin ? '看板每 15 秒自动刷新;告警由系统每 5 分钟巡检一次,同一问题 10 分钟内不重复通知。' : '看板只显示你自己的任务与用量,每 15 秒自动刷新。'}</span></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/* 页面组件:从 App.tsx 拆出(墨韵金重构) */
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api, localizeError } from '../api'
|
||||
import { useDialog } from '../dialog'
|
||||
import { Tag } from '../ui'
|
||||
import { PageHeader } from '../workbench-ui'
|
||||
import { STATUS_LABEL, KB_STATUS_LABEL, CLASSIFICATION_STATUS_LABEL, RUNTIME_EVENT_LABEL,
|
||||
RUNTIME_PHASE_LABEL, RUNTIME_TOOL_LABEL, PAGE_META, RULE_TEMPLATES } from '../constants'
|
||||
import type { Seg, Camp, CampaignState, RuntimeDetail, CampaignCreateResponse, ComposeResponse,
|
||||
AgentCreateResponse, AgentSummary, KnowledgeBaseSummary, NotificationSummary, AuthUser, AuthStatus,
|
||||
TraceSummary, TraceDetail, UsageSummary, UsageEventRow, PointState, PointAccountRow, UsageResponse,
|
||||
AdminUsageResponse, AgentListRow, MemoryListRow, KbFileRow, KbCategoryRow, KbDetailResponse, KbUsageResponse } from '../types'
|
||||
|
||||
type Portal = 'user' | 'admin'
|
||||
|
||||
|
||||
export default function OverviewPage({ onNavigate, portal, canAudit }: { onNavigate: (tab: string) => void; portal: Portal; canAudit: boolean }) {
|
||||
const [camps, setCamps] = useState<Camp[]>([])
|
||||
const [agents, setAgents] = useState<AgentSummary[]>([])
|
||||
const [kbs, setKbs] = useState<KnowledgeBaseSummary[]>([])
|
||||
const [notifications, setNotifications] = useState<NotificationSummary[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const reload = () => {
|
||||
setLoading(true); setError('')
|
||||
Promise.all([
|
||||
api.get('/campaigns').then(setCamps), api.get('/agents').then(setAgents),
|
||||
api.get('/kbs').then(setKbs), api.get('/notifications').then(setNotifications),
|
||||
]).catch(e => setError(localizeError(e))).finally(() => setLoading(false))
|
||||
}
|
||||
useEffect(() => {
|
||||
reload()
|
||||
}, [])
|
||||
const active = camps.filter(c => ['running', 'human_review', 'parsed', 'composing_ready'].includes(c.status)).length
|
||||
const done = camps.filter(c => c.status === 'done').length
|
||||
const failures = camps.filter(c => c.has_failure).length
|
||||
const unread = notifications.filter(n => n.status === 'unread').length
|
||||
const recent = camps.slice(0, 5)
|
||||
return <div className="overview-page">
|
||||
<PageHeader title="工作台总览" description="先处理待办,再开始创作。这里仅展示当前账号可访问的真实数据。" actions={<button onClick={reload} disabled={loading}>{loading ? "刷新中…" : "刷新数据"}</button>} />
|
||||
<section className="production-launch"><div><span className="scope-chip">内容生产</span><h2>把下一场内容,交给有据可查的流程。</h2><p>选择任务继续生产,或先完善知识库与 Agent 配置。</p></div><button className="primary-action" onClick={()=>onNavigate("tasks")}>进入内容任务 →</button></section>
|
||||
{error && <div className="err" role="alert">{error} <button className="mini" onClick={reload}>重试</button></div>}
|
||||
<div className="metric-grid">
|
||||
<div className="metric-card metric-accent"><div className="metric-top"><span>进行中的任务</span><span className="metric-icon">↗</span></div><strong>{loading ? '—' : active}</strong><small>包含待审查与生成中任务</small></div>
|
||||
<div className="metric-card"><div className="metric-top"><span>已交付内容</span><span className="metric-icon green">✓</span></div><strong>{loading ? '—' : done}</strong><small>已完成组合并可下载</small></div>
|
||||
<div className="metric-card"><div className="metric-top"><span>{portal === 'admin' ? '可用 Agent' : '可用智能流程'}</span><span className="metric-icon purple">◎</span></div><strong>{loading ? '—' : agents.filter(a => a.status !== 'disabled').length}</strong><small>{portal === 'admin' ? '生产、质检与组合节点' : '由管理员发布的生产流程'}</small></div>
|
||||
<div className={`metric-card ${failures || unread ? 'metric-warning' : ''}`}><div className="metric-top"><span>待处理事项</span><span className="metric-icon orange">!</span></div><strong>{loading ? '—' : failures + unread}</strong><small>{unread ? `${unread} 条未读通知(含充值/余额提醒)` : failures ? `${failures} 个任务存在失败段` : '当前没有失败任务'}</small></div>
|
||||
</div>
|
||||
<div className="overview-grid">
|
||||
<section className="panel recent-panel"><div className="panel-header"><div><h2>最近任务</h2><p>查看内容生产的最新进展</p></div><button className="text-action" onClick={() => onNavigate('tasks')}>查看全部 <span>→</span></button></div>
|
||||
<div className="task-table"><div className="task-table-head"><span>任务名称</span><span>状态</span><span>创建时间</span></div>{recent.map(c => <div className="task-table-row" key={c.id} onClick={() => onNavigate('tasks')}><strong>{c.name}</strong><Tag s={c.status} /><span className="muted">{c.created_at}</span>{c.has_failure && <span className="failure-dot" title="存在失败段" />}</div>)}{!loading && recent.length === 0 && <div className="empty-state">还没有任务,先创建第一场内容。</div>}</div>
|
||||
</section>
|
||||
<section className="panel ops-panel"><div className="panel-header"><div><h2>{portal === 'admin' ? '生产资源' : '我的资源'}</h2><p>{portal === 'admin' ? '系统当前配置概览' : '只显示当前账号可使用的资源'}</p></div></div><div className="resource-list">{portal === 'admin' ? <><button onClick={() => onNavigate('agents')}><span className="resource-icon blue">◎</span><span><strong>Agent 注册中心</strong><small>{agents.length} 个 Agent 已配置</small></span><span className="resource-arrow">→</span></button><button onClick={() => onNavigate('kbs')}><span className="resource-icon teal">▤</span><span><strong>RAG 知识库</strong><small>{kbs.length} 个知识库可检索</small></span><span className="resource-arrow">→</span></button><button onClick={() => onNavigate('canvas')}><span className="resource-icon violet">◇</span><span><strong>协作画布</strong><small>按拓扑编排生产流程</small></span><span className="resource-arrow">→</span></button><button onClick={() => onNavigate('logs')}><span className="resource-icon amber">◷</span><span><strong>运行日志</strong><small>{unread ? `${unread} 条通知待处理` : '暂无未读通知'}</small></span><span className="resource-arrow">→</span></button></> : <><button onClick={() => onNavigate('tasks')}><span className="resource-icon blue">▦</span><span><strong>我的内容任务</strong><small>{camps.length} 个任务,生成与审查都在这里完成</small></span><span className="resource-arrow">→</span></button><button onClick={() => onNavigate('kbs')}><span className="resource-icon teal">▤</span><span><strong>我的知识库</strong><small>{kbs.length} 个可访问知识库</small></span><span className="resource-arrow">→</span></button><button onClick={() => onNavigate('memories')}><span className="resource-icon violet">▣</span><span><strong>我的记忆库</strong><small>查看已生效和待确认内容</small></span><span className="resource-arrow">→</span></button>{canAudit && <button onClick={() => onNavigate('logs')}><span className="resource-icon amber">◷</span><span><strong>运行记录</strong><small>{unread ? `${unread} 条通知待处理` : '查看任务运行轨迹'}</small></span><span className="resource-arrow">→</span></button>}</>}</div></section>
|
||||
</div>
|
||||
<div className="overview-footer-note"><span className="note-mark">i</span><span>{portal === 'admin' ? '管理端负责模型、Agent、知识库授权和全局策略;所有任务均保留版本、引用和调用日志。' : '用户端只使用管理员发布的模型与智能流程,任务、知识库和记忆按当前账号隔离。'}</span>{canAudit && <button onClick={() => onNavigate('logs')}>查看运行记录</button>}</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
/* ---------------- 任务看板 ---------------- */
|
||||
@@ -0,0 +1,40 @@
|
||||
import './persona-profile.css'
|
||||
|
||||
export const PERSONA_LABELS: Record<string, string> = {
|
||||
persona_name: '人格称呼', identity: '身份人设', tone: '语气口吻', pace: '语言节奏',
|
||||
vocab: '常用词汇与口头禅', taboo: '表达禁忌', opening_habit: '开场习惯',
|
||||
emotion_style: '情感表达与情绪推进', interaction: '互动习惯', expertise_border: '专业边界',
|
||||
sample_excerpt: '本人原话样本', writing_style: '文稿风格', narrative_style: '叙事方式',
|
||||
thinking_style: '思考与论证方式', expression_prompt: '表达提示', expression_rules: '表达规则',
|
||||
audience_profile: '受众画像与场景', hook_patterns: '开场钩子与反差',
|
||||
pain_point_hooks: '痛点切入', itch_point_hooks: '痒点与欲望切入',
|
||||
sales_logic: '销售逻辑与转化路径', cta_transition: '内容到行动的转场',
|
||||
}
|
||||
const GROUPS = [
|
||||
{ title: '身份与思考', hint: '这个人是谁、如何看待和解释问题', fields: ['persona_name', 'identity', 'thinking_style', 'expertise_border'] },
|
||||
{ title: '受众与切入', hint: '从谁的问题、欲望和开场钩子切入', fields: ['audience_profile', 'pain_point_hooks', 'itch_point_hooks', 'hook_patterns'] },
|
||||
{ title: '文稿与叙事', hint: '如何开场、组织故事、展开论证', fields: ['writing_style', 'narrative_style', 'opening_habit', 'sample_excerpt'] },
|
||||
{ title: '销售与转场', hint: '教学、案例到产品或下一步行动的自然衔接', fields: ['sales_logic', 'cta_transition'] },
|
||||
{ title: '口吻与情感', hint: '用词、节奏、情绪推进与互动方式', fields: ['tone', 'pace', 'vocab', 'emotion_style', 'interaction'] },
|
||||
{ title: '表达提示与规则', hint: '统一维护人物的表达要求,绑定的 Agent 直接继承', fields: ['expression_prompt', 'expression_rules', 'taboo'] },
|
||||
]
|
||||
|
||||
export default function PersonaProfile({ persona, onChange, disabled = false }: {
|
||||
persona: Record<string, string>; onChange?: (value: Record<string, string>) => void; disabled?: boolean
|
||||
}) {
|
||||
return <div className="persona-profile">
|
||||
{GROUPS.map((group, index) => <details className="persona-group" key={group.title} open={index === 0 ? true : undefined}>
|
||||
<summary><span><strong>{group.title}</strong><small>{group.hint}</small></span><span className="persona-count">{group.fields.filter(key => persona[key]?.trim()).length}/{group.fields.length} 项</span></summary>
|
||||
<div className="persona-fields">
|
||||
{group.fields.map(key => <label className="persona-field" key={key}>
|
||||
<span>{PERSONA_LABELS[key]}</span>
|
||||
{onChange ? <textarea aria-label={PERSONA_LABELS[key]} rows={key.startsWith('expression_') ? 5 : 3} value={persona[key] || ''} disabled={disabled}
|
||||
maxLength={key.startsWith('expression_') ? 16000 : 6000}
|
||||
placeholder="资料中没有明确依据可留空;也可补充你希望保留的表达特征。"
|
||||
onChange={event => onChange({ ...persona, [key]: event.target.value })} />
|
||||
: <p className={persona[key]?.trim() ? '' : 'muted'}>{persona[key]?.trim() || '尚未提取,可补充资料蒸馏或手动填写'}</p>}
|
||||
</label>)}
|
||||
</div>
|
||||
</details>)}
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/* 页面组件:从 App.tsx 拆出(Rose Clay 重构) */
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useAgentEditSafety } from './AgentsPage'
|
||||
import { Search, Plus } from 'lucide-react'
|
||||
import { api, localizeError } from '../api'
|
||||
import { useDialog } from '../dialog'
|
||||
import { Tag } from '../ui'
|
||||
import { AiAssistDialog, type AiDraft } from './aiAssist'
|
||||
import { STATUS_LABEL, KB_STATUS_LABEL, CLASSIFICATION_STATUS_LABEL, RUNTIME_EVENT_LABEL,
|
||||
RUNTIME_PHASE_LABEL, RUNTIME_TOOL_LABEL, PAGE_META, RULE_TEMPLATES } from '../constants'
|
||||
import type { Seg, Camp, CampaignState, RuntimeDetail, CampaignCreateResponse, ComposeResponse,
|
||||
AgentCreateResponse, AgentSummary, KnowledgeBaseSummary, NotificationSummary, AuthUser, AuthStatus,
|
||||
TraceSummary, TraceDetail, UsageSummary, UsageEventRow, PointState, PointAccountRow, UsageResponse,
|
||||
AdminUsageResponse, AgentListRow, MemoryListRow, KbFileRow, KbCategoryRow, KbDetailResponse, KbUsageResponse } from '../types'
|
||||
|
||||
/* ---------------- 提示模板 ---------------- */
|
||||
export default function PromptTemplatesPage() {
|
||||
const dialog = useDialog()
|
||||
const empty = { name: '', desc: '', role_prompt: '', task_template: '', constraints: '', examples: '', output_schema: '', style: '', enabled: true }
|
||||
const [templates, setTemplates] = useState<any[]>([])
|
||||
const [form, setForm] = useState(empty)
|
||||
const [editing, setEditing] = useState<any>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [aiOpen, setAiOpen] = useState(false)
|
||||
const [tplQuery, setTplQuery] = useState('')
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
const [baseline, setBaseline] = useState(JSON.stringify(empty))
|
||||
const inFlight = useRef(false)
|
||||
const dirty = drawerOpen && JSON.stringify(form) !== baseline
|
||||
const allowLeave = useAgentEditSafety(dirty, busy)
|
||||
const closeEditor = async () => { if (await allowLeave()) { setDrawerOpen(false); reset() } }
|
||||
const [feedback, setFeedback] = useState('')
|
||||
const refresh = () => api.get('/prompt-templates').then(setTemplates)
|
||||
useEffect(() => { refresh() }, [])
|
||||
const applyDraft = (draft: AiDraft) => {
|
||||
setEditing(null)
|
||||
setForm({
|
||||
name: draft.name || '', desc: draft.desc || '', role_prompt: draft.role_prompt || '',
|
||||
task_template: draft.task_template || '', constraints: (draft.constraints || []).join('\n'),
|
||||
examples: '', output_schema: '', style: draft.style || '', enabled: true,
|
||||
})
|
||||
setAiOpen(false); setDrawerOpen(true); setBaseline(JSON.stringify(empty)); setError('')
|
||||
if (draft.advice) void dialog.notice({ title: 'AI 起草完成', description: draft.advice })
|
||||
}
|
||||
const reset = () => { setEditing(null); setForm(empty); setBaseline(JSON.stringify(empty)); setError('') }
|
||||
const edit = (template: any) => {
|
||||
setEditing(template)
|
||||
const value = {
|
||||
name: template.name, desc: template.desc || '', role_prompt: template.role_prompt || '',
|
||||
task_template: template.task_template || '', constraints: (template.constraints || []).join('\n'),
|
||||
examples: JSON.stringify(template.examples || [], null, 2),
|
||||
output_schema: JSON.stringify(template.output_schema || {}, null, 2), style: template.style || '',
|
||||
enabled: template.enabled !== false,
|
||||
}
|
||||
setForm(value); setBaseline(JSON.stringify(value)); setFeedback('')
|
||||
setError('')
|
||||
}
|
||||
const payload = () => {
|
||||
let examples: unknown = []
|
||||
let outputSchema: unknown = {}
|
||||
try { examples = form.examples.trim() ? JSON.parse(form.examples) : [] } catch { throw new Error('示例必须是合法 JSON 数组') }
|
||||
try { outputSchema = form.output_schema.trim() ? JSON.parse(form.output_schema) : {} } catch { throw new Error('输出 Schema 必须是合法 JSON 对象') }
|
||||
return {
|
||||
name: form.name, desc: form.desc, role_prompt: form.role_prompt, task_template: form.task_template,
|
||||
constraints: form.constraints.split('\n').map(item => item.trim()).filter(Boolean), examples,
|
||||
output_schema: outputSchema, style: form.style, enabled: form.enabled,
|
||||
}
|
||||
}
|
||||
const save = async () => {
|
||||
if (inFlight.current) return
|
||||
inFlight.current = true
|
||||
setFeedback(''); setBusy(true); setError('')
|
||||
try {
|
||||
const body = payload()
|
||||
let savedId = editing?.id
|
||||
if (savedId) await api.put(`/prompt-templates/${savedId}`, body)
|
||||
else {
|
||||
const created = await api.post<{ id: number }>('/prompt-templates', body)
|
||||
savedId = created.id
|
||||
// POST 成功但回读失败时,下次重试改为 PUT,不重复创建。
|
||||
setEditing({ id: savedId, name: form.name })
|
||||
}
|
||||
const rows = await api.get<any[]>('/prompt-templates')
|
||||
if (!rows.some(row => row.id === savedId && row.name === form.name.trim())) throw new Error('保存回读未确认,请保留输入并重试')
|
||||
setTemplates(rows)
|
||||
// 先读回列表确认服务端可见,再关闭编辑器;失败不清空输入。
|
||||
reset(); setDrawerOpen(false); setFeedback('已保存')
|
||||
} catch (e: unknown) { setError(e instanceof Error ? e.message : '保存提示模板失败') }
|
||||
finally { inFlight.current = false; setBusy(false) }
|
||||
}
|
||||
const remove = async (template: any) => {
|
||||
if (!await dialog.confirm({ title: `删除提示模板「${template.name}」?`, description: '已被 Agent 使用的模板需要先解除绑定。', confirmLabel: '确认删除', tone: 'danger' })) return
|
||||
setBusy(true); setError('')
|
||||
try { await api.delete(`/prompt-templates/${template.id}`); if (editing?.id === template.id) reset(); await refresh() }
|
||||
catch (e: unknown) { setError(e instanceof Error ? e.message : '删除提示模板失败') }
|
||||
finally { inFlight.current = false; setBusy(false) }
|
||||
}
|
||||
const filteredTpls = templates.filter(t =>
|
||||
!tplQuery.trim() || t.name.includes(tplQuery.trim()) || (t.desc || '').includes(tplQuery.trim()))
|
||||
return <div className="list-page">
|
||||
<div className="page-toolbar">
|
||||
<div className="toolbar-search"><Search size={14} /><input placeholder="搜索模板…" value={tplQuery} onChange={e => setTplQuery(e.target.value)} /></div>
|
||||
<button className="primary-action" onClick={async () => { if (await allowLeave()) { reset(); setFeedback(''); setDrawerOpen(true) } }} disabled={busy}><Plus size={14} /> 新建模板</button>
|
||||
<button className="ai-entry" onClick={async () => { if (await allowLeave()) setAiOpen(true) }} disabled={busy}>✦ AI 帮我创建</button>
|
||||
</div>
|
||||
{feedback && <p role="status">{feedback}</p>}
|
||||
{error && <div className="err">{error}</div>}
|
||||
{filteredTpls.map(template => (
|
||||
<div key={template.id} className={`row ${template.enabled ? '' : 'dim'}`}>
|
||||
<b>{template.name}</b>{template.builtin && <span className="badge gold">内置</span>}<span className={`badge ${template.enabled ? 'okbg' : ''}`}>{template.enabled ? '启用' : '停用'}</span>
|
||||
<span className="dim">v{template.version} · {template.desc || '无说明'}</span>
|
||||
<button className="mini" onClick={async () => { if (await allowLeave()) { edit(template); setDrawerOpen(true) } }} disabled={busy}>编辑</button>
|
||||
<button className="mini" onClick={async () => { setBusy(true); setError(''); try { await api.put(`/prompt-templates/${template.id}`, { enabled: !template.enabled }); await refresh() } catch (e: any) { setError(String(e.message || e)) } finally { inFlight.current = false; setBusy(false) } }} disabled={busy}>{template.enabled ? '停用' : '启用'}</button>
|
||||
<button className="mini danger-text" onClick={() => remove(template)} disabled={busy}>删除</button>
|
||||
</div>
|
||||
))}
|
||||
{!filteredTpls.length && <div className="empty-workspace compact"><strong>{tplQuery ? '没有匹配的模板' : '还没有模板'}</strong><span>{tplQuery ? '换个关键词试试。' : '创建可复用的提示模板,Agent 绑定后任务会冻结版本。'}</span></div>}
|
||||
|
||||
{drawerOpen && <div className="drawer-mask" onClick={closeEditor} />}
|
||||
{drawerOpen && <aside className="side-drawer" role="dialog" aria-label={editing ? '编辑模板' : '新建模板'}>
|
||||
<div className="trace-drawer-head"><strong>{editing ? `编辑模板:${editing.name}` : '新建提示模板'}</strong>
|
||||
<button className="icon-button" onClick={closeEditor} aria-label="关闭">✕</button></div>
|
||||
<fieldset disabled={busy} className="trace-drawer-body" style={{ border: 0, margin: 0, minWidth: 0 }}>
|
||||
<p role="status">{busy ? '正在保存,请勿重复操作…' : dirty ? '有未保存的修改' : '尚无修改'}</p>
|
||||
<div className="form-kicker">把角色、任务、约束、示例和输出格式变成可测试配置。Agent 绑定后任务冻结版本。</div>
|
||||
<label>模板名称<input placeholder="如:直播口播模板" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} /></label>
|
||||
<label>说明<input placeholder="一句话说明用途" value={form.desc} onChange={e => setForm({ ...form, desc: e.target.value })} /></label>
|
||||
<label>角色提示<textarea rows={4} value={form.role_prompt} onChange={e => setForm({ ...form, role_prompt: e.target.value })} /></label>
|
||||
<label>任务模板<textarea rows={6} value={form.task_template} onChange={e => setForm({ ...form, task_template: e.target.value })} /></label>
|
||||
<label>约束(每行一条)<textarea rows={4} value={form.constraints} onChange={e => setForm({ ...form, constraints: e.target.value })} /></label>
|
||||
{editing && <label className="inline-check"><input type="checkbox" checked={form.enabled} onChange={e => setForm({ ...form, enabled: e.target.checked })} />启用模板</label>}
|
||||
{error && <div className="err">{error}</div>}
|
||||
<div className="btns"><button className="ok" onClick={save} disabled={busy || !form.name.trim()}>{editing ? '保存修改' : '+ 创建模板'}</button>{editing && <button onClick={closeEditor}>取消编辑</button>}</div>
|
||||
</fieldset>
|
||||
</aside>}
|
||||
{aiOpen && <AiAssistDialog kind="template" onApply={applyDraft} onClose={() => setAiOpen(false)} />}
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api, localizeError } from '../api'
|
||||
import './resource-revisions.css'
|
||||
|
||||
type Revision = {
|
||||
id: number; resource_type: string; resource_id: number; resource_name?: string
|
||||
operation: string; version: number; base_version: number; status: string
|
||||
payload: Record<string, unknown>; owner_name?: string; owner_display_name?: string
|
||||
owner_username?: string; owner_user_id: number; created_at: string; reviewed_at?: string; review_note?: string
|
||||
}
|
||||
const TYPES: Record<string, string> = { knowledge_base: '知识库', agent: 'Agent', skill: '技能', canvas: '画布', memory: '记忆', digital_human: '数字人' }
|
||||
const STATUS: Record<string, string> = { pending: '待确认', approved: '已确认', rejected: '已驳回' }
|
||||
const FIELDS: Record<string, string> = { name: '名称', desc: '说明', title: '标题', description: '说明', content: '内容', instructions: '技能步骤', enabled: '是否启用', status: '状态', rules: '执行规则', content_tags: '内容标签', kb_ids: '知识库', kb_category_ids: '知识库分类', skill_ids: '技能', nodes: '画布节点', edges: '画布连线', persona: '人格与表达规则', persona_json: '人格与表达规则', source_ref: '来源', digital_human_id: '数字人', model_name: '模型', file_changes: '文件修改', files: '文件', categories: '分类' }
|
||||
|
||||
export default function ResourceRevisionReview({ names }: { names: Record<string, string> }) {
|
||||
const [rows, setRows] = useState<Revision[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [busy, setBusy] = useState<number | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [notes, setNotes] = useState<Record<number, string>>({})
|
||||
const load = async () => {
|
||||
setLoading(true); setError('')
|
||||
try { const result = await api.get<Revision[]>('/admin/resource-revisions'); setRows(Array.isArray(result) ? result : []) }
|
||||
catch (e) { setError(localizeError(e)) } finally { setLoading(false) }
|
||||
}
|
||||
useEffect(() => { void load() }, [])
|
||||
const review = async (revision: Revision, action: 'approve' | 'reject') => {
|
||||
if (busy !== null || revision.operation !== 'update') return
|
||||
setBusy(revision.id); setError(''); setNotice('')
|
||||
try {
|
||||
await api.post(`/admin/resource-revisions/${revision.id}/${action}`, { note: notes[revision.id]?.trim() || '' })
|
||||
await load()
|
||||
setNotice(action === 'approve' ? '版本已确认,全局资源已更新。' : '版本已驳回,用户的个人副本仍然保留。')
|
||||
} catch (e) { setError(localizeError(e)) } finally { setBusy(null) }
|
||||
}
|
||||
const updates = rows.filter(row => row.operation === 'update')
|
||||
const removals = rows.filter(row => row.operation === 'delete')
|
||||
const pendingCount = updates.filter(row => row.status === 'pending').length
|
||||
return <details className="grant-resource-drawer resource-revision-drawer" open>
|
||||
<summary><span className="grant-drawer-chevron" aria-hidden="true">›</span><span className="grant-drawer-title"><b>用户修改版本</b><small>{pendingCount ? `${pendingCount} 个版本待确认` : '查看用户修改与审核记录'}</small></span><span className="grant-drawer-count">{updates.length}</span></summary>
|
||||
<div className="resource-revision-body">
|
||||
<div className="resource-revision-intro"><p>用户编辑全局资源的个人副本后,修改会记录在这里。确认后才更新全局版本;用户从自己的列表移除资源,无需审核,也不会删除全局资源。</p><button className="mini" onClick={() => void load()} disabled={loading || busy !== null}>{loading ? '读取中…' : '刷新版本'}</button></div>
|
||||
{error && <p className="err" role="alert">{error}</p>}{notice && <p className="success-note" role="status">{notice}</p>}
|
||||
{!loading && !updates.length && !removals.length && <p className="usage-empty">暂无用户修改版本。</p>}
|
||||
{(['pending', 'approved', 'rejected'] as const).map(status => {
|
||||
const group = updates.filter(row => row.status === status)
|
||||
return group.length > 0 && <details key={status} className="revision-status-group" open={status === 'pending'}>
|
||||
<summary><b>{STATUS[status]}</b><span>{group.length} 个版本</span></summary>
|
||||
{group.map(row => <details key={row.id} className="resource-revision-item">
|
||||
<summary><span><b>{row.resource_name || names[`${row.resource_type}:${row.resource_id}`] || `${TYPES[row.resource_type] || '资源'} #${row.resource_id}`}</b><small>{TYPES[row.resource_type] || row.resource_type} · 修改版本 v{row.version} · {row.owner_name || row.owner_display_name || row.owner_username || `用户 #${row.owner_user_id}`}</small></span><span className={`badge ${status === 'pending' ? 'gold' : status === 'approved' ? 'okbg' : ''}`}>{STATUS[status]}</span></summary>
|
||||
<div className="revision-detail"><p className="field-help">提交于 {row.created_at} · 基于全局 v{row.base_version}</p>
|
||||
<dl className="revision-changes">{Object.entries(row.payload || {}).map(([key, value]) => <div key={key}><dt>{FIELDS[key] || key}</dt><dd><pre>{typeof value === 'string' ? value : JSON.stringify(value, null, 2)}</pre></dd></div>)}</dl>
|
||||
{!Object.keys(row.payload || {}).length && <p className="field-help">此版本没有字段修改。</p>}
|
||||
{status === 'pending' && <><label className="revision-review-note">审核备注(可选)<textarea rows={2} value={notes[row.id] || ''} onChange={event => setNotes(current => ({ ...current, [row.id]: event.target.value }))} placeholder="填写确认说明,或说明需要继续完善的地方" /></label><div className="btns"><button className="primary-action" disabled={busy !== null} onClick={() => void review(row, 'approve')}>{busy === row.id ? '正在处理…' : '确认版本并更新全局'}</button><button className="mini danger-text" disabled={busy !== null} onClick={() => void review(row, 'reject')}>驳回修改</button></div></>}
|
||||
{row.reviewed_at && <p className="field-help">审核于 {row.reviewed_at}{row.review_note ? ` · ${row.review_note}` : ''}</p>}
|
||||
</div>
|
||||
</details>)}
|
||||
</details>
|
||||
})}
|
||||
{removals.length > 0 && <details className="revision-status-group"><summary><b>用户个人移除(无需审核)</b><span>{removals.length} 条</span></summary><div className="revision-removal-list">{removals.map(row => <div key={row.id}><b>{row.resource_name || names[`${row.resource_type}:${row.resource_id}`] || `${TYPES[row.resource_type] || '资源'} #${row.resource_id}`}</b><span>{row.owner_name || row.owner_display_name || row.owner_username || `用户 #${row.owner_user_id}`} · {row.created_at}</span><small>仅从该用户列表隐藏,不影响全局资源。</small></div>)}</div></details>}
|
||||
</div>
|
||||
</details>
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// RunsPage(/runs):三页签 = 通知 / 待确认 / 运行记录(Trace,服务端分页 + 详情抽屉)
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { CheckCheck, X, Ban, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { api, localizeError } from '../api'
|
||||
import { useDialog } from '../dialog'
|
||||
import { Tag } from '../ui'
|
||||
import { STATUS_LABEL, RUNTIME_EVENT_LABEL, RUNTIME_TOOL_LABEL } from '../constants'
|
||||
import type { TraceDetail } from '../types'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
type TabKey = 'notifications' | 'pending' | 'traces'
|
||||
const NOTIFICATION_TYPE_LABEL: Record<string, string> = {
|
||||
alert: '告警', failure: '失败', review: '待审查', success: '成功', info: '信息', reminder: '提醒',
|
||||
}
|
||||
|
||||
export default function RunsPage() {
|
||||
const [tab, setTab] = useState<TabKey>('notifications')
|
||||
const [memBusy, setMemBusy] = useState(false)
|
||||
const [notifBusy, setNotifBusy] = useState(false)
|
||||
const dialog = useDialog()
|
||||
// 通知
|
||||
const [notifs, setNotifs] = useState<any[]>([])
|
||||
// 待确认记忆
|
||||
const [mems, setMems] = useState<any[]>([])
|
||||
// Trace
|
||||
const [traceRows, setTraceRows] = useState<any[]>([])
|
||||
const [traceTotal, setTraceTotal] = useState(0)
|
||||
const [tracePage, setTracePage] = useState(1)
|
||||
const [runStatus, setRunStatus] = useState('')
|
||||
const [traceLoading, setTraceLoading] = useState(false)
|
||||
const [detail, setDetail] = useState<TraceDetail | null>(null)
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
const timer = useRef<any>(null)
|
||||
|
||||
const loadSide = async () => {
|
||||
try {
|
||||
const [n, m] = await Promise.all([api.get<any[]>('/notifications'), api.get<any[]>('/pending_memories')])
|
||||
setNotifs(n); setMems(m)
|
||||
} catch (e: any) { setErr(localizeError(e)) }
|
||||
}
|
||||
const loadTraces = async (page = 1) => {
|
||||
setTraceLoading(true); setErr('')
|
||||
try {
|
||||
const offset = (page - 1) * PAGE_SIZE
|
||||
const qs = new URLSearchParams({ limit: String(PAGE_SIZE), offset: String(offset) })
|
||||
if (runStatus) qs.set('status', runStatus)
|
||||
const data = await api.get<any>(`/traces?${qs}`)
|
||||
setTraceRows(data.items || []); setTraceTotal(data.total || 0); setTracePage(page)
|
||||
} catch (e: any) { setErr(localizeError(e)) } finally { setTraceLoading(false) }
|
||||
}
|
||||
useEffect(() => { loadSide() }, [])
|
||||
useEffect(() => { loadTraces(1) }, [runStatus])
|
||||
// 通知/待确认 30s 轻轮询;Trace 不轮询(手动刷新按钮)
|
||||
useEffect(() => {
|
||||
timer.current = setInterval(loadSide, 30000)
|
||||
return () => clearInterval(timer.current)
|
||||
}, [])
|
||||
|
||||
const inspectTrace = async (traceId: string) => {
|
||||
setErr('')
|
||||
try { setDetail(await api.get<TraceDetail>(`/traces/${encodeURIComponent(traceId)}`)); setDetailOpen(true) }
|
||||
catch (e: any) { setErr(localizeError(e)) }
|
||||
}
|
||||
const memAction = async (id: number, action: string) => {
|
||||
setMemBusy(true)
|
||||
try { await api.post(`/pending_memories/${id}`, { action }); await loadSide() } finally { setMemBusy(false) }
|
||||
}
|
||||
const readAll = async () => {
|
||||
if (notifBusy) return
|
||||
setNotifBusy(true); setErr('')
|
||||
try {
|
||||
await api.post('/notifications/read-all', {})
|
||||
await loadSide()
|
||||
} catch (e: any) {
|
||||
setErr(localizeError(e))
|
||||
} finally {
|
||||
setNotifBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(traceTotal / PAGE_SIZE))
|
||||
const unread = notifs.filter(n => n.status === 'unread').length
|
||||
|
||||
const tabs: { key: TabKey; label: string; badge?: number }[] = [
|
||||
{ key: 'notifications', label: '通知', badge: unread || undefined },
|
||||
{ key: 'pending', label: '待确认', badge: mems.length || undefined },
|
||||
{ key: 'traces', label: '运行记录', badge: traceTotal || undefined },
|
||||
]
|
||||
|
||||
return <div className="runs-page">
|
||||
<div className="detail-tabs" role="tablist">
|
||||
{tabs.map(t => <button key={t.key} role="tab" aria-selected={tab === t.key} className={`detail-tab ${tab === t.key ? 'active' : ''}`} onClick={() => setTab(t.key)}>{t.label}{t.badge ? <span className="tab-badge">{t.badge}</span> : null}</button>)}
|
||||
</div>
|
||||
{err && <div className="err">{err}</div>}
|
||||
|
||||
{tab === 'notifications' && <div className="notif-list">
|
||||
<div className="page-toolbar"><button className="mini" onClick={readAll} disabled={notifBusy || unread === 0}><CheckCheck size={13} /> {notifBusy ? '正在处理…' : '一键全部已读'}</button></div>
|
||||
{notifs.map(n => (
|
||||
<div key={n.id} className={`row ${n.status === 'unread' ? 'warn' : 'dim'}`}>
|
||||
<span className="badge">{NOTIFICATION_TYPE_LABEL[n.type] || n.type}</span><span>{n.message}</span>
|
||||
<span className="dim">{n.created_at}</span>
|
||||
{n.status === 'unread' && <button className="mini" onClick={async () => { await api.post(`/notifications/${n.id}/read`, {}); loadSide() }}>已读</button>}
|
||||
</div>))}
|
||||
{!notifs.length && <div className="empty-state">暂无通知。任务失败、审查提醒、充值提醒会出现在这里。</div>}
|
||||
</div>}
|
||||
|
||||
{tab === 'pending' && <div>
|
||||
{mems.map(m => (
|
||||
<div key={m.id} className="card mem-card">
|
||||
<div className="row"><span className="badge gold">{m.type}</span><span className="dim">{m.agent_name || `Agent#${m.target_agent_id}`} · {m.source_ref}</span></div>
|
||||
<div>{m.content}</div>
|
||||
<div className="btns">
|
||||
<button className="ok" onClick={() => memAction(m.id, 'approve')} disabled={memBusy}>✓ 确认沉淀</button>
|
||||
<button onClick={() => memAction(m.id, 'reject')} disabled={memBusy}>✗ 拒绝</button>
|
||||
<button onClick={() => memAction(m.id, 'block')} disabled={memBusy}><Ban size={13} /> 屏蔽同类</button>
|
||||
</div>
|
||||
</div>))}
|
||||
{!mems.length && <div className="empty-state">待确认池为空。打回/修复原因会先进入这里,确认后才沉淀为 Agent 记忆。</div>}
|
||||
</div>}
|
||||
|
||||
{tab === 'traces' && <div>
|
||||
<div className="page-toolbar">
|
||||
<select value={runStatus} onChange={e => setRunStatus(e.target.value)} aria-label="运行状态筛选">
|
||||
<option value="">全部运行状态</option>
|
||||
<option value="queued">排队中</option><option value="running">运行中</option>
|
||||
<option value="awaiting_review">等待审查</option><option value="failed">失败</option>
|
||||
<option value="cancelled">已取消</option>
|
||||
</select>
|
||||
<button className="mini" onClick={() => loadTraces(tracePage)} disabled={traceLoading}>刷新</button>
|
||||
<span className="muted" style={{ fontSize: 12 }}>共 {traceTotal} 条 Trace</span>
|
||||
</div>
|
||||
{traceLoading && <div className="empty-state">加载中…</div>}
|
||||
{!traceLoading && !traceRows.length && <div className="empty-state">暂无运行记录。运行内容任务后这里会出现完整调用链。</div>}
|
||||
{traceRows.map(trace => <button key={trace.trace_id} className="trace-row" onClick={() => inspectTrace(trace.trace_id)}>
|
||||
<span><b>任务 #{trace.campaign_id || '—'}</b><small>{trace.trace_id.slice(0, 18)}</small></span>
|
||||
<Tag s={trace.run_status} />
|
||||
<span className="trace-metrics">{trace.call_count} 调用 · {trace.event_count} 事件</span>
|
||||
<span className="trace-metrics">{trace.prompt_tokens}+{trace.completion_tokens} tok · {trace.duration_ms}ms{trace.error_count ? ` · ${trace.error_count} 异常` : ''}</span>
|
||||
</button>)}
|
||||
{totalPages > 1 && <div className="pager">
|
||||
<button className="mini" disabled={tracePage <= 1} onClick={() => loadTraces(tracePage - 1)}><ChevronLeft size={13} /> 上一页</button>
|
||||
<span className="muted">第 {tracePage} / {totalPages} 页</span>
|
||||
<button className="mini" disabled={tracePage >= totalPages} onClick={() => loadTraces(tracePage + 1)}>下一页 <ChevronRight size={13} /></button>
|
||||
</div>}
|
||||
</div>}
|
||||
|
||||
{/* Trace 详情抽屉 */}
|
||||
{detailOpen && detail && <div className="drawer-mask" onClick={() => setDetailOpen(false)} />}
|
||||
{detailOpen && detail && <aside className="trace-drawer" role="dialog" aria-label="Trace 详情">
|
||||
<div className="trace-drawer-head">
|
||||
<strong>Trace 详情</strong>
|
||||
<small>{detail.trace_id}</small>
|
||||
<button className="icon-button" onClick={() => setDetailOpen(false)} aria-label="关闭">✕</button>
|
||||
</div>
|
||||
<div className="trace-drawer-body">
|
||||
<div className="trace-detail-grid">
|
||||
<div><b>运行配置</b><span>{detail.run ? `运行 #${detail.run.id} · ${detail.run.runtime_version} · ${STATUS_LABEL[detail.run.status] || detail.run.status}` : '无关联运行'}</span></div>
|
||||
<div><b>质检</b><span>{detail.qc_reports.map(report => `${report.gate}:${STATUS_LABEL[report.verdict] || report.verdict}`).join(' · ') || '尚无'}</span></div>
|
||||
<div><b>成稿</b><span>{detail.artifacts.map(artifact => `#${artifact.id} ${STATUS_LABEL[artifact.final_status] || artifact.final_status}`).join(' · ') || '尚无'}</span></div>
|
||||
</div>
|
||||
<h4>模型调用({detail.calls.length})</h4>
|
||||
<div className="trace-call-list">{detail.calls.map(call => <div className="trace-call" key={call.id}><Tag s={call.status} /><span><b>{call.agent_name || '系统'}</b><small>{call.provider}/{call.model} · 第 {call.attempt} 次 · 段 {call.segment_id || '—'}</small></span><span>{call.duration_ms}ms<br />{call.prompt_tokens}+{call.completion_tokens} tok</span></div>)}</div>
|
||||
<h4>运行事件({detail.events.length})</h4>
|
||||
<div className="trace-event-list">{detail.events.slice().reverse().map(event => <div key={event.id}><span>{event.created_at?.slice(11, 19) || '--:--:--'}</span><b>{RUNTIME_EVENT_LABEL[event.event_type] || event.event_type}</b><em>{event.actor_name || '系统'}{event.tool_name ? ` · ${RUNTIME_TOOL_LABEL[event.tool_name] || event.tool_name}` : ''}</em><Tag s={event.status || 'UNKNOWN'} /></div>)}</div>
|
||||
{detail.run && <details className="trace-snapshot"><summary>冻结配置快照</summary><pre className="art">{JSON.stringify(detail.run.config_snapshot, null, 2)}</pre></details>}
|
||||
</div>
|
||||
</aside>}
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/* 页面组件:从 App.tsx 拆出(Rose Clay 重构) */
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useAgentEditSafety } from './AgentsPage'
|
||||
import { Search, Plus } from 'lucide-react'
|
||||
import { api, localizeError } from '../api'
|
||||
import { useDialog } from '../dialog'
|
||||
import { Tag } from '../ui'
|
||||
import { AiAssistDialog, type AiDraft } from './aiAssist'
|
||||
import { OwnerLabel } from '../workbench-ui'
|
||||
import { STATUS_LABEL, KB_STATUS_LABEL, CLASSIFICATION_STATUS_LABEL, RUNTIME_EVENT_LABEL,
|
||||
RUNTIME_PHASE_LABEL, RUNTIME_TOOL_LABEL, PAGE_META, RULE_TEMPLATES } from '../constants'
|
||||
import type { Seg, Camp, CampaignState, RuntimeDetail, CampaignCreateResponse, ComposeResponse,
|
||||
AgentCreateResponse, AgentSummary, KnowledgeBaseSummary, NotificationSummary, AuthUser, AuthStatus,
|
||||
TraceSummary, TraceDetail, UsageSummary, UsageEventRow, PointState, PointAccountRow, UsageResponse,
|
||||
AdminUsageResponse, AgentListRow, MemoryListRow, KbFileRow, KbCategoryRow, KbDetailResponse, KbUsageResponse } from '../types'
|
||||
import { forkResource, canManageResource, isGlobalResource } from '../resourceActions'
|
||||
|
||||
/* ---------------- 技能库 ---------------- */
|
||||
export default function SkillsPage() {
|
||||
const dialog = useDialog()
|
||||
const empty = { name: '', desc: '', instructions: '', enabled: true, agent_ids: [] as number[] }
|
||||
const [skills, setSkills] = useState<any[]>([])
|
||||
const [agents, setAgents] = useState<any[]>([])
|
||||
const [me, setMe] = useState<any>(null)
|
||||
const [forkBusy, setForkBusy] = useState<number | null>(null)
|
||||
const [form, setForm] = useState<any>(empty)
|
||||
const [editing, setEditing] = useState<any>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [aiOpen, setAiOpen] = useState(false)
|
||||
const [skillQuery, setSkillQuery] = useState('')
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
const [baseline, setBaseline] = useState(JSON.stringify(empty))
|
||||
const inFlight = useRef(false)
|
||||
const dirty = drawerOpen && JSON.stringify(form) !== baseline
|
||||
const allowLeave = useAgentEditSafety(dirty, busy)
|
||||
const closeEditor = async () => { if (await allowLeave()) { setDrawerOpen(false); reset() } }
|
||||
const [feedback, setFeedback] = useState('')
|
||||
const [importing, setImporting] = useState(false)
|
||||
const importInputRef = useRef<HTMLInputElement>(null)
|
||||
const refresh = () => Promise.all([
|
||||
api.get('/skills').then(setSkills),
|
||||
api.get('/agents').then(setAgents),
|
||||
])
|
||||
useEffect(() => { refresh() }, [])
|
||||
useEffect(() => { api.get('/auth/me').then(setMe).catch(() => {}) }, [])
|
||||
const applyDraft = (draft: AiDraft) => {
|
||||
setEditing(null)
|
||||
setForm({ name: draft.name || '', desc: draft.desc || '',
|
||||
instructions: (draft.instructions || []).join('\n'), enabled: true })
|
||||
setAiOpen(false); setDrawerOpen(true); setBaseline(JSON.stringify(empty)); setError('')
|
||||
if (draft.advice) void dialog.notice({ title: 'AI 起草完成', description: draft.advice })
|
||||
}
|
||||
const reset = () => { setEditing(null); setForm(empty); setBaseline(JSON.stringify(empty)); setError('') }
|
||||
const save = async () => {
|
||||
if (inFlight.current) return
|
||||
inFlight.current = true
|
||||
setFeedback(''); setBusy(true); setError('')
|
||||
try {
|
||||
let savedId = editing?.id
|
||||
if (savedId) await api.put(`/skills/${savedId}`, form)
|
||||
else {
|
||||
const created = await api.post<{ id: number }>('/skills', form)
|
||||
savedId = created.id
|
||||
// POST 成功但回读失败时,下次重试改为 PUT,不重复创建。
|
||||
setEditing({ id: savedId, name: form.name })
|
||||
}
|
||||
const rows = await api.get<any[]>('/skills')
|
||||
if (!rows.some(row => row.id === savedId && row.name === form.name.trim())) throw new Error('保存回读未确认,请保留输入并重试')
|
||||
setSkills(rows)
|
||||
// 先读回列表确认服务端可见,再关闭编辑器;失败不清空输入。
|
||||
reset(); setDrawerOpen(false); setFeedback('已保存')
|
||||
} catch (e: any) { setError(String(e.message || e)) } finally { inFlight.current = false; setBusy(false) }
|
||||
}
|
||||
const edit = (skill: any) => {
|
||||
setEditing(skill)
|
||||
const value = { name: skill.name, desc: skill.desc || '', instructions: skill.instructions || '', enabled: skill.enabled !== false,
|
||||
agent_ids: Array.isArray(skill.agent_ids) ? skill.agent_ids.map(Number) : [] }
|
||||
setForm(value); setBaseline(JSON.stringify(value)); setFeedback('')
|
||||
setError('')
|
||||
}
|
||||
const toggle = async (skill: any) => {
|
||||
setBusy(true); setError('')
|
||||
try { await api.put(`/skills/${skill.id}`, { enabled: !skill.enabled }); await refresh() }
|
||||
catch (e: any) { setError(String(e.message || e)) } finally { inFlight.current = false; setBusy(false) }
|
||||
}
|
||||
const remove = async (skill: any) => {
|
||||
const global = isGlobalResource(skill) && !canManageResource(skill, me)
|
||||
if (!await dialog.confirm({ title: `${global ? '从我的列表移除' : '删除'} 技能「${skill.name}」?`, description: global ? '仅从当前账号隐藏,不影响其他用户和管理员使用。' : '系统会先自动解除所有 Agent 挂接和成员授权,再永久删除技能。', confirmLabel: global ? '确认移除' : '确认删除', tone: 'danger' })) return
|
||||
setBusy(true); setError('')
|
||||
try { await api.delete(`/skills/${skill.id}`); if (editing?.id === skill.id) reset(); await refresh() }
|
||||
catch (e: any) { setError(String(e.message || e)) } finally { inFlight.current = false; setBusy(false) }
|
||||
}
|
||||
const fork = async (skill: any) => {
|
||||
if (forkBusy) return
|
||||
setForkBusy(skill.id); setError('')
|
||||
try {
|
||||
const result = await forkResource('skills', Number(skill.id))
|
||||
await refresh(); setFeedback('已创建我的副本,可独立编辑和删除。')
|
||||
edit({ ...skill, id: result.id, name: `${skill.name} 副本`, visibility: 'private', owner_user_id: me?.id })
|
||||
setDrawerOpen(true)
|
||||
} catch (e: any) { setError(localizeError(e)) } finally { setForkBusy(null) }
|
||||
}
|
||||
const importMarkdown = async (files: FileList | null) => {
|
||||
const file = files?.[0]
|
||||
if (!file) return
|
||||
setImporting(true); setBusy(true); setError(''); setFeedback('')
|
||||
try {
|
||||
await api.upload('/skills/import', files as FileList)
|
||||
await refresh()
|
||||
setFeedback(`已导入技能:${file.name}`)
|
||||
} catch (e: any) {
|
||||
setError(localizeError(e))
|
||||
} finally {
|
||||
setImporting(false); setBusy(false)
|
||||
if (importInputRef.current) importInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
const filteredSkills = skills.filter(sk =>
|
||||
!skillQuery.trim() || sk.name.includes(skillQuery.trim()) || (sk.desc || '').includes(skillQuery.trim()))
|
||||
return (
|
||||
<div className="list-page">
|
||||
<div className="page-toolbar">
|
||||
<div className="toolbar-search"><Search size={14} /><input placeholder="搜索技能…" value={skillQuery} onChange={e => setSkillQuery(e.target.value)} /></div>
|
||||
<button className="primary-action" onClick={async () => { if (await allowLeave()) { reset(); setFeedback(''); setDrawerOpen(true) } }} disabled={busy}><Plus size={14} /> 新建技能</button>
|
||||
<button className="mini" onClick={() => importInputRef.current?.click()} disabled={busy} title="从 .md 文件创建一项技能">导入 Markdown</button>
|
||||
<input ref={importInputRef} type="file" accept=".md,.markdown,text/markdown" hidden onChange={e => { void importMarkdown(e.target.files) }} />
|
||||
<button className="ai-entry" onClick={async () => { if (await allowLeave()) setAiOpen(true) }} disabled={busy}>✦ AI 帮我创建</button>
|
||||
</div>
|
||||
{importing && <p role="status">正在读取 Markdown 并创建技能,请稍候…</p>}
|
||||
{feedback && <p role="status">{feedback}</p>}
|
||||
{error && <div className="err">{error}</div>}
|
||||
{filteredSkills.map(skill => (
|
||||
<div key={skill.id} className={`row ${skill.enabled ? '' : 'dim'}`}>
|
||||
<b>{skill.name}</b>{skill.builtin && <span className="badge gold">内置</span>}<span className={`badge ${skill.enabled ? 'okbg' : ''}`}>{skill.enabled ? '启用' : '停用'}</span>
|
||||
<span className="dim">{skill.desc || '未填写用途说明'} · 已挂接 {skill.agent_count} 个 Agent · v{skill.version}</span>
|
||||
<OwnerLabel resource={skill} />
|
||||
{canManageResource(skill, me) ? <><button className="mini" onClick={async () => { if (await allowLeave()) { edit(skill); setDrawerOpen(true) } }} disabled={busy}>编辑</button>
|
||||
<button className="mini" onClick={() => toggle(skill)} disabled={busy}>{skill.enabled ? '停用' : '启用'}</button>
|
||||
<button className="mini danger-text" onClick={() => remove(skill)} disabled={busy}>删除</button></> : isGlobalResource(skill) ? <><button className="mini" onClick={() => void fork(skill)} disabled={busy || forkBusy === skill.id}>{forkBusy === skill.id ? '创建副本中…' : '创建我的副本'}</button><button className="mini danger-text" onClick={() => remove(skill)} disabled={busy}>从我的列表移除</button></> : <span className="field-help">只读</span>}
|
||||
</div>
|
||||
))}
|
||||
{!filteredSkills.length && <div className="empty-workspace compact"><strong>{skillQuery ? '没有匹配的技能' : '还没有技能'}</strong><span>{skillQuery ? '换个关键词试试。' : '先创建一条可复用的执行方法,再到 Agent 管理中挂接。'}</span></div>}
|
||||
|
||||
{drawerOpen && <div className="drawer-mask" onClick={closeEditor} />}
|
||||
{drawerOpen && <aside className="side-drawer" role="dialog" aria-label={editing ? '编辑技能' : '新建技能'}>
|
||||
<div className="trace-drawer-head"><strong>{editing ? `编辑技能:${editing.name}` : '新建技能'}</strong>
|
||||
<button className="icon-button" onClick={closeEditor} aria-label="关闭">✕</button></div>
|
||||
<fieldset disabled={busy} className="trace-drawer-body" style={{ border: 0, margin: 0, minWidth: 0 }}>
|
||||
{editing && <OwnerLabel resource={editing} />}
|
||||
<p role="status">{busy ? '正在保存,请勿重复操作…' : dirty ? '有未保存的修改' : '尚无修改'}</p>
|
||||
<div className="form-kicker">技能是一段可复用的执行方法,可按需挂接给多个 Agent。不会写?先在列表页用「AI 帮我创建」。</div>
|
||||
<label>技能名称<input placeholder="如:直播口语润色" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} /></label>
|
||||
<label>用途说明<input placeholder="一句话说明它解决什么问题" value={form.desc} onChange={e => setForm({ ...form, desc: e.target.value })} /></label>
|
||||
<label>技能说明<span className="field-help">写清楚执行步骤、输出格式或检查标准。</span><textarea rows={10} value={form.instructions} onChange={e => setForm({ ...form, instructions: e.target.value })} /></label>
|
||||
<label>挂载到 Agent(可多选)
|
||||
<span className="field-help">选择后保存即可生效;只选一个就是单独挂载。未选择表示暂不挂载,Agent 仍可在自己的配置里选择这项技能。</span>
|
||||
<div className="skill-agent-picker">
|
||||
<div className="skill-agent-picker-head"><span>已选择 {form.agent_ids?.length || 0} 个</span>
|
||||
{!!agents.length && <button type="button" className="text-button" onClick={() => setForm({ ...form, agent_ids: agents.filter(agent => agent.status !== 'disabled').map(agent => agent.id) })}>全选启用 Agent</button>}
|
||||
{!!form.agent_ids?.length && <button type="button" className="text-button" onClick={() => setForm({ ...form, agent_ids: [] })}>清空</button>}
|
||||
</div>
|
||||
<div className="chips">{agents.map(agent => {
|
||||
const selected = (form.agent_ids || []).includes(agent.id)
|
||||
return <button type="button" key={agent.id} className={`chip agent-choice ${selected ? 'on' : ''}`} onClick={() => setForm({ ...form, agent_ids: selected ? form.agent_ids.filter((id: number) => id !== agent.id) : [...(form.agent_ids || []), agent.id] })}>
|
||||
<span className="agent-choice-check">{selected ? '✓' : '○'}</span>{agent.name}<small>{agent.status === 'disabled' ? '已停用' : '可用'}</small>
|
||||
</button>
|
||||
})}</div>
|
||||
{!agents.length && <span className="field-help">暂无可挂载的 Agent,请先创建 Agent。</span>}
|
||||
</div>
|
||||
</label>
|
||||
{editing && <label className="inline-check"><input type="checkbox" checked={form.enabled} onChange={e => setForm({ ...form, enabled: e.target.checked })} />启用技能</label>}
|
||||
{error && <div className="err">{error}</div>}
|
||||
<div className="btns"><button className="ok" onClick={save} disabled={busy || !form.name.trim() || !form.instructions.trim()}>{editing ? '保存修改' : '+ 添加技能'}</button>{editing && <button onClick={closeEditor} disabled={busy}>取消编辑</button>}</div>
|
||||
</fieldset>
|
||||
</aside>}
|
||||
{aiOpen && <AiAssistDialog kind="skill" onApply={applyDraft} onClose={() => setAiOpen(false)} />}
|
||||
</div>
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
/* ---------------- 提示模板 ---------------- */
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api, localizeError } from '../api'
|
||||
|
||||
type Row = { id: number; level: string; source: string; message: string; trace_id: string; status: string; created_at: string }
|
||||
type Response = { items: Row[]; page: number; page_size: number; total: number }
|
||||
|
||||
// 接口继续使用稳定的英文枚举值,页面展示时翻译为中文。
|
||||
const LEVEL_LABEL: Record<string, string> = {
|
||||
INFO: '信息', DEBUG: '调试', WARNING: '警告', WARN: '警告', ERROR: '错误', CRITICAL: '严重', FATAL: '致命',
|
||||
}
|
||||
const SOURCE_LABEL: Record<string, string> = {
|
||||
startup: '启动服务', http: 'HTTP 服务', worker: '后台任务', app: '应用', auth: '认证', system: '系统',
|
||||
scheduler: '调度器', backup: '备份', rag: '知识库', training_map: '训练营导图', 'training-map': '训练营导图',
|
||||
ai: 'AI 服务',
|
||||
}
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
ok: '正常', success: '成功', error: '错误', failed: '失败', failure: '失败', unhandled: '未处理',
|
||||
'5xx': '服务器错误', '4xx': '客户端错误', warning: '警告', pending: '待处理', running: '运行中',
|
||||
completed: '已完成', cancelled: '已取消', timeout: '超时', rejected: '已拒绝',
|
||||
}
|
||||
const MESSAGE_LABEL: Record<string, string> = { 'service started': '服务已启动' }
|
||||
|
||||
function localizeLevel(value: string) {
|
||||
return LEVEL_LABEL[value.trim().toUpperCase()] || value
|
||||
}
|
||||
|
||||
function localizeSource(value: string) {
|
||||
return SOURCE_LABEL[value.trim().toLowerCase()] || value
|
||||
}
|
||||
|
||||
function localizeStatus(value: string) {
|
||||
return STATUS_LABEL[value.trim().toLowerCase()] || value
|
||||
}
|
||||
|
||||
function localizeMessage(value: string) {
|
||||
const raw = String(value || '')
|
||||
const exact = MESSAGE_LABEL[raw.trim().toLowerCase()]
|
||||
if (exact) return exact
|
||||
const http = raw.match(/^HTTP\s+(\d{3})$/i)
|
||||
if (http) {
|
||||
const code = http[1]
|
||||
const prefix = code.startsWith('5') ? 'HTTP 请求失败' : code.startsWith('4') ? 'HTTP 请求异常' : 'HTTP 请求'
|
||||
return `${prefix}(状态码 ${code})`
|
||||
}
|
||||
// 仅翻译常见异常类型,保留后端返回的接口路径、参数摘要和错误原文,
|
||||
// 这样管理员可以直接据此定位问题。
|
||||
return raw
|
||||
.replace(/Unhandled exception/gi, '未处理异常')
|
||||
.replace(/ConnectTimeout|ReadTimeout|TimeoutError/gi, '连接超时')
|
||||
.replace(/ConnectionError|ConnectError/gi, '连接失败')
|
||||
.replace(/ValidationError/gi, '参数校验失败')
|
||||
}
|
||||
|
||||
export default function SystemLogsPage() {
|
||||
const [data, setData] = useState<Response>({ items: [], page: 1, page_size: 25, total: 0 })
|
||||
const [level, setLevel] = useState(''); const [source, setSource] = useState(''); const [error, setError] = useState('')
|
||||
const load = (page = data.page) => api.get<Response>(`/system-logs?page=${page}&page_size=25&level=${encodeURIComponent(level)}&source=${encodeURIComponent(source)}`).then(setData).catch(e => setError(localizeError(e)))
|
||||
useEffect(() => { load(1) }, [level, source])
|
||||
return <section data-testid="system-logs-page">
|
||||
<div className="section-title-row"><div><h2>系统日志</h2><p>仅管理员可见的运行时错误与服务状态记录(已隐藏普通警告)。</p></div><div className="btns"><select aria-label="日志级别" value={level} onChange={e => setLevel(e.target.value)}><option value="">全部级别</option><option value="INFO">信息</option><option value="ERROR">错误</option></select><input aria-label="日志来源" placeholder="按来源筛选" value={source} onChange={e => setSource(e.target.value)} /></div></div>
|
||||
{error && <div className="err">{error}</div>}
|
||||
<div className="table-wrap"><table><thead><tr><th>时间</th><th>级别</th><th>来源</th><th>详细消息(接口 · 状态 · 用户 · 耗时)</th><th>链路 ID</th><th>状态</th></tr></thead><tbody>{data.items.map(row => <tr key={row.id}><td>{row.created_at}</td><td><span className="badge">{localizeLevel(row.level)}</span></td><td>{localizeSource(row.source)}</td><td className="system-log-message" title={row.message}>{localizeMessage(row.message)}</td><td>{row.trace_id || '—'}</td><td>{localizeStatus(row.status)}</td></tr>)}</tbody></table></div>
|
||||
{!data.items.length && <div className="empty-workspace compact"><strong>暂无系统日志</strong></div>}
|
||||
<div className="btns"><button disabled={data.page <= 1} onClick={() => load(data.page - 1)}>上一页</button><span>第 {data.page} 页 · 共 {data.total} 条</span><button disabled={data.page * data.page_size >= data.total} onClick={() => load(data.page + 1)}>下一页</button></div>
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/* 页面组件:从 App.tsx 拆出(墨韵金重构) */
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Search, Plus } from 'lucide-react'
|
||||
import { api, localizeError } from '../api'
|
||||
import { useDialog } from '../dialog'
|
||||
import { Tag } from '../ui'
|
||||
import { STATUS_LABEL, KB_STATUS_LABEL, CLASSIFICATION_STATUS_LABEL, RUNTIME_EVENT_LABEL,
|
||||
RUNTIME_PHASE_LABEL, RUNTIME_TOOL_LABEL, PAGE_META, RULE_TEMPLATES } from '../constants'
|
||||
import type { Seg, Camp, CampaignState, RuntimeDetail, CampaignCreateResponse, ComposeResponse,
|
||||
AgentCreateResponse, AgentSummary, KnowledgeBaseSummary, NotificationSummary, AuthUser, AuthStatus,
|
||||
TraceSummary, TraceDetail, UsageSummary, UsageEventRow, PointState, PointAccountRow, UsageResponse,
|
||||
AdminUsageResponse, AgentListRow, MemoryListRow, KbFileRow, KbCategoryRow, KbDetailResponse, KbUsageResponse } from '../types'
|
||||
|
||||
/* ---------------- 标签管理 ---------------- */
|
||||
export default function TagsPage() {
|
||||
const dialog = useDialog()
|
||||
const [tags, setTags] = useState<any[]>([])
|
||||
const [name, setName] = useState(''); const [cat, setCat] = useState('内容')
|
||||
const [def, setDef] = useState(''); const [use, setUse] = useState('')
|
||||
const [tagQuery, setTagQuery] = useState('')
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<any>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const form = { name, desc: def }
|
||||
const setForm = (patch: any) => { if ('name' in patch) setName(patch.name); if ('desc' in patch) setDef(patch.desc) }
|
||||
const refresh = () => api.get('/tags').then(setTags)
|
||||
useEffect(() => { refresh() }, [])
|
||||
const reset = () => { setName(''); setCat('内容'); setDef(''); setUse(''); setEditing(null) }
|
||||
const add = async () => {
|
||||
setError('')
|
||||
try { await api.post('/tags', { name, category: cat, definition: def, usage: use }); reset(); await refresh() }
|
||||
catch (e: any) { setError(String(e.message || e)) }
|
||||
}
|
||||
const edit = (tag: any) => { setEditing(tag); setName(tag.name); setCat(tag.category); setDef(tag.definition || ''); setUse(tag.usage || ''); setError('') }
|
||||
const save = async () => {
|
||||
if (!editing) return
|
||||
setError('')
|
||||
try { await api.patch(`/tags/${editing.id}`, { name, category: cat, definition: def, usage: use }); reset(); await refresh() }
|
||||
catch (e: any) { setError(String(e.message || e)) }
|
||||
}
|
||||
const toggle = async (tag: any) => {
|
||||
setError('')
|
||||
try { await api.patch(`/tags/${tag.id}`, { enabled: !tag.enabled }); await refresh() }
|
||||
catch (e: any) { setError(String(e.message || e)) }
|
||||
}
|
||||
const remove = async (tag: any) => {
|
||||
if (tag.is_core || !await dialog.confirm({ title: `删除标签「${tag.name}」?`, description: '历史文本会保留快照;当前 Agent 的标签挂接会自动移除。', confirmLabel: '确认删除', tone: 'danger' })) return
|
||||
setError('')
|
||||
try { await api.delete(`/tags/${tag.id}`); if (editing?.id === tag.id) reset(); await refresh() }
|
||||
catch (e: any) { setError(String(e.message || e)) }
|
||||
}
|
||||
const filteredTags = tags.filter(t =>
|
||||
!tagQuery.trim() || t.name.includes(tagQuery.trim()))
|
||||
return (
|
||||
<div className="list-page">
|
||||
<div className="page-toolbar">
|
||||
<div className="toolbar-search"><Search size={14} /><input placeholder="搜索标签…" value={tagQuery} onChange={e => setTagQuery(e.target.value)} /></div>
|
||||
<button className="primary-action" onClick={() => { reset(); setDrawerOpen(true) }}><Plus size={14} /> 新建标签</button>
|
||||
</div>
|
||||
{error && <div className="err">{error}</div>}
|
||||
{filteredTags.map(tag => (
|
||||
<div key={tag.id} className={`row ${tag.enabled ? '' : 'dim'}`}>
|
||||
<b>{tag.name}</b>{tag.builtin && <span className="badge gold">内置</span>}<span className={`badge ${tag.enabled ? 'okbg' : ''}`}>{tag.enabled ? '启用' : '停用'}</span>
|
||||
<span className="dim">{tag.definition || tag.desc || '无说明'}</span>
|
||||
<button className="mini" onClick={() => { edit(tag); setDrawerOpen(true) }} disabled={busy}>编辑</button>
|
||||
<button className="mini" onClick={() => toggle(tag)} disabled={busy}>{tag.enabled ? '停用' : '启用'}</button>
|
||||
<button className="mini danger-text" onClick={() => remove(tag)} disabled={busy}>删除</button>
|
||||
</div>
|
||||
))}
|
||||
{!filteredTags.length && <div className="empty-workspace compact"><strong>{tagQuery ? '没有匹配的标签' : '还没有标签'}</strong><span>{tagQuery ? '换个关键词。' : '标签是任务段的内容类型。'}</span></div>}
|
||||
|
||||
{drawerOpen && <div className="drawer-mask" onClick={() => { setDrawerOpen(false); reset() }} />}
|
||||
{drawerOpen && <aside className="side-drawer" role="dialog" aria-label={editing ? '编辑标签' : '新建标签'}>
|
||||
<div className="trace-drawer-head"><strong>{editing ? `编辑标签:${editing.name}` : '新建标签'}</strong>
|
||||
<button className="icon-button" onClick={() => { setDrawerOpen(false); reset() }} aria-label="关闭">✕</button></div>
|
||||
<div className="trace-drawer-body">
|
||||
<div className="form-kicker">标签是任务段的内容类型,不是写给 Agent 的提示词。</div>
|
||||
<label>标签名称<input value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} /></label>
|
||||
<label>说明<input value={form.desc} onChange={e => setForm({ ...form, desc: e.target.value })} /></label>
|
||||
|
||||
{error && <div className="err">{error}</div>}
|
||||
<div className="btns"><button className="ok" onClick={save} disabled={busy || !form.name.trim()}>{editing ? '保存修改' : '+ 添加标签'}</button>{editing && <button onClick={() => { reset(); setDrawerOpen(false) }}>取消编辑</button>}</div>
|
||||
</div>
|
||||
</aside>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Upload } from 'lucide-react'
|
||||
import { api } from '../api'
|
||||
|
||||
/** 新建内容任务面板:选择已确认训练营导图→人工审核→创建并拆解。 */
|
||||
export function TaskCreatePanel({ onCreated, onClose, initialTrainingMapId, initialTrainingMapDay }: { onCreated: (id: number) => void; onClose: () => void; initialTrainingMapId?: number; initialTrainingMapDay?: number }) {
|
||||
const [name, setName] = useState('')
|
||||
const [mindmap, setMindmap] = useState('')
|
||||
const [trainingMaps, setTrainingMaps] = useState<any[]>([])
|
||||
const [trainingMapId, setTrainingMapId] = useState<number | ''>(initialTrainingMapId || '')
|
||||
const [trainingMapDays, setTrainingMapDays] = useState<{ day: number; title: string; map?: any }[]>([])
|
||||
const [trainingMapDay, setTrainingMapDay] = useState<number | ''>('')
|
||||
const [canvasId, setCanvasId] = useState<number | ''>('')
|
||||
const [canvases, setCanvases] = useState<any[]>([])
|
||||
const [durationMinutes, setDurationMinutes] = useState<number | ''>('')
|
||||
const [qcMode, setQcMode] = useState<'full' | 'lazy' | 'off'>('lazy')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [aiStage, setAiStage] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
const flattenMap = (tree: any): string => {
|
||||
const lines: string[] = []
|
||||
const walk = (node: any, depth = 0) => {
|
||||
if (!node) return
|
||||
lines.push(`${' '.repeat(depth)}- ${node.title}`)
|
||||
if (node.content) lines.push(`${' '.repeat(depth + 1)}${node.content}`)
|
||||
;(node.children || []).forEach((child: any) => walk(child, depth + 1))
|
||||
}
|
||||
walk(tree)
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
const selectTrainingMap = async (id: number) => {
|
||||
setTrainingMapId(id || '')
|
||||
setTrainingMapDays([]); setTrainingMapDay('')
|
||||
if (!id) return
|
||||
setBusy(true); setAiStage('正在读取训练营导图…'); setErr('')
|
||||
try {
|
||||
const detail = await api.get<any>(`/training-maps/${id}`)
|
||||
const days = (detail.plan?.days || []).map((d: any) => ({ day: d.day, title: d.title, map: detail.maps?.[String(d.day)] })).filter((d: any) => d.map)
|
||||
setTrainingMapDays(days)
|
||||
const picked = days.find((d: any) => d.day === initialTrainingMapDay) || days[0]
|
||||
if (picked) { setTrainingMapDay(picked.day); setMindmap(flattenMap(picked.map)); if (!name.trim()) setName(`${detail.topic} · 第${picked.day}天`) }
|
||||
else setErr('该训练营导图尚未生成每日导图,请先生成后再导入')
|
||||
} catch (e: any) { setErr(String(e.message || e)) } finally { setBusy(false); setAiStage('') }
|
||||
}
|
||||
|
||||
const selectTrainingMapDay = (day: number) => {
|
||||
setTrainingMapDay(day)
|
||||
const picked = trainingMapDays.find(d => d.day === day)
|
||||
if (picked?.map) { setMindmap(flattenMap(picked.map)); if (!name.trim()) setName(`训练营导图 · 第${day}天`) }
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
api.get<any[]>('/canvases').then(setCanvases).catch(() => {})
|
||||
api.get<any[]>('/training-maps').then(async rows => {
|
||||
const confirmed = rows.filter(r => ['confirmed', 'done'].includes(r.status))
|
||||
setTrainingMaps(confirmed)
|
||||
if (initialTrainingMapId && confirmed.some(r => r.id === initialTrainingMapId)) await selectTrainingMap(initialTrainingMapId)
|
||||
}).catch(() => {})
|
||||
}, [initialTrainingMapId])
|
||||
|
||||
const importFile = async (file: File) => {
|
||||
setBusy(true); setAiStage('正在读取导图文件…'); setErr('')
|
||||
try {
|
||||
let text = ''
|
||||
if (/\.(docx|doc|pdf)$/i.test(file.name)) {
|
||||
const fd = new FormData(); fd.append('files', file)
|
||||
const token = localStorage.getItem('yawan-workbench-access-token')
|
||||
const resp = await fetch('/api/utils/extract-text', { method: 'POST', headers: token ? { Authorization: `Bearer ${token}` } : {}, body: fd })
|
||||
const data = await resp.json(); if (!resp.ok) throw new Error(data.detail || '文档解析失败'); text = (data.results || []).map((r: any) => r.text).join('\n\n')
|
||||
} else text = await file.text()
|
||||
setMindmap(prev => (prev ? prev + '\n\n' : '') + text)
|
||||
if (!name.trim()) setName(file.name.replace(/\.[^.]+$/, ''))
|
||||
} catch (e: any) { setErr(String(e.message || e)) } finally { setBusy(false); setAiStage('') }
|
||||
}
|
||||
|
||||
const create = async () => {
|
||||
if (!canvasId) { setErr('请选择画布模板后再创建内容任务'); return }
|
||||
setBusy(true); setAiStage('正在创建内容任务…'); setErr('')
|
||||
try {
|
||||
// 任务目标和验收标准已由训练营导图承载,后端使用兼容默认值。
|
||||
const c = await api.post<any>('/campaigns', { name, mindmap_text: mindmap, canvas_id: canvasId || undefined, duration_minutes: durationMinutes ? Number(durationMinutes) : 0, qc_mode: qcMode })
|
||||
setAiStage('AI 正在拆解导图并生成内容段…')
|
||||
await api.post(`/campaigns/${c.id}/parse`, {}).catch(() => {})
|
||||
onCreated(c.id)
|
||||
} catch (e: any) { setErr(String(e.message || e)) } finally { setBusy(false); setAiStage('') }
|
||||
}
|
||||
|
||||
return <div className="card task-create-card">
|
||||
<div className="form-kicker">选择已确认的训练营导图和其中的第几天内容,再选择画布模板后即可创建任务并拆解。</div>
|
||||
<input placeholder="任务名(如 2026-08-26-day5)" value={name} onChange={e => setName(e.target.value)} />
|
||||
<div className="two-select-row">
|
||||
<label className="duration-field">训练营导图
|
||||
<select aria-label="选择训练营导图" value={trainingMapId} onChange={e => selectTrainingMap(Number(e.target.value))}>
|
||||
<option value="">请选择已确认导图</option>{trainingMaps.map(m => <option key={m.id} value={m.id}>{m.topic} · v{m.version}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="duration-field">对应天数(导图第几天)
|
||||
<select aria-label="选择导图天数" value={trainingMapDay} onChange={e => selectTrainingMapDay(Number(e.target.value))} disabled={!trainingMapDays.length}>
|
||||
<option value="">请选择天数</option>{trainingMapDays.map(d => <option key={d.day} value={d.day}>第 {d.day} 天 · {d.title}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="duration-field">画布模板(必选,独立于导图)
|
||||
<select aria-label="选择画布模板(必选)" value={canvasId} onChange={e => setCanvasId(e.target.value ? Number(e.target.value) : '')}><option value="">请选择画布模板</option>{canvases.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="two-select-row">
|
||||
<label className="duration-field">直播总时长<select value={durationMinutes} onChange={e => setDurationMinutes(e.target.value ? Number(e.target.value) : '')}><option value="">请选择直播时长</option><option value={30}>30 分钟</option><option value={60}>1 小时</option><option value={90}>1.5 小时</option><option value={120}>2 小时</option><option value={180}>3 小时</option><option value={240}>4 小时</option></select></label>
|
||||
<label className="duration-field">质检方式<select value={qcMode} onChange={e => setQcMode(e.target.value as 'full' | 'lazy' | 'off')}><option value="lazy">先出稿,后台质检(推荐)</option><option value="full">出稿+同步质检</option><option value="off">不质检</option></select></label>
|
||||
</div>
|
||||
<label className="mindmap-label">导图(作为拆解依据,可直接编辑)<textarea rows={10} placeholder="选择训练营导图后自动导入,也可粘贴文本" value={mindmap} onChange={e => setMindmap(e.target.value)} /></label>
|
||||
<div className="btns"><label className="mini file-import-label"><Upload size={13} /> 导入文件<input type="file" accept=".md,.txt,.srt,.docx,.doc,.pdf" style={{ display: 'none' }} onChange={e => { const f = e.target.files?.[0]; if (f) importFile(f) }} /></label><span className="flex-spacer" /><button className="mini" onClick={onClose}>取消</button><button className="ok" disabled={busy || !mindmap.trim() || !name.trim() || !canvasId} onClick={create}>创建并拆解</button></div>
|
||||
{busy && <div className="ai-operation-progress task-create-progress" role="status" aria-live="polite">
|
||||
<div><strong>{aiStage || 'AI 正在处理内容任务…'}</strong><span>正在准备内容任务,请不要关闭页面</span></div>
|
||||
<div className="ai-progress-track"><div className="ai-progress-indeterminate" /></div>
|
||||
<small>读取导图 → 创建任务 → AI 拆解内容段</small>
|
||||
</div>}
|
||||
{err && <div className="err">{err}</div>}
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import SegmentAgentPicker from '../SegmentAgentPicker'
|
||||
import MemoryRecall from '../MemoryRecall'
|
||||
//TaskDetailPage(/tasks/:id):四页签 = 内容段 / 运行轨迹 / 最终成稿 / 配置快照。
|
||||
// 业务逻辑整体沿用旧 TasksPage 详情区(拆解/清单编辑/逐段生成/审查/打回/合成),仅重排视图。
|
||||
// 段落默认折叠,仅自动展开待处理段;审查操作 sticky。
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { ArrowLeft, Play, Pause, Square, Trash2, RefreshCw, Sparkles, FileDown, ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { api, localizeError } from '../api'
|
||||
import type { Seg, CampaignState, RuntimeDetail } from '../types'
|
||||
import { STATUS_LABEL } from '../constants'
|
||||
import { Tag } from '../ui'
|
||||
import { useDialog } from '../dialog'
|
||||
import { OwnerLabel } from '../workbench-ui'
|
||||
|
||||
type TabKey = 'segments' | 'runs' | 'final' | 'config'
|
||||
|
||||
/** 检索超时通常是本段继承了过大的知识范围;给用户一个可执行的缩小范围提示。 */
|
||||
function isKnowledgeSearchTimeout(value: unknown) {
|
||||
const text = typeof value === 'string' ? value : ''
|
||||
return /知识库检索.*超时|检索超时|gateway timeout|timed? ?out|timeout/i.test(text)
|
||||
}
|
||||
|
||||
const QC_GATE_LABEL: Record<string, string> = {
|
||||
structure: '结构质检', content: '内容质检', style: '风格质检', cross: '跨稿质检',
|
||||
all: '质检',
|
||||
}
|
||||
|
||||
export default function TaskDetailPage() {
|
||||
const { id } = useParams()
|
||||
const nav = useNavigate()
|
||||
const dialog = useDialog()
|
||||
const cid = Number(id)
|
||||
const [camp, setCamp] = useState<any>(null)
|
||||
const [openSegments, setOpenSegments] = useState<number[]>([])
|
||||
const [tab, setTab] = useState<TabKey>('segments')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [operationLabel, setOperationLabel] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
const [runtime, setRuntime] = useState<RuntimeDetail | null>(null)
|
||||
const [finalPreview, setFinalPreview] = useState<any>(null)
|
||||
const [readiness, setReadiness] = useState<any>(null)
|
||||
const [adviceFor, setAdviceFor] = useState<number | null>(null)
|
||||
const [kbAction, setKbAction] = useState<'keep' | 'discard'>('keep')
|
||||
const [adviceText, setAdviceText] = useState('')
|
||||
const timer = useRef<any>(null)
|
||||
const detailVersion = useRef(0)
|
||||
|
||||
const selectCamp = async () => {
|
||||
const version = ++detailVersion.current
|
||||
const detail = await api.get<any>(`/campaigns/${cid}`)
|
||||
if (version !== detailVersion.current) return
|
||||
setCamp(detail)
|
||||
// 拆解完成后默认展开 pending 段,让 AI 分类推荐和“一键采用”立即可见。
|
||||
setOpenSegments(detail.segments?.filter((seg: Seg) => ['pending', 'human_review', 'rework', 'failed'].includes(seg.status)).map((seg: Seg) => seg.id) || [])
|
||||
// 拆解是后台任务。解析中不调用昂贵的准备度/成本检查,避免新建任务先卡在“没有内容段”。
|
||||
const [nextReadiness, nextRuntime] = await Promise.all([
|
||||
['parsed', 'running', 'human_review', 'composing_ready', 'done'].includes(detail.status)
|
||||
? api.get<any>(`/campaigns/${cid}/readiness`).catch(() => null) : Promise.resolve(null),
|
||||
detail.run?.id ? api.get<RuntimeDetail>(`/runs/${detail.run.id}`).catch(() => null) : Promise.resolve(null),
|
||||
])
|
||||
if (version !== detailVersion.current) return
|
||||
setReadiness(nextReadiness); setRuntime(nextRuntime)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (cid) selectCamp().catch(e => setErr(localizeError(e)))
|
||||
return () => { if (timer.current) clearInterval(timer.current) }
|
||||
}, [cid])
|
||||
|
||||
useEffect(() => {
|
||||
if (camp && (['running', 'human_review', 'parsed', 'parsing', 'composing_ready', 'composing'].includes(camp.status) || ['queued', 'running'].includes(camp.run?.status || '')) && !timer.current) {
|
||||
timer.current = setInterval(async () => {
|
||||
const version = detailVersion.current
|
||||
const c = await api.get<CampaignState>(`/campaigns/${camp.id}`).catch(() => null)
|
||||
if (c && version === detailVersion.current) {
|
||||
setCamp(c)
|
||||
// 首次打开时可能拿到 parsing 状态,旧 readiness 会残留“没有内容段”。
|
||||
// 段落落库后立即刷新一次,确保启动检查反映当前数据。
|
||||
if (camp.status === 'parsing' && c.status === 'parsed') {
|
||||
setReadiness(await api.get(`/campaigns/${c.id}/readiness`).catch(() => null))
|
||||
}
|
||||
if (c.run?.id) setRuntime(await api.get<RuntimeDetail>(`/runs/${c.run.id}`).catch(() => null))
|
||||
if (['done', 'failed'].includes(c.status) && !['queued', 'running'].includes(c.run?.status || '')) { clearInterval(timer.current); timer.current = null }
|
||||
}
|
||||
}, 4000)
|
||||
}
|
||||
}, [camp?.status, camp?.run?.status])
|
||||
|
||||
if (!camp) return <div className="empty-state">{err || '加载中…'}</div>
|
||||
|
||||
const segs: Seg[] = camp.segments || []
|
||||
const statusCounts = segs.reduce((acc: Record<string, number>, seg: Seg) => { acc[seg.status] = (acc[seg.status] || 0) + 1; return acc }, {})
|
||||
const doneCount = segs.filter(s => ['approved', 'skipped'].includes(s.status)).length
|
||||
const pendingCount = segs.filter(s => ['human_review', 'rework', 'failed'].includes(s.status)).length
|
||||
// 逐段生成完成后服务端会将任务置为 human_review,等待当前段人工通过/跳过。
|
||||
// 只判断 parsed 会让“生成下一段”在首段完成后消失;保留按钮并在前置段
|
||||
// 尚未确认时置灰,用户可以明确看到下一步入口和原因。
|
||||
const nextSegments = segs.filter(s => ['pending', 'rework', 'failed'].includes(s.status))
|
||||
const activeRun = ['queued', 'running', 'paused'].includes(camp.run?.status || '')
|
||||
const nextSegment = nextSegments[0]
|
||||
const nextSegmentReady = Boolean(nextSegment && segs.filter(s => (s.id || 0) < (nextSegment.id || 0)).every(s => ['approved', 'skipped'].includes(s.status)))
|
||||
const canGenerateNext = ['parsed', 'human_review'].includes(camp.status) && !activeRun && nextSegments.length > 0
|
||||
|
||||
const act = async (fn: () => Promise<any>, label = 'AI 正在处理内容任务…') => {
|
||||
setBusy(true); setOperationLabel(label); setErr('')
|
||||
try { await fn(); await selectCamp() }
|
||||
catch (e: any) { setErr(localizeError(e)) } finally { setBusy(false); setOperationLabel('') }
|
||||
}
|
||||
const runStep = () => act(async () => {
|
||||
const r = await api.post<{ status: string; message?: string; segment?: { id: number; title: string } }>(`/campaigns/${camp!.id}/run-step`, {})
|
||||
if (r.segment) setOpenSegments(prev => [...new Set([...(prev || []), r.segment!.id])])
|
||||
if (r.status === 'no_pending') await dialog.notice({ title: '全部段落已生成', description: r.message })
|
||||
}, 'AI 正在生成下一段内容…')
|
||||
const review = async (sid: number, action: string, opinion = '', kbAction2?: 'keep' | 'discard') => { await api.post(`/segments/${sid}/review`, { action, opinion, kb_action: kbAction2 || 'keep' }); await selectCamp() }
|
||||
const saveSegment = (seg: Seg, patch: Record<string, unknown>) => act(async () => { await api.put(`/campaigns/${camp!.id}/segments/${seg.id}`, patch) }, '正在保存内容段…')
|
||||
const recommendCategory = (seg: Seg) => act(async () => { await api.post(`/campaigns/${camp!.id}/segments/${seg.id}/recommend-category`) }, 'AI 正在推荐知识分类…')
|
||||
const categoryEditable = !['queued', 'running', 'paused'].includes(camp.run?.status || '') && camp.status !== 'done'
|
||||
const deleteSegment = (seg: Seg) => act(async () => {
|
||||
if (!await dialog.confirm({ title: `删除段落「${seg.title}」?`, description: '删除后生成时自动跳过该段。', confirmLabel: '删除', tone: 'danger' })) throw new Error('cancelled')
|
||||
await api.delete(`/campaigns/${camp!.id}/segments/${seg.id}`)
|
||||
})
|
||||
const reparseWithAdvice = async () => {
|
||||
const advice = await dialog.prompt({ title: '对拆解清单提修改意见', description: 'AI 会按你的意见重新拆解整个任务单(当前清单会被替换)。', inputLabel: '修改意见', placeholder: '例如:开场加一个粉丝提问互动环节;总时长控制在 90 分钟。', confirmLabel: 'AI 重新拆解' })
|
||||
if (advice === null || !advice.trim()) return
|
||||
await act(async () => { await api.post(`/campaigns/${camp!.id}/reparse`, { advice }) }, 'AI 正在按建议重新拆解任务…')
|
||||
}
|
||||
const retryParse = async () => {
|
||||
// 失败重试沿用当前导图原文,服务端会清理残留段落并重新生成任务单。
|
||||
await act(async () => { await api.post(`/campaigns/${camp!.id}/parse`, {}) }, 'AI 正在重新拆解任务…')
|
||||
// 后端拆解在后台线程执行,立即响应时可能还没来得及写入 parsing;
|
||||
// 先乐观显示处理中,轮询会在完成后刷新为 parsed 或 parse_failed。
|
||||
setCamp((current: any) => current ? { ...current, status: 'parsing' } : current)
|
||||
}
|
||||
const previewFinal = () => act(async () => { setFinalPreview(await api.get(`/artifacts/${camp!.id}/preview`)) }, '正在生成最终文稿预览…')
|
||||
|
||||
const tabs: { key: TabKey; label: string; badge?: string | number }[] = [
|
||||
{ key: 'segments', label: '内容段', badge: segs.length ? `${doneCount}/${segs.length}` : undefined },
|
||||
{ key: 'runs', label: '运行轨迹', badge: runtime?.events?.length || undefined },
|
||||
{ key: 'final', label: '最终成稿' },
|
||||
{ key: 'config', label: '配置快照' },
|
||||
]
|
||||
|
||||
return <div className="task-detail-page">
|
||||
<div className="detail-header">
|
||||
<button className="icon-button" onClick={() => nav(-1)} aria-label="返回列表"><ArrowLeft size={16} /></button>
|
||||
<div className="detail-title">
|
||||
<h1>{camp.name}</h1>
|
||||
<OwnerLabel resource={camp} />
|
||||
<div className="detail-meta">
|
||||
<Tag s={camp.status} />
|
||||
{camp.qc_mode === 'off' && <span className="tag r">质检关闭</span>}
|
||||
{camp.qc_mode === 'lazy' && <span className="tag y">后台质检</span>}
|
||||
{camp.duration_minutes ? <span className="tag b">{camp.duration_minutes} 分钟</span> : null}
|
||||
{camp.has_failure && <span className="tag r">有失败段</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="detail-actions">
|
||||
{canGenerateNext && <button className="primary-action" onClick={runStep} disabled={busy || !nextSegmentReady} title={!nextSegmentReady ? '请先通过或跳过当前内容段,再生成下一段' : undefined}><Play size={14} /> {segs.some(x => x.status !== 'pending') ? '生成下一段' : '开始生成(第 1 段)'}</button>}
|
||||
{camp.status === 'parsed' && segs.length > 0 && <button onClick={reparseWithAdvice} disabled={busy}><Sparkles size={14} /> 对清单提意见重拆</button>}
|
||||
{camp.status === 'parse_failed' && <button className="primary-action" onClick={retryParse} disabled={busy}><RefreshCw size={14} /> 重新拆解</button>}
|
||||
{['running', 'human_review'].includes(camp.status) && camp.run?.id && ['running', 'queued'].includes(camp.run?.status || '') && <button onClick={() => act(() => api.post(`/runs/${camp.run!.id}/pause`))} disabled={busy}><Pause size={14} /> 暂停</button>}
|
||||
{camp.run?.id && camp.run?.status === 'paused' && <button onClick={() => act(() => api.post(`/runs/${camp.run!.id}/resume`))} disabled={busy}><Play size={14} /> 继续</button>}
|
||||
{camp.run?.id && ['running', 'queued', 'paused'].includes(camp.run?.status || '') && <button onClick={() => act(() => api.post(`/runs/${camp.run!.id}/cancel`))} disabled={busy}><Square size={14} /> 停止</button>}
|
||||
{camp.status === 'composing_ready' && <button className="primary-action" onClick={() => act(() => api.post(`/campaigns/${camp.id}/compose`))} disabled={busy}><FileDown size={14} /> 组合成稿</button>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="task-health-strip"><span className="health-main"><b>{doneCount}</b>/{segs.length || 0} 段已完成</span><span>当前状态:{STATUS_LABEL[camp.status] || camp.status}</span>{pendingCount > 0 && <span className="health-alert">{pendingCount} 段需要处理</span>}<span className="health-updated">自动刷新中 · 每 4 秒</span></div>
|
||||
|
||||
{err && <div className="err">{err}</div>}
|
||||
{busy && <div className="ai-operation-progress task-detail-progress" role="status" aria-live="polite">
|
||||
<div><strong>{operationLabel || 'AI 正在处理内容任务…'}</strong><span>正在生成或检查内容,请不要关闭页面</span></div>
|
||||
<div className="ai-progress-track"><div className="ai-progress-indeterminate" /></div>
|
||||
<small>准备上下文 → Agent 生成 → 质检并保存</small>
|
||||
</div>}
|
||||
{camp.status === 'parsing' && <div className="ai-operation-progress task-detail-progress" role="status" aria-live="polite">
|
||||
<div><strong>AI 正在拆解导图并生成内容段…</strong><span>通常需要 1–2 分钟,完成后会自动显示任务清单</span></div>
|
||||
<div className="ai-progress-track"><div className="ai-progress-indeterminate" /></div>
|
||||
<small>读取导图 → 识别内容段 → 分配执行 Agent</small>
|
||||
</div>}
|
||||
{camp.status === 'composing' && <div className="ai-operation-progress task-detail-progress" role="status" aria-live="polite">
|
||||
<div><strong>DSH 正在组合最终成稿…</strong><span>正在执行五项跨段一致性总检并生成 docx,完成后自动刷新</span></div>
|
||||
<div className="ai-progress-track"><div className="ai-progress-indeterminate" /></div>
|
||||
<small>拼接正文 → 五项总检 → 生成六色文稿</small>
|
||||
</div>}
|
||||
{camp.status === 'parse_failed' && <div className="err parsing-banner">❌ 拆解失败:{(camp.notes || []).join(';') || '未返回具体原因'}。可以直接沿用当前导图重新拆解,或返回任务列表修改导图后再试。 <button className="mini" onClick={retryParse} disabled={busy}><RefreshCw size={13} /> 重新拆解</button></div>}
|
||||
{camp.status === 'running' && <div className="task-progress">
|
||||
<div className="task-progress-head"><span>生成进度:{doneCount}/{segs.length} 段</span><span className="pct">{segs.length ? Math.round(doneCount * 100 / segs.length) : 0}%</span></div>
|
||||
<div className="task-progress-track"><div className="task-progress-fill" style={{ width: `${segs.length ? doneCount * 100 / segs.length : 4}%` }} /></div>
|
||||
</div>}
|
||||
{readiness && !readiness.ready && camp.status === 'parsed' && ([...(readiness.blockers || []), ...(readiness.warnings || [])].filter((item: string) => !(camp.segments?.length && item.includes('还没有内容段'))).length > 0) && <div className="warn">⚠ 启动前检查:{[...(readiness.blockers || []), ...(readiness.warnings || [])].filter((item: string) => !(camp.segments?.length && item.includes('还没有内容段'))).join(';')}</div>}
|
||||
|
||||
<div className="detail-tabs" role="tablist">
|
||||
{tabs.map(t => <button key={t.key} role="tab" aria-selected={tab === t.key} className={`detail-tab ${tab === t.key ? 'active' : ''}`} onClick={() => setTab(t.key)}>{t.label}{t.badge && <span className="tab-badge">{t.badge}</span>}</button>)}
|
||||
</div>
|
||||
|
||||
{tab === 'segments' && <div className="segments-panel">
|
||||
<div className="status-strip">{Object.entries(statusCounts).map(([st, n]) => <span key={st} className="tag">{STATUS_LABEL[st] || st} {n}</span>)}</div>
|
||||
{segs.length === 0 && <div className="empty-state">还没有内容段。{camp.status === 'parsed' ? '点「开始生成」逐段生产。' : ''}</div>}
|
||||
{segs.map((seg: Seg) => {
|
||||
const open = openSegments.includes(seg.id)
|
||||
return <div key={seg.id} className={`segment-row ${['human_review', 'rework', 'failed'].includes(seg.status) ? 'attention' : ''}`}>
|
||||
<div className="segment-summary-wrap">
|
||||
<button className="segment-summary" onClick={() => setOpenSegments(cur => cur.includes(seg.id) ? cur.filter(x => x !== seg.id) : [...cur, seg.id])} aria-expanded={open}>
|
||||
{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<span className="seg-title">{seg.title}</span>
|
||||
<span className="seg-tag">{seg.content_tag}</span>
|
||||
<span className="seg-agent">执行 Agent:{seg.agent_name || `#${seg.agent_id || '未分配'}`}</span>
|
||||
{seg.time_range && <span className="seg-time">{seg.time_range}</span>}
|
||||
<Tag s={seg.status} />
|
||||
</button>
|
||||
{seg.status === 'pending' && !open && <button className="mini danger-text row-delete" title="删除此段" onClick={() => deleteSegment(seg)}><Trash2 size={13} /></button>}
|
||||
</div>
|
||||
{open && <div className="segment-content">
|
||||
<SegmentAgentPicker campaignId={camp.id} segment={seg} options={camp.agent_options || []} canEdit={Boolean(camp.can_edit_agents)} disabled={busy} onBusyChange={setBusy} onSaved={selectCamp} />
|
||||
{['pending', 'rework'].includes(seg.status) && <div className="segment-edit-grid">
|
||||
<label>标题<input defaultValue={seg.title} onBlur={e => e.target.value !== seg.title && saveSegment(seg, { title: e.target.value })} /></label>
|
||||
<label>标签<input defaultValue={seg.content_tag} onBlur={e => e.target.value !== seg.content_tag && saveSegment(seg, { content_tag: e.target.value })} /></label>
|
||||
<label>时长<input defaultValue={seg.time_range} onBlur={e => e.target.value !== seg.time_range && saveSegment(seg, { time_range: e.target.value })} /></label>
|
||||
<label>产品<input defaultValue={seg.product_ref} onBlur={e => e.target.value !== seg.product_ref && saveSegment(seg, { product_ref: e.target.value })} /></label>
|
||||
<button className="mini danger-text" onClick={() => deleteSegment(seg)}><Trash2 size={13} /> 删除此段</button>
|
||||
<span className="field-help">失焦自动保存;未手动指定智能体时,标签改动会自动匹配。</span>
|
||||
</div>}
|
||||
{seg.status !== 'skipped' && <div className="segment-category-picker">
|
||||
{(() => { const rec = seg.kb_category_recommendation || {}; const options = seg.kb_category_options || []; return <>
|
||||
<div><b>AI 推荐知识分类:{rec.categories?.length ? rec.categories.map((c: any) => c.path).join('、') : '暂无匹配分类'}</b><span className="field-help">理由:{rec.reason || 'AI 尚未生成推荐;可点击“重新获取推荐”。'}</span></div>
|
||||
{rec.category_ids?.length > 0 && <div className="btns"><button className="mini ok" onClick={() => saveSegment(seg, { kb_category_ids: rec.category_ids })} disabled={busy || !categoryEditable}>一键采用推荐</button></div>}
|
||||
<div className="btns"><button className="mini" onClick={() => recommendCategory(seg)} disabled={busy || !categoryEditable}>✦ 重新获取 AI 推荐</button><span className="field-help">推荐范围只来自本 Agent 已授权的知识库分类。</span>{!categoryEditable && <span className="field-help">任务运行中或已交付,知识范围只读。</span>}</div>
|
||||
<div className="field-help">可多选知识范围;不选择表示使用全部可用知识库。知识库较多时,建议为本段选 1–3 个相关分类,可明显缩短检索等待时间。</div>
|
||||
<div className="chip-row">{options.map((c: any) => <button key={c.id} type="button" className={`chip ${(seg.kb_category_ids || []).includes(c.id) ? 'on' : ''}`} onClick={() => { const cur: number[] = seg.kb_category_ids || []; const next = cur.includes(c.id) ? cur.filter(x => x !== c.id) : [...cur, c.id]; saveSegment(seg, { kb_category_ids: next }) }} disabled={busy || !categoryEditable}>{c.path || `${c.kb_name} / ${c.name}`}</button>)}</div>
|
||||
{!options.length && <span className="field-help">该 Agent 的知识库暂未配置分类。</span>}
|
||||
</> })()}
|
||||
</div>}
|
||||
{seg.product_ref && <span className="badge gold">产品:{seg.product_ref}</span>}
|
||||
{seg.artifact && <div className="seg-section"><h4>正文</h4><pre className="art">{seg.artifact}</pre></div>}
|
||||
{seg.qc_reports?.length > 0 && <div className="seg-section"><h4>质检({seg.qc_reports.length} 门)</h4>
|
||||
{seg.qc_reports.map((rp: any, i: number) => <details key={i}><summary>{QC_GATE_LABEL[rp.gate] || rp.gate || '质检'} · {rp.verdict}</summary><pre className="art">{JSON.stringify(rp.checks, null, 1)}</pre></details>)}
|
||||
</div>}
|
||||
{seg.references?.length > 0 && <div className="seg-section"><h4>引用来源({seg.references.length})</h4>
|
||||
{seg.references.map((r: any, i: number) => <div key={i} className="dim">[{r.file}] {r.text?.slice(0, 120)}</div>)}
|
||||
</div>}
|
||||
{seg.failure && Object.keys(seg.failure).length > 0 && <div className="seg-section"><h4>失败/打回信息</h4><pre className="art">{JSON.stringify(seg.failure, null, 1)}</pre></div>}
|
||||
{seg.status === 'failed' && isKnowledgeSearchTimeout(seg.failure?.detail) && <div className="warn kb-timeout-tip" role="alert">
|
||||
<b>知识库范围过大,检索等待超时</b>
|
||||
<span>{seg.kb_category_options?.length ? '请在上方“知识范围”中选择本段需要的分类(建议 1–3 个),保存后点击“重试”。' : '当前 Agent 没有可选分类,请先在 Agent 设置中减少挂接的知识库,或为知识库补充分组后再重试。'}未选择分类时会检索该 Agent 的全部可用知识库,资料很多时可能需要更久。</span>
|
||||
</div>}
|
||||
{['human_review', 'rework'].includes(seg.status) && <div className="btns sticky-review">
|
||||
<button className="ok" onClick={() => review(seg.id, 'approve')} disabled={busy}>✅ 通过</button>
|
||||
{adviceFor === seg.id ? <span className="advice-inline">
|
||||
<input autoFocus placeholder="打回意见:如开头没承接上段,改自然过渡" value={adviceText} onChange={e => setAdviceText(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && adviceText.trim()) { review(seg.id, 'reject', adviceText, kbAction); setAdviceFor(null); setAdviceText('') } }} />
|
||||
<label style={{ fontSize: 11, display: 'flex', alignItems: 'center', gap: 4, whiteSpace: 'nowrap' }}>这篇用过的知识
|
||||
<select value={kbAction} onChange={e => setKbAction(e.target.value as 'keep' | 'discard')} style={{ fontSize: 11 }}>
|
||||
<option value="keep">继续使用</option>
|
||||
<option value="discard">不再使用</option>
|
||||
</select>
|
||||
</label>
|
||||
<button className="ok" disabled={!adviceText.trim() || busy} onClick={() => { review(seg.id, 'reject', adviceText, kbAction); setAdviceFor(null); setAdviceText('') }}>打回并重做</button>
|
||||
<button className="mini" onClick={() => { setAdviceFor(null); setAdviceText('') }}>取消</button>
|
||||
</span> : <button onClick={() => { setAdviceFor(seg.id); setAdviceText(''); setKbAction('keep') }} disabled={busy}>🔁 打回</button>}
|
||||
<button onClick={() => review(seg.id, 'skip')} disabled={busy}>⏭ 跳过</button>
|
||||
<button className="mini danger-text" onClick={() => deleteSegment(seg)} disabled={busy}><Trash2 size={13} /></button>
|
||||
</div>}
|
||||
{seg.status === 'failed' && <div className="btns">
|
||||
<button onClick={() => act(() => api.post(`/segments/${seg.id}/fix`, { kind: 'retry' }))} disabled={busy}><RefreshCw size={13} /> 重试</button>
|
||||
<button onClick={() => act(() => api.post(`/segments/${seg.id}/fix`, { kind: 'skip' }))} disabled={busy}>跳过</button>
|
||||
</div>}
|
||||
</div>}
|
||||
</div>
|
||||
})}
|
||||
</div>}
|
||||
|
||||
{tab === 'runs' && <div className="runs-panel card">
|
||||
<MemoryRecall runtime={runtime} />
|
||||
{runtime?.events?.length ? runtime.events.map((ev: any, i: number) => (
|
||||
<div key={i} className="run-event"><span className="run-time">{ev.created_at || ''}</span><strong>{ev.event_type || ev.type}</strong><span className="dim">{ev.status || ''} {ev.title || ''}</span></div>
|
||||
)) : <div className="empty-state">暂无运行轨迹{camp.run?.id ? '' : '(尚未开始生成)'}</div>}
|
||||
{camp.run && <div className="run-meta">运行 #{camp.run.id} · {camp.run.status}</div>}
|
||||
</div>}
|
||||
|
||||
{tab === 'final' && <div className="final-panel card">
|
||||
{!finalPreview && <div className="empty-state">{camp.status === 'done' ? '' : '全部段落通过后可组合成稿。'}<br /><button className="primary-action" onClick={previewFinal} disabled={busy}>加载预览</button></div>}
|
||||
{finalPreview && <pre className="art final-art">{typeof finalPreview === 'string' ? finalPreview : JSON.stringify(finalPreview, null, 1)}</pre>}
|
||||
</div>}
|
||||
|
||||
{tab === 'config' && <div className="config-panel card">
|
||||
<div className="kv-list">
|
||||
<div><span>质检模式</span><p>{{ full: '出稿+同步质检', lazy: '先出稿后台质检', off: '不质检' }[camp.qc_mode || 'full']}</p></div>
|
||||
<div><span>直播时长</span><p>{camp.duration_minutes ? `${camp.duration_minutes} 分钟` : '未指定'}</p></div>
|
||||
<div><span>画布</span><p>{camp.canvas_name || '默认画布'}</p></div>
|
||||
<div><span>导图待确认项</span><p>{camp.pending_confirms?.length ? <>{camp.pending_confirms.join(';')}<small className="field-help">这些是 AI 拆解导图时识别出的不确定词或矛盾点,不是配置快照参数;确认后可继续生成。</small></> : '无(导图拆解没有发现需要确认的内容)'}</p></div>
|
||||
<div><span>导图原文</span><pre className="art">{camp.mindmap_text || '—'}</pre></div>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { Search, Plus, ChevronRight, Trash2, RefreshCw } from 'lucide-react'
|
||||
import { api } from '../api'
|
||||
import type { Camp } from '../types'
|
||||
import { STATUS_LABEL } from '../constants'
|
||||
import { Tag } from '../ui'
|
||||
import { PageHeader, OwnerLabel } from '../workbench-ui'
|
||||
import { useDialog } from '../dialog'
|
||||
import { ContentTabs } from './ContentTabs'
|
||||
import { TaskCreatePanel } from './TaskCreatePanel'
|
||||
|
||||
const PAGE_SIZE = 15
|
||||
const STATUS_FILTERS = ['all', 'running', 'human_review', 'parsed', 'parsing', 'composing_ready', 'done', 'failed'] as const
|
||||
|
||||
export default function TaskListPage() {
|
||||
const nav = useNavigate()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const dialog = useDialog()
|
||||
const [camps, setCamps] = useState<Camp[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [query, setQuery] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||
const [page, setPage] = useState(1)
|
||||
const initialTrainingMapId = Number(searchParams.get('training_map') || 0) || undefined
|
||||
const initialTrainingMapDay = Number(searchParams.get('day') || 0) || undefined
|
||||
const [showCreate, setShowCreate] = useState(!!initialTrainingMapId)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => { setShowCreate(!!initialTrainingMapId) }, [initialTrainingMapId])
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true); setError('')
|
||||
try { setCamps(await api.get<Camp[]>('/campaigns')) }
|
||||
catch (e: any) { setError(String(e.message || e)) }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
const filtered = useMemo(() => camps.filter(c =>
|
||||
(statusFilter === 'all' || c.status === statusFilter) &&
|
||||
(!query.trim() || c.name.includes(query.trim()))
|
||||
), [camps, query, statusFilter])
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
|
||||
const pageItems = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
|
||||
const activeCount = camps.filter(c => ['running', 'parsing', 'human_review', 'rework'].includes(c.status)).length
|
||||
const failedCount = camps.filter(c => c.has_failure || c.status === 'failed').length
|
||||
|
||||
return <div className="task-list-page">
|
||||
<ContentTabs />
|
||||
<PageHeader title="内容任务" description="从导图拆解到逐段审查,集中管理所有生产任务。" actions={<button className="primary-action" onClick={() => setShowCreate(true)}><Plus size={16}/> 新建任务</button>} />
|
||||
<div className="task-status-rail">{[["all","全部任务",camps.length],["running","进行中",activeCount],["human_review","待审查",camps.filter(c=>c.status === "human_review").length],["failed","需处理",failedCount]].map(([key,label,count])=><button key={key} className={statusFilter === key ? "active" : ""} onClick={()=>{setStatusFilter(String(key));setPage(1)}}>{label}<b>{loading ? "—" : count}</b></button>)}</div>
|
||||
<div className="page-toolbar">
|
||||
<div className="toolbar-search">
|
||||
<Search size={14} />
|
||||
<input placeholder="搜索任务名…" value={query} onChange={e => { setQuery(e.target.value); setPage(1) }} />
|
||||
</div>
|
||||
<select value={statusFilter} onChange={e => { setStatusFilter(e.target.value); setPage(1) }} aria-label="状态筛选">
|
||||
{STATUS_FILTERS.map(st => <option key={st} value={st}>{st === 'all' ? '全部状态' : STATUS_LABEL[st] || st}</option>)}
|
||||
</select>
|
||||
<button className="mini" onClick={load} disabled={loading} title="刷新任务列表"><RefreshCw size={14} className={loading ? 'spin' : ''} /></button>
|
||||
<span className="result-count">{loading ? "正在加载…" : `${filtered.length} 个匹配任务`}</span>
|
||||
</div>
|
||||
|
||||
{showCreate && <TaskCreatePanel initialTrainingMapId={initialTrainingMapId} initialTrainingMapDay={initialTrainingMapDay} onCreated={id => nav(`./${id}`)} onClose={() => { setShowCreate(false); if (searchParams.has('training_map')) setSearchParams({}, { replace: true }) }} />}
|
||||
|
||||
{error && <div className="err">{error}</div>}
|
||||
{loading && <div className="task-table card loading-table" aria-label="正在加载任务"><div className="skeleton-row" /><div className="skeleton-row" /><div className="skeleton-row" /><div className="skeleton-row" /></div>}
|
||||
{!loading && filtered.length === 0 && <div className="empty-state">没有匹配的任务。{camps.length > 0 ? '换个筛选条件试试。' : '点右上角「新建任务」创建第一场内容。'}</div>}
|
||||
|
||||
{!loading && pageItems.length > 0 && <div className="task-table card">
|
||||
<div className="task-table-head"><span>任务名称</span><span>状态</span><span>创建时间</span><span /></div>
|
||||
{pageItems.map(c => (
|
||||
<div key={c.id} className="task-table-row as-link" role="button" tabIndex={0}
|
||||
onClick={() => nav(`/admin/tasks/${c.id}`)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') nav(`/admin/tasks/${c.id}`) }} style={{ cursor: 'pointer' }}>
|
||||
<strong>{c.name}{c.has_failure && <span className="failure-dot" title="存在失败段" />}<OwnerLabel resource={c} /></strong>
|
||||
<Tag s={c.status} />
|
||||
<span className="muted">{c.created_at}</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'flex-end' }}>
|
||||
<button className="mini danger-text" title="删除此任务"
|
||||
onClick={async e => {
|
||||
e.stopPropagation()
|
||||
if (!await dialog.confirm({ title: `删除任务「${c.name}」?`, description: '任务的段落、成稿、运行记录与消费记录将一并删除,无法恢复。', confirmLabel: '确认删除', tone: 'danger' })) return
|
||||
setBusy(true)
|
||||
try { await api.delete(`/campaigns/${c.id}`); await load() }
|
||||
catch (err: any) { setError(String(err.message || err)) }
|
||||
finally { setBusy(false) }
|
||||
}} disabled={busy}><Trash2 size={13} /></button>
|
||||
<ChevronRight size={14} className="row-chevron" />
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>}
|
||||
|
||||
{totalPages > 1 && <div className="pager">
|
||||
<button className="mini" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</button>
|
||||
<span className="muted">第 {page} / {totalPages} 页 · 共 {filtered.length} 条</span>
|
||||
<button className="mini" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</button>
|
||||
</div>}
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,652 @@
|
||||
import SegmentAgentPicker from '../SegmentAgentPicker'
|
||||
import MemoryRecall from '../MemoryRecall'
|
||||
/* 页面组件:从 App.tsx 拆出(墨韵金重构) */
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api, localizeError } from '../api'
|
||||
import { useDialog } from '../dialog'
|
||||
import { Tag } from '../ui'
|
||||
import { STATUS_LABEL, KB_STATUS_LABEL, CLASSIFICATION_STATUS_LABEL, RUNTIME_EVENT_LABEL,
|
||||
RUNTIME_PHASE_LABEL, RUNTIME_TOOL_LABEL, PAGE_META, RULE_TEMPLATES } from '../constants'
|
||||
import type { Seg, Camp, CampaignState, RuntimeDetail, CampaignCreateResponse, ComposeResponse,
|
||||
AgentCreateResponse, AgentSummary, KnowledgeBaseSummary, NotificationSummary, AuthUser, AuthStatus,
|
||||
TraceSummary, TraceDetail, UsageSummary, UsageEventRow, PointState, PointAccountRow, UsageResponse,
|
||||
AdminUsageResponse, AgentListRow, MemoryListRow, KbFileRow, KbCategoryRow, KbDetailResponse, KbUsageResponse } from '../types'
|
||||
|
||||
|
||||
function errorText(error: unknown) {
|
||||
return localizeError(error)
|
||||
}
|
||||
|
||||
function isKnowledgeSearchTimeout(value: unknown) {
|
||||
const text = typeof value === 'string' ? value : ''
|
||||
return /知识库检索.*超时|检索超时|gateway timeout|timed? ?out|timeout/i.test(text)
|
||||
}
|
||||
|
||||
const QC_GATE_LABEL: Record<string, string> = {
|
||||
structure: '结构质检', content: '内容质检', style: '风格质检', cross: '跨稿质检',
|
||||
all: '质检',
|
||||
}
|
||||
|
||||
/* ---------------- 任务看板 ---------------- */
|
||||
export default function TasksPage() {
|
||||
const dialog = useDialog()
|
||||
const [camps, setCamps] = useState<Camp[]>([])
|
||||
const [camp, setCamp] = useState<any>(null)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [openSegments, setOpenSegments] = useState<number[]>([])
|
||||
const [finalPreview, setFinalPreview] = useState<any>(null)
|
||||
const [name, setName] = useState('')
|
||||
const [objective, setObjective] = useState('')
|
||||
const [successCriteria, setSuccessCriteria] = useState('')
|
||||
const [mindmap, setMindmap] = useState('')
|
||||
const [canvasId, setCanvasId] = useState<number | ''>('')
|
||||
const [prevId, setPrevId] = useState<number>(0)
|
||||
const [durationMinutes, setDurationMinutes] = useState<number | ''>('')
|
||||
const [qcMode, setQcMode] = useState<'full' | 'lazy' | 'off'>('full')
|
||||
const [canvases, setCanvases] = useState<any[]>([])
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [aiStage, setAiStage] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
const [runtime, setRuntime] = useState<RuntimeDetail | null>(null)
|
||||
// Hermes 融合:任务启动前准备度检查 + 成本预估结果。
|
||||
const [readiness, setReadiness] = useState<{ ready: boolean; blockers: string[]; warnings: string[]; cost_estimate?: any } | null>(null)
|
||||
const [planDetail, setPlanDetail] = useState<any>(null)
|
||||
const [acknowledgePlanRisks, setAcknowledgePlanRisks] = useState(false)
|
||||
const timer = useRef<any>(null)
|
||||
const detailVersion = useRef(0)
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const items = await api.get<Camp[]>('/campaigns')
|
||||
setCamps(items)
|
||||
return items
|
||||
} catch { return [] as Camp[] }
|
||||
}
|
||||
const syncListItem = (detail: any) => {
|
||||
if (!detail?.id) return
|
||||
setCamps(current => current.map(item => item.id === detail.id
|
||||
? { ...item, status: detail.status || item.status, run_id: detail.run?.id ?? item.run_id,
|
||||
run_status: detail.run?.status ?? null, has_failure: detail.has_failure ?? item.has_failure }
|
||||
: item))
|
||||
}
|
||||
const selectCamp = async (id: number) => {
|
||||
const version = ++detailVersion.current
|
||||
const detail = await api.get<any>(`/campaigns/${id}`)
|
||||
if (version !== detailVersion.current) return
|
||||
setCamp(detail); syncListItem(detail); setFinalPreview(null)
|
||||
setOpenSegments(detail.segments?.filter((seg: Seg) => !['approved', 'skipped'].includes(seg.status)).map((seg: Seg) => seg.id) || [])
|
||||
const needsPlanDetail = ['plan_review', 'plan_failed'].includes(detail.status) && detail.latest_plan?.id
|
||||
const [nextReadiness, nextRuntime, nextPlan] = await Promise.all([
|
||||
['parsed', 'running', 'human_review', 'composing_ready', 'done'].includes(detail.status)
|
||||
? api.get<any>(`/campaigns/${id}/readiness`).catch(() => null) : Promise.resolve(null),
|
||||
detail.run?.id ? api.get<RuntimeDetail>(`/runs/${detail.run.id}`).catch(() => null) : Promise.resolve(null),
|
||||
needsPlanDetail ? api.get<any>(`/campaigns/${id}/plans/${detail.latest_plan.id}`).catch(() => null) : Promise.resolve(null),
|
||||
])
|
||||
if (version !== detailVersion.current) return
|
||||
setReadiness(nextReadiness); setRuntime(nextRuntime)
|
||||
setPlanDetail(nextPlan); setAcknowledgePlanRisks(false)
|
||||
}
|
||||
useEffect(() => {
|
||||
refresh().then(items => {
|
||||
const preferred = items.find(item => ['planning', 'plan_review', 'plan_failed', 'running', 'human_review', 'parsed', 'composing_ready'].includes(item.status) || ['queued', 'running'].includes((item as any).run_status || '')) || items[0]
|
||||
if (preferred) selectCamp(preferred.id)
|
||||
})
|
||||
api.get('/canvases').then(setCanvases).catch(() => {})
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
if (camp && (['planning', 'plan_review', 'plan_failed', 'running', 'human_review', 'parsed', 'parsing', 'composing_ready', 'composing'].includes(camp.status) || ['queued', 'running'].includes(camp.run?.status || '')) && !timer.current) {
|
||||
timer.current = setInterval(async () => {
|
||||
const version = detailVersion.current
|
||||
const c = await api.get<CampaignState>(`/campaigns/${camp.id}`).catch(() => null)
|
||||
if (c && version === detailVersion.current) {
|
||||
setCamp(c)
|
||||
syncListItem(c)
|
||||
const nextComposeIssues = (c.compose_report?.consistency || c.compose_report)?.issues || []
|
||||
setCompIssues(nextComposeIssues)
|
||||
if (['parsing', 'plan_review'].includes(camp.status || '') && ['parsed'].includes(c.status)) {
|
||||
setReadiness(await api.get<{ ready: boolean; blockers: string[]; warnings: string[]; cost_estimate?: any }>(`/campaigns/${c.id}/readiness`).catch(() => null))
|
||||
}
|
||||
const latestPlanId = c.latest_plan?.id
|
||||
const shouldLoadPlan = ['plan_review', 'plan_failed'].includes(c.status) && latestPlanId && (!planDetail || planDetail.id !== latestPlanId)
|
||||
if (shouldLoadPlan) setPlanDetail(await api.get<any>(`/campaigns/${c.id}/plans/${latestPlanId}`).catch(() => null))
|
||||
if (!['plan_review', 'plan_failed', 'planning'].includes(c.status)) setPlanDetail(current => current?.status === 'approved' ? current : null)
|
||||
if (c.run?.id) setRuntime(await api.get<RuntimeDetail>(`/runs/${c.run.id}`).catch(() => null))
|
||||
if (['done', 'failed', 'plan_failed'].includes(c.status) && !['queued', 'running'].includes(c.run?.status || '')) { clearInterval(timer.current); timer.current = null }
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
return () => { if (timer.current) { clearInterval(timer.current); timer.current = null } }
|
||||
}, [camp?.id, camp?.status])
|
||||
|
||||
const create = async () => {
|
||||
setErr(''); setBusy(true); setAiStage('正在创建内容任务…')
|
||||
try {
|
||||
const c = await api.post<CampaignCreateResponse>('/campaigns', { name, objective, success_criteria: successCriteria, mindmap_text: mindmap, canvas_id: canvasId || undefined, previous_campaign_id: prevId || undefined, duration_minutes: durationMinutes ? Number(durationMinutes) : 0, qc_mode: qcMode })
|
||||
await selectCamp(c.id)
|
||||
setAiStage('DSH Planner 正在生成分段与 DAG…')
|
||||
await requestPlan(c.id)
|
||||
refresh()
|
||||
setShowCreate(false)
|
||||
} catch (e: any) { setErr(String(e.message || e)) }
|
||||
setBusy(false); setAiStage('')
|
||||
}
|
||||
const requestPlan = async (cid: number, advice = '') => {
|
||||
setAiStage(advice ? 'DSH Planner 正在按意见重新生成分段与 DAG…' : 'DSH Planner 正在生成分段与 DAG…')
|
||||
setPlanDetail(null); setAcknowledgePlanRisks(false); setErr('')
|
||||
try { await api.post(`/campaigns/${cid}/plans`, advice ? { advice } : {}); await selectCamp(cid) }
|
||||
catch (e: any) { setErr('规划失败: ' + String(e.message || e)) }
|
||||
finally { setBusy(false); setAiStage('') }
|
||||
}
|
||||
const runStep = async () => {
|
||||
setBusy(true); setAiStage('AI 正在生成下一段内容…'); setErr('')
|
||||
try {
|
||||
const r = await api.post<{ status: string; message?: string; segment?: { id: number; title: string } }>(`/campaigns/${camp!.id}/run-step`, {})
|
||||
if (r.segment) { setOpenSegments(prev => [...new Set([...(prev || []), r.segment!.id])]) }
|
||||
if (r.message && r.status === 'no_pending') await dialog.notice({ title: '全部段落已生成', description: r.message })
|
||||
await selectCamp(camp!.id)
|
||||
} catch (e: any) { setErr(String(e.message || e)) }
|
||||
setBusy(false); setAiStage('')
|
||||
}
|
||||
const saveSegment = async (seg: Seg, patch: Record<string, unknown>) => {
|
||||
setBusy(true); setErr('')
|
||||
try { await api.put(`/campaigns/${camp!.id}/segments/${seg.id}`, patch); await selectCamp(camp!.id) }
|
||||
catch (e: any) { setErr(String(e.message || e)) } finally { setBusy(false) }
|
||||
}
|
||||
const deleteSegment = async (seg: Seg) => {
|
||||
if (!await dialog.confirm({ title: `删除段落「${seg.title}」?`, description: '仅生成前的段落可以删除。', confirmLabel: '删除', tone: 'danger' })) return
|
||||
setBusy(true); setErr('')
|
||||
try { await api.delete(`/campaigns/${camp!.id}/segments/${seg.id}`); await selectCamp(camp!.id) }
|
||||
catch (e: any) { setErr(String(e.message || e)) } finally { setBusy(false) }
|
||||
}
|
||||
const addSegment = async () => {
|
||||
const title = await dialog.prompt({ title: '新增段落', description: '新段落会追加到任务清单末尾,生成前可继续编辑。', inputLabel: '段落标题', placeholder: '例如:中场互动:粉丝提问答疑', confirmLabel: '下一步' })
|
||||
if (title === null || !title.trim()) return
|
||||
const tag = await dialog.prompt({ title: '内容标签', description: '决定由哪个 Agent 执行(心法/技法/功法/情欲内容/产品销售/开场/收尾…)', inputLabel: '标签', placeholder: '心法', confirmLabel: '下一步' })
|
||||
const time_range = await dialog.prompt({ title: '时间范围(可选)', description: '该段在直播中的时间,如 30:00-45:00', inputLabel: '时间', placeholder: '如 30:00-45:00,可留空', confirmLabel: '添加段落' })
|
||||
setBusy(true); setErr('')
|
||||
try {
|
||||
await api.post(`/campaigns/${camp!.id}/segments`, { title, content_tag: (tag || '').trim() || '心法', time_range: (time_range || '').trim() })
|
||||
await selectCamp(camp!.id)
|
||||
} catch (e: any) { setErr(String(e.message || e)) } finally { setBusy(false) }
|
||||
}
|
||||
const reparseWithAdvice = async () => {
|
||||
const advice = await dialog.prompt({
|
||||
title: '对 DSH 计划提修改意见',
|
||||
description: 'Planner 会按你的意见生成新的计划版本;旧版本保留审计,Worker 不会执行未批准版本。',
|
||||
inputLabel: '修改意见',
|
||||
placeholder: '例如:开场加一个粉丝提问互动环节;技法段拆得更细,每个手法单独一段;总时长控制在 90 分钟。',
|
||||
confirmLabel: '重新生成计划' })
|
||||
if (advice === null || !advice.trim()) return
|
||||
setBusy(true)
|
||||
await requestPlan(camp!.id, advice)
|
||||
}
|
||||
const approvePlan = async () => {
|
||||
if (!planDetail || !camp) return
|
||||
const hasHighRisk = (planDetail.risks || []).some((risk: any) => risk.severity === 'high')
|
||||
if (hasHighRisk && !acknowledgePlanRisks) {
|
||||
await dialog.notice({ title: '需要确认高风险项', description: '请逐项核对高风险内容,并勾选“我已核对并确认”后再批准。', tone: 'danger' })
|
||||
return
|
||||
}
|
||||
setBusy(true); setAiStage('正在批准计划并生成任务单…'); setErr('')
|
||||
try {
|
||||
await api.post(`/campaigns/${camp.id}/plans/${planDetail.id}/approve`, { acknowledge_risks: acknowledgePlanRisks })
|
||||
setPlanDetail(null); setAcknowledgePlanRisks(false)
|
||||
await selectCamp(camp.id)
|
||||
} catch (e: any) { setErr(String(e.message || e)) }
|
||||
finally { setBusy(false); setAiStage('') }
|
||||
}
|
||||
const rejectPlan = async () => {
|
||||
if (!planDetail || !camp) return
|
||||
const note = await dialog.prompt({
|
||||
title: '驳回 DSH 计划',
|
||||
description: '填写驳回原因,Planner 可根据意见重新生成新版本。',
|
||||
inputLabel: '驳回原因',
|
||||
placeholder: '例如:产品口径缺少素材证据,请重新核对知识库后规划。',
|
||||
confirmLabel: '确认驳回' })
|
||||
if (note === null) return
|
||||
setBusy(true); setErr('')
|
||||
try {
|
||||
await api.post(`/campaigns/${camp.id}/plans/${planDetail.id}/reject`, { review_note: note || '' })
|
||||
setPlanDetail(null)
|
||||
await selectCamp(camp.id)
|
||||
} catch (e: any) { setErr(String(e.message || e)) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
const run = async () => {
|
||||
setBusy(true); setAiStage('AI 正在启动内容生成…'); setErr('')
|
||||
try { await api.post(`/campaigns/${camp.id}/run`); await selectCamp(camp.id) }
|
||||
catch (e: any) { setErr(String(e.message || e)) }
|
||||
setBusy(false); setAiStage('')
|
||||
}
|
||||
const cancelRun = async () => {
|
||||
if (!camp?.run?.id) return
|
||||
setBusy(true)
|
||||
try { await api.post(`/runs/${camp.run.id}/cancel`); await selectCamp(camp.id) }
|
||||
catch (e: any) { setErr(String(e.message || e)) }
|
||||
setBusy(false)
|
||||
}
|
||||
const pauseRun = async () => {
|
||||
if (!camp?.run?.id) return
|
||||
setBusy(true); setErr('')
|
||||
try {
|
||||
await api.post(`/runs/${camp.run.id}/pause`)
|
||||
await dialog.notice({ title: '暂停已请求', description: '正在生成的这段完成后任务就会停下,已完成的内容全部保留。' })
|
||||
await selectCamp(camp.id)
|
||||
}
|
||||
catch (e: any) { setErr(String(e.message || e)) }
|
||||
setBusy(false)
|
||||
}
|
||||
const deleteCamp = async () => {
|
||||
if (!camp) return
|
||||
if (!await dialog.confirm({ title: `删除任务「${camp.name}」?`, description: '将删除该任务的全部内容段、运行记录和生成正文,无法恢复。运行中的任务需先停止。', confirmLabel: '确认删除', tone: 'danger' })) return
|
||||
setBusy(true); setErr('')
|
||||
try { await api.delete(`/campaigns/${camp.id}`); setCamp(null); refresh() }
|
||||
catch (e: any) { setErr(String(e.message || e)) }
|
||||
setBusy(false)
|
||||
}
|
||||
const resumeRun = async () => {
|
||||
if (!camp?.run?.id) return
|
||||
setBusy(true)
|
||||
try { await api.post(`/runs/${camp.run.id}/resume`); await selectCamp(camp.id) }
|
||||
catch (e: any) { setErr(String(e.message || e)) }
|
||||
setBusy(false)
|
||||
}
|
||||
const decideTool = async (sessionId: number, approved: boolean) => {
|
||||
setBusy(true); setErr('')
|
||||
try {
|
||||
await api.post(`/agent-sessions/${sessionId}/tool-approval`, { approved })
|
||||
if (camp?.run?.id) await api.post(`/runs/${camp.run.id}/resume`)
|
||||
await selectCamp(camp.id)
|
||||
} catch (e: any) { setErr(String(e.message || e)) } finally { setBusy(false) }
|
||||
}
|
||||
const review = async (sid: number, action: string, opinion = '') => {
|
||||
await api.post(`/segments/${sid}/review`, { action, opinion })
|
||||
await selectCamp(camp.id)
|
||||
}
|
||||
const [compIssues, setCompIssues] = useState<any[]>([])
|
||||
const compose = async () => {
|
||||
setBusy(true); setAiStage('AI 正在检查并组合最终成稿…')
|
||||
try {
|
||||
const r = await api.post<ComposeResponse & { queued?: boolean; message?: string }>(`/campaigns/${camp.id}/compose`)
|
||||
if (r.queued) {
|
||||
await selectCamp(camp.id)
|
||||
return
|
||||
}
|
||||
setCompIssues(r.consistency?.issues || [])
|
||||
if (r.status === 'has_issues') {
|
||||
const issues = r.consistency?.issues || []
|
||||
await dialog.notice({ title: `组合完成,但发现 ${issues.length} 处跨段问题`,
|
||||
description: `${issues.slice(0, 6).map((i: any) => `段${i.seg_id}:${i.desc}`).join(';')}。可点击“一键打回问题段”后重新生成。`, tone: 'danger' })
|
||||
}
|
||||
await selectCamp(camp.id)
|
||||
} catch (e: any) { setErr(String(e.message || e)) }
|
||||
setBusy(false); setAiStage('')
|
||||
}
|
||||
|
||||
const rejectIssues = async () => {
|
||||
for (const iss of compIssues) {
|
||||
if (iss.seg_id) await api.post(`/segments/${iss.seg_id}/review`, { action: 'reject', opinion: `组合总检:${iss.desc}(建议:${iss.suggestion || ''})` })
|
||||
}
|
||||
await dialog.notice({ title: '已打回问题段', description: `已打回 ${compIssues.filter(i => i.seg_id).length} 段,可在下方逐段重新审查。`, tone: 'success' })
|
||||
await selectCamp(camp.id)
|
||||
}
|
||||
|
||||
const toggleSegment = (id: number) => setOpenSegments(current => current.includes(id)
|
||||
? current.filter(item => item !== id) : [...current, id])
|
||||
const previewFinal = async () => {
|
||||
if (!camp?.final_artifact?.id) return
|
||||
setBusy(true); setErr('')
|
||||
try { setFinalPreview(await api.get(`/artifacts/${camp.final_artifact.id}/preview`)) }
|
||||
catch (e: any) { setErr(String(e.message || e)) } finally { setBusy(false) }
|
||||
}
|
||||
const downloadFinal = async () => {
|
||||
if (!camp?.final_artifact?.id) return
|
||||
setBusy(true); setErr('')
|
||||
try { await api.download(`/artifacts/${camp.final_artifact.id}/download`) }
|
||||
catch (e: any) { setErr(String(e.message || e)) } finally { setBusy(false) }
|
||||
}
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (!Number.isFinite(bytes)) return '-'
|
||||
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
return `${Math.max(1, Math.round(bytes / 1024))} KB`
|
||||
}
|
||||
const statusCounts = camp?.segments?.reduce((acc: Record<string, number>, seg: Seg) => {
|
||||
acc[seg.status] = (acc[seg.status] || 0) + 1
|
||||
return acc
|
||||
}, {}) || {}
|
||||
// run-step 完成一段后任务状态为 human_review;此时仍要保留“生成下一段”入口,
|
||||
// 但必须先确认(通过/跳过)前置段,避免跳过人工审查造成上下文断裂。
|
||||
const nextSegments = (camp?.segments || []).filter((seg: Seg) => ['pending', 'rework', 'failed'].includes(seg.status))
|
||||
const nextSegment = nextSegments[0]
|
||||
const activeRun = ['queued', 'running', 'paused'].includes(camp?.run?.status || '')
|
||||
const nextSegmentReady = Boolean(nextSegment && (camp?.segments || []).filter((seg: Seg) => (seg.id || 0) < (nextSegment.id || 0)).every((seg: Seg) => ['approved', 'skipped'].includes(seg.status)))
|
||||
const canGenerateNext = Boolean(camp && ['parsed', 'human_review'].includes(camp.status) && !activeRun && nextSegments.length > 0)
|
||||
|
||||
return (
|
||||
<div className="task-workspace">
|
||||
<section className="task-sidebar-panel">
|
||||
<div className="section-title-row"><div><h2>内容任务</h2><p>选择任务后,在右侧查看生成和审查内容。</p></div><button className="mini ok" onClick={() => setShowCreate(value => !value)}>{showCreate ? '收起' : '+ 新建任务'}</button></div>
|
||||
{showCreate && <div className="card task-create-card">
|
||||
<div className="form-kicker">新建内容任务:目标必填,验收标准可让 AI 预填后修改,导图可粘贴文本或导入文件。</div>
|
||||
<input placeholder="任务名(如 2026-08-26-day5)" value={name} onChange={e => setName(e.target.value)} />
|
||||
<input placeholder="本次任务唯一目标(例如:完成今晚直播的心法段)" value={objective} onChange={e => setObjective(e.target.value)} />
|
||||
<textarea rows={3} placeholder="验收标准:留空则系统自动生成;也可点「AI 建议」生成后自行修改" value={successCriteria} onChange={e => setSuccessCriteria(e.target.value)} />
|
||||
<div className="btns">
|
||||
<button className="mini ai-entry" disabled={busy || !objective.trim()} onClick={async () => {
|
||||
setBusy(true); setAiStage('AI 正在生成验收标准建议…'); setErr('')
|
||||
try {
|
||||
const r = await api.post<{ criteria: string; source: string }>('/campaigns/suggest-criteria', { objective })
|
||||
setSuccessCriteria(r.criteria)
|
||||
if (r.source === 'default') await dialog.notice({ title: '已生成默认验收标准', description: '当前 AI 不可用,已填入系统默认标准,可直接修改。' })
|
||||
} catch (e: any) { setErr(String(e.message || e)) }
|
||||
setBusy(false); setAiStage('')
|
||||
}}>✦ AI 建议验收标准</button>
|
||||
</div>
|
||||
<select value={canvasId} onChange={e => setCanvasId(e.target.value ? Number(e.target.value) : '')}>
|
||||
<option value="">使用默认画布(完整场)</option>
|
||||
{canvases.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
<label className="duration-field">直播总时长(拆解会按它分配各段时间)
|
||||
<select value={durationMinutes} onChange={e => setDurationMinutes(e.target.value ? Number(e.target.value) : '')}>
|
||||
<option value="">请选择直播时长</option>
|
||||
<option value={30}>30 分钟</option>
|
||||
<option value={60}>1 小时</option>
|
||||
<option value={90}>1.5 小时</option>
|
||||
<option value={120}>2 小时</option>
|
||||
<option value={180}>3 小时</option>
|
||||
<option value={240}>4 小时</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="duration-field">质检方式
|
||||
<select value={qcMode} onChange={e => setQcMode(e.target.value as 'full' | 'lazy' | 'off')}>
|
||||
<option value="lazy">先出稿,后台质检(推荐,出稿快)</option>
|
||||
<option value="full">出稿+同步质检(严谨,每段慢 2-4 分钟)</option>
|
||||
<option value="off">不质检(最快,人工自己把关)</option>
|
||||
</select>
|
||||
</label>
|
||||
<select value={prevId} onChange={e => setPrevId(e.target.value ? Number(e.target.value) : 0)}>
|
||||
<option value="">不关联上一天(单场)</option>
|
||||
{camps.map(c => <option key={c.id} value={c.id}>{c.name}({STATUS_LABEL[c.status] || c.status})</option>)}
|
||||
</select>
|
||||
<textarea rows={8} placeholder={'粘贴导图/框架/思维文本…\n(也可上传导图图片自动 OCR,或导入文档)'} value={mindmap} onChange={e => setMindmap(e.target.value)} />
|
||||
<div className="btns">
|
||||
<button onClick={create} disabled={busy || !mindmap.trim() || !objective.trim()}>创建并拆解</button>
|
||||
<label className="file-btn">📄 导入文档
|
||||
<input type="file" accept=".txt,.md,.markdown" onChange={async e => {
|
||||
const f = e.target.files?.[0]; if (!f) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const text = await f.text()
|
||||
setMindmap(current => current.trim() ? current + '\n\n' + text : text)
|
||||
} finally { setBusy(false) }
|
||||
if (e.target) e.target.value = ''
|
||||
}} />
|
||||
</label>
|
||||
<label className="file-btn">📷 上传导图图片 OCR
|
||||
<input type="file" accept="image/*" onChange={async e => {
|
||||
const f = e.target.files?.[0]; if (!f) return
|
||||
const fd = new FormData(); fd.append('file', f)
|
||||
setBusy(true)
|
||||
try {
|
||||
const r = await fetch('/api/ocr', { method: 'POST', body: fd }).then(x => x.json())
|
||||
if (r.ok) setMindmap(r.text)
|
||||
else await dialog.notice({ title: 'OCR 识别失败', description: localizeError(r.error), tone: 'danger' })
|
||||
} catch (ex: unknown) { await dialog.notice({ title: 'OCR 请求失败', description: localizeError(ex), tone: 'danger' }) }
|
||||
setBusy(false)
|
||||
}} />
|
||||
</label>
|
||||
</div>
|
||||
</div>}
|
||||
<div className="task-list-toolbar"><span>{camps.length} 个任务</span><button className="mini" onClick={refresh}>刷新</button></div>
|
||||
{camps.map(c => (
|
||||
<button key={c.id} className={`task-list-item ${camp?.id === c.id ? 'selected' : ''}`} onClick={() => selectCamp(c.id)}>
|
||||
<span className="task-list-name"><b>{c.name}</b><span>{c.created_at}</span></span>
|
||||
<span className="task-list-meta"><Tag s={['queued', 'running'].includes(c.run_status || '') ? c.run_status || c.status : c.status} />{c.has_failure && <span className="failure-dot" title="存在失败段" />}</span>
|
||||
</button>
|
||||
))}
|
||||
{err && <div className="err">{err}</div>}
|
||||
{aiStage && <div className="ai-operation-progress task-create-progress" role="status" aria-live="polite">
|
||||
<div><strong>{aiStage}</strong><span>AI 正在处理,请不要关闭页面</span></div>
|
||||
<div className="ai-progress-track"><div className="ai-progress-indeterminate" /></div>
|
||||
<small>准备上下文 → Agent 处理 → 保存结果</small>
|
||||
</div>}
|
||||
</section>
|
||||
<section className="task-detail-panel">
|
||||
{!camp && <div className="empty-workspace"><strong>选择一个内容任务</strong><span>这里会显示 Agent 的生成状态、可审查正文和最终交付文稿。</span></div>}
|
||||
{camp && (
|
||||
<div>
|
||||
<div className="task-summary">
|
||||
<div><p className="eyebrow">当前任务</p><h2>{camp.name}</h2><p>查看每个 Agent 的实时状态,完成后在此审查与领取成稿。</p></div>
|
||||
<div className="task-summary-status"><Tag s={camp.status} />{camp.run?.status && <Tag s={camp.run.status} />}<button className="mini" onClick={() => selectCamp(camp.id)}>刷新状态</button>
|
||||
{['queued', 'running'].includes(camp.run?.status || '') && <button className="mini" onClick={pauseRun} disabled={busy}>⏸ 暂停</button>}
|
||||
{camp.run && ['queued', 'running', 'paused'].includes(camp.run.status) && <button className="mini danger-text" onClick={cancelRun} disabled={busy}>■ 停止</button>}
|
||||
{!['queued', 'running'].includes(camp.run?.status || '') && <button className="mini danger-text" onClick={deleteCamp} disabled={busy}>删除任务</button>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card task-contract">
|
||||
<div><b>本次任务目标</b><span>{camp.objective || '按导图完成本次内容任务(旧任务兼容默认值)'}</span></div>
|
||||
<div><b>验收标准</b><span>{camp.success_criteria || '内容可审查、引用可溯源、跨段衔接一致(旧任务兼容默认值)'}</span></div>
|
||||
</div>
|
||||
{camp.run?.error && <div className="err">运行失败:{errorText(camp.run.error)}</div>}
|
||||
{camp.final_artifact && camp.final_artifact.final_status === 'composed' && (
|
||||
<div className="card delivery-card delivery-card-top">
|
||||
<div>
|
||||
<strong>最终交付文件(DOCX)</strong>
|
||||
<span title={camp.final_artifact.file_name}>{camp.final_artifact.file_name}</span>
|
||||
<span>{formatFileSize(camp.final_artifact.size_bytes)} · 生成时间 {camp.final_artifact.created_at || '-'}</span>
|
||||
</div>
|
||||
<div className="delivery-actions">
|
||||
<button onClick={previewFinal} disabled={busy}>在线预览</button>
|
||||
<button className="ok" onClick={downloadFinal} disabled={busy}>下载 DOCX</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{finalPreview && camp.final_artifact && (
|
||||
<div className="card final-preview">
|
||||
<div className="section-title-row"><div><h3>最终文稿:{camp.final_artifact.file_name}</h3><p>可在线核对后下载 DOCX</p></div><button className="mini" onClick={() => setFinalPreview(null)}>收起</button></div>
|
||||
<pre className="art">{finalPreview.text}</pre>
|
||||
</div>
|
||||
)}
|
||||
{['planning', 'plan_review', 'plan_failed'].includes(camp.status) && (
|
||||
<div className="card plan-review-card">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<h3>DSH Planner 计划审批</h3>
|
||||
<p>工作台负责模板、权限、资源和审批;Planner 只提交分段与 DAG 草案,程序校验通过后仍需人工确认。</p>
|
||||
</div>
|
||||
{planDetail && <Tag s={planDetail.status} />}
|
||||
</div>
|
||||
{camp.status === 'planning' && <div className="warn parsing-banner">⏳ DSH Planner 正在读取导图、生成分段和线性 DAG,完成后会自动进入审批。</div>}
|
||||
{!planDetail && camp.status !== 'planning' && <div className="warn">正在加载计划版本…</div>}
|
||||
{planDetail && <>
|
||||
<div className="plan-summary-line">
|
||||
<b>v{planDetail.version}</b><span>{planDetail.summary}</span><span>模板:{planDetail.template_key}</span>
|
||||
</div>
|
||||
{(planDetail.validation?.errors || []).length > 0 && (
|
||||
<div className="err plan-issue-list"><b>程序校验未通过,Worker 不会执行该版本</b>
|
||||
<ul>{planDetail.validation.errors.map((item: any, i: number) => <li key={i}>[{item.node_id || '全局'}] {item.message}</li>)}</ul>
|
||||
</div>
|
||||
)}
|
||||
{(planDetail.risks || []).length > 0 && (
|
||||
<div className="warn plan-issue-list"><b>需要人工核对的风险({planDetail.risks.length})</b>
|
||||
<ul>{planDetail.risks.map((item: any, i: number) => <li key={i}><Tag s={item.severity === 'high' ? 'failed' : 'parsing'} />[{item.node_id || '全局'}] {item.message}</li>)}</ul>
|
||||
</div>
|
||||
)}
|
||||
{(!planDetail.validation?.errors || planDetail.validation.errors.length === 0) && (planDetail.validation?.warnings || []).length > 0 && (
|
||||
<details className="plan-issue-list"><summary>程序警告({planDetail.validation.warnings.length})</summary>
|
||||
<ul>{planDetail.validation.warnings.map((item: any, i: number) => <li key={i}>[{item.node_id || '全局'}] {item.message}</li>)}</ul>
|
||||
</details>
|
||||
)}
|
||||
<div className="plan-dag-head">
|
||||
<b>执行 DAG</b><span>{planDetail.plan?.nodes?.length || 0} 个节点 · {planDetail.plan?.edges?.length || 0} 条依赖 · {planDetail.validation?.waves?.length || 0} 个拓扑波次</span>
|
||||
</div>
|
||||
<div className="plan-node-list">
|
||||
{(planDetail.plan?.nodes || []).map((node: any, index: number) => {
|
||||
const agent = (camp.agent_options || []).find((item: any) => item.id === node.agent_id)
|
||||
const highRisk = node.risk_level === 'high'
|
||||
return <div className={`plan-node ${highRisk ? 'is-risk' : ''}`} key={node.node_id}>
|
||||
<span className="plan-node-order">{index + 1}</span>
|
||||
<div><b>{node.title}</b><small>{node.time_range || '时间待确认'} · {node.content_tag} · {node.duration_minutes ?? '-'} 分钟</small><small>导图:{node.mindmap_node}</small></div>
|
||||
<div className="plan-node-meta"><Tag s={highRisk ? 'failed' : node.agent_id ? 'active' : 'failed'} /><span>{agent ? agent.name : node.agent_id ? `Agent #${node.agent_id}` : '未分配 Agent'}</span><small>{node.agent_match_type}{node.source_canvas_node_id ? ` · 画布 ${node.source_canvas_node_id}` : ''}</small></div>
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
{(planDetail.plan?.pending_confirms || []).length > 0 && (
|
||||
<details><summary>Planner 待确认项({planDetail.plan.pending_confirms.length})</summary>
|
||||
<ul>{planDetail.plan.pending_confirms.map((item: string, i: number) => <li key={i}>{item}</li>)}</ul>
|
||||
</details>
|
||||
)}
|
||||
{(planDetail.risks || []).some((risk: any) => risk.severity === 'high') && (
|
||||
<label className="plan-risk-ack"><input type="checkbox" checked={acknowledgePlanRisks} onChange={e => setAcknowledgePlanRisks(e.target.checked)} /> 我已逐项核对高风险内容、产品口径和知识来源,确认按此计划生成任务单。</label>
|
||||
)}
|
||||
<div className="btns">
|
||||
{planDetail.status === 'waiting_review' && <button className="ok" onClick={approvePlan} disabled={busy}>批准计划并生成任务单</button>}
|
||||
{planDetail.status === 'waiting_review' && <button className="danger-text" onClick={rejectPlan} disabled={busy}>驳回计划</button>}
|
||||
<button onClick={reparseWithAdvice} disabled={busy}>✦ 带意见重新规划</button>
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
)}
|
||||
<div className="task-progress card">
|
||||
<div className="section-title-row"><div><h3>生产进度</h3><p>生成中会自动刷新;“待审查”表示正文已经可以阅读和决定是否通过。</p></div><span className="dim">共 {camp.segments?.length || 0} 段</span></div>
|
||||
{camp.status === 'parsing' && <div className="warn parsing-banner">⏳ 旧流程正在拆解导图(一般 1-2 分钟)…完成后自动显示任务单,无需刷新。</div>}
|
||||
{camp.status === 'composing' && <div className="warn parsing-banner">📄 DSH 正在执行全文一致性总检并生成六色 docx…完成后自动显示最终成稿,无需刷新。</div>}
|
||||
{['running', 'parsing', 'parse_failed', 'planning', 'plan_review', 'plan_failed'].includes(camp.status) && (() => {
|
||||
const segs = camp.segments || []
|
||||
const done = segs.filter((x: Seg) => ['human_review', 'approved', 'rework', 'skipped', 'failed'].includes(x.status)).length
|
||||
const total = segs.length
|
||||
const pct = total ? Math.round(done * 100 / total) : (['parsing', 'planning'].includes(camp.status) ? 15 : 5)
|
||||
return <div className="task-progress">
|
||||
<div className="task-progress-head"><span>{['parsing', 'planning'].includes(camp.status) ? (camp.status === 'planning' ? 'DSH 规划中' : '拆解中') : `生成进度:${done}/${total} 段`}</span><span className="pct">{pct}%</span></div>
|
||||
<div className="task-progress-track"><div className="task-progress-fill" style={{ width: `${pct}%` }} /></div>
|
||||
<div className="task-progress-note">{['parsing', 'planning'].includes(camp.status) ? 'Planner 只生成待审批计划;审批后才会物化为任务单并开放 Worker 执行' : '每段完成会实时推送通知(右下角),通过一段后点「生成下一段」继续'}</div>
|
||||
</div>
|
||||
})()}
|
||||
{camp.status === 'parse_failed' && <div className="err parsing-banner">❌ 拆解失败:{(camp.notes || []).join(';') || '未知原因'}——可修改导图后重新规划。</div>}
|
||||
<div className="status-summary">
|
||||
{['pending', 'running', 'qc', 'human_review', 'rework', 'approved', 'failed', 'skipped'].filter(status => statusCounts[status]).map(status => <div key={status}><Tag s={status} /><strong>{statusCounts[status]}</strong></div>)}
|
||||
</div>
|
||||
{camp.has_failure && <div className="err">存在生成失败段,请在下方点击“重试”或“跳过”。</div>}
|
||||
{readiness && !readiness.ready && camp.segments?.length > 0 && readiness.blockers.some(item => !item.includes('还没有内容段')) && (
|
||||
<div className="warn readiness-block"><b>⚠ 启动前检查未通过</b>
|
||||
<ul>{readiness.blockers.filter(item => !item.includes('还没有内容段')).map((item, i) => <li key={i}>{item}</li>)}</ul>
|
||||
</div>)}
|
||||
{readiness && readiness.ready && readiness.warnings.length > 0 && (
|
||||
<div className="warn"><b>启动提示</b>
|
||||
<ul>{readiness.warnings.map((item, i) => <li key={i}>{item}</li>)}</ul>
|
||||
</div>)}
|
||||
{readiness?.cost_estimate?.ready && (
|
||||
<div className="cost-estimate-line">
|
||||
<span className="ce-label">本次预估</span>
|
||||
<span>≈ <b>{new Intl.NumberFormat('zh-CN').format(readiness.cost_estimate.estimated_tokens)}</b> Token</span>
|
||||
<span>点数 <b>{new Intl.NumberFormat('zh-CN').format(readiness.cost_estimate.estimated_points)}</b></span>
|
||||
{readiness.cost_estimate.estimated_amount_minor > 0 && <span>约 <b>¥{(readiness.cost_estimate.estimated_amount_minor / 100).toFixed(2)}</b></span>}
|
||||
<span>耗时 <b>{readiness.cost_estimate.estimated_duration_text}</b></span>
|
||||
<span className="ce-source">{readiness.cost_estimate.source === 'history' ? '按近期真实用量外推' : '无历史数据,按保守常量估算'}</span>
|
||||
</div>)}
|
||||
{camp.pending_confirms?.length > 0 && (
|
||||
<details><summary>待确认清单({camp.pending_confirms.length})</summary>
|
||||
<ul>{camp.pending_confirms.map((p: string, i: number) => <li key={i}>{p}</li>)}</ul>
|
||||
</details>)}
|
||||
<div className="btns">
|
||||
{canGenerateNext && <button className="ok" onClick={runStep} disabled={busy || !nextSegmentReady} title={!nextSegmentReady ? '请先通过或跳过当前内容段,再生成下一段' : undefined}>{camp.segments?.some((x: Seg) => x.status !== 'pending') ? '▶ 生成下一段' : '▶ 开始生成(第 1 段)'}</button>}
|
||||
{camp.status === 'draft' && <button className="ok" onClick={() => requestPlan(camp.id)} disabled={busy}>▶ 开始 DSH 规划</button>}
|
||||
{camp.status === 'parsed' && camp.segments?.length > 0 && <button onClick={reparseWithAdvice} disabled={busy}>✦ 对计划提意见,重新规划</button>}
|
||||
{camp.status === 'parse_failed' && <button onClick={reparseWithAdvice} disabled={busy}>✦ 带意见重新规划</button>}
|
||||
{camp.run?.status === 'paused' && <button onClick={resumeRun} disabled={busy}>▶ 继续生成(从暂停处)</button>}
|
||||
{camp.run && ['queued', 'running'].includes(camp.run.status) && <button onClick={cancelRun} disabled={busy}>停止本次运行</button>}
|
||||
{camp.run && ['failed', 'cancelled'].includes(camp.run.status) && <button onClick={resumeRun} disabled={busy}>从检查点继续</button>}
|
||||
{camp.status === 'composing_ready' && <button onClick={compose} disabled={busy}>📄 组合成稿(六色 docx)</button>}
|
||||
{compIssues.filter(i => i.seg_id).length > 0 && (
|
||||
<button onClick={rejectIssues}>🔁 一键打回问题段({compIssues.filter(i => i.seg_id).length})</button>)}
|
||||
</div>
|
||||
</div>
|
||||
{runtime && <details className="runtime-trace card" open={['queued', 'running'].includes(camp.run?.status || '')}>
|
||||
<summary><span>运行轨迹</span><small>{runtime.trace_id ? `Trace ${runtime.trace_id.slice(-10)}` : '历史任务'} · {runtime.sessions.length} 个会话 · {runtime.events.length} 条事件</small></summary>
|
||||
<MemoryRecall runtime={runtime} />
|
||||
<div className="runtime-session-list">
|
||||
{runtime.sessions.map(item => <div className="runtime-session" key={item.id}>
|
||||
<div><b>{item.agent_name}</b><span>{RUNTIME_PHASE_LABEL[item.phase] || item.phase} · {item.turn}/{item.max_turns} 轮{item.parent_session_id ? ' · 协作会话' : ''}</span>{item.error && <small>{errorText(item.error)}</small>}</div>
|
||||
<div className="runtime-session-actions"><Tag s={item.status} />
|
||||
{item.status === 'waiting_approval' && <><button className="mini ok" disabled={busy} onClick={() => decideTool(item.id, true)}>批准工具</button><button className="mini danger-text" disabled={busy} onClick={() => decideTool(item.id, false)}>拒绝</button></>}
|
||||
</div>
|
||||
</div>)}
|
||||
</div>
|
||||
<div className="runtime-event-list">
|
||||
{runtime.events.slice(-30).reverse().map(event => <div className="runtime-event" key={event.id}>
|
||||
<span>{event.created_at?.slice(11, 19) || '--:--:--'}</span><b>{RUNTIME_EVENT_LABEL[event.event_type] || event.event_type}</b><em>{event.actor_name || (event.segment_id ? `内容段 ${event.segment_id}` : '系统')}{event.tool_name ? ` · ${RUNTIME_TOOL_LABEL[event.tool_name] || event.tool_name}` : ''}</em><Tag s={event.status || 'UNKNOWN'} />
|
||||
</div>)}
|
||||
</div>
|
||||
</details>}
|
||||
<div className="section-title-row review-heading"><div><h3>{camp.approved_plan_version_id ? '已批准计划任务单与生成审查' : (camp.segments?.some((x: Seg) => x.status === 'pending') ? '任务清单(可编辑)与生成审查' : '内容审查')}</h3><p>{camp.approved_plan_version_id ? `当前 Worker 锁定执行计划 v${camp.latest_plan?.version || ''} 的批准版本;需调整 Agent 或分段时,请重新发起规划并审批新版本。` : (camp.segments?.some((x: Seg) => x.status === 'pending') ? '生成前的段落可以直接改标题/标签/时长;点「生成下一段」逐段生成,每段生成完在这里人工确认。' : '点击任意段展开正文、质检结果和引用来源。待审查段可以直接通过、打回或跳过。')}</p></div>{!camp.approved_plan_version_id && <button className="mini" onClick={addSegment} disabled={busy}>+ 新增段落</button>}</div>
|
||||
{camp.segments?.map((seg: Seg) => (
|
||||
<article key={seg.id} className={`segment-review ${openSegments.includes(seg.id) ? 'expanded' : ''}`}>
|
||||
<button className="segment-header" onClick={() => toggleSegment(seg.id)} aria-expanded={openSegments.includes(seg.id)}>
|
||||
<span className="segment-order">{String(seg.id).slice(-2)}</span>
|
||||
<span className="segment-main"><b>{seg.title}</b><small>{seg.time_range} · {seg.content_tag} · 执行 Agent:{seg.agent_name || `#${seg.agent_id || '未分配'}`} · 第{seg.round}轮</small></span>
|
||||
<Tag s={seg.status} />
|
||||
<span className="segment-chevron">⌄</span>
|
||||
</button>
|
||||
{openSegments.includes(seg.id) && <div className="segment-content">
|
||||
<SegmentAgentPicker campaignId={camp.id} segment={seg} options={camp.agent_options || []} canEdit={Boolean(camp.can_edit_agents)} disabled={busy} onBusyChange={setBusy} onSaved={() => selectCamp(camp.id)} />
|
||||
{seg.status === 'pending' && <div className="segment-edit-grid">
|
||||
<label>标题<input defaultValue={seg.title} onBlur={e => e.target.value !== seg.title && saveSegment(seg, { title: e.target.value })} /></label>
|
||||
<label>标签<input defaultValue={seg.content_tag} onBlur={e => e.target.value !== seg.content_tag && saveSegment(seg, { content_tag: e.target.value })} /></label>
|
||||
<label>时长<input defaultValue={seg.time_range} onBlur={e => e.target.value !== seg.time_range && saveSegment(seg, { time_range: e.target.value })} /></label>
|
||||
<label>产品<input defaultValue={seg.product_ref} onBlur={e => e.target.value !== seg.product_ref && saveSegment(seg, { product_ref: e.target.value })} /></label>
|
||||
<button className="mini danger-text" onClick={() => deleteSegment(seg)}>删除此段</button>
|
||||
<span className="field-help">改完自动保存(失焦生效);手动指定的智能体不受标签修改影响。</span>
|
||||
</div>}
|
||||
{seg.product_ref && <span className="badge gold">产品:{seg.product_ref}</span>}
|
||||
{seg.failure?.detail && <div className="err">⚠ 失败:{errorText(seg.failure.detail)}</div>}
|
||||
{seg.status === 'failed' && isKnowledgeSearchTimeout(seg.failure?.detail) && <div className="warn kb-timeout-tip" role="alert">
|
||||
<b>知识库范围过大,检索等待超时</b>
|
||||
<span>请打开任务详情,在“知识范围”中选择本段需要的分类(建议 1–3 个)后再重试。未选择分类时会检索该 Agent 的全部可用知识库。</span>
|
||||
</div>}
|
||||
{seg.status === 'rework' && <div className="warn">质检未过,请在产物下处理(可打回重跑/改规则后修复)</div>}
|
||||
{seg.artifact ? <div className="generated-content"><div className="content-label">Agent 生成正文 <span>{seg.artifact.length} 字</span></div><pre className="art">{seg.artifact}</pre></div> : <div className="empty-segment">正文将在 Agent 生成完成后显示在这里。</div>}
|
||||
{seg.qc_reports?.map((r: any, i: number) => (
|
||||
<details key={i}><summary>{QC_GATE_LABEL[r.gate] || r.gate || '质检'} · {r.verdict}</summary>
|
||||
<pre className="art">{JSON.stringify(r.checks, null, 1)}</pre></details>))}
|
||||
{seg.anchor_card && Object.keys(seg.anchor_card).length > 0 && (
|
||||
<details><summary>锚点卡</summary><pre className="art">{JSON.stringify(seg.anchor_card, null, 1)}</pre></details>)}
|
||||
{seg.references?.length > 0 && (
|
||||
<details><summary>引用来源({seg.references.length} 块,可溯源核对)</summary>
|
||||
{seg.references.map((r: any, i: number) => (
|
||||
<div key={i} className="dim">[{r.file}{r.heading ? ' / ' + r.heading : ''}] {r.text?.slice(0, 120)}</div>))}
|
||||
</details>)}
|
||||
{['human_review', 'rework'].includes(seg.status) && (
|
||||
<div className="btns">
|
||||
{camp.qc_mode === 'off' && <span className="tag r">质检已关闭</span>}
|
||||
{camp.qc_mode === 'lazy' && <span className="tag y">后台质检中</span>}
|
||||
<button className="ok" onClick={async () => { await review(seg.id, 'approve'); const d = await api.get<any>(`/campaigns/${camp.id}`); if (d.segments?.some((x: Seg) => x.status === 'pending')) await dialog.notice({ title: '已通过 ✓', description: '本段确认完成。点「生成下一段」继续下一段的生成。' }) }}>✅ 通过</button>
|
||||
<button onClick={async () => { const o = await dialog.prompt({ title: '打回该内容段', description: '意见会进入该 Agent 的待确认记忆池,方便后续生成避免重复问题。', inputLabel: '打回意见', placeholder: '例如:开头没有承接上一段的钩子,改成更自然的过渡。', confirmLabel: '打回并记录' }); if (o !== null) await review(seg.id, 'reject', o) }}>🔁 打回</button>
|
||||
<button onClick={() => review(seg.id, 'skip')}>⏭️ 跳过</button>
|
||||
</div>)}
|
||||
{seg.status === 'failed' && (
|
||||
<div className="btns">
|
||||
<button onClick={async () => { await api.post(`/segments/${seg.id}/fix`, { kind: 'retry' }); setCamp(await api.get(`/campaigns/${camp.id}`)) }}>🔁 重试</button>
|
||||
<button onClick={async () => { const p = await dialog.prompt({ title: '重试并记录原因', description: '这条原因会生成待确认记忆,用于避免同类问题再次发生。', inputLabel: '修复原因', placeholder: '例如:补足与上一段的衔接,并保持大白话单段表达。', confirmLabel: '重试并记录' }); await api.post(`/segments/${seg.id}/fix`, { kind: 'retry', reason: p || '' }); setCamp(await api.get(`/campaigns/${camp.id}`)) }}>🔁 重试+记原因</button>
|
||||
<button onClick={async () => { await api.post(`/segments/${seg.id}/fix`, { kind: 'skip' }); setCamp(await api.get(`/campaigns/${camp.id}`)) }}>⏭️ 跳过</button>
|
||||
</div>)}
|
||||
</div>}
|
||||
</article>
|
||||
))}
|
||||
{camp.compose_report && (
|
||||
<div className="card compose-report"><div className="section-title-row"><div><h3>最终成稿检查</h3><p>全文拼接前的跨段一致性检查。</p></div><Tag s={(camp.compose_report.consistency || camp.compose_report).verdict || 'UNKNOWN'} /></div>
|
||||
{(camp.compose_report.consistency || camp.compose_report).issues?.length > 0
|
||||
? <ul>{(camp.compose_report.consistency || camp.compose_report).issues.map((issue: any, index: number) => (
|
||||
<li key={index}>[段{issue.seg_id || '-'}] {issue.desc}{issue.suggestion ? `;建议:${issue.suggestion}` : ''}</li>
|
||||
))}</ul>
|
||||
: <div className="ok-text">全部一致性检查已通过。</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---------------- Agent 管理 ---------------- */
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api, localizeError } from '../api'
|
||||
import './training-map-categories.css'
|
||||
|
||||
export type CategoryScope = Record<string, number[]>
|
||||
type Category = { id: number; name: string; parent_id: number | null; file_count: number; chunk_count: number }
|
||||
type Options = { categories: Category[]; unclassified: { file_count: number; chunk_count: number }; unregistered_index_files?: number }
|
||||
const count = (value: number) => (value || 0).toLocaleString('zh-CN')
|
||||
|
||||
export function TrainingMapCategories({ library, scope, disabled, onChange }: {
|
||||
library: { id: number; name: string }; scope: CategoryScope; disabled: boolean; onChange: (value: CategoryScope) => void
|
||||
}) {
|
||||
const key = String(library.id), selected = scope[key]
|
||||
const [open, setOpen] = useState(selected !== undefined)
|
||||
const [options, setOptions] = useState<Options | null>(null)
|
||||
const [error, setError] = useState(''), [loading, setLoading] = useState(false), [retry, setRetry] = useState(0)
|
||||
const [query, setQuery] = useState('')
|
||||
useEffect(() => {
|
||||
if (!open || options) return
|
||||
let current = true
|
||||
setLoading(true); setError('')
|
||||
api.get<Options>(`/training-maps/knowledge-bases/${library.id}/categories`).then(data => {
|
||||
if (current) setOptions(data)
|
||||
}).catch(reason => { if (current) setError(localizeError(reason)) }).finally(() => { if (current) setLoading(false) })
|
||||
return () => { current = false }
|
||||
}, [open, library.id, options, retry])
|
||||
const choose = (id: number) => {
|
||||
const ids = new Set(selected || [])
|
||||
ids.has(id) ? ids.delete(id) : ids.add(id)
|
||||
onChange({ ...scope, [key]: [...ids].sort((a, b) => a - b) })
|
||||
}
|
||||
const all = () => { const next = { ...scope }; delete next[key]; onChange(next) }
|
||||
const cats = options?.categories || []
|
||||
const label = selected === undefined ? '全部分类' : selected.length ? `已选 ${selected.length} 个分类(含子分类)` : '请选择分类'
|
||||
const ancestry = (cat: Category) => {
|
||||
const result: Category[] = [], seen = new Set([cat.id])
|
||||
let parent = cats.find(item => item.id === cat.parent_id)
|
||||
while (parent && !seen.has(parent.id)) { result.unshift(parent); seen.add(parent.id); parent = cats.find(item => item.id === parent!.parent_id) }
|
||||
return result
|
||||
}
|
||||
const choice = (cat: Category, inherited: boolean, path = '') => <label className="tm-category-choice">
|
||||
<input type="checkbox" checked={!!selected?.includes(cat.id) || inherited} disabled={disabled || inherited} onChange={() => choose(cat.id)} />
|
||||
<span>{path}{cat.name}<small>{count(cat.file_count)} 个文件 · {count(cat.chunk_count)} 段{inherited ? ' · 已由上级分类包含' : ''}</small></span>
|
||||
</label>
|
||||
const node = (cat: Category, parents: number[] = []): React.ReactNode => {
|
||||
if (parents.includes(cat.id)) return null
|
||||
const children = cats.filter(item => item.parent_id === cat.id)
|
||||
const inherited = parents.some(id => selected?.includes(id))
|
||||
return <li key={cat.id}>{choice(cat, inherited)}{children.length > 0 && <details><summary>展开 {children.length} 个子分类</summary><ul>{children.map(child => node(child, [...parents, cat.id]))}</ul></details>}</li>
|
||||
}
|
||||
return <details className="tm-library-scope" open={open} onToggle={event => setOpen(event.currentTarget.open)}>
|
||||
<summary><strong>{library.name}</strong><span>{label} · 调整范围</span></summary>
|
||||
{open && <div className="tm-library-scope-body">
|
||||
<div className="tm-scope-modes" role="radiogroup" aria-label={`${library.name}资料范围`}>
|
||||
<label><input type="radio" name={`kb-scope-${library.id}`} checked={selected === undefined} disabled={disabled} onChange={all} />全部分类</label>
|
||||
<label><input type="radio" name={`kb-scope-${library.id}`} checked={selected !== undefined} disabled={disabled} onChange={() => onChange({ ...scope, [key]: [] })} />指定分类</label>
|
||||
</div>
|
||||
{selected !== undefined && <>
|
||||
<p>选择父分类会包含它的全部子分类。检索、阅读和预计耗时仅按这个范围计算。</p>
|
||||
{loading && <p role="status">正在读取分类与资料数量…</p>}
|
||||
{error && <p role="alert">{error} <button disabled={disabled || loading} onClick={() => setRetry(value => value + 1)}>重试</button></p>}
|
||||
{options && <><input className="tm-category-search" aria-label={`搜索${library.name}分类`} placeholder="搜索分类名称…" value={query} disabled={disabled} onChange={event => setQuery(event.target.value)} />
|
||||
<ul className="tm-category-tree">{query.trim() ? cats.filter(cat => cat.name.includes(query.trim())).map(cat => <li key={cat.id}>{choice(cat, ancestry(cat).some(parent => selected.includes(parent.id)), ancestry(cat).map(parent => parent.name).join(' / ') + (cat.parent_id ? ' / ' : ''))}</li>) : cats.filter(cat => cat.parent_id === null || !cats.some(parent => parent.id === cat.parent_id)).map(cat => node(cat))}</ul>
|
||||
<label className="tm-category-choice"><input type="checkbox" checked={selected.includes(0)} disabled={disabled} onChange={() => choose(0)} /><span>未分类<small>{count(options.unclassified.file_count)} 个文件 · {count(options.unclassified.chunk_count)} 段</small></span></label>
|
||||
{!cats.length && <p>当前知识库尚未创建分类,可选择未分类资料,或先到知识库整理分类。</p>}
|
||||
{!!options.unregistered_index_files && <p>有 {options.unregistered_index_files} 个索引来源尚未登记分类,指定分类时不包含它们。</p>}
|
||||
</>}
|
||||
{!selected.length && <p className="tm-scope-required">至少选择一个分类后才能开始,或切换为全部分类。</p>}
|
||||
</>}
|
||||
</div>}
|
||||
</details>
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'
|
||||
import { api, localizeError } from '../api'
|
||||
import type { AssessmentEstimate, AssessmentMode } from './TrainingMapTiming'
|
||||
|
||||
export type ReadingCoverage = {
|
||||
kb_category_ids?: Record<string, number[]>; scope_label?: string; estimate?: AssessmentEstimate; progress_percent?: number; elapsed_seconds?: number
|
||||
remaining_seconds_low?: number; remaining_seconds_high?: number; overdue?: boolean
|
||||
mode: AssessmentMode | 'full_corpus'
|
||||
stage: 'scanning' | 'reading' | 'summarizing' | 'reducing' | 'rechecking' | 'planning' | 'complete' | 'failed' | 'cancelled'
|
||||
new_read_chunks?: number; reused_read_chunks?: number; rechecked_chunks?: number; new_read_batches?: number
|
||||
parallelism?: number; effective_parallelism?: number; active_batches?: number; total_read_batches?: number; rate_limited?: boolean
|
||||
timing?: { active_model_calls?: number }
|
||||
retrieved_chunks?: number; batches_done?: number; cached_batches?: number; source_complete?: boolean; selected_kbs?: number; scanned_chunks: number; read_chunks: number; duplicate_chunks: number; empty_chunks: number
|
||||
total_files?: number; indexed_files?: number; unindexed_files?: number; processed_batches?: number
|
||||
files_total?: number; files_indexed?: number; batches_completed?: number; evidence_count?: number
|
||||
kbs: { kb_id: number; kb_name: string; index_status: string; total_files?: number; indexed_files?: number; files_total?: number; files_indexed?: number; scanned_chunks: number; read_chunks: number; new_read_chunks?: number; reused_read_chunks?: number; unindexed_files: string[] }[]
|
||||
warnings: string[]
|
||||
}
|
||||
export type TrainingEvidence = {
|
||||
id: string; kb_id: number; kb_name: string; file: string; text: string; chunk_id: string
|
||||
kind?: string; source_count?: number; files?: string[]
|
||||
}
|
||||
type EvidenceSource = { kb_id: number; kb_name: string; file: string; chunk_id: string; text: string }
|
||||
type SourcesResponse = { items: EvidenceSource[]; total: number; offset: number; limit: number }
|
||||
const stageLabels: Record<ReadingCoverage['stage'], string> = {
|
||||
scanning: '正在扫描已索引文本', reading: 'AI 正在分批阅读原文', reducing: '正在汇总各批资料', summarizing: '正在汇总各批资料',
|
||||
rechecking: '正在按本次主题回查原文',
|
||||
planning: '正在依据资料评估课程', complete: '已索引文本阅读与评估完成', cancelled: '评估已停止,可重试', failed: '阅读或评估未完成',
|
||||
}
|
||||
const indexLabels: Record<string, string> = {
|
||||
ready: '索引就绪', indexed: '已索引', complete: '已完成', completed: '已完成', partial: '部分已索引',
|
||||
indexing: '索引中', pending: '等待索引', failed: '索引失败', error: '索引异常', empty: '暂无索引', missing: '缺少索引', unindexed: '未索引',
|
||||
}
|
||||
const count = (value?: number) => Math.max(0, Number(value) || 0).toLocaleString('zh-CN')
|
||||
|
||||
function KnowledgeCoverage({ kb, reuse }: { kb: ReadingCoverage['kbs'][number]; reuse: boolean }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [fileLimit, setFileLimit] = useState(20)
|
||||
const missing = kb.unindexed_files || []
|
||||
return <article className="tm-coverage-kb">
|
||||
<div><strong>{kb.kb_name}</strong><span>{indexLabels[kb.index_status] || '索引状态待核实'}</span></div>
|
||||
<p>文件索引 {count(kb.indexed_files ?? kb.files_indexed)} / {count(kb.total_files ?? kb.files_total)} · 已扫描 {count(kb.scanned_chunks)} 段 · {reuse ? `本次新读 ${count(kb.new_read_chunks)} 段 · 复用已读 ${count(kb.reused_read_chunks)} 段` : `AI 已读 ${count(kb.read_chunks)} 段`}</p>
|
||||
{missing.length > 0 && <details open={open} onToggle={event => setOpen(event.currentTarget.open)}>
|
||||
<summary>查看未索引文件({count(missing.length)})</summary>
|
||||
{open && <><p>这些文件不在本次可读的索引文本中,需要完成解析与索引后重新评估。</p><ul>{missing.slice(0, fileLimit).map((file, index) => <li key={`${file}-${index}`}>{file}</li>)}</ul>{fileLimit < missing.length && <button onClick={() => setFileLimit(value => value + 20)}>再显示 20 个文件</button>}</>}
|
||||
</details>}
|
||||
</article>
|
||||
}
|
||||
|
||||
export function TrainingMapCoverage({ coverage, hasAssessment }: { coverage?: ReadingCoverage; hasAssessment: boolean }) {
|
||||
if (!coverage?.mode) return hasAssessment ? <section className="tm-panel tm-note" aria-label="资料阅读范围"><p>旧版评估只使用相关片段,重新评估后可查看全库阅读情况。</p></section> : null
|
||||
const corpus = coverage.mode !== 'quick'
|
||||
const reuse = coverage.mode === 'reuse'
|
||||
const hasFreshCount = coverage.new_read_chunks !== undefined
|
||||
const scanned = Math.max(0, (corpus ? coverage.scanned_chunks : coverage.retrieved_chunks) || 0)
|
||||
const eligible = Math.max(0, scanned - (coverage.duplicate_chunks || 0) - (coverage.empty_chunks || 0))
|
||||
const read = Math.max(0, coverage.read_chunks || 0)
|
||||
return <section className="tm-panel tm-coverage" aria-label={corpus ? '所选范围阅读覆盖情况' : '相关检索覆盖情况'}>
|
||||
<div className="tm-coverage-heading"><h2>资料阅读覆盖</h2><span role="status" aria-live="polite">{reuse && coverage.stage === 'complete' ? '资料阅读覆盖与评估完成' : reuse && coverage.stage === 'reading' ? '正在复用已读资料并补充阅读' : !corpus && coverage.stage === 'complete' ? '快速相关检索评估完成' : !corpus && coverage.stage === 'scanning' ? '正在检索相关片段' : !corpus && coverage.stage === 'reading' ? 'AI 正在阅读相关片段' : stageLabels[coverage.stage] || '正在更新阅读进度'}</span></div>
|
||||
{corpus ? <p>本次阅读范围:所选 {count(coverage.selected_kbs ?? coverage.kbs?.length)} 个知识库中{coverage.scope_label || (Object.keys(coverage.kb_category_ids || {}).length ? '所选分类(含子分类)' : '全部分类')}的已索引文本。{reuse ? '先核对资料版本,复用内容未变的知识底稿,新读缺少底稿或发生变化的原文,再按本次主题回查相关原文并评估课程。复用不表示本次重新阅读了全部原文。' : '系统先扫描与去重,再按本次主题分批重新阅读原文、汇总资料,最后评估课程。'}文件原件是否完整解析仍取决于索引情况。</p> : <p>本次使用快速相关检索:根据主题查找相关片段并评估,未阅读所选知识库的全部文本。需要核查所选范围的完整阅读覆盖时,可选择复用已读资料或重新阅读全文。</p>}
|
||||
<dl className="tm-coverage-stats">
|
||||
<div><dt>{corpus ? '已扫描文本' : '已检索候选文本'}</dt><dd>{count(scanned)} <small>段</small></dd></div>
|
||||
{reuse ? <>
|
||||
<div><dt>本次新读</dt><dd>{count(coverage.new_read_chunks)} <small>段</small></dd></div>
|
||||
<div><dt>复用已读</dt><dd>{count(coverage.reused_read_chunks)} <small>段</small></dd></div>
|
||||
<div><dt>主题原文回查</dt><dd>{count(coverage.rechecked_chunks)} <small>段</small></dd></div>
|
||||
</> : <div><dt>{corpus && hasFreshCount ? '本次新读' : 'AI 已完成阅读'}</dt><dd>{count(corpus && hasFreshCount ? coverage.new_read_chunks : read)} <small>段</small></dd></div>}
|
||||
{corpus && <div><dt>已完成阅读与汇总</dt><dd>{count(coverage.processed_batches ?? coverage.batches_done ?? coverage.batches_completed)} <small>批</small></dd></div>}
|
||||
{corpus && <div><dt>文件索引覆盖</dt><dd>{count(coverage.indexed_files ?? coverage.files_indexed)} / {count(coverage.total_files ?? coverage.files_total)}</dd></div>}
|
||||
</dl>
|
||||
{corpus && coverage.stage !== 'scanning' && eligible > 0 && <div className="tm-reading-progress"><label htmlFor="training-map-reading-progress">去重及过滤空段后,{reuse ? '新读与复用合计覆盖' : '已完成阅读覆盖'} {count(read)} / {count(eligible)} 段</label><progress id="training-map-reading-progress" value={Math.min(read, eligible)} max={eligible} /></div>}
|
||||
<p className="tm-coverage-note">{reuse ? '新读仅统计本次完成模型阅读的原文;复用表示已核对内容一致的历史知识底稿;主题回查表示额外提供给课程评估的原文,不重复计入阅读覆盖。' : corpus ? '扫描表示找到文本,完成阅读表示该批原文已有模型阅读结果。' : 'AI 已读仅统计本次检索后实际用于模型评估的文本,不表示全库覆盖。'}已排除重复 {count(coverage.duplicate_chunks)} 段、空文本 {count(coverage.empty_chunks)} 段。{corpus && coverage.unindexed_files !== undefined ? `未索引文件 ${count(coverage.unindexed_files)} 个。` : ''}{coverage.evidence_count !== undefined ? `已整理 ${count(coverage.evidence_count)} 条资料证据。` : ''}</p>
|
||||
{(coverage.cached_batches || 0) > 0 && <p>已复用之前成功阅读的 {count(coverage.cached_batches)} 批相同资料,避免重复调用 AI。</p>}
|
||||
{(coverage.warnings || []).length > 0 && <div className="tm-coverage-warnings"><strong>覆盖范围与待处理项</strong><ul>{coverage.warnings.map((warning, index) => <li key={index}>{warning}</li>)}</ul></div>}
|
||||
<div className="tm-coverage-kbs">{(coverage.kbs || []).map(kb => <KnowledgeCoverage key={kb.kb_id} kb={kb} reuse={reuse} />)}</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
function EvidenceCard({ draftId, version, evidence, open, onOpen }: { draftId: number; version: number; evidence: TrainingEvidence; open: boolean; onOpen: (open: boolean) => void }) {
|
||||
const [sources, setSources] = useState<EvidenceSource[]>([])
|
||||
const [total, setTotal] = useState<number | null>(null)
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [filesOpen, setFilesOpen] = useState(false)
|
||||
const [fileLimit, setFileLimit] = useState(20)
|
||||
const alive = useRef(true), loadingRef = useRef(false)
|
||||
const summary = evidence.kind === 'summary'
|
||||
useEffect(() => { alive.current = true; return () => { alive.current = false } }, [])
|
||||
const loadSources = async () => {
|
||||
if (loadingRef.current) return
|
||||
loadingRef.current = true; setLoading(true); setError('')
|
||||
try {
|
||||
const result = await api.get<SourcesResponse>(`/training-maps/${draftId}/evidence/${encodeURIComponent(evidence.id)}/sources?version=${version}&offset=${offset}&limit=20`)
|
||||
if (!alive.current) return
|
||||
setSources(previous => offset === 0 ? result.items : [...previous, ...result.items])
|
||||
setTotal(result.total); setOffset(result.offset + result.items.length)
|
||||
if (result.items.length === 0 && result.offset < result.total) setError('本页来源暂不可用,请重试加载。')
|
||||
} catch (reason) { if (alive.current) setError(localizeError(reason)) }
|
||||
finally { loadingRef.current = false; if (alive.current) setLoading(false) }
|
||||
}
|
||||
return <details id={`evidence-${evidence.id}`} className="tm-evidence" open={open} onToggle={event => onOpen(event.currentTarget.open)}>
|
||||
<summary><strong>[{evidence.id}] {summary ? '资料摘要' : '原文片段'} · {evidence.kb_name || '所选知识库'}</strong><span>{summary ? `${count(evidence.source_count)} 个来源片段` : evidence.file || '未命名来源'}</span></summary>
|
||||
{open && <div className="tm-evidence-body">
|
||||
{summary ? <p>这是分批阅读后汇总的资料摘要,可继续展开其原文来源核对。</p> : <p>知识库 #{evidence.kb_id} · 文件:{evidence.file || '未命名来源'} · 片段 {evidence.chunk_id || '无片段编号'}</p>}
|
||||
<p className="tm-evidence-text">{evidence.text}</p>
|
||||
{summary && <>
|
||||
{!!evidence.files?.length && <details open={filesOpen} onToggle={event => setFilesOpen(event.currentTarget.open)}><summary>涉及文件({count(evidence.files.length)})</summary>{filesOpen && <><ul>{evidence.files.slice(0, fileLimit).map((file, index) => <li key={`${file}-${index}`}>{file}</li>)}</ul>{fileLimit < evidence.files.length && <button onClick={() => setFileLimit(value => value + 20)}>再显示 20 个文件</button>}</>}</details>}
|
||||
<div className="tm-actions"><button onClick={loadSources} disabled={loading || total !== null && offset >= total}>{loading ? '正在加载原文来源…' : total === null ? '查看原文来源' : offset >= total ? '来源已全部显示' : '再加载 20 条原文来源'}</button>{total !== null && <span>已显示 {count(sources.length)} / {count(total)} 条</span>}</div>
|
||||
{error && <p className="err" role="alert">{error}</p>}
|
||||
{total === 0 && <p>暂无可回查的原文来源。</p>}
|
||||
{sources.map((source, index) => <details key={`${source.kb_id}-${source.chunk_id}-${index}`} className="tm-evidence-source"><summary>{source.kb_name} · {source.file || '未命名文件'} · 片段 {source.chunk_id}</summary><SourceText text={source.text} /></details>)}
|
||||
</>}
|
||||
</div>}
|
||||
</details>
|
||||
}
|
||||
|
||||
// Each source is rendered only when opened, even after a page of source metadata has loaded.
|
||||
function SourceText({ text }: { text: string }) {
|
||||
const element = useRef<HTMLDivElement>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
useEffect(() => {
|
||||
const details = element.current?.parentElement as HTMLDetailsElement | null
|
||||
if (!details) return
|
||||
const update = () => setOpen(details.open)
|
||||
update(); details.addEventListener('toggle', update)
|
||||
return () => details.removeEventListener('toggle', update)
|
||||
}, [])
|
||||
return <div ref={element}>{open && <p className="tm-evidence-text">{text}</p>}</div>
|
||||
}
|
||||
|
||||
export type EvidencePanelHandle = { openEvidence: (id: string) => void }
|
||||
export const TrainingMapEvidence = forwardRef<EvidencePanelHandle, { draftId: number; version: number; evidence: TrainingEvidence[] }>(function TrainingMapEvidence({ draftId, version, evidence }, ref) {
|
||||
const [openIds, setOpenIds] = useState<Set<string>>(new Set())
|
||||
const [limit, setLimit] = useState(20)
|
||||
const [scrollRequest, setScrollRequest] = useState<{ id: string } | null>(null)
|
||||
const [missing, setMissing] = useState('')
|
||||
useImperativeHandle(ref, () => ({ openEvidence: id => {
|
||||
const index = evidence.findIndex(item => item.id === id)
|
||||
if (index < 0) { setMissing(`来源 ${id} 暂不可用,请重新评估核对。`); return }
|
||||
setMissing(''); setLimit(value => Math.max(value, index + 1))
|
||||
setOpenIds(previous => new Set([...previous, id])); setScrollRequest({ id })
|
||||
} }), [evidence])
|
||||
useEffect(() => {
|
||||
if (!scrollRequest) return
|
||||
const frame = requestAnimationFrame(() => {
|
||||
const element = document.getElementById(`evidence-${scrollRequest.id}`)
|
||||
element?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
element?.querySelector('summary')?.focus({ preventScroll: true })
|
||||
})
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [scrollRequest])
|
||||
if (!evidence.length) return null
|
||||
return <section className="tm-panel" aria-label="独立证据区">
|
||||
<h2>独立证据区 · {count(evidence.length)} 条资料</h2><p>展开查看资料正文。资料摘要可按需回查原文,每次加载 20 条来源。</p>
|
||||
{missing && <p className="err" role="alert">{missing}</p>}
|
||||
{evidence.slice(0, limit).map(item => <EvidenceCard key={item.id} draftId={draftId} version={version} evidence={item} open={openIds.has(item.id)} onOpen={open => setOpenIds(previous => { if (previous.has(item.id) === open) return previous; const next = new Set(previous); if (open) next.add(item.id); else next.delete(item.id); return next })} />)}
|
||||
{limit < evidence.length && <button onClick={() => setLimit(value => value + 20)}>再显示 20 条资料(已显示 {count(Math.min(limit, evidence.length))} / {count(evidence.length)})</button>}
|
||||
</section>
|
||||
})
|
||||
@@ -0,0 +1,444 @@
|
||||
import { Component, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { ContentTabs } from './ContentTabs'
|
||||
import { ReactFlow, Background, Controls, Handle, Position } from '@xyflow/react'
|
||||
import type { ReactFlowInstance } from '@xyflow/react'
|
||||
import type { Node, Edge, NodeProps } from '@xyflow/react'
|
||||
import '@xyflow/react/dist/style.css'
|
||||
import { api, localizeError } from '../api'
|
||||
import { TrainingMapCoverage, TrainingMapEvidence } from './TrainingMapCoverage'
|
||||
import type { ReadingCoverage, TrainingEvidence, EvidencePanelHandle } from './TrainingMapCoverage'
|
||||
import { AssessmentTiming, DayGenerationTiming, EstimatePreview, useAssessmentEstimate, timeRange } from './TrainingMapTiming'
|
||||
import type { AssessmentMode } from './TrainingMapTiming'
|
||||
import { TrainingMapCategories } from './TrainingMapCategories'
|
||||
import type { CategoryScope } from './TrainingMapCategories'
|
||||
import './training-map.css'
|
||||
|
||||
type Tree = { title: string; content?: string; source_ids?: string[]; children?: Tree[]; day?: number; hook?: string; image_url?: string; image_alt?: string; image_stage?: 'preview' | 'final' }
|
||||
type Candidate = { day: number; title: string; source_ids: string[]; support: string; prerequisite?: string; outcome?: string }
|
||||
type Draft = { id: number; topic: string; kb_ids: number[]; kb_category_ids?: CategoryScope; requested_days: number | null; agent_id?: number | null; version: number; confirmed_version: number | null; status: string; error: string; owner_user_id?: number; owner_display_name?: string; owner_username?: string; assessment_cancel_requested?: boolean; plan: { recommended_days?: number; rationale?: string; insufficiencies?: string[]; days?: Candidate[] }; evidence: TrainingEvidence[]; coverage?: ReadingCoverage; maps: Record<string, Tree> }
|
||||
type Library = { id: number; name: string; file_count: number; chunk_count: number; index_status: string; auto_read?: { status?: string; files_done?: number; total_files?: number; batches_done?: number; estimated_total_batches?: number; estimated_remaining_seconds_low?: number | null; estimated_remaining_seconds_high?: number | null; current_rate_batches_per_minute?: number; queue_position?: number; error?: string } }
|
||||
type AgentOption = { id: number; name: string; role?: string; status?: string; desc?: string }
|
||||
const agentRoleLabel = (role?: string) => ({ training_map: '导图生成', production: '内容生产', qc: '质检', service: '服务', coordinator: '主控', composer: '组合' } as Record<string, string>)[role || ''] || role || '通用'
|
||||
type Summary = Pick<Draft, 'id' | 'topic' | 'version' | 'status' | 'owner_user_id' | 'owner_display_name' | 'owner_username'>
|
||||
type CommentAnchor = { nodeId: string; path: string; quote: string }
|
||||
type MapComment = CommentAnchor & { id: number; day: number; advice: string }
|
||||
type MapData = { title: string; content?: string; hook?: string; depth: number; path: string; folded: boolean; expandable: boolean; comments: MapComment[]; toggle: () => void; openComment: (comment: MapComment) => void }
|
||||
|
||||
/** Keep a visualization failure local to the current day. The page remains
|
||||
* usable (and the generated draft stays saved) if a browser/ReactFlow edge
|
||||
* case still slips through legacy data normalization. */
|
||||
class MindMapRenderBoundary extends Component<{ children: ReactNode }, { failed: boolean }> {
|
||||
state = { failed: false }
|
||||
static getDerivedStateFromError() { return { failed: true } }
|
||||
render() {
|
||||
if (!this.state.failed) return this.props.children
|
||||
return <div className="tm-empty" role="alert"><strong>导图暂时无法显示</strong><p>当天文字已保存,重新尝试渲染即可继续查看。</p><button className="mini" onClick={() => this.setState({ failed: false })}>重新显示导图</button></div>
|
||||
}
|
||||
}
|
||||
// Evidence identifiers remain available in the separate reference panel.
|
||||
/**
|
||||
* AI responses are validated on the server, but older drafts (and partially
|
||||
* failed generations) can still contain non-string node fields. Rendering a
|
||||
* malformed field used to throw inside the ReactFlow tree and bubble to the
|
||||
* route error boundary, which showed the whole-page "重新加载" screen after
|
||||
* generation. Coerce primitive values and ignore objects so one bad field
|
||||
* cannot take down the page.
|
||||
*/
|
||||
const cleanMapText = (value?: unknown) => {
|
||||
if (value == null) return ''
|
||||
const text = typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' ? String(value) : ''
|
||||
return text
|
||||
.replace(/[((\[]?\bE\d{1,6}\b[))\]]?\s*[::、,,;;-]?\s*/gi, '')
|
||||
.replace(/[ \t]{2,}/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/** Normalize persisted/AI data before handing it to ReactFlow. */
|
||||
const normalizeTree = (value: unknown, fallbackTitle = '未命名节点'): Tree => {
|
||||
const source = value && typeof value === 'object' ? value as Record<string, unknown> : {}
|
||||
const children = Array.isArray(source.children)
|
||||
? source.children.map(child => normalizeTree(child)).filter(Boolean)
|
||||
: []
|
||||
const tree: Tree = {
|
||||
title: cleanMapText(source.title) || fallbackTitle,
|
||||
content: cleanMapText(source.content) || undefined,
|
||||
hook: cleanMapText(source.hook) || undefined,
|
||||
source_ids: Array.isArray(source.source_ids) ? source.source_ids.filter(id => typeof id === 'string') : undefined,
|
||||
children,
|
||||
}
|
||||
if (typeof source.day === 'number') tree.day = source.day
|
||||
if (cleanMapText(source.image_url)) tree.image_url = cleanMapText(source.image_url)
|
||||
if (cleanMapText(source.image_alt)) tree.image_alt = cleanMapText(source.image_alt)
|
||||
if (source.image_stage === 'preview' || source.image_stage === 'final') tree.image_stage = source.image_stage
|
||||
return tree
|
||||
}
|
||||
const frameworkSummary = (value?: string) => { const clean = cleanMapText(value) || ''; const first = clean.split(/[。!?;!?;]/)[0].trim(); return (first || clean).slice(0, 140) }
|
||||
function MapNode({ data }: NodeProps<Node<MapData>>) {
|
||||
const comments = Array.isArray(data.comments) ? data.comments : []
|
||||
return <div className={`tm-node tm-depth-${data.depth}${comments.length ? ' has-comments' : ''}`} data-map-path={data.path}>
|
||||
{data.depth > 0 && <Handle type="target" position={Position.Left} />}
|
||||
<strong className="nodrag">{cleanMapText(data.title)}</strong>
|
||||
{cleanMapText(data.content) && <p className="nowheel nodrag">{cleanMapText(data.content)}</p>}
|
||||
{cleanMapText(data.hook) && <p className="tm-node-hook nowheel nodrag">↳ {cleanMapText(data.hook)}</p>}
|
||||
{comments.length > 0 && <div className="tm-node-comments nodrag">{comments.map(comment => <button key={comment.id} className="tm-comment-pin" title={comment.advice} aria-label={`查看评论 ${comment.id}`} onClick={() => data.openComment(comment)}>{comment.id}</button>)}</div>}
|
||||
{data.expandable && <button className="nodrag tm-node-fold" onClick={data.toggle} aria-label={`${data.folded ? '展开' : '收起'} ${cleanMapText(data.title)}`}>{data.folded ? '+ 展开' : '− 收起'}</button>}
|
||||
{data.expandable && <Handle type="source" position={Position.Right} />}
|
||||
</div>
|
||||
}
|
||||
const nodeTypes = { training: MapNode }
|
||||
function MindMap({ tree, comments, onOpenComment }: { tree: Tree; comments: MapComment[]; onOpenComment: (comment: MapComment) => void }) {
|
||||
const [folded, setFolded] = useState<Set<string>>(new Set())
|
||||
const flow = useRef<ReactFlowInstance<Node<MapData>> | null>(null)
|
||||
const container = useRef<HTMLElement | null>(null)
|
||||
useEffect(() => {
|
||||
if (!container.current) return
|
||||
const observer = new ResizeObserver(() => { requestAnimationFrame(() => flow.current?.fitView({ padding: 0.15, duration: 200 })) })
|
||||
observer.observe(container.current)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
const { nodes, edges } = useMemo(() => {
|
||||
// Keep the visualizer resilient to a legacy draft or a transient partial
|
||||
// response. A normalized root always has a title and an array of children.
|
||||
const safeTree = normalizeTree(tree)
|
||||
const nodes: Node<MapData>[] = [], edges: Edge[] = []
|
||||
const heights = new Map<string, { own: number; subtree: number }>()
|
||||
const lines = (text?: string) => (cleanMapText(text) || '').split('\n').reduce((total, line) => total + Math.ceil([...line].reduce((n, c) => n + (c.charCodeAt(0) > 255 ? 1 : .55), 0) / 16), 0)
|
||||
const measure = (node: Tree, id: string): number => {
|
||||
const children = folded.has(id) ? [] : (node.children || [])
|
||||
const own = Math.max(96, 48 + lines(node.title) * 23 + lines(node.content) * 21 + lines(node.hook) * 21 + (node.children?.length ? 38 : 0))
|
||||
const subtree = Math.max(own, children.reduce((sum, child, i) => sum + measure(child, `${id}-${i}`), 0) + Math.max(0, children.length - 1) * 36)
|
||||
heights.set(id, { own, subtree })
|
||||
return subtree
|
||||
}
|
||||
const visit = (node: Tree, id: string, depth: number, top: number, ancestors: string[]) => {
|
||||
const { own, subtree } = heights.get(id)!
|
||||
const children = folded.has(id) ? [] : (node.children || [])
|
||||
const path = [...ancestors, cleanMapText(node.title) || '未命名节点']
|
||||
nodes.push({ id, type: 'training', position: { x: depth * 360, y: top + (subtree - own) / 2 }, sourcePosition: Position.Right, targetPosition: Position.Left,
|
||||
data: { title: node.title, content: node.content, hook: node.hook, depth, path: path.join(' → '), folded: folded.has(id), expandable: !!node.children?.length, comments: comments.filter(comment => comment.nodeId === id), openComment: onOpenComment,
|
||||
toggle: () => setFolded(old => { const next = new Set(old); if (next.has(id)) next.delete(id); else next.add(id); return next }) } })
|
||||
const childrenHeight = children.reduce((sum, _, i) => sum + heights.get(`${id}-${i}`)!.subtree, 0) + Math.max(0, children.length - 1) * 36
|
||||
let childTop = top + (subtree - childrenHeight) / 2
|
||||
children.forEach((child, i) => {
|
||||
const childId = `${id}-${i}`
|
||||
visit(child, childId, depth + 1, childTop, path)
|
||||
edges.push({ id: `${childId}-edge`, source: id, target: childId, type: 'smoothstep', style: { stroke: '#8aa7ba', strokeWidth: 1.8 } })
|
||||
childTop += heights.get(childId)!.subtree + 36
|
||||
})
|
||||
}
|
||||
measure(safeTree, 'root'); visit(safeTree, 'root', 0, 0, [])
|
||||
return { nodes, edges }
|
||||
}, [tree, folded, comments, onOpenComment])
|
||||
return <section ref={container} aria-label="每日连线思维导图" className="tm-canvas"><ReactFlow onInit={instance => { flow.current = instance }} nodes={nodes} edges={edges} nodeTypes={nodeTypes} fitView minZoom={0.1} maxZoom={2} nodesDraggable={false} nodesConnectable={false} elementsSelectable={false} colorMode="light" attributionPosition="bottom-right"><Background gap={24} color="#dce5eb"/><Controls showInteractive={false}/></ReactFlow></section>
|
||||
}
|
||||
const statusText: Record<string, string> = { draft: '草稿', assessing: '资料阅读与评估中', proposed: '待确认', insufficient: '知识不足', confirmed: '已确认', generating: '生成中(中断可重试)', done: '全部完成', failed: '失败,可重试' }
|
||||
export default function TrainingMapPage() {
|
||||
const navigate = useNavigate()
|
||||
const [params, setParams] = useSearchParams()
|
||||
const [list, setList] = useState<Summary[]>([]), [kbs, setKbs] = useState<Library[]>([]), [agents, setAgents] = useState<AgentOption[]>([])
|
||||
const [draft, setDraft] = useState<Draft | null>(null), [topic, setTopic] = useState(''), [ids, setIds] = useState<number[]>([]), [days, setDays] = useState(''), [agentId, setAgentId] = useState<number | ''>('')
|
||||
const [currentUserId, setCurrentUserId] = useState<number | null>(null), [isAdmin, setIsAdmin] = useState(false)
|
||||
const [operationBusy, setOperationBusy] = useState(''), [error, setError] = useState(''), [day, setDay] = useState(1), [ready, setReady] = useState(false)
|
||||
const [revisionTarget, setRevisionTarget] = useState<'plan' | 'plan_day' | 'map_day' | 'all_maps'>('plan')
|
||||
const [revisionDay, setRevisionDay] = useState(1), [revisionAdvice, setRevisionAdvice] = useState('')
|
||||
const [mapFullscreen, setMapFullscreen] = useState(false)
|
||||
const [mapAdviceOpen, setMapAdviceOpen] = useState(false)
|
||||
const [mapCommentAnchor, setMapCommentAnchor] = useState<CommentAnchor>({ nodeId: '', path: '', quote: '' })
|
||||
const [mapComments, setMapComments] = useState<MapComment[]>([])
|
||||
const [commentAdvice, setCommentAdvice] = useState('')
|
||||
const [editingCommentId, setEditingCommentId] = useState<number | null>(null)
|
||||
const [commentsSent, setCommentsSent] = useState(false)
|
||||
const nextCommentId = useRef(1)
|
||||
const [categoryScope, setCategoryScope] = useState<CategoryScope>({})
|
||||
const [assessmentMode, setAssessmentMode] = useState<AssessmentMode>('reuse')
|
||||
const { estimate, estimateLoading, estimateError } = useAssessmentEstimate(ids, assessmentMode, days, categoryScope)
|
||||
const [progressError, setProgressError] = useState('')
|
||||
const lock = useRef(false), draftRef = useRef<Draft | null>(null), mounted = useRef(true)
|
||||
const pollVersion = useRef(0), evidencePanel = useRef<EvidencePanelHandle>(null)
|
||||
const inputSignature = useRef('')
|
||||
inputSignature.current = JSON.stringify({ topic, ids, days, categoryScope, agentId })
|
||||
const assessing = draft?.status === 'assessing'
|
||||
const generatingDay = draft?.status === 'generating' && !draft.maps[String(day)]
|
||||
const busy = operationBusy || (assessing ? draft.coverage?.mode === 'quick' ? '正在检索相关片段并评估…' : draft.coverage?.mode === 'reuse' ? '正在核对已读资料、补充阅读并评估…' : '正在重新阅读所选资料并评估…' : generatingDay ? `正在后台生成第 ${day} 天文字导图…` : '')
|
||||
const aiOperation = !!operationBusy && !/保存|导出|恢复|停止/.test(operationBusy)
|
||||
const dirty = !!draft && (JSON.stringify(categoryScope) !== JSON.stringify(draft.kb_category_ids || {}) || topic !== draft.topic || JSON.stringify(ids) !== JSON.stringify(draft.kb_ids) || (days ? Number(days) : null) !== draft.requested_days || Number(agentId || 0) !== Number(draft.agent_id || 0))
|
||||
const adminReadonly = !!(isAdmin && draft && draft.owner_user_id && draft.owner_user_id !== currentUserId)
|
||||
const valid = Object.values(categoryScope).every(values => values.length > 0) && topic.trim().length >= 2 && ids.length > 0 && !!agentId && (!days || (+days >= 1 && +days <= 30))
|
||||
const updateServerDraft = (d: Draft) => { draftRef.current = d; setDraft(d) }
|
||||
const adopt = (d: Draft, restore = false) => {
|
||||
if (!mounted.current) return
|
||||
updateServerDraft(d); setTopic(d.topic); setIds(d.kb_ids); setCategoryScope(d.kb_category_ids || {}); setDays(d.requested_days ? String(d.requested_days) : ''); setAgentId(d.agent_id || '')
|
||||
if (restore) { setAssessmentMode(d.coverage?.mode === 'reuse' ? 'reuse' : d.coverage?.mode === 'quick' ? 'quick' : 'full'); setDay(1) }
|
||||
setParams({ draft: String(d.id) }, { replace: true })
|
||||
}
|
||||
useEffect(() => {
|
||||
setMapComments([]); setCommentAdvice(''); setEditingCommentId(null); setMapAdviceOpen(false); setCommentsSent(false); nextCommentId.current = 1
|
||||
}, [draft?.id])
|
||||
const draftUrl = (id: number, ownerId?: number) => isAdmin && ownerId && currentUserId !== ownerId ? `/training-maps/admin/${id}` : `/training-maps/${id}`
|
||||
const refreshList = async () => { const rows = await api.get<Summary[]>('/training-maps'); if (mounted.current) setList(rows) }
|
||||
const removeDraft = async () => {
|
||||
if (!draft || adminReadonly || busy) return
|
||||
if (!window.confirm(`确定删除导图「${draft.topic}」?删除后无法恢复。`)) return
|
||||
await run('正在删除导图…', async () => {
|
||||
await api.delete(`/training-maps/${draft.id}`)
|
||||
if (!mounted.current) return
|
||||
setDraft(null); draftRef.current = null; setTopic(''); setIds([]); setAgentId(''); setCategoryScope({}); setDays(''); setParams({})
|
||||
})
|
||||
}
|
||||
useEffect(() => {
|
||||
mounted.current = true
|
||||
let cancelled = false
|
||||
Promise.all([api.get<Library[]>('/training-maps/knowledge-bases'), api.get<Summary[]>('/training-maps'), api.get<AgentOption[]>('/agents'), api.get<any>('/auth/status')]).then(async ([ks, ls, as, auth]) => {
|
||||
if (cancelled) return
|
||||
setCurrentUserId(auth?.user?.id || null); setIsAdmin(auth?.user?.role === 'admin')
|
||||
setKbs(ks); setList(ls); setAgents(as.filter(a => a.status !== 'disabled' && a.role === 'training_map'))
|
||||
const id = params.get('draft') || ls[0]?.id
|
||||
if (id) { const summary = ls.find(row => row.id === Number(id)); const restored = await api.get<Draft>((auth?.user?.role === 'admin' && summary?.owner_user_id && summary.owner_user_id !== auth.user.id) ? `/training-maps/admin/${id}` : `/training-maps/${id}`); if (!cancelled) adopt(restored, true) }
|
||||
}).catch(e => { if (!cancelled) setError(localizeError(e)) }).finally(() => { if (!cancelled) setReady(true) })
|
||||
return () => { cancelled = true; mounted.current = false; ++pollVersion.current }
|
||||
}, [])
|
||||
// Automatic knowledge reading runs in the backend after upload/rebuild. Keep
|
||||
// the library badges current while this page is open so users can switch to
|
||||
// “复用已读资料” as soon as the saved summaries are ready.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const refreshReadingStatus = async () => {
|
||||
try {
|
||||
const rows = await api.get<Library[]>('/training-maps/knowledge-bases')
|
||||
if (!cancelled && mounted.current) setKbs(rows)
|
||||
} catch {
|
||||
// The initial page load already reports access/network failures. A
|
||||
// transient status poll failure should not disturb the form.
|
||||
}
|
||||
}
|
||||
const timer = window.setInterval(refreshReadingStatus, 5000)
|
||||
return () => { cancelled = true; window.clearInterval(timer) }
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
const before = (e: BeforeUnloadEvent) => { if (dirty || (!draft && topic)) { e.preventDefault(); e.returnValue = '' } }
|
||||
window.addEventListener('beforeunload', before); return () => window.removeEventListener('beforeunload', before)
|
||||
}, [dirty, draft, topic])
|
||||
useEffect(() => {
|
||||
const version = ++pollVersion.current
|
||||
const id = draft?.id
|
||||
setProgressError('')
|
||||
if (!id || !assessing || operationBusy) return
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let cancelled = false
|
||||
const current = () => !cancelled && mounted.current && version === pollVersion.current && draftRef.current?.id === id
|
||||
const poll = async () => {
|
||||
try {
|
||||
const next = await api.get<Draft>(draftUrl(id, draftRef.current?.owner_user_id))
|
||||
if (!current()) return
|
||||
// Progress changes never rewrite the user's form inputs.
|
||||
updateServerDraft(next); setProgressError('')
|
||||
if (next.status !== 'assessing') {
|
||||
void refreshList().catch(() => { /* The completed draft remains usable if the sidebar refresh fails. */ })
|
||||
return
|
||||
}
|
||||
} catch (reason) {
|
||||
if (!current()) return
|
||||
setProgressError(`进度查询暂时失败:${localizeError(reason)}。正在自动重试,已保存的草稿仍会保留。`)
|
||||
}
|
||||
if (current()) timer = setTimeout(poll, 2000)
|
||||
}
|
||||
timer = setTimeout(poll, 2000)
|
||||
return () => { cancelled = true; if (timer) clearTimeout(timer) }
|
||||
}, [draft?.id, assessing, operationBusy])
|
||||
// Daily generation is accepted by the API immediately so a long model call
|
||||
// cannot hit the Cloudflare request timeout. Poll the persisted draft until
|
||||
// the worker publishes the map or records a retryable failure.
|
||||
useEffect(() => {
|
||||
const version = ++pollVersion.current
|
||||
const id = draft?.id
|
||||
if (!id || !generatingDay || operationBusy) return
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let cancelled = false
|
||||
const current = () => !cancelled && mounted.current && version === pollVersion.current && draftRef.current?.id === id
|
||||
const poll = async () => {
|
||||
try {
|
||||
const next = await api.get<Draft>(draftUrl(id, draftRef.current?.owner_user_id))
|
||||
if (!current()) return
|
||||
updateServerDraft(next); setProgressError('')
|
||||
if (next.status !== 'generating') {
|
||||
void refreshList().catch(() => { /* The generated map remains visible if the sidebar refresh fails. */ })
|
||||
return
|
||||
}
|
||||
} catch (reason) {
|
||||
if (!current()) return
|
||||
setProgressError(`导图生成进度查询暂时失败:${localizeError(reason)}。正在自动重试。`)
|
||||
}
|
||||
if (current()) timer = setTimeout(poll, 2000)
|
||||
}
|
||||
timer = setTimeout(poll, 1200)
|
||||
return () => { cancelled = true; if (timer) clearTimeout(timer) }
|
||||
}, [draft?.id, draft?.status, generatingDay, operationBusy, day])
|
||||
const run = async (label: string, fn: () => Promise<void>) => {
|
||||
if (lock.current) return
|
||||
const before = inputSignature.current
|
||||
lock.current = true; setOperationBusy(label); setError('')
|
||||
try { await fn(); if (mounted.current) await refreshList() }
|
||||
catch (reason) {
|
||||
if (!mounted.current) return
|
||||
setError(localizeError(reason))
|
||||
const id = draftRef.current?.id
|
||||
if (id && inputSignature.current === before) {
|
||||
try {
|
||||
const recovered = await api.get<Draft>(draftUrl(id, draftRef.current?.owner_user_id))
|
||||
if (mounted.current && draftRef.current?.id === id && inputSignature.current === before) updateServerDraft(recovered)
|
||||
} catch { /* Keep both local form inputs and the last saved content on failure. */ }
|
||||
}
|
||||
}
|
||||
finally { lock.current = false; if (mounted.current) setOperationBusy('') }
|
||||
}
|
||||
const save = async () => {
|
||||
const payload = { topic, kb_ids: ids, kb_category_ids: categoryScope, requested_days: days ? Number(days) : null, agent_id: agentId ? Number(agentId) : null }
|
||||
const d = draft ? await api.put<Draft>(`/training-maps/${draft.id}`, { ...payload, version: draft.version }) : await api.post<Draft>('/training-maps', payload)
|
||||
if (mounted.current) adopt(d)
|
||||
return d
|
||||
}
|
||||
const assess = () => run('正在提交后台评估…', async () => {
|
||||
const d = await save()
|
||||
const assessed = await api.post<Draft>(`/training-maps/${d.id}/assess?background=true`, { version: d.version, mode: assessmentMode, agent_id: agentId ? Number(agentId) : null })
|
||||
if (mounted.current) adopt(assessed)
|
||||
})
|
||||
const revise = (forcedTarget?: 'plan' | 'plan_day' | 'map_day' | 'all_maps', forcedDay?: number, forcedAdvice?: string) => run('正在按建议请 AI 修改…', async () => {
|
||||
const advice = (forcedAdvice || revisionAdvice).trim()
|
||||
if (!draft || !advice) return
|
||||
const target = forcedTarget || revisionTarget
|
||||
const result = await api.post<Draft>(`/training-maps/${draft.id}/revise`, {
|
||||
version: draft.version, target, day: target === 'plan' || target === 'all_maps' ? undefined : (forcedDay || revisionDay),
|
||||
advice
|
||||
})
|
||||
adopt(result)
|
||||
setRevisionAdvice('')
|
||||
if (target === 'map_day' || target === 'all_maps') setDay(forcedDay || revisionDay)
|
||||
})
|
||||
const dayComments = mapComments.filter(comment => comment.day === day)
|
||||
const openMapComment = (comment?: MapComment) => {
|
||||
setMapAdviceOpen(true); setCommentsSent(false)
|
||||
setMapCommentAnchor(comment ? { nodeId: comment.nodeId, path: comment.path, quote: comment.quote } : { nodeId: '', path: `第 ${day} 天整体框架`, quote: '' })
|
||||
setCommentAdvice(comment?.advice || ''); setEditingCommentId(comment?.id || null)
|
||||
}
|
||||
const addMapComment = () => {
|
||||
if (!commentAdvice.trim() || busy) return
|
||||
const comment: MapComment = { ...mapCommentAnchor, id: editingCommentId || nextCommentId.current++, day, advice: commentAdvice.trim() }
|
||||
setMapComments(old => editingCommentId ? old.map(item => item.id === editingCommentId ? comment : item) : [...old, comment])
|
||||
setCommentAdvice(''); setEditingCommentId(null); setCommentsSent(false)
|
||||
setMapCommentAnchor({ nodeId: '', path: `第 ${day} 天整体框架`, quote: '' })
|
||||
}
|
||||
const submitMapComments = () => run('正在按批改评论请 AI 修改…', async () => {
|
||||
if (!draft || !dayComments.length) return
|
||||
const advice = ['请逐条处理以下人工评论,保留未涉及节点,并检查相邻节点与前后天的内容承接:', ...dayComments.map(comment => `评论 ${comment.id}\n节点路径:${comment.path}\n${comment.quote ? `选中文字:${comment.quote}\n` : ''}修改意见:${comment.advice}`)].join('\n\n')
|
||||
const result = await api.post<Draft>(`/training-maps/${draft.id}/revise`, { version: draft.version, target: 'map_day', day, advice })
|
||||
adopt(result); setMapComments(old => old.filter(comment => comment.day !== day)); setCommentsSent(true)
|
||||
})
|
||||
const generateDay = () => run(`正在生成第 ${day} 天文字导图…`, async () => {
|
||||
if (!draft) return
|
||||
const result = await api.post<Draft>(`/training-maps/${draft.id}/days/${day}/generate?background=true`, { version: draft.version })
|
||||
adopt(result)
|
||||
})
|
||||
const exportFile = (format: string) => run('正在导出…', async () => {
|
||||
const result = await api.get<{ filename: string; content: string }>(`/training-maps/${draft!.id}/export?format=${format}`)
|
||||
const url = URL.createObjectURL(new Blob([result.content], { type: format === 'json' ? 'application/json' : 'text/markdown;charset=utf-8' }))
|
||||
const a = document.createElement('a'); a.href = url; a.download = result.filename; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
})
|
||||
const confirmed = !!draft && !dirty && draft.version === draft.confirmed_version
|
||||
const previousDayReady = day <= 1 || !!draft?.maps[String(day - 1)]
|
||||
useEffect(() => {
|
||||
if (!mapFullscreen) return
|
||||
const closeOnEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') setMapFullscreen(false) }
|
||||
window.addEventListener('keydown', closeOnEscape)
|
||||
return () => window.removeEventListener('keydown', closeOnEscape)
|
||||
}, [mapFullscreen])
|
||||
return <div className="training-map-page">
|
||||
<ContentTabs map/>
|
||||
<header><p className="tm-eyebrow">EVIDENCE FIRST · 知识先行</p><h1>训练营导图</h1><p>选择资料阅读方式,评估能讲几天;你确认后,再逐天生成有内容的导图。</p></header>
|
||||
<div className="tm-toolbar"><label>已保存草稿<select aria-label="已保存草稿" value={draft?.id || ''} disabled={!!operationBusy || !ready} onChange={e => { const id = e.target.value; if ((dirty || (!draft && topic)) && !window.confirm('有未保存修改,放弃并切换?')) return; if (id) { ++pollVersion.current; const summary = list.find(row => row.id === Number(id)); run('恢复草稿…', async () => adopt(await api.get<Draft>(draftUrl(Number(id), summary?.owner_user_id)), true)) } }}><option value="">选择草稿</option>{list.map(d => <option value={d.id} key={d.id}>{d.topic} · v{d.version}{d.owner_display_name ? ` · ${d.owner_display_name}` : ''}</option>)}</select></label>
|
||||
<button disabled={!!busy || !ready} onClick={() => { if ((dirty || (!draft && topic)) && !window.confirm('有未保存修改,放弃并新建?')) return; setDraft(null); draftRef.current = null; setTopic(''); setIds([]); setAgentId(''); setCategoryScope({}); setDays(''); setAssessmentMode('reuse'); setDay(1); ++pollVersion.current; setParams({}); setError('') }}>新建导图草稿</button>
|
||||
{draft && <button className="danger-text" disabled={!!busy || adminReadonly} onClick={() => void removeDraft()}>删除当前导图</button>}
|
||||
</div>
|
||||
{draft?.owner_display_name && <div className="tm-owner-badge">生成用户:<strong>{draft.owner_display_name}</strong>{draft.owner_username ? `(${draft.owner_username})` : ''}{isAdmin && draft.owner_user_id !== currentUserId ? ' · 管理员只读查看' : ''}</div>}
|
||||
<section className="tm-panel"><h2>01 / 训练营主题与知识范围</h2><fieldset disabled={!!busy || !ready || !!(isAdmin && draft && draft.owner_user_id !== currentUserId)}>
|
||||
<label>训练营主题<input value={topic} maxLength={300} placeholder="例如:时间管理与专注力训练营" onChange={e => setTopic(e.target.value)}/></label>
|
||||
<label>生成 Agent <select required aria-label="选择导图生成 Agent" value={agentId} onChange={e => setAgentId(e.target.value ? Number(e.target.value) : '')}><option value="">请选择导图生成 Agent</option>{agents.map(a => <option key={a.id} value={a.id}>{a.name} · {agentRoleLabel(a.role)}</option>)}</select><small className="field-help">这里只显示职责为“导图生成”的 Agent。它不挂接知识库,实际资料范围以本页选择的知识库和分类为准。</small>{ready && !agents.length && <small className="field-help">当前没有可用的“导图生成” Agent,请先在「Agent 与技能」中创建并启用。</small>}</label>
|
||||
<label>期望天数(可选,AI 不会强行凑天数)<input type="number" min={1} max={30} value={days} placeholder="交给 AI 评估" onChange={e => setDays(e.target.value)}/></label>
|
||||
<div className="tm-kbs" role="group" aria-label="可使用知识库">{kbs.map(k => { const reading = k.auto_read?.status === 'reading' || k.auto_read?.status === 'queued' || k.auto_read?.status === 'paused'; const readError = k.auto_read?.status === 'error'; const low = k.auto_read?.estimated_remaining_seconds_low; const high = k.auto_read?.estimated_remaining_seconds_high; const eta = low != null && high != null ? timeRange(low, high) : ''; return <label key={k.id}><input type="checkbox" checked={ids.includes(k.id)} onChange={e => { const checked = e.target.checked; setIds(old => checked ? [...old, k.id] : old.filter(id => id !== k.id)); if (!checked) setCategoryScope(old => { const next = { ...old }; delete next[String(k.id)]; return next }) }}/><span>{k.name}</span>{reading && <small className={`tm-kb-read-status${k.auto_read?.status === 'paused' ? ' is-paused' : ''}`} role="status">{k.auto_read?.status === 'paused' ? '自动预读已暂停' : k.auto_read?.status === 'queued' ? `已排队${k.auto_read?.queue_position ? `(第 ${k.auto_read.queue_position} 个)` : ''}` : '自动预读中'} {k.auto_read?.files_done || 0}/{k.auto_read?.total_files || 0}{k.auto_read?.status !== 'paused' && (eta ? ` · 预计剩余 ${eta}` : k.auto_read?.status === 'reading' ? ' · 正在计算速度' : ' · 等待前序资料完成')}</small>}{!reading && readError && <small className="tm-kb-read-status is-error" title={k.auto_read?.error || '自动预读失败'}>自动预读失败</small>}{!reading && !readError && k.auto_read?.status === 'complete' && <small className="tm-kb-read-status is-ready">已预读,可复用</small>}</label> })}{ready && !kbs.length && <p>没有可使用的知识库,请先上传资料或联系管理员授权。</p>}</div>
|
||||
<div aria-label="知识库分类范围">{kbs.filter(k => ids.includes(k.id)).map(k => <TrainingMapCategories key={k.id} library={k} scope={categoryScope} disabled={!!busy} onChange={setCategoryScope} />)}</div>
|
||||
<div className="tm-assessment-modes" role="radiogroup" aria-label="资料阅读方式">
|
||||
<label className={assessmentMode === 'reuse' ? 'is-selected' : ''}><input type="radio" name="assessment-mode" value="reuse" checked={assessmentMode === 'reuse'} onChange={() => setAssessmentMode('reuse')} /><span><strong>复用已读资料(推荐)</strong><small>系统会在资料首次进入索引后自动完整阅读并保存知识底稿;以后优先复用未变资料,只补读新增或变更内容,再按本次主题回查原文。复用不表示本次重读了全部原文。</small></span></label>
|
||||
<label className={assessmentMode === 'full' ? 'is-selected' : ''}><input type="radio" name="assessment-mode" value="full" checked={assessmentMode === 'full'} onChange={() => setAssessmentMode('full')} /><span><strong>重新阅读全文</strong><small>按本次主题重新阅读所选知识库及分类范围内的全部已索引文本;资料较多时耗时和模型用量更高。</small></span></label>
|
||||
<label className={assessmentMode === 'quick' ? 'is-selected' : ''}><input type="radio" name="assessment-mode" value="quick" checked={assessmentMode === 'quick'} onChange={() => setAssessmentMode('quick')} /><span><strong>快速相关检索</strong><small>依据主题检索相关片段后评估,适合快速预览;不会阅读所选知识库的全部文本。</small></span></label>
|
||||
</div>
|
||||
</fieldset><p>已选 {ids.length} 个知识库 · {Object.keys(categoryScope).length ? '按指定分类范围' : '全部分类'} · {estimate ? <>{estimate.total_files.toLocaleString('zh-CN')} 个文件 · {estimate.total_chunks.toLocaleString('zh-CN')} 个已索引片段</> : Object.values(categoryScope).some(values => !values.length) ? '请完成分类选择' : estimateLoading ? '正在统计所选范围…' : estimateError ? '资料数量暂不可用' : '请选择资料'}。{assessmentMode === 'reuse' ? '阅读按所选范围核对;已完成的知识底稿可供后续主题或新草稿复用。' : assessmentMode === 'full' ? '本次重新分批阅读全部所选资料,可在评估过程中停止。' : '快速模式仅检索与主题相关的片段。'}</p><p>修改主题、知识库、分类或期望天数后,旧确认立即在本页失效;保存后服务端升版,必须重新评估并确认。</p>
|
||||
{!assessing && <EstimatePreview estimate={estimate} loading={estimateLoading} error={estimateError} selected={ids.length > 0} />}
|
||||
<div className="tm-actions"><button disabled={!valid || !!busy} onClick={() => run('保存中…', async () => { await save() })}>保存草稿</button><button className="primary-action" disabled={!valid || !!busy} onClick={assess}>{assessmentMode === 'reuse' ? '复用资料并评估' : assessmentMode === 'full' ? '重新阅读全文并评估' : '快速检索并评估'}</button></div>
|
||||
</section>
|
||||
{busy && <div role="status" className={`tm-panel tm-ai-progress${assessing ? ' tm-assessment-busy' : ''}`} aria-live="polite"><strong>{busy}</strong>{aiOperation && <><div className="tm-progress-track"><div className="tm-progress-indeterminate" /></div><div className="tm-image-steps"><span className="done">✓ 已提交任务</span><span className="active">● AI 正在处理并检查连贯性</span><span>○ 保存结果</span></div></>}{assessing && <><p>评估在后台继续运行。页面每 2 秒更新进度,可刷新恢复,也可以切换查看其他草稿。</p><button disabled={!!operationBusy || draft.assessment_cancel_requested || adminReadonly} onClick={() => run('正在停止评估…', async () => updateServerDraft(await api.post<Draft>(`/training-maps/${draft.id}/cancel-assessment`, { version: draft.version })))}>{draft.assessment_cancel_requested ? '停止请求已提交,等待当前批次结束…' : '停止评估'}</button></>}</div>}
|
||||
{(error || draft?.error) && <div role="alert" className="err">{error || draft?.error}</div>}
|
||||
{progressError && <p role="status" className="tm-note">{progressError}</p>}
|
||||
{draft && <p role="status">草稿 #{draft.id} · 版本 {draft.version} · {statusText[draft.status] || draft.status}{dirty ? ' · 未保存修改,确认已失效' : ' · 已保存,可刷新恢复'}</p>}
|
||||
{draft?.coverage?.mode && <AssessmentTiming coverage={draft.coverage} stopping={draft.assessment_cancel_requested} />}
|
||||
<DayGenerationTiming active={operationBusy.startsWith('正在生成第')} label={operationBusy} estimate={draft?.coverage?.estimate || estimate} />
|
||||
{draft && <TrainingMapCoverage coverage={draft.coverage} hasAssessment={draft.status !== 'draft' || !!draft.evidence.length || !!draft.plan.rationale} />}
|
||||
{!!draft?.plan.rationale && <section className="tm-panel"><h2>02 / AI 推荐 {draft.plan.recommended_days} 天</h2><p>{cleanMapText(draft.plan.rationale)}</p><h3>支撑不足与范围限制</h3><p>请结合资料阅读覆盖、未索引文件和来源证据判断课程范围。</p><ul>{draft.plan.insufficiencies?.map((x, i) => <li key={i}>{cleanMapText(x)}</li>)}</ul>
|
||||
<div className="tm-candidates">{draft.plan.days?.map(d => <article key={d.day}><div className="tm-candidate-heading"><span className="tm-day-index">第 {d.day} 天</span><h3>{cleanMapText(d.title)}</h3></div><div className="tm-candidate-focus"><span>当天框架</span><p>{frameworkSummary(d.support)}</p>{(cleanMapText(d.support) || '').length > 140 && <details className="tm-candidate-detail"><summary>展开说明</summary><p>{cleanMapText(d.support)}</p></details>}</div><dl className="tm-candidate-outcomes"><div><dt>学习前置</dt><dd>{cleanMapText(d.prerequisite) || (d.day === 1 ? '从基础开始' : `先完成第 ${d.day - 1} 天`)}</dd></div><div><dt>当天达成</dt><dd>{cleanMapText(d.outcome) || '完成当天练习并能进入下一天'}</dd></div></dl>{d.source_ids.length > 0 && <details className="tm-candidate-references"><summary>查看参考资料({d.source_ids.length})</summary><div>{d.source_ids.map((id, index) => <button type="button" key={id} onClick={() => evidencePanel.current?.openEvidence(id)}>参考资料 {index + 1}</button>)}</div></details>}</article>)}</div>
|
||||
<button className="primary-action" disabled={!!busy || dirty || confirmed || !draft.plan.recommended_days || adminReadonly} onClick={() => run('确认方案…', async () => adopt(await api.post<Draft>(`/training-maps/${draft.id}/confirm`, { version: draft.version })))}>{confirmed ? `已确认 v${draft.version}` : `确认此方案 v${draft.version}`}</button>
|
||||
{!adminReadonly && <div className="tm-revision-panel" aria-label="人工建议修改训练方案"><h3>人工建议修改方案</h3><p className="field-help">可以让 AI 重做整套方案,或只修改某一天;修改后会生成新版本,需要重新确认。</p><div className="tm-actions"><select aria-label="修改范围" value={revisionTarget === 'map_day' || revisionTarget === 'all_maps' ? 'plan' : revisionTarget} onChange={e => setRevisionTarget(e.target.value as any)}><option value="plan">修改整套方案</option><option value="plan_day">只修改某一天方案</option></select>{revisionTarget === 'plan_day' && <select aria-label="方案修改天数" value={revisionDay} onChange={e => setRevisionDay(Number(e.target.value))}>{draft.plan.days?.map(d => <option key={d.day} value={d.day}>第 {d.day} 天</option>)}</select>}</div><textarea rows={3} placeholder="例如:第2天增加一个循序渐进练习,删掉重复的概念,并说明与第1天的承接。" value={revisionAdvice} onChange={e => setRevisionAdvice(e.target.value)} /><button className="primary-action" disabled={!!busy || dirty || !revisionAdvice.trim()} onClick={() => revise()}>让 AI 按建议修改</button></div>}
|
||||
</section>}
|
||||
{!!draft?.plan.days?.length && <section className="tm-panel"><h2>03 / 每日连线导图</h2><p>当天主题 → 一级章节 → 二级知识点 → 三级框架节点。每个节点保持短标题和清晰层级,生成会拦截重复标题、重复内容及逻辑断点。拖动画布平移,左下角缩放与适应视图;节点可收起。</p>
|
||||
<div className="tm-actions">{draft.plan.days.map(d => {
|
||||
// Keep day navigation available even when a prerequisite day has not
|
||||
// been generated yet. The generation action below remains guarded by
|
||||
// the prerequisite (and the API enforces it as well), so users can
|
||||
// inspect the next day's plan and see exactly what is still required
|
||||
// instead of encountering an apparently dead, grey button.
|
||||
const blocked = d.day > 1 && !draft.maps[String(d.day - 1)]
|
||||
return <button key={d.day} aria-pressed={day === d.day} disabled={!!busy} title={blocked ? `请先完成第 ${d.day - 1} 天` : ''} onClick={() => setDay(d.day)}>第 {d.day} 天 {draft.maps[String(d.day)] ? '✓' : blocked ? '🔒' : ''}</button>
|
||||
})}</div>
|
||||
{confirmed && !draft.maps[String(day)] && <div className="tm-actions tm-day-generate-actions"><button className="primary-action" disabled={!!busy || !previousDayReady || adminReadonly} onClick={generateDay}>生成第 {day} 天文字导图</button><span className="field-help">{previousDayReady ? (day === 1 ? '可以生成第 1 天。' : `第 ${day - 1} 天已生成,可以继续。`) : `请先生成并查看第 ${day - 1} 天,才能继续。`}</span></div>}
|
||||
{busy && operationBusy.startsWith('正在生成第') && <div className="tm-panel tm-ai-progress tm-day-generation-progress" role="status" aria-live="polite"><strong>AI 正在生成第 {day} 天文字导图</strong><div className="tm-progress-track"><div className="tm-progress-indeterminate" /></div><div className="tm-image-steps"><span className="done">✓ 已确认前置内容</span><span className="active">● 正在生成节点框架并检查承接</span><span>○ 保存当天导图</span></div></div>}
|
||||
{!adminReadonly && <div className="tm-revision-panel" aria-label="人工建议修改每日导图"><h3>人工建议修改每日连线导图</h3><p className="field-help">导图只生成文字框架节点。右键导图或在这里提出建议,AI 修改后会保留上一天到当天的承接关系。</p><div className="tm-actions"><select aria-label="每日导图修改范围" value={revisionTarget === 'plan' || revisionTarget === 'plan_day' ? 'map_day' : revisionTarget} onChange={e => setRevisionTarget(e.target.value as any)}><option value="map_day">修改选中的一天</option><option value="all_maps">修改全部已生成天数</option></select>{revisionTarget === 'map_day' && <select aria-label="导图修改天数" value={revisionDay} onChange={e => setRevisionDay(Number(e.target.value))}>{draft.plan.days?.filter(d => draft.maps[String(d.day)]).map(d => <option key={d.day} value={d.day}>第 {d.day} 天</option>)}</select>}</div><textarea rows={3} placeholder="例如:第2天只保留章节框架,删除详细解释,补上与第1天的承接节点。" value={revisionAdvice} onChange={e => setRevisionAdvice(e.target.value)} /><button className="primary-action" disabled={!!busy || dirty || !revisionAdvice.trim() || (revisionTarget === 'map_day' && !draft.maps[String(revisionDay)])} onClick={() => revise()}>让 AI 修改文字导图</button></div>}
|
||||
{!confirmed && <p>请先确认当前版本方案,未确认不能生成。</p>}
|
||||
{(draft.coverage?.estimate || estimate) && <p>每一天导图预计 {timeRange((draft.coverage?.estimate || estimate)?.per_day_seconds_low, (draft.coverage?.estimate || estimate)?.per_day_seconds_high)},开始生成后显示等待进度。</p>}
|
||||
{confirmed && day > 1 && !draft.maps[String(day - 1)] && <p className="tm-note">课程按前置知识递进,请先生成并查看第 {day - 1} 天,再继续第 {day} 天。</p>}
|
||||
{draft.maps[String(day)] ? <div className={`tm-map-viewer ${mapFullscreen ? 'is-fullscreen' : ''} ${mapAdviceOpen ? 'has-comment-panel' : ''}`} onContextMenu={event => {
|
||||
event.preventDefault()
|
||||
if (adminReadonly || busy) return
|
||||
const target = event.target as HTMLElement
|
||||
const node = target.closest<HTMLElement>('.tm-node')
|
||||
const selection = window.getSelection()
|
||||
const quote = selection?.anchorNode && event.currentTarget.contains(selection.anchorNode) ? selection.toString().trim().slice(0, 1600) : ''
|
||||
setMapCommentAnchor({ nodeId: node?.closest('.react-flow__node')?.getAttribute('data-id') || '', path: node?.dataset.mapPath || `第 ${day} 天整体框架`, quote })
|
||||
setCommentAdvice(''); setEditingCommentId(null); setCommentsSent(false); setMapAdviceOpen(true)
|
||||
}}><div className="tm-map-viewer-toolbar"><strong>第 {day} 天 · 文字框架导图</strong><span>右键节点或选中文字,添加定位评论</span><button className="mini" onClick={() => setMapFullscreen(value => !value)}>{mapFullscreen ? '退出全屏' : '全屏查看'}</button><button className="mini" onClick={() => openMapComment()} disabled={adminReadonly}>评论{dayComments.length ? ` · ${dayComments.length}` : ''}</button></div>
|
||||
{aiOperation && <div className="tm-viewer-progress" role="status" aria-live="polite"><strong>{operationBusy}</strong><div className="tm-progress-track"><div className="tm-progress-indeterminate" /></div><small>正在处理评论并检查内容衔接,完成后自动显示新版本。</small></div>}
|
||||
<MindMapRenderBoundary key={`${draft.version}-${day}`}><MindMap tree={draft.maps[String(day)]} comments={dayComments} onOpenComment={openMapComment}/></MindMapRenderBoundary>
|
||||
</div> : <div className="tm-empty">当天导图尚未生成。失败会保留草稿,重试不会重复创建。</div>}
|
||||
|
||||
</section>}
|
||||
{draft && <TrainingMapEvidence key={`${draft.id}-${draft.version}-${assessing ? 'assessing' : 'settled'}`} ref={evidencePanel} draftId={draft.id} version={draft.version} evidence={draft.evidence} />}
|
||||
{mapAdviceOpen && createPortal(<aside className="tm-comment-drawer" aria-label={`第 ${day} 天导图评论`}>
|
||||
<div className="tm-comment-head"><div><strong>导图评论</strong><span>第 {day} 天 · {dayComments.length} 条待提交</span></div><button onClick={() => setMapAdviceOpen(false)} aria-label="关闭评论面板">×</button></div>
|
||||
<div className="tm-comment-body">
|
||||
<p className="tm-comment-help">右键节点或选中文字添加评论。可连续批改多处,再一次性交给 AI 修改。</p>
|
||||
{commentsSent && <p className="tm-comment-success" role="status">评论已处理,导图已更新为新版本。</p>}
|
||||
{error && <p role="alert" className="tm-comment-error">{error}</p>}
|
||||
{aiOperation ? <div className="tm-comment-progress" role="status" aria-live="polite"><strong>{operationBusy}</strong><div className="tm-progress-track"><div className="tm-progress-indeterminate" /></div><p>AI 正在修改并检查连贯性,请稍候。</p></div> : <div className="tm-comment-composer">
|
||||
<span className="tm-comment-label">{editingCommentId ? `编辑评论 ${editingCommentId}` : '添加评论'}</span>
|
||||
<div className="tm-comment-anchor"><span>定位</span><strong>{mapCommentAnchor.path || `第 ${day} 天整体框架`}</strong></div>
|
||||
{mapCommentAnchor.quote && <blockquote>{mapCommentAnchor.quote}</blockquote>}
|
||||
<textarea aria-label="评论内容" rows={4} maxLength={3000} value={commentAdvice} onChange={e => setCommentAdvice(e.target.value)} placeholder="写下要调整的节点、顺序或衔接关系…" />
|
||||
<div className="tm-comment-composer-actions">{editingCommentId && <button onClick={() => openMapComment()}>取消编辑</button>}<button className="primary-action" disabled={!!busy || !commentAdvice.trim()} onClick={addMapComment}>{editingCommentId ? '保存评论' : '添加评论'}</button></div>
|
||||
</div>}
|
||||
<ol className="tm-comment-list">{dayComments.map(comment => <li key={comment.id}><div className="tm-comment-item-head"><span className="tm-comment-number">{comment.id}</span><strong>{comment.path.split(' → ').slice(-1)[0]}</strong><button disabled={!!busy} aria-label={`编辑评论 ${comment.id}`} onClick={() => openMapComment(comment)}>编辑</button><button disabled={!!busy} aria-label={`删除评论 ${comment.id}`} onClick={() => { setMapComments(old => old.filter(item => item.id !== comment.id)); if (editingCommentId === comment.id) openMapComment() }}>删除</button></div><small className="tm-comment-path">{comment.path}</small>{comment.quote && <blockquote>{comment.quote}</blockquote>}<p>{comment.advice}</p></li>)}</ol>
|
||||
{!dayComments.length && !commentsSent && <p className="tm-comment-empty">还没有评论。右键导图中需要调整的节点,即可定位批改。</p>}
|
||||
</div>
|
||||
<div className="tm-comment-footer">{commentAdvice.trim() && <small>当前输入尚未添加,请先保存为评论。</small>}<button className="primary-action" disabled={!!busy || dirty || !dayComments.length || !!commentAdvice.trim()} onClick={submitMapComments}>{aiOperation ? 'AI 正在修改…' : `发送 ${dayComments.length} 条评论给 AI`}</button><small>修改后保留未涉及节点,并检查前后内容承接。</small></div>
|
||||
</aside>, document.body)}
|
||||
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api, localizeError } from '../api'
|
||||
import type { ReadingCoverage } from './TrainingMapCoverage'
|
||||
import './training-map-timing.css'
|
||||
|
||||
export type AssessmentMode = 'reuse' | 'full' | 'quick'
|
||||
export type AssessmentEstimate = {
|
||||
mode: AssessmentMode; total_chunks: number; total_files: number; estimated_batches: number
|
||||
parallelism?: number; reusable_chunks?: number; reusable_files?: number; reusable_batches?: number
|
||||
new_chunks?: number; new_batches?: number
|
||||
seconds_low: number; seconds_high: number; basis: string
|
||||
stages: { id: string; label: string; seconds_low: number; seconds_high: number }[]
|
||||
per_day_seconds_low: number; per_day_seconds_high: number
|
||||
}
|
||||
const count = (value?: number) => Math.max(0, Number(value) || 0).toLocaleString('zh-CN')
|
||||
|
||||
function duration(seconds: number): string {
|
||||
const value = Math.max(0, Math.ceil(seconds))
|
||||
if (value < 60) return `${value} 秒`
|
||||
if (value < 3600) return `${Math.ceil(value / 60)} 分钟`
|
||||
const hours = Math.floor(value / 3600), minutes = Math.ceil((value % 3600) / 60)
|
||||
return minutes === 60 ? `${hours + 1} 小时` : `${hours} 小时${minutes ? ` ${minutes} 分钟` : ''}`
|
||||
}
|
||||
export function timeRange(low?: number, high?: number): string {
|
||||
if (low === undefined || high === undefined || !Number.isFinite(low) || !Number.isFinite(high)) return '正在估算'
|
||||
const first = duration(low), last = duration(Math.max(low, high))
|
||||
return first === last ? `约 ${first}` : `约 ${first}~${last}`
|
||||
}
|
||||
|
||||
export function useAssessmentEstimate(ids: number[], mode: AssessmentMode, requestedDays: string, categoryScope: Record<string, number[]> = {}) {
|
||||
const selection = JSON.stringify({ kb_ids: [...new Set(ids)].sort((a, b) => a - b), mode, kb_category_ids: categoryScope,
|
||||
requested_days: requestedDays && Number(requestedDays) >= 1 && Number(requestedDays) <= 30 ? Number(requestedDays) : null })
|
||||
const [result, setResult] = useState<{ key: string; data?: AssessmentEstimate; error?: string } | null>(null)
|
||||
const valid = ids.length > 0 && Object.values(categoryScope).every(values => values.length > 0)
|
||||
useEffect(() => {
|
||||
if (!valid) return
|
||||
let cancelled = false
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const refresh = async () => {
|
||||
try {
|
||||
// An estimate is advisory; never let a busy production gateway hold
|
||||
// the form for the full Cloudflare timeout window.
|
||||
const data = await Promise.race([
|
||||
api.post<AssessmentEstimate>('/training-maps/estimate', JSON.parse(selection)),
|
||||
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('估时服务繁忙,稍后自动重试')), 8000)),
|
||||
])
|
||||
if (cancelled) return
|
||||
setResult({ key: selection, data })
|
||||
// Automatic background reading can finish while this form stays open.
|
||||
// Keep the reuse count current without forcing the user to reselect libraries.
|
||||
if (mode === 'reuse' && (data.new_chunks ?? data.total_chunks - (data.reusable_chunks || 0)) > 0)
|
||||
timer = setTimeout(refresh, 30000)
|
||||
} catch (error) {
|
||||
if (cancelled) return
|
||||
const message = error instanceof Error && error.message === '估时服务繁忙,稍后自动重试'
|
||||
? error.message
|
||||
: localizeError(error)
|
||||
setResult({ key: selection, error: /524|超时|timeout/i.test(message) ? '估时服务繁忙,稍后自动重试' : message })
|
||||
}
|
||||
}
|
||||
timer = setTimeout(refresh, 350)
|
||||
return () => { cancelled = true; if (timer) clearTimeout(timer) }
|
||||
}, [selection, valid])
|
||||
const current = result?.key === selection ? result : null
|
||||
return { estimate: current?.data, estimateError: current?.error || '', estimateLoading: valid && !current }
|
||||
}
|
||||
|
||||
export function EstimatePreview({ estimate, loading, error, selected }: {
|
||||
estimate?: AssessmentEstimate; loading: boolean; error: string; selected: boolean
|
||||
}) {
|
||||
return <section className="tm-time-estimate" aria-label="预计完成时间">
|
||||
<div className="tm-time-heading"><strong>预计检索与方案生成时间</strong><span aria-live="polite">{!selected ? '选择知识库后显示' : loading ? '正在估算…' : estimate ? estimate.total_chunks ? timeRange(estimate.seconds_low, estimate.seconds_high) : '暂无可估算资料' : '暂时无法估算'}</span></div>
|
||||
{estimate && <>
|
||||
{estimate.mode !== 'quick' && estimate.total_chunks > 0 && <dl className="tm-estimate-inventory">
|
||||
{estimate.mode === 'reuse' && <div><dt>预计复用已读</dt><dd>{count(estimate.reusable_files)} 个文件 · {count(estimate.reusable_chunks)} 段</dd></div>}
|
||||
<div><dt>预计本次新读</dt><dd>{count(estimate.new_chunks ?? estimate.total_chunks)} 段{estimate.new_batches !== undefined ? ` · 约 ${count(estimate.new_batches)} 批` : ''}</dd></div>
|
||||
{estimate.parallelism !== undefined && <div><dt>阅读并发上限</dt><dd>最多 {count(estimate.parallelism)} 批</dd></div>}
|
||||
</dl>}
|
||||
{estimate.mode === 'reuse' && estimate.total_chunks > 0 && <p>{(estimate.reusable_chunks || 0) > 0 ? '以上复用量来自已保存的阅读记录;开始后会核对实际内容,以进度中的复用量为准。' : '当前没有可确认复用的阅读记录,先按全部所选资料需要新读估算;开始后会核对实际可复用内容。'}</p>}
|
||||
<ol className="tm-time-stages">{estimate.stages.filter(stage => estimate.mode !== 'quick' || stage.id !== 'reading').map(stage => <li key={stage.id}><span>{stage.label}</span><strong>{timeRange(stage.seconds_low, stage.seconds_high)}</strong></li>)}</ol>
|
||||
<p>{estimate.basis}。</p>
|
||||
{estimate.total_chunks > 0 && <p>以上估时截止到课程方案生成。确认方案后,每一天导图另需 {timeRange(estimate.per_day_seconds_low, estimate.per_day_seconds_high)}。</p>}
|
||||
</>}
|
||||
{error && <p role="status">估时暂不可用:{error}。仍可正常开始评估,页面会自动重试。</p>}
|
||||
</section>
|
||||
}
|
||||
|
||||
export function AssessmentTiming({ coverage, stopping }: { coverage?: ReadingCoverage; stopping?: boolean }) {
|
||||
if (!coverage?.mode) return null
|
||||
const terminal = ['complete', 'failed', 'cancelled'].includes(coverage.stage)
|
||||
const complete = coverage.stage === 'complete'
|
||||
const failed = coverage.stage === 'failed' || coverage.stage === 'cancelled'
|
||||
const percent = complete ? 100 : coverage.progress_percent === undefined ? undefined : Math.min(99, Math.max(0, coverage.progress_percent))
|
||||
const corpus = coverage.mode !== 'quick'
|
||||
const reuse = coverage.mode === 'reuse'
|
||||
const phases = reuse ? ['核对资料与已读记录', '复用与补充阅读', '按主题回查原文', '生成课程方案'] : corpus ? ['扫描资料', '分批阅读与汇总', '生成课程方案'] : ['相关资料检索', '生成课程方案']
|
||||
const stage = complete ? phases.length : coverage.stage === 'planning' ? phases.length - 1 : coverage.stage === 'rechecking' && reuse ? 2 : ['reading', 'reducing', 'summarizing'].includes(coverage.stage) ? 1 : 0
|
||||
const reading = ['reading', 'reducing', 'summarizing'].includes(coverage.stage)
|
||||
const parallelism = coverage.effective_parallelism ?? coverage.parallelism ?? coverage.estimate?.parallelism
|
||||
const active = coverage.timing?.active_model_calls ?? coverage.active_batches
|
||||
return <section className={`tm-task-progress${failed ? ' is-paused' : ''}`} aria-label="检索与方案生成进度">
|
||||
<div className="tm-time-heading"><strong>{complete ? '课程方案评估完成' : failed ? '评估已停止' : stopping ? '正在停止,等待当前批次结束' : '检索与方案生成进度'}</strong><span>{percent === undefined ? (failed ? '已停止' : '处理中') : `${Math.round(percent)}%`}</span></div>
|
||||
<progress max={100} value={percent ?? (terminal ? 0 : undefined)} aria-label="检索与方案生成整体进度" />
|
||||
<ol className="tm-live-stages">{phases.map((phase, index) => <li key={phase} className={index < stage ? 'is-complete' : index === stage && !failed ? 'is-current' : ''} aria-current={index === stage && !terminal ? 'step' : undefined}>{index < stage ? '✓ ' : `${index + 1}. `}{phase}</li>)}</ol>
|
||||
<div className="tm-time-metrics"><span>已用时:{coverage.elapsed_seconds === undefined ? '正在统计' : duration(coverage.elapsed_seconds)}</span>
|
||||
{!terminal && !stopping && <strong>{coverage.overdue ? '本次处理较久,正在更新预估时间' : `预计剩余:${timeRange(coverage.remaining_seconds_low, coverage.remaining_seconds_high)}`}</strong>}
|
||||
{complete && <strong>已完成,可查看并确认方案</strong>}
|
||||
</div>
|
||||
{!terminal && corpus && reading && parallelism !== undefined && <div className="tm-reading-activity">
|
||||
<span>{coverage.timing?.active_model_calls !== undefined ? '模型正在处理' : '正在处理'}:{active === undefined ? '正在统计' : `${count(active)} 批`} · 当前并发上限 {count(parallelism)} 批</span>
|
||||
{coverage.rate_limited && <strong role="status">服务商限流,已自动降低并发</strong>}
|
||||
{!coverage.rate_limited && active === 0 && <span>{stopping ? '正在收尾' : '正在核对或等待可用阅读名额'}</span>}
|
||||
</div>}
|
||||
{!terminal && coverage.stage === 'rechecking' && <p role="status">正在所选范围内查找与本次主题相关的原文,补充到课程评估中。</p>}
|
||||
{!terminal && !stopping && <p>进度随实际完成的阶段和资料更新;AI 返回方案后才会到 100%。</p>}
|
||||
</section>
|
||||
}
|
||||
|
||||
export function DayGenerationTiming({ active, label, estimate }: { active: boolean; label: string; estimate?: AssessmentEstimate }) {
|
||||
const [elapsed, setElapsed] = useState(0)
|
||||
useEffect(() => {
|
||||
if (!active) { setElapsed(0); return }
|
||||
const started = Date.now()
|
||||
const timer = setInterval(() => setElapsed(Math.floor((Date.now() - started) / 1000)), 1000)
|
||||
return () => clearInterval(timer)
|
||||
}, [active, label])
|
||||
if (!active) return null
|
||||
return <section className="tm-task-progress" aria-label="每日导图生成进度"><strong>{label}</strong>
|
||||
<progress aria-label="等待当天导图生成" />
|
||||
<div className="tm-time-metrics"><span>已用时:{duration(elapsed)}</span><span>{estimate && elapsed > estimate.per_day_seconds_high ? '模型仍在生成,已超过预估时间' : `本天预计耗时:${timeRange(estimate?.per_day_seconds_low, estimate?.per_day_seconds_high)}`}</span></div>
|
||||
<p>模型正在组织当天内容与衔接关系,完成后显示导图。</p>
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/* 页面组件:从 App.tsx 拆出(墨韵金重构) */
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api, localizeError } from '../api'
|
||||
import { useDialog } from '../dialog'
|
||||
import { Tag } from '../ui'
|
||||
import { STATUS_LABEL, KB_STATUS_LABEL, CLASSIFICATION_STATUS_LABEL, RUNTIME_EVENT_LABEL,
|
||||
RUNTIME_PHASE_LABEL, RUNTIME_TOOL_LABEL, PAGE_META, RULE_TEMPLATES } from '../constants'
|
||||
import type { Seg, Camp, CampaignState, RuntimeDetail, CampaignCreateResponse, ComposeResponse,
|
||||
AgentCreateResponse, AgentSummary, KnowledgeBaseSummary, NotificationSummary, AuthUser, AuthStatus,
|
||||
TraceSummary, TraceDetail, UsageSummary, UsageEventRow, PointState, PointAccountRow, UsageResponse,
|
||||
AdminUsageResponse, AgentListRow, MemoryListRow, KbFileRow, KbCategoryRow, KbDetailResponse, KbUsageResponse } from '../types'
|
||||
|
||||
|
||||
function errorText(error: unknown) {
|
||||
return localizeError(error)
|
||||
}
|
||||
|
||||
export default function UsagePage({ currentUser, adminMode }: { currentUser: AuthUser; adminMode: boolean }) {
|
||||
const dialog = useDialog()
|
||||
const [period, setPeriod] = useState<'monthly' | 'daily'>('monthly')
|
||||
const [usageTab, setUsageTab] = useState<'analysis' | 'billing'>(adminMode && (currentUser.role === 'admin' || currentUser.permissions?.includes('*') || currentUser.permissions?.includes('admin:manage')) ? 'billing' : 'analysis')
|
||||
const [service, setService] = useState('')
|
||||
const [summary, setSummary] = useState<UsageSummary | null>(null)
|
||||
const [events, setEvents] = useState<UsageEventRow[]>([])
|
||||
const [adminUsers, setAdminUsers] = useState<AdminUsageResponse['users']>([])
|
||||
const [allUsers, setAllUsers] = useState<AuthUser[]>([])
|
||||
const [points, setPoints] = useState<PointState>({ points: 0, token_equivalent: 0, remainder_points: 0, points_per_token: 10 })
|
||||
const [pointAccounts, setPointAccounts] = useState<PointAccountRow[]>([])
|
||||
const [pointForm, setPointForm] = useState({ user_id: '', points: '', reason: '' })
|
||||
const [grantTiers, setGrantTiers] = useState<{ tier: number; label: string; points: number }[]>([])
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const refreshRef = useRef(false)
|
||||
const mutationRef = useRef(false)
|
||||
// 门户由 AuthGate 固定,账单视图不再根据可伪造或过期的前端角色状态切换。
|
||||
const isAdmin = adminMode && (currentUser.role === 'admin' || currentUser.permissions?.includes('*') || currentUser.permissions?.includes('admin:manage'))
|
||||
const refresh = async () => {
|
||||
if (refreshRef.current) return
|
||||
refreshRef.current = true
|
||||
setBusy(true); setError('')
|
||||
try {
|
||||
const query = `period=${period}${service ? `&service_type=${encodeURIComponent(service)}` : ''}`
|
||||
if (isAdmin) {
|
||||
const [overview, rows, pointRows, users, tiers] = await Promise.all([
|
||||
api.get<AdminUsageResponse>(`/admin/usage/summary?${query}`),
|
||||
api.get<UsageEventRow[]>(`/admin/usage/events?${query}`),
|
||||
api.get<PointAccountRow[]>('/admin/points'),
|
||||
api.get<AuthUser[]>('/auth/users'),
|
||||
api.get<{ tier: number; label: string; points: number }[]>('/admin/points/grant-tiers'),
|
||||
])
|
||||
setSummary(overview.summary); setAdminUsers(overview.users); setEvents(rows); setPointAccounts(pointRows); setAllUsers(users)
|
||||
setGrantTiers(tiers)
|
||||
} else {
|
||||
const [overview, rows] = await Promise.all([
|
||||
api.get<UsageResponse>(`/usage/summary?${query}`),
|
||||
api.get<UsageEventRow[]>(`/usage/events?${query}`),
|
||||
])
|
||||
setSummary(overview.summary); setPoints(overview.points); setAdminUsers([]); setPointAccounts([]); setEvents(rows)
|
||||
}
|
||||
} catch (requestError: unknown) { setError(errorText(requestError)) }
|
||||
finally { refreshRef.current = false; setBusy(false) }
|
||||
}
|
||||
useEffect(() => { refresh() }, [period, service])
|
||||
const money = (minor: number) => new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format((minor || 0) / 100)
|
||||
const number = (value: number) => new Intl.NumberFormat('zh-CN').format(value || 0)
|
||||
return <div className="usage-page">
|
||||
<div className="detail-tabs" role="tablist">
|
||||
<button role="tab" aria-selected={usageTab === 'analysis'} className={`detail-tab ${usageTab === 'analysis' ? 'active' : ''}`} onClick={() => setUsageTab('analysis')}>用量分析</button>
|
||||
{isAdmin && <button role="tab" aria-selected={usageTab === 'billing'} className={`detail-tab ${usageTab === 'billing' ? 'active' : ''}`} onClick={() => setUsageTab('billing')}>余额</button>}
|
||||
</div>
|
||||
{error && <div className="err">{error}</div>}
|
||||
{usageTab === 'analysis' && <>
|
||||
<div className="page-toolbar">
|
||||
<div className="usage-filters"><select disabled={busy} value={period} onChange={event => setPeriod(event.target.value as 'monthly' | 'daily')} aria-label="账单周期"><option value="monthly">本月</option><option value="daily">今天</option></select><select disabled={busy} value={service} onChange={event => setService(event.target.value)} aria-label="服务类型"><option value="">全部服务</option><option value="llm">LLM</option><option value="embedding">Embedding</option><option value="ocr">OCR</option><option value="storage">存储</option><option value="export">导出</option></select><button className="mini" onClick={refresh} disabled={busy}>↻ 刷新</button></div>
|
||||
</div>
|
||||
<p className="field-help">口径说明:这里统计的是所选周期(本月/今天)内、当前账号可见的计量事件;运营看板的「近 7 天」为全组织口径,两者数字不同属正常。</p>
|
||||
<div className="metric-grid usage-metrics">
|
||||
|
||||
<div className="metric-card metric-accent"><div className="metric-top"><span>Token 总量</span><span className="metric-icon">∑</span></div><strong>{number(summary?.tokens || 0)}</strong><small>输入 {number(summary?.input_tokens || 0)} · 输出 {number(summary?.output_tokens || 0)}</small></div>
|
||||
<div className="metric-card metric-warning"><div className="metric-top"><span>本周期费用</span><span className="metric-icon orange">¥</span></div><strong>{money(summary?.amount_minor || 0)}</strong><small>{summary?.settled_events || 0} 条已结算 · {summary?.estimated_events || 0} 条估算</small></div>
|
||||
<div className="metric-card"><div className="metric-top"><span>计量事件</span><span className="metric-icon green">✓</span></div><strong>{number(summary?.events || 0)}</strong><small>{number(summary?.measured_events || 0)} 条已测量</small></div>
|
||||
<div className="metric-card"><div className="metric-top"><span>{isAdmin ? '用户归属' : '计量方式'}</span><span className="metric-icon purple">◫</span></div><strong>{isAdmin ? number(adminUsers.length) : number(summary?.events || 0)}</strong><small>{isAdmin ? '产生过用量的用户数' : '本周期计量事件条数'}</small></div>
|
||||
</div>
|
||||
{adminUsers.length > 0 && <section><div className="section-title-row"><div><h2>用户用量汇总</h2><p>每条用量事件按发起账号归属,避免多人共用模型时互相串账。</p></div></div><div className="usage-user-list">{adminUsers.map(item => <div className="usage-user-row" key={item.user_id}><span><b>{item.display_name || `用户 #${item.user_id}`}</b><small>用户 #{item.user_id}</small></span><span>{number(item.summary.tokens)} Token</span><span>{money(item.summary.amount_minor)}</span><span>{item.summary.events} 条事件</span></div>)}</div></section>}
|
||||
<section><div className="section-title-row"><div><h2>用量明细({events.length})</h2><p>费率与状态来自事件落账时的快照;估算项会明确标识。</p></div></div>{events.length ? <div className="usage-event-list">{events.map(event => { const meta = event.metadata || {}; const operation = String(meta.operation || (event.service_type === 'llm' ? '内容生成' : event.service_type === 'embedding' ? '知识检索' : event.service_type === 'ocr' ? '图片文字识别' : '系统服务')); const agent = String(meta.agent_name || ''); return <div className="usage-event-row" key={event.id}><div><b>{operation} · {agent ? `${agent} · ` : ''}{event.model || event.provider || '本地服务'}</b><small>{event.created_at} · {event.trace_id || '无 Trace'}{isAdmin ? ` · ${event.display_name || event.username || '未知用户'}` : ''}</small></div><span>{number(event.input_quantity)} + {number(event.output_quantity)} tok</span><span>{number(Math.round(event.quantity))} {event.unit}</span><span>{money(event.amount_minor)}</span><Tag s={event.status} /></div> })}</div> : <div className="empty-workspace compact"><strong>当前周期暂无用量</strong><span>运行任务、检索知识或使用 OCR 后,这里会显示“创建内容、内容生成、知识检索”等计量明细。</span></div>}</section>
|
||||
</>}
|
||||
{isAdmin && usageTab === 'billing' && (
|
||||
<section><div className="section-title-row"><div><h2>用户点数余额</h2><p>查看本组织全部用户余额,选择用户发放点数。普通用户在右上角查看自己的余额。</p></div></div><div className="usage-user-list">{pointAccounts.map(account => <div className="usage-user-row" key={account.user_id}><span><b>{account.display_name || account.username}</b><small>{account.username} · {account.status || '正常'}</small></span><span>{number(account.points)} 点数</span><span>{account.last_change_at ? <><b>最近变更</b><small>{account.last_change_reason || '点数调整'} · {account.last_change_at}</small></> : <small>暂无变更</small>}</span><Tag s={account.status || 'active'} /></div>)}</div><div className="card quota-editor point-editor"><select value={pointForm.user_id} onChange={event => setPointForm(current => ({ ...current, user_id: event.target.value }))}><option value="">选择成员</option>{allUsers.map(user => <option key={user.id} value={user.id}>{user.display_name || user.username}</option>)}</select><input type="number" min="1" step="1" placeholder="增加点数" value={pointForm.points} onChange={event => setPointForm(current => ({ ...current, points: event.target.value }))} /><input placeholder="增加原因(可选)" value={pointForm.reason} onChange={event => setPointForm(current => ({ ...current, reason: event.target.value }))} /><button className="mini" disabled={!pointForm.user_id || !pointForm.points || busy} onClick={async () => { if (mutationRef.current) return; mutationRef.current = true; setBusy(true); setError(''); try { await api.post('/admin/points/grant', { user_id: Number(pointForm.user_id), points: Number(pointForm.points), reason: pointForm.reason || '管理员增加点数' }); setPointForm({ user_id: '', points: '', reason: '' }); await refresh(); window.dispatchEvent(new Event('points-updated')) } catch (requestError: unknown) { setError(errorText(requestError)) } finally { mutationRef.current = false; setBusy(false) } }}>增加点数</button></div><div className="tier-row">{grantTiers.map(tier => <button key={tier.tier} className="tier-button" disabled={!pointForm.user_id || busy} onClick={async () => { if (mutationRef.current) return; mutationRef.current = true; setBusy(true); setError(''); try { const r = await api.post<{ points: { points: number } }>('/admin/points/grant', { user_id: Number(pointForm.user_id), tier: tier.tier }); await dialog.notice({ title: `${tier.label} 发放成功`, description: `当前余额 ${new Intl.NumberFormat('zh-CN').format(r.points.points)} 点数。` }); setPointForm(current => ({ ...current, })); await refresh(); window.dispatchEvent(new Event('points-updated')) } catch (requestError: unknown) { setError(errorText(requestError)) } finally { mutationRef.current = false; setBusy(false) } }}>+ {tier.label}</button>)}</div></section>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { PageHeader } from '../workbench-ui'
|
||||
|
||||
const Step = ({ n, title, children }: { n: number; title: string; children: React.ReactNode }) => <section className="card manual-card manual-step"><div className="manual-step-head"><span className="manual-step-no">{n}</span><h2>{title}</h2></div><div className="manual-step-body">{children}</div></section>
|
||||
|
||||
export default function UserManualPage() {
|
||||
return <div className="manual-page">
|
||||
<PageHeader title="新用户操作手册" description="第一次使用时,请从上到下按这份手册操作。每一步都写明入口、填写内容、按钮和完成后的结果。" meta={<span className="scope-chip">普通用户指南</span>} />
|
||||
<div className="manual-notice"><b>先记住 3 件事</b><span>知识库是资料来源;Agent 与技能决定怎么做;内容任务负责把导图变成可审查的成稿。</span></div>
|
||||
<div className="manual-grid">
|
||||
<Step n={1} title="登录并认识页面"><p>打开系统网址,输入用户名和密码,点击“登录”。登录后:</p><ul><li>左侧是功能入口;没有显示的菜单代表当前账号暂时不能使用。</li><li>右上角依次是“操作手册”、通知铃铛、余额和退出。</li><li>页面顶部的面包屑显示当前位置。浏览器后退可返回上一页。</li></ul><p className="manual-result">看到“内容生产工作区”和左侧菜单,就表示登录成功。</p></Step>
|
||||
<Step n={2} title="准备知识库"><ol><li>入口:左侧“知识库” → “新建”。填写“知识库名称”(必填)和“说明”(可选),点击创建。</li><li>进入知识库 → “文件” → “上传文件”,选择 doc、docx、pdf、txt、md、srt 或表格;也可以选择“上传文件夹”。</li><li>等待状态从“处理中/索引中”变为“索引就绪”。大文件会显示上传百分比和后台进度,不要连续重复点击上传。</li><li>“分类”页可新建分类、选择上级分类并整理文件;标签也在知识库里维护。</li><li>“检索测试”输入具体问题,点击“检索”,查看命中片段和来源文件。</li><li>“消费记录”可以按“已消费/已开放”查看自己的记录,选择片段后开放使用。</li></ol><p className="manual-tip">上传相同文件或重复片段时,系统会提示跳过重复或合并重复,这是正常的去重结果。</p></Step>
|
||||
<Step n={3} title="没有 Agent 时,先创建一个"><ol><li>入口:左侧“Agent 与技能”→点击“新建”。如果不想从空白开始,也可以点击“AI 帮我创建”。</li><li>在“基本信息”填写 Agent 名称、职责类型、描述和内容标签。名称要能说明用途,例如“课程写作 Agent”。</li><li>在“知识与技能”选择它可以使用的知识库、知识库分类和技能;没有知识库时先回到“知识库”上传并索引资料。</li><li>在“工具与模型”选择模型,并按需要开启工具;普通写作任务不必打开无关工具。</li><li>在“数字人身份”选择已生效的数字人,统一语气、叙事方式和表达风格;没有数字人也可以先创建,后续再绑定。</li><li>点击底部“保存草稿(v…)”。保存后先检查配置,再点击“发布(冻结配置)”,之后内容任务才会使用这份固定配置。</li><li>回到 Agent 列表,看到状态为“启用”并能在内容段选择它,就表示创建成功。修改后要重新保存;需要让新配置稳定用于后续任务时再次发布。</li></ol><p className="manual-tip">创建 Agent 的最小条件:名称、职责、至少一个可用知识库和模型。没有知识库时,Agent 可以保存,但不能稳定生成有依据的内容。</p></Step>
|
||||
<Step n={4} title="选择 Agent、技能和记忆"><ol><li>入口:左侧“Agent 与技能”,在左侧列表点击 Agent 名称。</li><li>先看职责和内容标签,再看“知识与技能”中的知识库分类、技能和融合 Agent。</li><li>查看“工具与模型”确认它能使用的工具和模型;查看“数字人身份”确认表达风格。</li><li>记忆已经并入 Agent 与技能,不需要寻找单独的“记忆库”。长期记忆会影响后续生成。</li></ol><p className="manual-result">选择原则:写标题选选题类 Agent,写正文选写作类 Agent,检查事实和逻辑选审校类 Agent。</p></Step>
|
||||
<Step n={5} title="创建或确认训练营导图"><ol><li>训练营导图已经并入“内容任务”流程。先填写主题、期望天数,并选择要使用的知识库和分类范围。</li><li>阅读模式有三种:复用已读资料(推荐)、重新阅读全文(更慢且用量更高)、快速相关检索(更快但不读全库)。</li><li>点击“保存草稿”后再点击“评估”。页面会显示推荐天数、每天标题、前置知识、当天目标、来源证据和预计时间。</li><li>确认方案后,选择“修改整套方案”或“只修改某一天”,写明建议并点击让 AI 按建议修改。</li><li>按天生成文字导图。前一天未确认时,后一天会保持锁定;逐天检查承接、重复和逻辑。</li><li>确认文字导图后勾选“我已确认当天文字导图”,再生成导图图片。确认后的导图才能导入内容任务。</li></ol><p className="manual-tip">发现跳跃时写“补充前置概念并增加过渡”;发现重复时写“删除与第 X 天重复的内容并保留递进关系”。</p></Step>
|
||||
<Step n={6} title="创建内容任务"><ol><li>入口:左侧“内容任务” → 右上角“新建任务”。</li><li>填写“任务名”,例如“课程第 1 天直播稿”。</li><li>在“训练营导图”选择已确认导图;在“对应天数(导图第几天)”选择要生成的那一天,导图文本会自动导入。</li><li>在“画布模板(必选)”选择模板。模板决定 Agent 的处理顺序,不能留空。</li><li>按需选择直播总时长和质检方式:先出稿后台质检(推荐)、出稿+同步质检、不质检。</li><li>确认导图文本无误后点击“创建并拆解”。按钮置灰通常是因为任务名、导图文本或画布模板缺少。</li></ol><p className="manual-result">创建成功后会自动进入任务详情,并看到拆解出的内容段。</p></Step>
|
||||
<Step n={7} title="逐段生成和审查"><ol><li>在任务详情点击“开始生成(第 1 段)”,以后点击“生成下一段”。</li><li>展开内容段,可看到标题、正文、负责 Agent、引用来源和质检结果。</li><li>满意点击“通过”;不满意点击“打回”,填写具体意见(例如“补充与上一段的过渡”),再点击“打回并重做”。</li><li>本段不需要时点击“跳过”,下一段会继续衔接已通过的内容。</li><li>需要更换负责者时,在内容段的 Agent 选择处更换并保存,然后重新检查语气、事实和上下文。</li><li>全部段落通过后点击“组合成稿”,打开预览确认全文,再交付或下载。</li></ol><p className="manual-tip">从上一段结尾读到下一段开头,才能发现重复、断层和突然换话题。</p></Step>
|
||||
<Step n={8} title="数字人和画布怎么用"><p><b>数字人:</b>进入“数字人” → “蒸馏新数字人”,填写名称,粘贴资料或上传文档,点击开始蒸馏。状态变为“待人工测试”后,填写测试主题并试写;不满意写建议后按建议重新蒸馏,满意后再审核生效。只有生效版本会被 Agent 使用。</p><p><b>画布:</b>进入“画布编排”可以查看节点顺序和每个节点对应的 Agent。画布模板和训练营导图是两回事:导图提供学习内容,画布决定内容如何生产。</p></Step>
|
||||
<Step n={9} title="查看进度、通知和失败原因"><ul><li>右上角铃铛查看生成完成、失败和待审查通知,打开后可“一键已读”。</li><li>“运行记录”查看排队、运行、暂停、完成或失败状态,也能展开 Trace 和 AI 调用轨迹。</li><li>失败段点击“重试”或“跳过”;生成中可暂停、继续或停止,不要反复创建同一个任务。</li></ul><p className="manual-tip">处理问题顺序:先读红色错误原文 → 回到对应知识库/导图/任务修正 → 再点击重试。</p></Step>
|
||||
<Step n={10} title="常见提示怎么处理"><ul><li><b>没有可使用知识库:</b>先上传资料并等待索引就绪,再确认 Agent 已绑定该知识库。</li><li><b>导图尚未生成每日导图:</b>回到导图流程按天生成并确认。</li><li><b>请选择画布模板:</b>模板是必选项,回到创建任务面板选择。</li><li><b>任务还没有内容段:</b>确认导图文本不为空、画布已选,再重新拆解。</li><li><b>检索无结果:</b>等待索引完成,缩小分类范围,换成更具体的问题。</li><li><b>文档解析失败:</b>扫描版 PDF 可能没有文字层,改用可复制文字版。</li><li><b>页面版本可能过期:</b>按 Cmd/Ctrl+Shift+R 强制刷新后重试。</li><li><b>暂无此功能权限:</b>该入口暂时未向你的账号开放,请联系系统负责人。</li></ul></Step>
|
||||
<Step n={11} title="看懂状态"><div className="manual-status-table"><div><b>知识库</b><span>索引中=正在处理;索引就绪=可以检索;索引异常=需要重新处理。</span></div><div><b>任务</b><span>拆解中=正在读取导图;待生成=可以开始;生成中=AI 正在写;待审查=等待你通过/打回/跳过;可合成=全部段落已处理;已交付=成稿完成。</span></div><div><b>内容段</b><span>待派发=还未生成;生成中=正在生成;待审查=等待你检查;已通过=可继续下一段;重做=正在按意见修改;失败=可重试;跳过=不纳入成稿。</span></div></div></Step>
|
||||
<Step n={12} title="完整示例:从资料到成稿"><p><b>目标:</b>生成“课程第 1 天直播稿”。</p><ol><li>知识库 → 新建“课程资料” → 上传《课程.md》 → 等待索引就绪。</li><li>检索测试输入“第 1 天要学会什么”,确认能找到来源片段。</li><li>Agent 与技能 → 选择“写作 Agent”,确认绑定“课程资料”。</li><li>内容任务 → 新建任务 → 选择已确认导图 → 选择“第 1 天” → 选择画布模板。</li><li>点击创建并拆解 → 生成第 1 段 → 通过或打回 → 生成下一段。</li><li>全部段落通过 → 组合成稿 → 预览 → 交付。</li></ol></Step>
|
||||
</div>
|
||||
<section className="card manual-footer"><h2>你已经学会了什么</h2><p>能独立完成“资料上传并检索 → 选择 Agent → 选择导图天数和画布模板 → 创建任务 → 逐段审查 → 组合成稿”,就掌握了系统的主要使用方法。</p></section>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// AI 帮我创建 v2:草稿出来先人工审核 → 确认才应用;不满意填建议 → AI 按建议重新生成(带上一稿)。
|
||||
import { useState } from 'react'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { api, localizeError } from '../api'
|
||||
|
||||
export type AiDraft = {
|
||||
kind: string
|
||||
name?: string
|
||||
desc?: string
|
||||
role?: string
|
||||
rules?: string[]
|
||||
content_tags?: string[]
|
||||
skill_suggestions?: string[]
|
||||
instructions?: string[]
|
||||
role_prompt?: string
|
||||
task_template?: string
|
||||
constraints?: string[]
|
||||
style?: string
|
||||
advice?: string
|
||||
conflicts?: string[]
|
||||
warnings?: string[]
|
||||
_meta?: { provider?: string; model?: string; tokens?: number; duration_ms?: number }
|
||||
}
|
||||
|
||||
const KIND_LABEL: Record<string, string> = { agent: 'Agent', skill: '技能', template: '提示模板' }
|
||||
|
||||
const PLACEHOLDER: Record<string, string> = {
|
||||
agent: '例:帮我创建一个写小红书笔记的助手,说话要口语化像跟姐妹聊天,每篇带 3 个 emoji,结尾引导评论互动',
|
||||
skill: '例:我需要一个写口播稿的技能,开头 3 秒抓人,中间讲干货,最后引导关注',
|
||||
template: '例:做一个美妆类内容的提示模板,要求输出大白话,像当面跟姐妹讲,禁止书面语',
|
||||
}
|
||||
|
||||
export function AiAssistDialog({ kind, onApply, onClose }: {
|
||||
kind: 'agent' | 'skill' | 'template'
|
||||
onApply: (draft: AiDraft) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [description, setDescription] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [draft, setDraft] = useState<AiDraft | null>(null)
|
||||
const [reviseOpen, setReviseOpen] = useState(false)
|
||||
const [advice, setAdvice] = useState('')
|
||||
const [rounds, setRounds] = useState(0)
|
||||
|
||||
const generate = async (reviseAdvice = '') => {
|
||||
setLoading(true); setError('')
|
||||
try {
|
||||
const payload: Record<string, unknown> = { kind, description }
|
||||
if (reviseAdvice) { payload.advice = reviseAdvice; if (draft) payload.previous_draft = draft }
|
||||
const result = await api.post<AiDraft>('/ai-assist/draft', payload)
|
||||
setDraft(result)
|
||||
setReviseOpen(false); setAdvice('')
|
||||
if (reviseAdvice) setRounds(n => n + 1)
|
||||
} catch (e: unknown) { setError(localizeError(e)) }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dialog-backdrop" onClick={onClose}>
|
||||
<div className="app-dialog ai-assist-dialog" onClick={e => e.stopPropagation()}>
|
||||
<div className="dialog-icon">✦</div>
|
||||
<div className="dialog-copy">
|
||||
<h2>AI 帮我创建{KIND_LABEL[kind]}</h2>
|
||||
<p>AI 只出草稿:<b>你审核确认后才会创建</b>。不满意就填建议,AI 按你的意见重新生成。</p>
|
||||
</div>
|
||||
<div className="ai-assist-body">
|
||||
<textarea
|
||||
className="ai-assist-input"
|
||||
placeholder={PLACEHOLDER[kind]}
|
||||
value={description}
|
||||
onChange={event => setDescription(event.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
{error && <div className="err">{error}</div>}
|
||||
{loading && <div className="inline-ai-progress" role="status" aria-live="polite"><div><strong>AI 正在生成草稿</strong><span>正在理解需求、整理规则并检查结果</span></div><div className="ai-progress-track"><div className="ai-progress-indeterminate" /></div><small>理解需求 → 组织内容 → 返回待确认草稿</small></div>}
|
||||
{!draft && <button className="ok ai-assist-go" onClick={() => generate()} disabled={loading || description.trim().length < 5}>
|
||||
{loading ? <><span className="auth-spinner light" /> AI 正在理解…</> : '✦ 让 AI 起草'}
|
||||
</button>}
|
||||
{draft && <>
|
||||
{rounds > 0 && <div className="tag y" style={{ alignSelf: 'flex-start' }}>已按你的建议重做 {rounds} 轮</div>}
|
||||
<DraftPreview draft={draft} />
|
||||
{reviseOpen && <div className="advice-inline" style={{ width: '100%' }}>
|
||||
<input autoFocus placeholder="修改建议:如名字换成 XX;规则里加上禁止书面语;面向小白用户…" value={advice} onChange={e => setAdvice(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && advice.trim()) generate(advice) }} />
|
||||
<button className="ok" disabled={!advice.trim() || loading} onClick={() => generate(advice)}>{loading ? '生成中…' : 'AI 重做'}</button>
|
||||
<button className="mini" onClick={() => { setReviseOpen(false); setAdvice('') }}>取消</button>
|
||||
</div>}
|
||||
<div className="ai-assist-actions">
|
||||
<button className="ok" onClick={() => onApply(draft)}>✓ 审核通过,用这份创建</button>
|
||||
{!reviseOpen && <button onClick={() => setReviseOpen(true)}><RefreshCw size={13} /> 不满意,提建议重做</button>}
|
||||
<button className="mini" onClick={onClose}>先不要</button>
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DraftPreview({ draft }: { draft: AiDraft }) {
|
||||
return <div className="ai-draft">
|
||||
{draft.conflicts && draft.conflicts.length > 0 && (
|
||||
<div className="warn"><b>先处理这两个问题</b><ul>{draft.conflicts.map((c, i) => <li key={i}>{c}</li>)}</ul></div>
|
||||
)}
|
||||
{draft.warnings && draft.warnings.length > 0 && (
|
||||
<div className="warn"><b>提醒</b><ul>{draft.warnings.map((w, i) => <li key={i}>{w}</li>)}</ul></div>
|
||||
)}
|
||||
<div className="ai-draft-grid">
|
||||
{draft.name && <div className="ai-draft-row"><b>名称</b><span>{draft.name}</span></div>}
|
||||
{draft.role && <div className="ai-draft-row"><b>角色</b><span>{{ production: '生产', qc: '质检', composer: '组合', service: '客服' }[draft.role] || draft.role}</span></div>}
|
||||
{draft.desc && <div className="ai-draft-row"><b>说明</b><span>{draft.desc}</span></div>}
|
||||
{draft.rules && draft.rules.length > 0 && <div className="ai-draft-row"><b>规则</b><span>{draft.rules.join(';')}</span></div>}
|
||||
{draft.content_tags && draft.content_tags.length > 0 && <div className="ai-draft-row"><b>标签</b><span>{draft.content_tags.join(' / ')}</span></div>}
|
||||
{draft.instructions && draft.instructions.length > 0 && <div className="ai-draft-row"><b>指令</b><span>{draft.instructions.join(';')}</span></div>}
|
||||
{draft.role_prompt && <div className="ai-draft-row"><b>角色提示</b><span>{draft.role_prompt.slice(0, 160)}</span></div>}
|
||||
{draft.task_template && <div className="ai-draft-row"><b>任务模板</b><span>{draft.task_template.slice(0, 160)}</span></div>}
|
||||
{draft.constraints && draft.constraints.length > 0 && <div className="ai-draft-row"><b>约束</b><span>{draft.constraints.join(';')}</span></div>}
|
||||
{draft._meta && <div className="ai-draft-row"><b>生成信息</b><span>{draft._meta.model || ''} · {draft._meta.tokens || 0} tok · {draft._meta.duration_ms || 0}ms</span></div>}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
#root .knowledge-usage-list{margin-top:18px}
|
||||
#root .knowledge-usage-toolbar{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:14px}
|
||||
#root .knowledge-usage-toolbar>strong>span{font-size:13px;color:#697a8b;font-weight:400;margin-left:5px}
|
||||
#root .knowledge-usage-toolbar>div{display:flex;gap:7px;flex-wrap:wrap}
|
||||
#root .usage-drawer{border:1px solid #cedae5;border-radius:10px;background:#fff;margin:12px 0;overflow:hidden}
|
||||
#root .usage-drawer>summary{display:flex;align-items:center;gap:9px;padding:15px 16px;list-style:none;cursor:pointer;background:#f5f8fb;color:#304760}
|
||||
#root .usage-drawer>summary::-webkit-details-marker{display:none}
|
||||
#root .usage-drawer>summary>svg{transition:transform .15s ease}
|
||||
#root .usage-drawer[open]>summary>svg{transform:rotate(90deg)}
|
||||
#root .usage-drawer>summary:focus-visible{outline:2px solid #2868a8;outline-offset:-3px}
|
||||
#root .usage-drawer-count{font-size:12px;font-weight:600;min-width:25px;text-align:center;padding:2px 7px;border-radius:12px;background:#e2eaf3;color:#3a5976}
|
||||
#root .usage-drawer-hint{margin-left:auto;font-size:12px;color:#61758a}
|
||||
#root .usage-drawer-body{padding:4px 14px 14px}
|
||||
#root .knowledge-usage-card{display:flex;align-items:center;gap:12px;border:1px solid #dce4ec;border-radius:8px;margin-top:9px;padding:12px;background:#fff}
|
||||
#root .knowledge-usage-card.is-selected{background:#eaf3ff;border-color:#73a5d7}
|
||||
#root .usage-card-choice{display:flex;align-items:flex-start;gap:12px;flex:1;min-width:0;margin:0;padding:0;cursor:pointer;color:#324960}
|
||||
#root div.usage-card-choice{cursor:default}
|
||||
#root .knowledge-usage-card input[type=checkbox]{appearance:auto;display:block;width:18px!important;height:18px!important;min-height:18px!important;max-height:18px!important;min-width:18px;flex:0 0 18px;max-width:18px;padding:0;margin:3px 0 0;accent-color:#2868a8;box-shadow:none}
|
||||
#root .usage-card-text{display:flex;flex-direction:column;gap:5px;min-width:0;flex:1}
|
||||
#root .usage-card-title{display:flex;gap:8px;flex-wrap:wrap;font-size:13px;overflow-wrap:anywhere}
|
||||
#root .usage-card-title>span{color:#53667b}
|
||||
#root .usage-card-excerpt{font-weight:400;color:#425970;font-size:13px;line-height:1.65;overflow-wrap:anywhere;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
|
||||
#root .usage-card-meta{font-size:12px;font-weight:400;color:#718195;overflow-wrap:anywhere}
|
||||
#root .usage-release{display:flex;align-items:center;gap:5px;flex-shrink:0;margin:0}
|
||||
#root .usage-open-icon{flex-shrink:0;color:#557b72;margin-top:3px}
|
||||
#root .usage-empty{padding:12px;color:#758497;font-size:13px}
|
||||
#root .usage-kb-group{margin:10px 0 14px;padding:0 4px}
|
||||
#root .usage-kb-drawer{margin:8px 0;border:1px solid #e1e8ef;border-radius:8px;background:#fbfcfd;overflow:hidden}
|
||||
#root .usage-kb-drawer>summary{display:flex;align-items:center;gap:8px;padding:10px 12px;list-style:none;cursor:pointer;color:#3d5872;font-size:13px;font-weight:600;background:#f8fafc}
|
||||
#root .usage-kb-drawer>summary::-webkit-details-marker{display:none}
|
||||
#root .usage-kb-drawer>summary>svg{flex:0 0 auto;transition:transform .15s ease}
|
||||
#root .usage-kb-drawer[open]>summary>svg{transform:rotate(90deg)}
|
||||
#root .usage-kb-drawer>summary span{font-size:11px;color:#718397;background:#edf2f7;padding:2px 7px;border-radius:10px}
|
||||
#root .usage-kb-drawer>summary em{margin-left:auto;font-size:11px;font-style:normal;font-weight:400;color:#718397}
|
||||
#root .usage-kb-drawer .usage-kb-group{margin:0;padding:0 8px 8px}
|
||||
@media(max-width:600px){#root .knowledge-usage-card{flex-wrap:wrap}#root .usage-release{margin-left:30px}#root .usage-drawer-body{padding:2px 10px 10px}}
|
||||
@media(prefers-reduced-motion:reduce){#root .usage-drawer>summary>svg{transition:none}}
|
||||
#root .usage-card-kb{color:#2868a8;background:#eaf3ff;border-radius:4px;padding:2px 6px;font-weight:600}
|
||||
#root .all-usage-page{max-width:1120px;margin:0 auto;padding:0 24px 40px}
|
||||
#root .all-usage-header{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:wrap;margin:18px 0 12px}
|
||||
#root .all-usage-header h2{margin:0;color:#20354d;font-size:20px}
|
||||
#root .all-usage-header p{margin:6px 0 0;color:#6b7b8d;font-size:13px}
|
||||
#root .all-usage-count{font-size:13px;color:#52677d}
|
||||
#root .all-usage-loading{padding:32px;text-align:center;color:#6d7f92}
|
||||
@@ -0,0 +1,27 @@
|
||||
.persona-profile { display: grid; gap: 10px; margin-top: 12px; }
|
||||
.persona-group { border: 1px solid var(--border); border-radius: 12px; overflow: hidden; background: var(--card); }
|
||||
.persona-group > summary { display: flex; align-items: center; justify-content: space-between; gap: 12px; cursor: pointer; padding: 14px 16px; color: var(--ink); list-style: none; }
|
||||
.persona-group > summary::after { content: '+'; color: #315e86; font-size: 20px; }
|
||||
.persona-group[open] > summary::after { content: '−'; }
|
||||
.persona-group > summary:focus-visible { outline: 2px solid #548bb6; outline-offset: -3px; }
|
||||
.persona-group > summary small { display: block; font-size: 12px; font-weight: 400; color: var(--ink-2); margin-top: 4px; }
|
||||
.persona-group[open] > summary { background: #edf5fb; color: #234d70; }
|
||||
.persona-count { white-space: nowrap; font-size: 12px; color: #315e86; margin-left: auto; }
|
||||
.persona-fields { display: grid; gap: 16px; padding: 16px; }
|
||||
.persona-field { display: grid; gap: 7px; font-size: 13px; }
|
||||
.persona-field > span { font-weight: 600; }
|
||||
.persona-field > p { margin: 0; font-weight: 400; white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.7; }
|
||||
.persona-field > textarea { width: 100%; resize: vertical; font-weight: 400; }
|
||||
.persona-intro { color: var(--ink-2); font-size: 13px; line-height: 1.7; margin: 10px 0; }
|
||||
.persona-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 12px 0; }
|
||||
.persona-legacy { margin-top: 16px; border-top: 1px solid var(--border); padding-top: 12px; }
|
||||
.persona-legacy summary { cursor: pointer; font-size: 13px; font-weight: 600; }
|
||||
.persona-legacy pre, .persona-migration-preview { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 320px; overflow-y: auto; font-family: inherit; font-size: 12px; line-height: 1.7; background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 12px; }
|
||||
.persona-binding select { width: 100%; background: #f5f9fc; color: #234d70; border: 1px solid #bad1e3; }
|
||||
.persona-binding select option { background: #fff; color: #233b50; }
|
||||
#root .app-dialog.persona-migration-modal { display: block; width: min(720px, 100%); max-height: 90vh; overflow-y: auto; }
|
||||
.persona-migration-modal h2 { margin: 0 0 12px; font-size: 18px; line-height: 1.5; }
|
||||
.persona-migration-modal .persona-migration-preview { margin-top: 16px; }
|
||||
.persona-migration-modal label { display: grid; gap: 8px; text-align: left; width: 100%; }
|
||||
.persona-migration-modal select { width: 100%; }
|
||||
@media (max-width: 600px) { .persona-group > summary { padding: 12px; } .persona-fields { padding: 12px; } }
|
||||
@@ -0,0 +1,2 @@
|
||||
.resource-revision-body{padding:16px;display:grid;gap:12px}.resource-revision-intro{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}.resource-revision-intro p{margin:0;line-height:1.7;color:var(--ink-2);font-size:12px;max-width:780px}.resource-revision-intro button{flex-shrink:0}.revision-status-group{border:1px solid var(--line,#e8e2df);border-radius:10px;overflow:hidden}.revision-status-group>summary{display:flex;gap:10px;align-items:center;padding:12px 14px;cursor:pointer;background:var(--card,#fff)}.revision-status-group>summary span{font-size:12px;color:var(--ink-2)}.resource-revision-item{margin:0 12px 10px;border-top:1px solid var(--line,#e8e2df)}.resource-revision-item>summary{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:14px 2px;cursor:pointer}.resource-revision-item>summary>span:first-child{display:grid;gap:4px;min-width:0}.resource-revision-item b{overflow-wrap:anywhere}.resource-revision-item small{font-size:12px;color:var(--ink-2)}.revision-detail{padding:0 2px 14px}.revision-changes{margin:12px 0;display:grid;gap:10px}.revision-changes>div{border:1px solid var(--line,#e8e2df);border-radius:8px;overflow:hidden}.revision-changes dt{font-size:12px;font-weight:600;padding:8px 10px;background:var(--page,#f7f4f2)}.revision-changes dd{margin:0;padding:10px}.revision-changes pre{margin:0;font:inherit;font-size:12px;white-space:pre-wrap;overflow-wrap:anywhere;max-height:320px;overflow:auto;line-height:1.7}.revision-review-note{display:grid;gap:6px;font-size:12px;margin:14px 0}.revision-review-note textarea{width:100%;box-sizing:border-box}@media(max-width:640px){.resource-revision-body{padding:10px}.resource-revision-intro{flex-direction:column}.resource-revision-item>summary{gap:8px;align-items:flex-start}}
|
||||
.revision-removal-list{display:grid;gap:8px;padding:10px 14px 14px}.revision-removal-list>div{display:grid;gap:3px;padding:10px;border:1px solid var(--line,#e8e2df);border-radius:8px}.revision-removal-list span,.revision-removal-list small{font-size:12px;color:var(--ink-2)}
|
||||
@@ -0,0 +1,19 @@
|
||||
.training-map-page .tm-library-scope{margin:10px 0;border:1px solid #cbd8e8;border-radius:8px;background:#fff;color:#30435b}
|
||||
.training-map-page .tm-library-scope>summary{padding:13px 16px;cursor:pointer;font-size:14px}
|
||||
.training-map-page .tm-library-scope>summary span{margin-left:12px;color:#3a5e88;font-size:13px}
|
||||
.training-map-page .tm-library-scope-body{padding:0 16px 16px;border-top:1px solid #e1e8f1}
|
||||
.training-map-page .tm-library-scope-body p{font-size:13px;color:#50627a}
|
||||
.training-map-page .tm-scope-modes{display:flex;gap:20px;flex-wrap:wrap;padding-top:14px}
|
||||
.training-map-page .tm-scope-modes label,.training-map-page .tm-category-choice{display:flex;gap:9px;align-items:flex-start;cursor:pointer}
|
||||
.training-map-page .tm-scope-modes input,.training-map-page .tm-category-choice input{width:17px;height:17px;margin:3px 0 0;accent-color:#2868a8;flex-shrink:0;padding:0}
|
||||
.training-map-page .tm-category-choice{padding:9px 12px;border:1px solid transparent;border-radius:6px;font-size:14px}
|
||||
.training-map-page .tm-category-choice:has(input:checked){background:#eaf3ff;color:#174777;border-color:#aac4e3}
|
||||
.training-map-page .tm-category-choice:has(input:focus-visible){outline:2px solid #2868a8;outline-offset:2px}
|
||||
.training-map-page .tm-category-choice small{display:block;color:#50627a;font-size:12px;margin-top:3px}
|
||||
.training-map-page .tm-category-tree,.training-map-page .tm-category-tree ul{list-style:none;margin:8px 0;padding-left:0}
|
||||
.training-map-page .tm-category-tree ul{padding-left:18px;border-left:1px solid #d5dfec}
|
||||
.training-map-page .tm-category-tree details>summary{padding:4px 12px;font-size:13px;cursor:pointer;color:#215b94}
|
||||
.training-map-page .tm-category-tree{max-height:390px;overflow:auto}
|
||||
.training-map-page .tm-library-scope-body .tm-scope-required{color:#8a431b}
|
||||
.training-map-page .tm-category-search{width:100%;max-width:440px}
|
||||
@media(max-width:600px){.training-map-page .tm-library-scope>summary span{display:block;margin:5px 0 0 18px}}
|
||||
@@ -0,0 +1,28 @@
|
||||
.training-map-page .tm-time-estimate,.training-map-page .tm-task-progress{margin:18px 0;padding:18px 20px;background:#f4f7fc;border:1px solid #cbd8eb;border-radius:8px;color:#24364d}
|
||||
.training-map-page .tm-time-heading,.training-map-page .tm-time-metrics{display:flex;align-items:baseline;justify-content:space-between;flex-wrap:wrap;gap:8px 20px}
|
||||
.training-map-page .tm-time-heading>span{color:#194b89;font-size:17px;font-weight:650;font-variant-numeric:tabular-nums}
|
||||
.training-map-page .tm-time-stages,.training-map-page .tm-live-stages{list-style:none;padding:0;display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:10px;margin:16px 0}
|
||||
.training-map-page .tm-time-stages li{padding:10px 12px;background:#fff;border:1px solid #d9e1ec;border-radius:6px;font-size:13px}
|
||||
.training-map-page .tm-time-stages span,.training-map-page .tm-time-stages strong{display:block}
|
||||
.training-map-page .tm-time-stages strong{margin-top:5px;font-size:14px}
|
||||
.training-map-page .tm-live-stages li{font-size:13px;color:#56647a;padding-top:6px;border-top:2px solid #d1dceb}
|
||||
.training-map-page .tm-live-stages li.is-current{color:#194b89;border-color:#3773ba;font-weight:650}
|
||||
.training-map-page .tm-live-stages li.is-complete{color:#354d6c;border-color:#7798c1}
|
||||
.training-map-page .tm-task-progress progress{display:block;appearance:none;width:100%;height:10px;margin:16px 0 12px;border:0;border-radius:6px;overflow:hidden;background:#dbe4f0;accent-color:#3773ba}
|
||||
.training-map-page .tm-task-progress progress::-webkit-progress-bar{background:#dbe4f0;border-radius:6px}
|
||||
.training-map-page .tm-task-progress progress::-webkit-progress-value{background:#3773ba;border-radius:6px;transition:width .3s ease}
|
||||
.training-map-page .tm-task-progress progress::-moz-progress-bar{background:#3773ba;border-radius:6px}
|
||||
.training-map-page .tm-task-progress progress:indeterminate{background:linear-gradient(90deg,#dbe4f0 25%,#709ace 50%,#dbe4f0 75%);background-size:200% 100%;animation:tm-waiting 2s linear infinite}
|
||||
.training-map-page .tm-task-progress progress:indeterminate::-webkit-progress-bar{background:transparent}
|
||||
.training-map-page .tm-time-estimate p,.training-map-page .tm-task-progress p{font-size:13px;color:#4d5d74;margin:12px 0 0}
|
||||
.training-map-page .tm-time-metrics{font-size:14px;font-variant-numeric:tabular-nums}
|
||||
.training-map-page .tm-estimate-inventory{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,180px),1fr));gap:12px;margin:16px 0}
|
||||
.training-map-page .tm-estimate-inventory>div{min-width:0;padding-left:12px;border-left:2px solid #a9c4e5}
|
||||
.training-map-page .tm-estimate-inventory dt{font-size:13px;color:#4d5d74}
|
||||
.training-map-page .tm-estimate-inventory dd{margin:5px 0 0;color:#174777;font-size:15px;font-weight:650;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}
|
||||
.training-map-page .tm-reading-activity{display:flex;flex-wrap:wrap;align-items:center;gap:8px 16px;margin-top:12px;font-size:13px;font-variant-numeric:tabular-nums;color:#36516d}
|
||||
.training-map-page .tm-reading-activity strong{padding:5px 9px;border:1px solid #ddc18e;border-radius:5px;background:#fff5df;color:#795114;font-weight:600}
|
||||
.training-map-page .tm-task-progress.is-paused{background:#f5f5f6;border-color:#d7dae0}
|
||||
@keyframes tm-waiting{to{background-position:-200% 0}}
|
||||
@media(prefers-reduced-motion:reduce){.training-map-page .tm-task-progress progress:indeterminate{animation:none}.training-map-page .tm-task-progress progress::-webkit-progress-value{transition:none}}
|
||||
@media(max-width:600px){.training-map-page .tm-time-estimate,.training-map-page .tm-task-progress{padding:14px}.training-map-page .tm-live-stages{grid-template-columns:1fr}}
|
||||
@@ -0,0 +1,84 @@
|
||||
.training-map-page{min-width:0;max-width:1400px;margin:auto;font-size:15px;line-height:1.65}.training-map-page h1{font-size:30px;margin:0}.training-map-page h2{font-size:20px}.training-map-page h3{font-size:16px}.training-map-page p{overflow-wrap:anywhere}.training-map-page header{margin:24px 0}.tm-eyebrow{color:#375f89;font-size:14px;letter-spacing:.12em}.tm-tabs{display:flex;gap:8px;margin-bottom:20px;border-bottom:1px solid #d4dce5;padding-bottom:12px}.tm-tabs a{font-size:16px;padding:10px 18px;border-radius:6px;text-decoration:none;color:inherit}.tm-tabs a[aria-current=page]{background:#eaf3ff;color:#174777;font-weight:650}.tm-panel{border:1px solid #d4dce5;border-radius:8px;padding:24px;margin:20px 0;background:var(--panel,#fcfdfc)}.tm-panel fieldset{border:0;padding:0;display:grid;gap:16px;min-width:0}.tm-panel label,.tm-toolbar label{display:grid;gap:8px}.tm-panel input:not([type=checkbox]),.tm-toolbar select{width:100%;min-width:0;border-radius:6px;font-size:15px;padding:10px}.tm-kbs{display:flex;flex-wrap:wrap;gap:12px}.tm-kbs label{display:flex;align-items:center;gap:8px;border:1px solid #8493a5;padding:8px 12px;border-radius:6px;background:#fff;color:#3f4148;cursor:pointer;overflow-wrap:anywhere}.tm-kbs input{width:18px;height:18px;flex:0 0 18px;accent-color:#2868a8}.tm-actions,.tm-toolbar{display:flex;flex-wrap:wrap;align-items:center;gap:12px;margin:16px 0}.training-map-page button{font-size:14px;border-radius:6px;min-height:38px}.tm-candidates{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:14px;margin:20px 0}.tm-candidates article,.tm-evidence{border-top:1px solid #d4dce5;padding:14px 0}.tm-candidates a{color:#215b94;text-decoration:underline;text-underline-offset:3px}.tm-empty{padding:40px 20px;border:1px dashed #8493a5;border-radius:8px;text-align:center}.tm-canvas{height:650px;width:100%;min-width:0;overflow:hidden;border:1px solid #456256;border-radius:8px;background:#14201e;margin-top:20px}.tm-node{width:280px;padding:18px;border-radius:8px;background:#263a35;border:1px solid #79bfa4;color:#eef8f2;font-size:15px;line-height:1.6;white-space:normal;overflow-wrap:anywhere}.tm-node p{font-size:15px;white-space:pre-wrap;margin:12px 0 0}.tm-node button{display:block;margin-top:8px;background:#1b302a;color:#b1e6cf;border:1px solid #48695b}.tm-depth-0{background:#b4d2a0;color:#17271e;font-size:20px;font-weight:700}.tm-depth-3{width:320px;background:#1d302a}.tm-node .react-flow__handle{background:#a8dbbd}.tm-canvas .react-flow__controls button{min-height:28px;border-radius:0}.tm-actions button[aria-pressed=true]{border-color:#2868a8;background:#eaf3ff;color:#174777;font-weight:650}@media(max-width:600px){.tm-panel{padding:16px}.tm-canvas{height:520px}.tm-toolbar{align-items:stretch}.tm-toolbar label{width:100%}.tm-candidates{grid-template-columns:minmax(0,1fr)}.tm-tabs a{padding:10px 12px}.training-map-page h1{font-size:26px}}
|
||||
.tm-node-image{display:block;width:100%;max-height:150px;object-fit:cover;border-radius:6px;margin-top:10px;border:1px solid #4d7564}.tm-node-hook{font-size:13px!important;color:#b8e6cd;margin-top:10px!important;padding-top:8px;border-top:1px dashed #4d7564}.tm-rendered-image{margin-top:18px;padding:14px;border:1px solid #456256;border-radius:8px;background:#14201e;display:grid;gap:10px;justify-items:start}.tm-rendered-image img{display:block;max-width:100%;max-height:720px;border-radius:6px}.tm-rendered-image a{color:#b8e6cd}
|
||||
|
||||
.tm-assessment-modes{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,260px),1fr));gap:12px}.tm-assessment-modes label{display:flex;align-items:flex-start;gap:12px;border:1px solid #8493a5;padding:14px;border-radius:8px;background:#fff;color:#3f4148;cursor:pointer}.tm-assessment-modes label.is-selected{border-color:#2868a8;background:#eaf3ff;color:#174777}.tm-panel .tm-assessment-modes input[type=radio]{width:18px;height:18px;flex:0 0 18px;margin:4px 0 0;padding:0;accent-color:#2868a8}.tm-assessment-modes strong,.tm-assessment-modes small{display:block}.tm-assessment-modes small{font-size:13px;line-height:1.7;font-weight:400;margin-top:6px}.tm-coverage-heading{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px}.tm-coverage-heading h2{margin:0}.tm-coverage-heading>span{font-size:14px;color:#435971}.tm-coverage-stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin:20px 0}.tm-coverage-stats>div{border:1px solid #d4dce5;border-radius:6px;padding:14px}.tm-coverage-stats dt{font-size:13px}.tm-coverage-stats dd{margin:4px 0 0;font-size:24px;font-variant-numeric:tabular-nums;color:#174777}.tm-coverage-stats dd small{font-size:13px}.tm-reading-progress progress{display:block;width:100%;height:12px;accent-color:#2868a8;margin-top:10px}.tm-coverage-note{font-size:13px;color:#526373}.tm-coverage-warnings{border-left:3px solid #a87728;padding:8px 16px;margin:18px 0}.tm-coverage-kbs{display:grid;gap:12px}.tm-coverage-kb{border-top:1px solid #d4dce5;padding-top:12px}.tm-coverage-kb>div{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}.tm-coverage-kb>div span{font-size:13px}.tm-coverage-kb p{font-size:14px}.tm-coverage summary,.tm-evidence summary{cursor:pointer;overflow-wrap:anywhere}.tm-coverage summary:focus-visible,.tm-evidence summary:focus-visible{outline:2px solid #2868a8;outline-offset:4px}.tm-evidence>summary{padding:8px 0}.tm-evidence>summary strong{font-size:16px}.tm-evidence>summary>span{display:block;font-size:13px;color:#526373;margin-top:4px}.tm-evidence-text{white-space:pre-wrap;overflow-wrap:anywhere}.tm-evidence-body{padding:6px 0 4px}.tm-evidence-source{border-left:2px solid #8493a5;margin:12px 0;padding:8px 14px}.tm-evidence-source>summary{font-size:14px}.tm-evidence{scroll-margin-top:24px}.tm-coverage li,.tm-evidence li{overflow-wrap:anywhere}.tm-evidence button{max-width:100%;white-space:normal}
|
||||
|
||||
/* Use light selection surfaces; keep the dark mind-map canvas independent. */
|
||||
#root .training-map-page .tm-assessment-modes small{color:#526373}
|
||||
#root .training-map-page .tm-assessment-modes .is-selected small{color:#36516d}
|
||||
#root .training-map-page .tm-kbs label:has(input:checked){border-color:#2868a8;background:#eaf3ff;color:#174777}
|
||||
#root .training-map-page fieldset:not(:disabled) :is(.tm-assessment-modes,.tm-kbs) label:hover{border-color:#2868a8;background:#f0f6ff}
|
||||
#root .training-map-page fieldset:not(:disabled) .tm-assessment-modes label.is-selected:hover,
|
||||
#root .training-map-page fieldset:not(:disabled) .tm-kbs label:has(input:checked):hover{background:#deecff}
|
||||
#root .training-map-page :is(.tm-assessment-modes,.tm-kbs) label:has(input:focus-visible){outline:2px solid #2868a8;outline-offset:3px}
|
||||
#root .training-map-page :is(.tm-assessment-modes,.tm-kbs) input:focus-visible{outline-color:#2868a8}
|
||||
#root .training-map-page fieldset:disabled :is(.tm-assessment-modes,.tm-kbs) label{cursor:not-allowed;border-style:dashed}
|
||||
#root .training-map-page .tm-actions button[aria-pressed]{border-color:#8493a5;background:#fff;color:#3f4148}
|
||||
#root .training-map-page .tm-actions button[aria-pressed]:hover:not(:disabled){border-color:#2868a8;background:#f0f6ff;color:#174777}
|
||||
#root .training-map-page .tm-actions button[aria-pressed=true]{border-color:#2868a8;background:#eaf3ff;color:#174777}
|
||||
#root .training-map-page .tm-actions button[aria-pressed=true]:hover:not(:disabled){background:#deecff}
|
||||
#root .training-map-page .tm-actions button[aria-pressed]:focus-visible{outline-color:#2868a8}
|
||||
#root .training-map-page .tm-actions button[aria-pressed]:disabled{opacity:1;border-style:dashed;background:#f0f2f5;color:#526373}
|
||||
#root .training-map-page .tm-actions button[aria-pressed=true]:disabled{background:#eaf3ff;color:#36516d}
|
||||
.tm-tabs a:hover{background:#f0f6ff;color:#174777}
|
||||
.tm-tabs a[aria-current=page]:hover{background:#deecff}
|
||||
#root .tm-tabs a:focus-visible{outline-color:#2868a8}
|
||||
.tm-candidates a:hover{color:#174777}
|
||||
.tm-note{color:#526373}
|
||||
#root .training-map-page .tm-revision-panel{margin-top:18px;padding:16px;border:1px solid #b8cfe4;border-radius:8px;background:#f7fbff;display:grid;gap:10px}
|
||||
#root .training-map-page .tm-revision-panel h3{margin:0;font-size:16px;color:#174777}
|
||||
#root .training-map-page .tm-revision-panel textarea{width:100%;min-height:74px;resize:vertical;border:1px solid #9eb6cf;border-radius:6px;padding:10px;font:inherit;box-sizing:border-box}
|
||||
#root .training-map-page .tm-revision-panel select{min-width:170px;border:1px solid #9eb6cf;border-radius:6px;padding:8px;background:#fff;color:#24384f}
|
||||
#root .training-map-page .tm-image-confirm{display:inline-flex;align-items:center;gap:7px;color:#34536f;font-size:13px}
|
||||
#root .training-map-page .tm-image-confirm input{width:16px;height:16px;accent-color:#2868a8}
|
||||
.tm-image-progress { border: 1px solid #e7cdbd; background: linear-gradient(135deg, #fffaf6, #fff); }
|
||||
.tm-ai-progress { border: 1px solid #c7d9ed; background: linear-gradient(135deg, #f6faff, #fff); }
|
||||
.tm-ai-progress > strong { display:block; color:#174777; margin-bottom:10px; }
|
||||
.tm-ai-progress .tm-image-steps { margin-top:10px; }
|
||||
.tm-progress-title { display:flex; align-items:baseline; justify-content:space-between; gap:12px; margin-bottom:10px; }
|
||||
.tm-progress-title strong { color:#704738; }
|
||||
.tm-progress-title span { color:#8d766d; font-size:12px; }
|
||||
.tm-progress-track { height:9px; overflow:hidden; border-radius:99px; background:#eadfd8; }
|
||||
.tm-progress-indeterminate { height:100%; width:38%; border-radius:99px; background:linear-gradient(90deg,#a96c52,#dfae8b,#a96c52); animation:tm-image-progress 1.25s ease-in-out infinite; }
|
||||
.tm-image-steps { display:flex; flex-wrap:wrap; gap:16px; margin-top:10px; color:#9b877e; font-size:12px; }
|
||||
.tm-image-steps .done { color:#4d8a68; }.tm-image-steps .active { color:#9a5d45; font-weight:600; }
|
||||
@keyframes tm-image-progress { 0% { transform:translateX(-130%); } 100% { transform:translateX(290%); } }
|
||||
@media (max-width:720px) { .tm-progress-title { display:block; }.tm-progress-title span { display:block; margin-top:4px; }.tm-image-steps { gap:8px; flex-direction:column; } }
|
||||
|
||||
.tm-image-preview-trigger{display:grid;gap:7px;padding:0;border:0;background:transparent;color:#b8e6cd;cursor:zoom-in;text-align:left}.tm-image-preview-trigger span{font-size:13px}.tm-image-preview-trigger:focus-visible{outline:2px solid #9bd0b4;outline-offset:4px}.tm-image-lightbox{position:fixed;z-index:1000;inset:0;padding:clamp(20px,5vw,64px);display:flex;align-items:center;justify-content:center;background:rgba(8,17,14,.88);cursor:zoom-out}.tm-image-lightbox img{display:block;max-width:min(96vw,1800px);max-height:92vh;width:auto;height:auto;object-fit:contain;border-radius:8px;box-shadow:0 16px 56px rgba(0,0,0,.45);cursor:default}.tm-image-lightbox-close{position:absolute;top:16px;right:20px;width:42px;height:42px;min-height:42px;border:1px solid rgba(255,255,255,.5);border-radius:50%;background:rgba(0,0,0,.35);color:#fff;font-size:28px;line-height:1;cursor:pointer}.tm-image-lightbox-close:hover{background:rgba(255,255,255,.2)}.tm-image-lightbox-close:focus-visible{outline:2px solid #b8e6cd;outline-offset:3px}.tm-text-map-note{margin-top:12px}
|
||||
.tm-map-viewer{position:relative;margin-top:16px}.tm-map-viewer-toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:9px 12px;border:1px solid #d4dce5;border-bottom:0;border-radius:8px 8px 0 0;background:#f7fbff;color:#36516d}.tm-map-viewer-toolbar span{font-size:12px;color:#526373}.tm-map-viewer-toolbar button{margin-left:auto}.tm-map-viewer-toolbar button+button{margin-left:0}.tm-map-viewer .tm-canvas{margin-top:0;border-radius:0 0 8px 8px}.tm-map-viewer.is-fullscreen{position:fixed;z-index:900;inset:0;padding:18px;background:#101916}.tm-map-viewer.is-fullscreen .tm-map-viewer-toolbar{border-color:#48695b;background:#1b302a;color:#d9f4e5}.tm-map-viewer.is-fullscreen .tm-map-viewer-toolbar span{color:#b8d7c6}.tm-map-viewer.is-fullscreen .tm-canvas{height:calc(100vh - 80px);border-radius:0 0 8px 8px}.tm-day-generation-progress{margin-top:14px}.tm-map-advice-drawer textarea{width:100%;min-height:180px;box-sizing:border-box;border:1px solid #9eb6cf;border-radius:6px;padding:10px;font:inherit;resize:vertical}.tm-map-viewer .tm-node{width:250px;padding:13px;font-size:14px}.tm-map-viewer .tm-node p{font-size:13px;margin:7px 0 0;line-height:1.4}.tm-map-viewer .tm-depth-3{width:270px}
|
||||
/* Clean, regular framework canvas and Codex-like comment anchor. */
|
||||
.tm-canvas{height:680px;border-color:#c8d8d1;background:#f7faf8;box-shadow:inset 0 0 0 1px rgba(47,93,73,.04)}
|
||||
.tm-map-viewer .tm-canvas{background:#f7faf8}
|
||||
.tm-map-viewer .tm-node{width:250px;min-height:78px;padding:14px 16px;border-radius:12px;background:#fff;border:1px solid #b8cec2;color:#20382c;box-shadow:0 5px 14px rgba(31,65,48,.1);line-height:1.45}
|
||||
.tm-map-viewer .tm-node strong{display:block;font-size:15px;letter-spacing:.01em;color:#1d4935}
|
||||
.tm-map-viewer .tm-node p{color:#4d6658}
|
||||
.tm-map-viewer .tm-depth-0{width:280px;background:#dcefe4;border:2px solid #4b8b68;color:#173e2b;box-shadow:0 8px 20px rgba(45,104,72,.18)}
|
||||
.tm-map-viewer .tm-depth-0 strong{font-size:19px;color:#173e2b}
|
||||
.tm-map-viewer .tm-depth-1{background:#f0f8f3;border-color:#8bb59e}
|
||||
.tm-map-viewer .tm-depth-2{background:#fff}
|
||||
.tm-map-viewer .tm-depth-3{width:230px;background:#fbfdfb;border-color:#cadbd1}
|
||||
.tm-map-viewer .react-flow__edge-path{stroke:#7cae91;stroke-width:2.2}
|
||||
.tm-map-viewer .react-flow__controls{box-shadow:0 3px 12px rgba(31,65,48,.15)}
|
||||
.tm-comment-anchor{display:flex;align-items:center;gap:10px;padding:10px 12px;margin:10px 0 14px;border-left:3px solid #4d8b69;background:#eef7f1;border-radius:4px;color:#28583e}
|
||||
.tm-comment-anchor span{font-size:12px;color:#668574}.tm-comment-anchor strong{font-size:14px}
|
||||
/* Structured day plan cards */
|
||||
.tm-candidates{grid-template-columns:repeat(2,minmax(0,1fr));gap:18px;margin:22px 0}
|
||||
.tm-candidates article{padding:20px;border:1px solid #d3e0d8;border-radius:12px;background:#fff;min-width:0}
|
||||
.tm-candidate-heading{display:flex;align-items:flex-start;gap:12px;padding-bottom:16px;border-bottom:1px solid #e2eae5}
|
||||
.tm-candidate-heading .tm-day-index{flex:0 0 auto;display:inline-flex;align-items:center;padding:4px 10px;border-radius:6px;background:#eaf3ee;color:#24523a;font-size:13px;font-weight:650;white-space:nowrap}
|
||||
.tm-candidates .tm-candidate-heading h3{margin:1px 0 0;font-size:16px;line-height:1.65;color:#223e2d;text-wrap:pretty}
|
||||
.tm-candidate-focus{padding:15px 0}.tm-candidate-focus>span{font-size:12px;color:#607767;font-weight:650}.tm-candidate-focus p{font-size:14px;line-height:1.85;color:#344a3d;margin:7px 0 0}
|
||||
.tm-candidate-outcomes{display:grid;gap:13px;margin:0;padding:16px;border-radius:8px;background:#f6f9f7}.tm-candidate-outcomes>div{display:grid;grid-template-columns:60px minmax(0,1fr);gap:12px}.tm-candidate-outcomes dt{font-size:13px;font-weight:650;color:#4d6555}.tm-candidate-outcomes dd{margin:0;color:#344a3d;font-size:13px;line-height:1.8}
|
||||
.tm-candidate-references{margin-top:13px;border-top:1px solid #e2eae5;padding-top:10px}.tm-candidate-references summary{cursor:pointer;color:#37664b;font-size:12px}.tm-candidate-references>div{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}.tm-candidate-references button{min-height:28px;padding:4px 9px;background:#f3f8f5;border:1px solid #c4d9ca;border-radius:5px;color:#37664b;font-size:12px}
|
||||
/* Codex-style anchored review panel. Portal keeps it above fullscreen viewers. */
|
||||
.tm-comment-drawer{position:fixed;z-index:1200;top:16px;right:16px;bottom:16px;width:min(410px,calc(100vw - 32px));display:flex;flex-direction:column;background:#fff;border:1px solid #cddbd2;border-radius:12px;box-shadow:0 18px 60px rgba(22,49,35,.24);color:#243b2d}
|
||||
.tm-comment-head{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;padding:17px 18px;border-bottom:1px solid #e1e9e4}.tm-comment-head strong{display:block;font-size:16px}.tm-comment-head span{display:block;margin-top:3px;color:#668070;font-size:12px}.tm-comment-head>button{border:0;background:transparent;color:#567262;font-size:23px;line-height:1;cursor:pointer;padding:0 2px}.tm-comment-head>button:hover{color:#183f2a}
|
||||
.tm-comment-body{flex:1;overflow:auto;padding:16px 18px}.tm-comment-help{margin:0 0 14px;color:#506859;font-size:13px;line-height:1.7}.tm-comment-label{display:block;margin-bottom:7px;color:#567262;font-size:12px;font-weight:650}.tm-comment-anchor{display:flex;align-items:flex-start;gap:9px;padding:9px 11px;margin-bottom:9px;border:1px solid #b8d3c0;border-radius:6px;background:#eef7f1}.tm-comment-anchor span{flex:0 0 auto;color:#6e8877;font-size:11px}.tm-comment-anchor strong{font-size:13px;line-height:1.45;color:#28583e}.tm-comment-composer blockquote,.tm-comment-list blockquote{margin:8px 0;padding:8px 10px;border:1px solid #e0e8e3;border-radius:5px;background:#f8faf9;color:#66796d;font-size:12px;line-height:1.55;white-space:pre-wrap;overflow-wrap:anywhere}.tm-comment-composer textarea{width:100%;box-sizing:border-box;resize:vertical;min-height:100px;border:1px solid #b7cbbb;border-radius:6px;padding:9px 10px;font:inherit;font-size:13px;line-height:1.6;color:#243b2d}.tm-comment-composer textarea:focus{outline:2px solid #9ac8aa;outline-offset:1px}.tm-comment-composer-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:9px}.tm-comment-composer-actions button{min-height:32px;padding:6px 11px}.tm-comment-list{display:grid;gap:10px;margin:18px 0 0;padding:0;list-style:none}.tm-comment-list li{padding:11px 12px;border:1px solid #dae6de;border-radius:8px;background:#fbfdfb}.tm-comment-item-head{display:flex;align-items:center;gap:7px}.tm-comment-item-head strong{flex:1;min-width:0;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.tm-comment-item-head button{padding:2px 3px;border:0;background:transparent;color:#4e7960;font-size:12px;cursor:pointer}.tm-comment-item-head button:hover{text-decoration:underline}.tm-comment-number{display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;border-radius:50%;background:#dcefe3;color:#245c3c;font-size:11px;font-weight:700}.tm-comment-path{display:block;color:#739080;font-size:11px;line-height:1.5;margin-top:4px}.tm-comment-list p{font-size:13px;line-height:1.65;margin:7px 0 0;color:#344a3d;white-space:pre-wrap}.tm-comment-empty{padding:18px 10px;text-align:center;color:#718278;font-size:13px}.tm-comment-footer{display:grid;gap:8px;padding:13px 18px;border-top:1px solid #e1e9e4;background:#f8fbf9;border-radius:0 0 12px 12px}.tm-comment-footer button{width:100%}.tm-comment-footer small{color:#718278;font-size:11px;line-height:1.45}.tm-comment-success{padding:8px 10px;border-radius:6px;background:#edf8f0;color:#2f6d45;font-size:12px}.tm-comment-error{padding:8px 10px;border-radius:6px;background:#fff1ef;color:#a3483e;font-size:12px}.tm-comment-progress{padding:12px 0}.tm-comment-progress strong{display:block;font-size:13px}.tm-comment-progress p{font-size:12px;color:#62776a}.tm-viewer-progress{position:absolute;z-index:4;left:12px;right:12px;top:52px;padding:10px 13px;border:1px solid #b8d3c0;border-radius:7px;background:rgba(255,255,255,.96);box-shadow:0 5px 15px rgba(25,55,37,.14)}.tm-viewer-progress strong{display:block;color:#28583e;font-size:13px}.tm-viewer-progress small{display:block;margin-top:5px;color:#617868;font-size:11px}
|
||||
.tm-node{position:relative}.tm-node-comments{position:absolute;top:-10px;right:-8px;display:flex;gap:3px}.tm-comment-pin{width:21px!important;height:21px;min-height:21px!important;padding:0!important;border:1px solid #fff!important;border-radius:50%!important;background:#d78b3d!important;color:#fff!important;box-shadow:0 2px 5px rgba(45,35,20,.2);font-size:11px!important;cursor:pointer}.tm-comment-pin:hover{background:#b86f28!important}.tm-node-fold{margin-top:9px!important}
|
||||
@media(max-width:900px){.tm-candidates{grid-template-columns:minmax(0,1fr)}}
|
||||
@media(max-width:600px){.tm-candidates article{padding:16px}.tm-candidate-outcomes>div{grid-template-columns:1fr;gap:4px}.tm-comment-drawer{top:8px;right:8px;bottom:8px;width:calc(100vw - 16px)}}
|
||||
@media(prefers-reduced-motion:reduce){.tm-progress-indeterminate{animation:none;transform:none}}
|
||||
.tm-map-viewer .tm-node p{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3;overflow:hidden}
|
||||
.tm-candidate-detail{margin-top:9px}.tm-candidate-detail summary{font-size:12px;color:#5d7868;cursor:pointer}.tm-candidate-detail p{margin:6px 0 0;font-size:12px;line-height:1.65;color:#627669}
|
||||
.tm-kbs label{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.tm-kbs label>span{flex:1 1 auto}.tm-kb-read-status{font-size:12px;color:#566b7b;flex:0 0 auto}.tm-kb-read-status.is-ready{color:#23734b}.tm-kb-read-status.is-error{color:#a03b32}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
export interface ProgressEvent {
|
||||
event: string
|
||||
ts: number
|
||||
campaign_id?: number
|
||||
segment_id?: number
|
||||
title?: string
|
||||
status?: string
|
||||
round?: number
|
||||
pct?: number
|
||||
total?: number
|
||||
files_done?: number
|
||||
current?: string
|
||||
kb_id?: number
|
||||
segments?: number
|
||||
gates?: number
|
||||
issues?: number
|
||||
error?: string
|
||||
reason?: string
|
||||
mode?: string
|
||||
agent_id?: number
|
||||
}
|
||||
|
||||
const EVENT_TEXT: Record<string, string> = {
|
||||
parse_start: 'AI 正在拆解导图',
|
||||
parse_done: '拆解完成',
|
||||
parse_failed: '拆解失败',
|
||||
run_step_start: '开始生成',
|
||||
segment_start: 'Agent 生成中',
|
||||
segment_done: '段落完成',
|
||||
qc_start: '质检开始',
|
||||
qc_done: '质检完成',
|
||||
qc_issues: '质检发现问题',
|
||||
redo_start: '打回重做中',
|
||||
redo_done: '重做完成',
|
||||
kb_upload: '知识库上传处理中',
|
||||
compose_start: '开始组合成稿',
|
||||
compose_done: '成稿组合完成',
|
||||
compose_failed: '成稿组合失败',
|
||||
}
|
||||
|
||||
export function useProgress() {
|
||||
const [events, setEvents] = useState<ProgressEvent[]>([])
|
||||
const [latest, setLatest] = useState<ProgressEvent | null>(null)
|
||||
const esRef = useRef<EventSource | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let closed = false
|
||||
const connect = () => {
|
||||
if (closed) return
|
||||
const token = localStorage.getItem('yawan-workbench-access-token') || ''
|
||||
const es = new EventSource(`/api/events/progress?token=${encodeURIComponent(token)}`)
|
||||
esRef.current = es
|
||||
es.onmessage = e => {
|
||||
try {
|
||||
const ev = JSON.parse(e.data) as ProgressEvent
|
||||
if (ev.event === 'connected') return
|
||||
setLatest(ev)
|
||||
setEvents(prev => [...prev.slice(-50), ev])
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
es.onerror = () => {
|
||||
es.close()
|
||||
esRef.current = null
|
||||
setTimeout(connect, 3000)
|
||||
}
|
||||
}
|
||||
connect()
|
||||
return () => { closed = true; esRef.current?.close() }
|
||||
}, [])
|
||||
|
||||
return { latest, events }
|
||||
}
|
||||
|
||||
/** 全局浮动进度提示(右下角,不挡操作,几秒后自动淡出) */
|
||||
export function ProgressToast() {
|
||||
const { latest } = useProgress()
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [text, setText] = useState('')
|
||||
const [detail, setDetail] = useState('')
|
||||
const [busy, setBusy] = useState(true)
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!latest) return
|
||||
const label = EVENT_TEXT[latest.event]
|
||||
if (!label) return
|
||||
const seg = latest.title ? `「${latest.title}」` : ''
|
||||
let d = ''
|
||||
if (latest.event === 'kb_upload') {
|
||||
setText(`知识库处理中 ${latest.pct ?? 0}%`)
|
||||
d = `${latest.current ?? ''}(${latest.files_done ?? 0}/${latest.total ?? '?'} 个文件)`
|
||||
setBusy(true)
|
||||
} else if (latest.event === 'segment_start' || latest.event === 'run_step_start') {
|
||||
setText(`正在生成 ${seg}`)
|
||||
d = `第 ${latest.round ?? 1} 轮 · 生成约需 1-2 分钟`
|
||||
setBusy(true)
|
||||
} else if (latest.event === 'qc_start') {
|
||||
setText(`质检中 ${seg}`)
|
||||
d = '4 道质检并行执行,不阻塞查看正文'
|
||||
setBusy(true)
|
||||
} else if (latest.event === 'redo_start') {
|
||||
setText(`正在按意见重做 ${seg}`)
|
||||
d = latest.reason === 'user' ? '你的修改意见已注入' : '质检意见驱动重做'
|
||||
setBusy(true)
|
||||
} else if (latest.event === 'parse_start') {
|
||||
setText('AI 正在拆解导图')
|
||||
d = '一般 1-2 分钟,完成后自动显示任务单'
|
||||
setBusy(true)
|
||||
} else if (latest.event === 'parse_done') {
|
||||
setText('拆解完成')
|
||||
d = `共 ${latest.segments ?? 0} 段`
|
||||
setBusy(false)
|
||||
} else if (latest.event === 'segment_done') {
|
||||
setText(`段落完成 ${seg}`)
|
||||
d = latest.status === 'human_review' ? '已到待审查,请查看' : `状态:${latest.status}`
|
||||
setBusy(false)
|
||||
} else if (latest.event === 'redo_done') {
|
||||
setText(`重做完成 ${seg}`)
|
||||
d = `状态:${latest.status}`
|
||||
setBusy(false)
|
||||
} else if (latest.event === 'parse_failed') {
|
||||
setText('拆解失败')
|
||||
d = latest.error ?? ''
|
||||
setBusy(false)
|
||||
} else if (latest.event === 'compose_start') {
|
||||
setText('DSH 正在组合最终成稿')
|
||||
d = '五项跨段一致性总检完成后将生成 docx'
|
||||
setBusy(true)
|
||||
} else if (latest.event === 'compose_done') {
|
||||
setText('成稿组合完成')
|
||||
d = latest.status === 'has_issues' ? '已生成文稿,但仍有跨段问题待处理' : '最终文稿已交付'
|
||||
setBusy(false)
|
||||
} else if (latest.event === 'compose_failed') {
|
||||
setText('成稿组合失败')
|
||||
d = latest.error ?? ''
|
||||
setBusy(false)
|
||||
} else if (latest.event === 'qc_issues') {
|
||||
setText(`质检发现 ${latest.issues ?? '?'} 处问题 ${seg}`)
|
||||
d = '正文已可查看,建议参考质检意见'
|
||||
setBusy(false)
|
||||
} else if (latest.event === 'qc_done') {
|
||||
setText('质检完成')
|
||||
setBusy(false)
|
||||
} else { return }
|
||||
setVisible(true)
|
||||
if (timer.current) clearTimeout(timer.current)
|
||||
if (!busy) timer.current = setTimeout(() => setVisible(false), 5000)
|
||||
}, [latest])
|
||||
|
||||
if (!visible) return null
|
||||
return <div className="progress-toast" data-busy={busy}>
|
||||
<div className="progress-toast-bar"><span className={busy ? 'spin' : 'done'}>{busy ? '⏳' : '✓'}</span>{text}</div>
|
||||
{detail && <div className="progress-toast-detail">{detail}</div>}
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { api } from './api'
|
||||
|
||||
/**
|
||||
* 全局资源对普通用户保持只读;需要修改时先创建自己的工作副本。
|
||||
* 服务端返回 { id } 后由调用方跳转到副本详情。
|
||||
*/
|
||||
export async function forkResource(kind: string, id: number): Promise<{ id: number }> {
|
||||
return api.post<{ id: number }>(`/${kind}/${id}/fork`, {})
|
||||
}
|
||||
|
||||
export function isGlobalResource(resource: any): boolean {
|
||||
return resource?.visibility === 'global'
|
||||
}
|
||||
|
||||
export function canManageResource(resource: any, user?: any): boolean {
|
||||
if (resource?.can_manage === true) return true
|
||||
if (user?.role === 'admin' || user?.permissions?.includes?.('*')) return true
|
||||
return Boolean(resource?.owner_user_id && user?.id && Number(resource.owner_user_id) === Number(user.id))
|
||||
}
|
||||
|
||||
/** 复制/隐藏入口需要对应功能的写权限;管理员和全量权限始终允许。 */
|
||||
export function canWriteResource(kind: string, user?: any): boolean {
|
||||
if (!user || user.role === 'admin' || user.permissions?.includes?.('*')) return true
|
||||
const required = kind === 'knowledge_base' || kind === 'kbs' ? 'kb:write' : kind === 'memory' || kind === 'memories' ? 'memories:write' : 'agents:write'
|
||||
return Boolean(user.permissions?.includes?.(required))
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Warm route chunks before navigation. Pages remain lazy so the initial
|
||||
* bundle stays small, while a pointer/focus over a navigation item can load
|
||||
* its chunk during the user's natural decision time.
|
||||
*/
|
||||
type RouteLoader = () => Promise<unknown>
|
||||
|
||||
const loaders: Array<{ match: (path: string) => boolean; load: RouteLoader }> = [
|
||||
{ match: path => /\/overview\/?$/.test(path), load: () => import('./pages/OverviewPage') },
|
||||
{ match: path => /\/training-maps\/?$/.test(path), load: () => import('./pages/TrainingMapPage') },
|
||||
{ match: path => /\/tasks\/[^/]+\/?$/.test(path), load: () => import('./pages/TaskDetailPage') },
|
||||
{ match: path => /\/tasks\/?$/.test(path), load: () => import('./pages/TaskListPage') },
|
||||
{ match: path => /\/agents(?:\/[^/]+)?\/?$/.test(path), load: () => import('./pages/AgentWorkbench') },
|
||||
{ match: path => /\/knowledge(?:\/[^/]+)?\/?$/.test(path), load: () => import('./pages/KnowledgePage') },
|
||||
{ match: path => /\/flows\/?$/.test(path), load: () => import('./CanvasPage') },
|
||||
{ match: path => /\/runs\/?$/.test(path), load: () => import('./pages/RunsPage') },
|
||||
{ match: path => /\/audit-logs\/?$/.test(path), load: () => import('./pages/AuditLogsPage') },
|
||||
{ match: path => /\/system-logs\/?$/.test(path), load: () => import('./pages/SystemLogsPage') },
|
||||
{ match: path => /\/settings\/providers\/?$/.test(path), load: () => import('./pages/ModelsPage') },
|
||||
{ match: path => /\/settings\/usage\/?$/.test(path), load: () => import('./pages/UsagePage') },
|
||||
{ match: path => /\/settings\/access\/?$/.test(path), load: () => import('./pages/AccessPage') },
|
||||
{ match: path => /\/settings\/flags\/?$/.test(path), load: () => import('./pages/FeatureFlagsPage') },
|
||||
{ match: path => /\/digital-humans(?:\/[^/]+)?\/?$/.test(path), load: () => import('./pages/DigitalHumansPage') },
|
||||
{ match: path => /\/ops\/?$/.test(path), load: () => import('./pages/OpsPage') },
|
||||
{ match: path => /\/manual\/?$/.test(path), load: () => import('./pages/UserManualPage') },
|
||||
]
|
||||
|
||||
const warmed = new Set<RouteLoader>()
|
||||
|
||||
function start(loader: RouteLoader) {
|
||||
if (warmed.has(loader)) return
|
||||
warmed.add(loader)
|
||||
void loader().catch(() => {
|
||||
// A transient network error should not poison future hover/click retries.
|
||||
warmed.delete(loader)
|
||||
})
|
||||
}
|
||||
|
||||
/** Start loading a route chunk without making navigation wait for it. */
|
||||
export function prefetchRoute(to: string) {
|
||||
const path = to.split(/[?#]/, 1)[0]
|
||||
const entry = loaders.find(item => item.match(path))
|
||||
if (entry) start(entry.load)
|
||||
}
|
||||
|
||||
/**
|
||||
* Warm all reachable page chunks during browser idle time. The first render
|
||||
* stays small, while an authenticated user can move between pages without
|
||||
* waiting for a new JS chunk over the public tunnel.
|
||||
*/
|
||||
export function prefetchAllRoutes() {
|
||||
const queue = loaders.map(item => item.load).filter(loader => !warmed.has(loader))
|
||||
if (!queue.length) return
|
||||
const run = () => queue.splice(0, 1).forEach(start)
|
||||
// Schedule one chunk per idle turn. This avoids competing with the current
|
||||
// page's API calls and still warms the complete navigation quickly.
|
||||
const idle = (window as Window & { requestIdleCallback?: (cb: () => void, opts?: { timeout: number }) => number }).requestIdleCallback
|
||||
if (idle) {
|
||||
const pump = () => {
|
||||
if (!queue.length) return
|
||||
run()
|
||||
idle(pump, { timeout: 800 })
|
||||
}
|
||||
idle(pump, { timeout: 800 })
|
||||
} else {
|
||||
let index = 0
|
||||
const pump = () => {
|
||||
if (index >= queue.length) return
|
||||
start(queue[index++])
|
||||
window.setTimeout(pump, 80)
|
||||
}
|
||||
window.setTimeout(pump, 300)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { BalanceMenu } from './BalanceMenu'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { NavLink, useLocation } from 'react-router-dom'
|
||||
import {
|
||||
LayoutDashboard, ListChecks, Bot, Database, Settings, PanelLeftClose, PanelLeft,
|
||||
X, CircleUser, Clock3,
|
||||
} from 'lucide-react'
|
||||
import { api } from './api'
|
||||
import type { NotificationSummary, AuthUser } from './types'
|
||||
import { ProgressToast } from './progress'
|
||||
import { prefetchRoute } from './routePreload'
|
||||
|
||||
export type Portal = 'user' | 'admin'
|
||||
|
||||
interface NavItem { to: string; label: string; icon: typeof LayoutDashboard; end?: boolean; permissions?: string[] }
|
||||
interface NavGroup { label: string; items: NavItem[] }
|
||||
|
||||
function navFor(portal: Portal, permissions: string[]): NavGroup[] {
|
||||
// 功能权限或对应操作权限任一命中即可显示入口,兼容旧角色数据;
|
||||
// 访问控制中勾选 feature:* 可独立控制整个功能栏。
|
||||
const has = (required?: string[]) => { if (!required?.length || permissions.includes('*')) return true; const feature = required.find(item => item.startsWith('feature:')); return feature ? permissions.includes(feature) : required.some(item => permissions.includes(item)) }
|
||||
const B = portal === 'admin' ? '/admin' : '/app'
|
||||
const groups: NavGroup[] = [
|
||||
{
|
||||
label: '工作台',
|
||||
items: [{ to: `${B}/overview`, label: '总览', icon: LayoutDashboard, end: true }],
|
||||
},
|
||||
{
|
||||
label: '生产',
|
||||
items: [
|
||||
{ to: `${B}/tasks`, label: '内容任务', icon: ListChecks, permissions: ['feature:tasks', 'tasks:read', 'tasks:write', 'tasks:review', 'tasks:deliver'] },
|
||||
{ to: `${B}/runs`, label: '运行记录', icon: Clock3, permissions: ['feature:runs', 'runs:read', 'logs:read'] },
|
||||
{ to: `${B}/audit-logs`, label: '操作日志', icon: ShieldIcon, permissions: ['feature:audit', 'audit:read', 'audit:manage'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '智能体',
|
||||
items: [
|
||||
{ to: `${B}/agents`, label: 'Agent 与技能', icon: Bot, permissions: ['feature:agents', 'agents:read', 'agents:write'] },
|
||||
{ to: `${B}/digital-humans`, label: '数字人', icon: Bot, permissions: ['feature:digital-humans', 'digital_humans:read', 'agents:write'] },
|
||||
{ to: `${B}/flows`, label: '画布编排', icon: WorkflowIcon, permissions: ['feature:flows', 'flows:read', 'agents:write'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '知识资产',
|
||||
items: [
|
||||
{ to: `${B}/knowledge`, label: '知识库', icon: Database, permissions: ['feature:knowledge', 'kb:read'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '系统管理',
|
||||
items: [
|
||||
{ to: `${B}/settings/providers`, label: '模型管理', icon: CoinsIcon, permissions: ['feature:models', 'providers:read', 'providers:write'] },
|
||||
{ to: `${B}/settings/usage`, label: '用量分析', icon: ActivityIcon, permissions: ['feature:usage', 'usage:read', 'logs:read', 'billing:manage'] },
|
||||
{ to: `${B}/system-logs`, label: '系统日志', icon: Clock3, permissions: ['feature:system-logs', 'system_logs:read', 'logs:read'] },
|
||||
{ to: `${B}/settings/access`, label: '访问控制', icon: ShieldIcon, permissions: ['feature:access', 'access:read', 'roles:manage', 'users:manage', 'audit:manage'] },
|
||||
{ to: `${B}/settings/flags`, label: '功能开关', icon: ShieldIcon, permissions: ['feature:flags', 'flags:read', 'ops:manage'] },
|
||||
{ to: `${B}/ops`, label: '运营看板', icon: ActivityIcon, permissions: ['feature:ops', 'ops:read', 'ops:manage'] },
|
||||
],
|
||||
},
|
||||
]
|
||||
return groups.map(group => ({ ...group, items: group.items.filter(item => has(item.permissions)) })).filter(group => group.items.length)
|
||||
}
|
||||
|
||||
// 延迟取图标(避免顶部 import 块过长且便于 tree-shake 校验)
|
||||
import { Workflow, Coins, Activity, Shield } from 'lucide-react'
|
||||
const WorkflowIcon = Workflow as typeof LayoutDashboard
|
||||
const CoinsIcon = Coins as typeof LayoutDashboard
|
||||
const ActivityIcon = Activity as typeof LayoutDashboard
|
||||
const ShieldIcon = Shield as typeof LayoutDashboard
|
||||
|
||||
export function AppShell({ currentUser, portal, onLogout, children }: {
|
||||
currentUser: AuthUser
|
||||
portal: Portal
|
||||
onLogout: () => Promise<void>
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
const [notifs, setNotifs] = useState<NotificationSummary[]>([])
|
||||
const [sysStatus, setSysStatus] = useState<{ status: string; environment?: 'dev' | 'prod'; checks: Record<string, { ok: boolean; label: string; detail?: string }> } | null>(null)
|
||||
const location = useLocation()
|
||||
|
||||
// 移动端:路由变化自动关抽屉
|
||||
useEffect(() => { setDrawerOpen(false) }, [location.pathname])
|
||||
// Esc 关抽屉
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setDrawerOpen(false) }
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => api.get('/notifications').then(setNotifs).catch(() => {}), 5000)
|
||||
return () => clearInterval(t)
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
const load = () => api.get('/system/status').then(setSysStatus).catch(() => setSysStatus(null))
|
||||
load()
|
||||
const t = setInterval(load, 15000)
|
||||
return () => clearInterval(t)
|
||||
}, [])
|
||||
|
||||
const unread = notifs.filter(n => n.status === 'unread').length
|
||||
const permissions = currentUser.permissions || []
|
||||
const groups = navFor(portal, permissions)
|
||||
const degraded = sysStatus && sysStatus.status !== 'ok'
|
||||
|
||||
const sidebar = (
|
||||
<>
|
||||
<div className="brand-block">
|
||||
<div className="brand-mark">私</div>
|
||||
{!collapsed && <div className="brand-copy"><strong>私域Agent</strong><span>内容生产中枢</span></div>}
|
||||
</div>
|
||||
<button className="collapse-toggle" onClick={() => setCollapsed(v => !v)} aria-label={collapsed ? '展开侧栏' : '收起侧栏'}>
|
||||
{collapsed ? <PanelLeft size={15} /> : <PanelLeftClose size={15} />}
|
||||
</button>
|
||||
<nav className="side-nav" aria-label="主导航">
|
||||
{groups.map(group => (
|
||||
<div className="nav-group" key={group.label}>
|
||||
{!collapsed && <div className="nav-label">{group.label}</div>}
|
||||
{group.items.map(item => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className={({ isActive }) => `side-nav-item ${isActive ? 'active' : ''}`}
|
||||
title={collapsed ? item.label : undefined}
|
||||
// Warm the lazy route while the user is deciding where to go.
|
||||
onPointerEnter={() => prefetchRoute(item.to)}
|
||||
onTouchStart={() => prefetchRoute(item.to)}
|
||||
onFocus={() => prefetchRoute(item.to)}
|
||||
>
|
||||
<item.icon size={16} strokeWidth={1.8} />
|
||||
{!collapsed && <span className="nav-text">{item.label}</span>}
|
||||
{item.to.endsWith('/runs') && unread > 0 && !collapsed && <span className="nav-count">{unread}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
<div className="sidebar-footer">
|
||||
<div className={`system-health ${degraded ? 'degraded' : ''}`} title={sysStatus ? Object.values(sysStatus.checks).filter(c => !c.ok).map(c => `${c.label}:${c.detail || '异常'}`).join(';') || '全部组件正常' : '正在检查…'}>
|
||||
<span className={`health-pulse ${degraded ? 'warn' : ''}`} />
|
||||
{!collapsed && <div><strong>{sysStatus ? (sysStatus.status === 'ok' ? '系统运行正常' : '部分组件异常') : '自检中…'}</strong>
|
||||
<span>{sysStatus ? Object.values(sysStatus.checks).map(c => c.ok ? c.label : `⚠ ${c.label}`).join(' · ') || 'API · RAG · 调度' : 'API · RAG · 调度'}</span></div>}
|
||||
</div>
|
||||
<div className="user-chip">
|
||||
<span className="avatar">{currentUser.display_name.slice(0, 1) || '雅'}</span>
|
||||
{!collapsed && <>
|
||||
<div><strong>{currentUser.display_name}</strong>
|
||||
<span>{({ admin: '管理员', editor: '编辑', reviewer: '审查', viewer: '只读' } as Record<string, string>)[currentUser.role] || '成员'}</span></div>
|
||||
<button className="mini user-logout" onClick={() => onLogout()}>退出</button>
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
const currentLabel = (() => {
|
||||
for (const g of groups) for (const i of g.items) {
|
||||
if (i.end ? location.pathname === i.to : location.pathname.startsWith(i.to)) return i.label
|
||||
}
|
||||
// 训练营导图已并入内容任务,旧地址或内容任务内页签仍应显示正确面包屑。
|
||||
if (location.pathname.endsWith('/training-maps') || location.pathname.includes('/training-maps/')) return '内容任务'
|
||||
if (location.pathname.endsWith('/memories') || location.pathname.includes('/memories/')) return 'Agent 与技能'
|
||||
if (location.pathname.endsWith('/manual')) return '操作手册'
|
||||
return '工作台'
|
||||
})()
|
||||
|
||||
return (
|
||||
<div className={`app-shell ${collapsed ? 'sidebar-collapsed' : ''} ${drawerOpen ? 'drawer-open' : ''}`}>
|
||||
{/* 桌面侧栏 */}
|
||||
<aside className="sidebar desktop-sidebar">{sidebar}</aside>
|
||||
{/* 移动端遮罩 + 抽屉 */}
|
||||
{drawerOpen && <div className="drawer-mask" onClick={() => setDrawerOpen(false)} />}
|
||||
<aside className={`sidebar mobile-sidebar ${drawerOpen ? 'open' : ''}`}>
|
||||
<button className="drawer-close" onClick={() => setDrawerOpen(false)} aria-label="关闭导航"><X size={18} /></button>
|
||||
{sidebar}
|
||||
</aside>
|
||||
|
||||
<div className="workspace">
|
||||
<a className="skip-link" href="#router-main">跳到主要内容</a>
|
||||
<header className="topbar">
|
||||
<div className="topbar-left">
|
||||
<button className="icon-button menu-button" onClick={() => setDrawerOpen(true)} aria-label="打开导航"><PanelLeft size={17} /></button>
|
||||
<div className="breadcrumb"><span>{portal === 'admin' ? '管理控制台' : '工作台'}</span><b>/</b><strong>{currentLabel}</strong></div>
|
||||
</div>
|
||||
<div className="topbar-actions">
|
||||
<NavLink to="/admin/manual" className="manual-link" title="查看新用户操作手册">操作手册</NavLink>
|
||||
<NavLink to="/admin/runs" className="icon-button" aria-label="通知" title="通知与运行">
|
||||
<Clock3 size={16} />{unread > 0 && <em>{unread}</em>}
|
||||
</NavLink>
|
||||
<BalanceMenu key={currentUser.id} userId={currentUser.id} />
|
||||
{sysStatus?.environment && <span className="environment-badge">{sysStatus.environment === 'dev' ? '开发环境' : '生产环境'}</span>}
|
||||
<span className="topbar-divider" />
|
||||
<span className="user-chip-mini"><span className="avatar">{currentUser.display_name.slice(0, 1)}</span>{currentUser.display_name}</span>
|
||||
<button className="icon-button" onClick={() => onLogout()} aria-label="退出" title="退出"><CircleUser size={16} /></button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="workspace-context"><span><b>内容生产工作区</b> / {currentLabel}</span><span>{sysStatus?.environment === 'dev' ? '开发环境 · 独立数据' : sysStatus?.environment === 'prod' ? '生产环境' : ''}</span></div>
|
||||
<main className="content-area" id="router-main" tabIndex={-1}>{children}</main>
|
||||
</div>
|
||||
<ProgressToast />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+1235
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
/* 全局共享类型定义(从 App.tsx 抽出) */
|
||||
export type SegmentAgentOption = { id: number; name: string; content_tags: string[]; desc?: string; kb_count?: number }
|
||||
export type Seg = {
|
||||
agent_assignment_mode?: 'auto' | 'manual'; previous_version_count?: number; agent_change_affected?: { id: number; title: string }[]
|
||||
id: number; node_id: string; agent_id: number; agent_name?: string; agent_role?: string; kb_category_options?: any[]; kb_category_recommendation?: { category_ids?: number[]; categories?: any[]; reason?: string; confidence?: number; source?: string; generated_at?: string }; content_tag: string; title: string
|
||||
time_range: string; status: string; round: number; product_ref: string
|
||||
anchor_card: any; qc_reports: any[]; failure: any; artifact: string; references: any[]
|
||||
kb_category_ids?: number[]
|
||||
}
|
||||
export type OwnerFields = { owner_user_id?: number; owner_display_name?: string; owner_username?: string; owner_name?: string }
|
||||
export type Camp = OwnerFields & { id: number; name: string; objective?: string; success_criteria?: string; status: string; run_id?: number | null; run_status?: string | null; has_failure: boolean; created_at: string }
|
||||
export type CampaignState = { id: number; status: string; run?: { id: number; status: string; checkpoint?: any; error?: string } | null }
|
||||
export type RuntimeEvent = { id: number; event_type: string; status: string; segment_id: number; actor_name: string; tool_name: string; payload: any; created_at: string }
|
||||
export type RuntimeSession = { result_meta?: { prompt?: { memory_recall?: { agent_id: number; agent_name: string; items: { id: number; title?: string; source_ref?: string; reason?: string }[] }[] } }; id: number; agent_name: string; phase: string; status: string; turn: number; max_turns: number; parent_session_id?: number | null; error?: string; tool_state?: any }
|
||||
export type RuntimeDetail = { trace_id: string; events: RuntimeEvent[]; sessions: RuntimeSession[] }
|
||||
export type CampaignCreateResponse = { id: number }
|
||||
export type ComposeResponse = { status?: string; consistency?: { issues?: any[] } }
|
||||
export type AgentCreateResponse = { id: number }
|
||||
export type AgentSummary = { id: number; status: string }
|
||||
export type KnowledgeBaseSummary = { id: number }
|
||||
export type NotificationSummary = { id: number; status: string }
|
||||
export type AuthUser = { id: number; username: string; display_name: string; role: string; role_label?: string; enabled: boolean; organization_id?: number; permissions?: string[]; portal?: 'admin' | 'user'; created_at: string }
|
||||
export type AuthStatus = { bootstrap_required: boolean; authenticated: boolean; user: AuthUser | null }
|
||||
export type TraceSummary = { trace_id: string; run_id: number; campaign_id: number; run_status: string; created_at: string; finished_at: string; call_count: number; error_count: number; prompt_tokens: number; completion_tokens: number; duration_ms: number; session_count: number; event_count: number; agents: string[] }
|
||||
export type TraceEvent = { id: number; event_type: string; status: string; actor_name: string; tool_name: string; segment_id: number; created_at: string }
|
||||
export type TraceCall = { id: number; agent_name: string; provider: string; model: string; status: string; error: string; span_id: string; attempt: number; prompt_hash: string; output_hash: string; prompt_tokens: number; completion_tokens: number; duration_ms: number; created_at: string; segment_id: number }
|
||||
export type TraceDetail = { trace_id: string; run: { id: number; status: string; runtime_version: string; config_snapshot: unknown } | null; calls: TraceCall[]; sessions: RuntimeSession[]; events: TraceEvent[]; qc_reports: { id: number; gate: string; verdict: string; segment_id: number }[]; artifacts: { id: number; final_status: string }[] }
|
||||
export type UsageSummary = { events: number; input_tokens: number; output_tokens: number; tokens: number; amount_minor: number; amount: number; estimated_events: number; measured_events: number; settled_events: number; by_model: Record<string, { events: number; quantity: number; amount_minor: number }>; by_service: Record<string, { events: number; quantity: number; amount_minor: number }> }
|
||||
export type UsageEventRow = { id: number; user_id: number; display_name: string; username: string; organization_id: number; campaign_id: number; run_id: number; segment_id: number; trace_id: string; llm_call_id: number | null; agent_id: number; provider_id: number; provider: string; model: string; service_type: string; unit: string; quantity: number; input_quantity: number; output_quantity: number; input_rate: number; output_rate: number; amount_minor: number; amount: number; currency: string; pricing_version: string; status: string; metadata: Record<string, unknown>; created_at: string }
|
||||
export type PointState = { points: number; token_equivalent: number; remainder_points: number; points_per_token: number }
|
||||
export type PointAccountRow = { user_id: number; display_name: string; username: string; points: number; token_equivalent: number; remainder_points: number; points_per_token: number; updated_at?: string; last_change_at?: string; last_change_reason?: string; status?: string }
|
||||
export type UsageResponse = { period: string; user_id: number; summary: UsageSummary; points: PointState }
|
||||
export type AdminUsageResponse = { period: string; summary: UsageSummary; users: { user_id: number; display_name: string; summary: UsageSummary }[] }
|
||||
export type AgentListRow = AgentSummary & OwnerFields & { name: string; role?: string }
|
||||
export type MemoryListRow = OwnerFields & {
|
||||
id: number; agent_id?: number; target_agent_id?: number; agent_name: string
|
||||
type: string; status: string; content: string; title?: string; description?: string
|
||||
source_ref?: string; created_at: string; usage_count?: number; last_used_at?: string | null
|
||||
organization_id?: number; visibility?: string; can_manage?: boolean
|
||||
}
|
||||
export type KbFileRow = { name: string; category_id?: number | null; category_path?: string; classification_status?: string; ai_confidence?: number }
|
||||
export type KbCategoryRow = { id: number; name: string; depth?: number; children?: KbCategoryRow[] }
|
||||
export type KbDetailResponse = { files?: KbFileRow[]; categories?: KbCategoryRow[]; [key: string]: unknown }
|
||||
export type KbUsageResponse = { id: number; agent_name: string; file_name: string; heading?: string; status: string; used_at: string; [key: string]: unknown }
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
declare module 'qrcode' {
|
||||
export function toDataURL(text: string, options?: any): Promise<string>
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react'
|
||||
import { STATUS_COLOR, STATUS_LABEL } from './constants'
|
||||
|
||||
/* 共享 UI 小组件:状态徽标 + 全局错误边界 */
|
||||
|
||||
export function Tag({ s }: { s: string }) {
|
||||
return <span className="badge" style={{ background: STATUS_COLOR[s] || '#8a8f98', color: '#fff' }}>{STATUS_LABEL[s] || s}</span>
|
||||
}
|
||||
|
||||
export class WorkspaceErrorBoundary extends Component<{ children: ReactNode; onRetry?: () => void }, { error: Error | null }> {
|
||||
state: { error: Error | null } = { error: null }
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error('工作台页面渲染失败', error, info)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return <div className="render-error-shell"><div className="render-error-card"><span className="eyebrow">YAWEN CONTENT OPS</span><h1>页面加载失败</h1><p>当前页面数据或缓存脚本异常,重新加载后会重新读取最新配置。</p><code>{this.state.error.message || '未知渲染错误'}</code><button className="primary-action" onClick={() => this.props.onRetry ? this.props.onRetry() : window.location.reload()}>重试加载</button><button className="mini" onClick={() => window.location.reload()}>刷新页面</button></div></div>
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { ArrowRight, FolderOpen } from 'lucide-react'
|
||||
|
||||
export function PageHeader({ title, description, actions, meta }: { title: string; description: string; actions?: ReactNode; meta?: ReactNode }) {
|
||||
return <header className="page-heading workbench-page-heading"><div><h1>{title}</h1><p>{description}</p></div><div className="page-header-aside">{meta}{actions}</div></header>
|
||||
}
|
||||
|
||||
export function EmptyWorkspace({ title, description, steps }: { title: string; description: string; steps: string[] }) {
|
||||
return <div className="workspace-onboarding"><FolderOpen size={32} strokeWidth={1.4}/><h2>{title}</h2><p>{description}</p><ul>{steps.map(step=><li key={step}><ArrowRight size={16}/>{step}</li>)}</ul></div>
|
||||
}
|
||||
|
||||
export function ResourceSummary({ items }: { items: { label: string; value: ReactNode }[] }) {
|
||||
return <dl className="resource-summary">{items.map(item=><div key={item.label}><dt>{item.label}</dt><dd>{item.value}</dd></div>)}</dl>
|
||||
}
|
||||
|
||||
/** 管理员资源归属信息;普通用户接口不返回 owner 字段时自动隐藏。 */
|
||||
export function OwnerLabel({ resource, className = '' }: { resource?: { owner_display_name?: string; owner_name?: string; owner_username?: string }; className?: string }) {
|
||||
const owner = resource?.owner_display_name || resource?.owner_name || resource?.owner_username
|
||||
if (!owner) return null
|
||||
return <span className={`resource-owner ${className}`.trim()}>创建人:{owner}</span>
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/* finesse · register=product · A=jade/sage · B=600/400 · C=collection-detail · D=feedback-only · E=editorial-workdesk · SOUL=5 SPECTACLE=2 DENSITY=7 */
|
||||
:root { --page:#f0f4f1; --bg:#fcfdfc; --panel:#fcfdfc; --panel-2:#f3f6f3; --ink-3:#5d6c64; --border:#dce5de; --border-strong:#c4d2c9; --rail:#f9fbf9; --rail-ink-dim:#607368; --rail-ink-active:#1f6b52; --r:8px; --r-sm:6px; --r-lg:8px; }
|
||||
html,body { overflow-x:clip; }
|
||||
/* Legacy views use inline small print. This explicit floor preserves their hierarchy above 14px. */
|
||||
#root * { font-size:max(14px,1em) !important; }
|
||||
#root :where(.field-help,.dim,small,.avatar) { font-size:14px !important; }
|
||||
#root :where(.eyebrow,.nav-label) { font-size:14px !important; }
|
||||
#root :where(div,section,article,aside,button,input,select,textarea,a,span,label,pre) { border-radius: min(8px, var(--r,8px)); }
|
||||
|
||||
/* Batch 2 asset surfaces: readable controls and compact geometry. */
|
||||
#root .knowledge-page, #root .digital-human-page { font-size: 14px; }
|
||||
#root .knowledge-page :where(button,input,select,textarea,label),
|
||||
#root .digital-human-page :where(button,input,select,textarea,label) { font-size: 14px; }
|
||||
#root .knowledge-page :where(.dim,.field-help,.muted),
|
||||
#root .digital-human-page :where(.dim,.field-help,.muted) { font-size: 14px; }
|
||||
#root .knowledge-page :where(.badge,.tag,.tab-badge),
|
||||
#root .digital-human-page :where(.badge,.tag,.tab-badge) { font-size: 14px; }
|
||||
#root .knowledge-page :where(.empty-state,.empty-workspace),
|
||||
#root .digital-human-page :where(.empty-state,.empty-workspace) { font-size: 14px; }
|
||||
#root :where(button,input,select,textarea) { font-family:inherit; font-size:14px; }
|
||||
#root :where(button,.icon-button,.mini,.side-nav-item) { min-height:40px; }
|
||||
#root :where(input:not([type=checkbox]):not([type=radio]),select) { min-height:42px; }
|
||||
#root :where(input,textarea,select) { background:var(--bg); border:1px solid var(--border-strong); padding:10px 12px; }
|
||||
#root :where(input,textarea,select):hover { border-color:var(--accent); }
|
||||
#root :where(button,a,input,select,textarea):focus-visible { outline:2px solid var(--accent); outline-offset:3px; box-shadow:none; }
|
||||
#root button { transition:background .15s ease,border-color .15s ease; }
|
||||
#root button:disabled { opacity:.5; cursor:not-allowed; }
|
||||
#root :where(.primary-action,.ok,.primary,.auth-submit) { background:var(--accent); color:#fcfdfc; border:1px solid var(--accent); padding:10px 16px; }
|
||||
#root :where(.primary-action,.ok,.primary):hover:not(:disabled) { background:var(--accent-strong); }
|
||||
#root :where(.danger-text,.danger-button) { color:#a32e37; }
|
||||
#root :where(.badge,.tag,.chip,.avatar,.metric-icon,.resource-icon,.health-pulse) { border-radius:6px!important; }
|
||||
#root .app-shell { grid-template-columns:248px minmax(0,1fr); }
|
||||
#root .app-shell.sidebar-collapsed { grid-template-columns:76px minmax(0,1fr); }
|
||||
#root .desktop-sidebar { position:sticky; top:0; height:100dvh; width:auto; display:flex; flex-direction:column; border-right:1px solid var(--border); box-shadow:none; border-radius:0; }
|
||||
#root .sidebar-collapsed .workspace { margin-left:0; }
|
||||
#root .brand-block { min-height:90px; padding:22px 20px; }
|
||||
#root .brand-mark { background:var(--accent); color:#fcfdfc; border-radius:8px; }
|
||||
#root .brand-copy strong { font-size:20px; }
|
||||
#root .side-nav { padding:0 14px 16px; overflow:auto; flex:1; }
|
||||
#root .nav-group { margin:12px 0 18px; }
|
||||
#root .nav-label { color:var(--ink-3); padding:4px 12px 8px; letter-spacing:0; }
|
||||
#root .side-nav-item { gap:12px; padding:10px 12px; margin-bottom:4px; color:#43564a; }
|
||||
#root .side-nav-item.active { background:#e0eee5; color:var(--accent-strong); box-shadow:none; font-weight:650; }
|
||||
#root .side-nav-item:hover { background:#eaf1eb; }
|
||||
#root .sidebar-footer { padding:14px; }
|
||||
#root .system-health span { line-height:1.6; }
|
||||
#root .topbar { height:64px; padding:0 28px; background:var(--bg); border-bottom:1px solid var(--border); box-shadow:none; }
|
||||
#root .workspace-context { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:14px 28px; color:var(--ink-3); border-bottom:1px solid var(--border); background:#eaf1eb; border-radius:0; }
|
||||
#root .workspace-context b { color:var(--accent-strong); font-weight:600; }
|
||||
#root .environment-badge,.scope-chip { display:inline-flex; padding:5px 9px; background:#e6f0e9; color:var(--accent-strong); font-weight:600; white-space:nowrap; }
|
||||
#root .content-area { padding:28px 28px 48px; max-width:1580px; }
|
||||
#root .skip-link { position:fixed; left:270px; top:-100px; z-index:100; padding:12px; background:var(--bg); }
|
||||
#root .skip-link:focus { top:12px; }
|
||||
#root h1 { font-size:26px; line-height:1.3; letter-spacing:-.025em; overflow-wrap:anywhere; }
|
||||
#root h2 { font-size:18px; line-height:1.45; }
|
||||
#root h3 { font-size:16px; }
|
||||
#root .page-heading { align-items:center; gap:20px; margin-bottom:24px; }
|
||||
#root .page-heading h1 { margin:0 0 8px; }
|
||||
#root .page-heading p { color:var(--ink-3); max-width:760px; line-height:1.7; }
|
||||
#root .page-header-aside { display:flex; gap:12px; align-items:center; flex-shrink:0; }
|
||||
#root .production-launch { padding:24px; display:flex; align-items:center; justify-content:space-between; gap:24px; background:#e5efe7; border:1px solid #cbded0; margin-bottom:24px; }
|
||||
#root .production-launch h2 { margin:14px 0 8px; font-size:22px; color:var(--accent-strong); }
|
||||
#root .production-launch p { margin:0; color:var(--ink-3); line-height:1.7; }
|
||||
#root .metric-grid { gap:16px; margin-bottom:24px; }
|
||||
#root .metric-card { padding:20px; background:var(--bg); border:1px solid var(--border); box-shadow:none; }
|
||||
#root .metric-card strong { font-size:32px; color:var(--ink-1); font-variant-numeric:tabular-nums; }
|
||||
#root .metric-card small { color:var(--ink-3); line-height:1.6; }
|
||||
#root .metric-accent { background:var(--bg); }
|
||||
#root .metric-top { color:var(--ink-2); }
|
||||
#root .metric-icon,.resource-icon { background:#e8f1eb; color:var(--accent); }
|
||||
#root .overview-grid { grid-template-columns:minmax(0,1.65fr) minmax(290px,1fr); gap:24px; }
|
||||
#root .panel { background:var(--bg); border:1px solid var(--border); box-shadow:none; }
|
||||
#root .panel-header { padding:20px; border-bottom:1px solid var(--border); }
|
||||
#root .panel-header p { line-height:1.6; }
|
||||
#root .resource-list button { min-height:82px; padding:16px 20px; }
|
||||
#root .resource-summary { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); padding:0; margin:0 0 24px; border:1px solid var(--border); background:var(--bg); }
|
||||
#root .resource-summary>div { padding:18px 20px; border-right:1px solid var(--border); }
|
||||
#root .resource-summary>div:last-child { border-right:0; }
|
||||
#root .resource-summary dt { color:var(--ink-3); margin-bottom:8px; }
|
||||
#root .resource-summary dd { margin:0; font-weight:650; font-size:18px; color:var(--ink-1); }
|
||||
#root .task-status-rail { display:flex; gap:0; border-bottom:1px solid var(--border-strong); margin-bottom:20px; overflow:auto; }
|
||||
#root .task-status-rail button { display:flex; gap:16px; align-items:center; background:transparent; border:0; border-bottom:3px solid transparent; border-radius:0; padding:14px 20px; white-space:nowrap; }
|
||||
#root .task-status-rail button.active { border-bottom-color:var(--accent); color:var(--accent); background:#e6f0e9; }
|
||||
#root .task-status-rail b { font-variant-numeric:tabular-nums; }
|
||||
#root .page-toolbar { gap:12px; padding:0; margin-bottom:20px; flex-wrap:wrap; }
|
||||
#root .toolbar-search { min-width:0; flex:1; background:var(--bg); min-height:42px; }
|
||||
#root .toolbar-search input { border:0; background:transparent; min-width:0; width:100%; }
|
||||
#root .result-count { margin-left:auto; color:var(--ink-3); }
|
||||
#root .task-table { overflow:hidden; padding:0; }
|
||||
#root .task-table-head,#root .task-table-row.as-link { grid-template-columns:minmax(0,1fr) 120px 180px 72px; gap:16px; padding:16px 20px; }
|
||||
#root .recent-panel .task-table-head,#root .recent-panel .task-table-row { grid-template-columns:minmax(0,1fr) 100px 170px; }
|
||||
#root .task-table-head { background:#eaf0eb; color:#506257; font-weight:600; }
|
||||
#root .task-table-row { min-height:76px; border-radius:0!important; }
|
||||
#root .task-table-row strong { font-size:15px; }
|
||||
#root .task-table-row:hover { background:#f0f6f1; }
|
||||
#root .task-table-row .mini { min-width:40px; }
|
||||
#root .agent-split,#root .knowledge-split { grid-template-columns:300px minmax(0,1fr); gap:24px; }
|
||||
#root .agent-list-panel,#root .kb-list-panel { background:var(--bg); border:1px solid var(--border); padding:16px; max-height:calc(100dvh - 220px); overflow:auto; position:sticky; top:84px; }
|
||||
#root .collection-heading { display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border); padding-bottom:14px; margin-bottom:16px; }
|
||||
#root .collection-heading h2 { margin:0; }
|
||||
#root .collection-heading span { color:var(--ink-3); }
|
||||
#root .agent-row { display:flex; flex-wrap:wrap; gap:8px; padding:16px; width:100%; text-align:left; margin-bottom:10px; border:1px solid var(--border); background:var(--bg); box-shadow:none; }
|
||||
#root .agent-row b { flex-basis:100%; color:var(--ink-1); }
|
||||
#root .agent-row.active { border-color:var(--accent); background:#eaf3ed; box-shadow:none; }
|
||||
#root .agent-row .dim { color:var(--ink-3); opacity:1; }
|
||||
#root .agent-row.dim { opacity:1; }
|
||||
#root .detail-tabs { display:flex; gap:4px; overflow-x:auto; border-bottom:1px solid var(--border); margin-bottom:20px; padding-bottom:8px; }
|
||||
#root .detail-tab { white-space:nowrap; flex-shrink:0; padding:10px 14px; border:0; background:transparent; }
|
||||
#root .detail-tab.active { background:#e1eee6; color:var(--accent-strong); box-shadow:none; }
|
||||
#root .agent-detail-panel,#root .kb-detail-panel { min-width:0; }
|
||||
#root .workspace-onboarding { background:var(--bg); border:1px solid var(--border); padding:48px 36px; min-height:400px; display:flex; align-items:flex-start; flex-direction:column; justify-content:center; color:var(--accent); }
|
||||
#root .workspace-onboarding h2 { font-size:22px; margin:20px 0 12px; color:var(--ink-1); }
|
||||
#root .workspace-onboarding p { margin:0; color:var(--ink-3); line-height:1.8; }
|
||||
#root .workspace-onboarding ul { list-style:none; padding:20px 0 0; margin:0; display:grid; gap:16px; }
|
||||
#root .workspace-onboarding li { display:flex; align-items:center; gap:12px; color:var(--ink-2); }
|
||||
#root .workspace-onboarding svg { flex-shrink:0; }
|
||||
#root .card { padding:20px; border:1px solid var(--border); box-shadow:none; background:var(--bg); }
|
||||
#root .agent-form { display:grid; gap:24px; }
|
||||
#root label { line-height:1.7; }
|
||||
#root .agent-form label { display:grid; gap:8px; font-weight:600; }
|
||||
#root .field-help { color:var(--ink-3); font-weight:400; line-height:1.6; }
|
||||
#root .persona-row { display:grid; grid-template-columns:minmax(140px,1fr) 100px minmax(0,2fr) 0 auto auto; gap:16px; padding:20px; background:var(--bg); border:1px solid var(--border); margin-bottom:12px; }
|
||||
#root .persona-row .dim { line-height:1.7; }
|
||||
#root .empty-state,#root .empty-workspace { background:var(--bg); border:1px dashed var(--border-strong); padding:40px 24px; color:var(--ink-3); line-height:1.8; }
|
||||
#root .dialog-backdrop { padding:24px; background:rgba(24,46,33,.4); backdrop-filter:none; }
|
||||
#root .app-dialog { width:min(640px,100%); max-height:calc(100dvh - 48px); overflow:auto; padding:28px; border-radius:8px; background:var(--bg); box-shadow:0 24px 70px #183b3333; }
|
||||
#root .dialog-copy p { color:var(--ink-3); line-height:1.8; }
|
||||
#root .dialog-actions { padding-top:20px; margin-top:20px; border-top:1px solid var(--border); gap:12px; }
|
||||
#root .drawer { width:min(560px,100vw); padding:28px; background:var(--bg); overflow:auto; max-height:100dvh; }
|
||||
#root .auth-brand-pane { background:#174c3b; }
|
||||
#root .auth-brand-glow { display:none; }
|
||||
#root .auth-card input { min-height:48px; }
|
||||
#root .auth-card h1 { font-size:28px; }
|
||||
#root .auth-brand-copy h1 { font-size:44px; }
|
||||
@media(max-width:1100px) { #root .overview-grid {grid-template-columns:1fr;} #root .metric-grid{grid-template-columns:repeat(2,minmax(0,1fr));} #root .agent-split,#root .knowledge-split{grid-template-columns:260px minmax(0,1fr);} }
|
||||
@media(max-width:900px) {
|
||||
#root .app-shell,#root .app-shell.sidebar-collapsed{grid-template-columns:minmax(0,1fr);} #root .desktop-sidebar{display:none;} #root .mobile-sidebar{background:var(--bg);width:288px;border-radius:0;} #root .mobile-sidebar .brand-copy,#root .mobile-sidebar .nav-label,#root .mobile-sidebar .nav-text{display:block;} #root .mobile-sidebar .collapse-toggle{display:none;}
|
||||
#root .menu-button{display:inline-flex;} #root .agent-split,#root .knowledge-split{grid-template-columns:minmax(0,1fr);} #root .agent-list-panel,#root .kb-list-panel{position:static;max-height:360px;} #root .workspace-context{padding:12px 20px;} #root .workspace-context>span:last-child{display:none;}
|
||||
#root .persona-row{grid-template-columns:minmax(0,1fr) auto auto;} #root .persona-row>.dim{grid-column:1/-1;} #root .persona-row>.flex-spacer{display:none;}
|
||||
}
|
||||
@media(max-width:600px) {
|
||||
#root .topbar{padding:0 14px;} #root .topbar-actions{gap:4px;} #root .user-chip-mini,#root .topbar-divider,#root .breadcrumb>span,#root .breadcrumb>b{display:none;} #root .environment-badge{font-size:14px;padding:4px 6px;} #root .content-area{padding:24px 16px 40px;} #root .workspace-context{padding:12px 16px;}
|
||||
#root .page-heading{align-items:flex-start;gap:16px;} #root h1{font-size:24px;} #root .page-header-aside{flex-wrap:wrap;} #root .production-launch{display:block;padding:20px;} #root .production-launch h2{font-size:20px;} #root .production-launch button{margin-top:20px;width:100%;} #root .metric-grid{gap:12px;} #root .metric-card{padding:16px;} #root .metric-card strong{font-size:28px;} #root .metric-icon{display:none;}
|
||||
#root .resource-summary{grid-template-columns:1fr;} #root .resource-summary>div{display:flex;justify-content:space-between;gap:12px;border-right:0;border-bottom:1px solid var(--border);padding:14px 16px;} #root .resource-summary dt{margin:0;} #root .resource-summary dd{font-size:16px;}
|
||||
#root .page-toolbar>.toolbar-search{flex-basis:100%;} #root .task-status-rail button{padding:12px 14px;gap:12px;} #root .task-table-head{display:none;} #root .task-table-row.as-link,#root .recent-panel .task-table-row{grid-template-columns:minmax(0,1fr) auto;gap:12px;padding:16px;} #root .task-table-row strong{white-space:normal;line-height:1.6;} #root .task-table-row .muted{grid-column:1;overflow-wrap:anywhere;} #root .task-table-row.as-link>span:last-child{grid-column:2;grid-row:2;}
|
||||
#root .workspace-onboarding{padding:28px 20px;min-height:320px;} #root .workspace-onboarding h2{font-size:20px;} #root .card{padding:16px;} #root .detail-header{flex-direction:column;align-items:flex-start;gap:16px;} #root .detail-actions{flex-wrap:wrap;} #root .dialog-backdrop{padding:12px;} #root .app-dialog{padding:20px;max-height:calc(100dvh - 24px);} #root .dialog-actions{flex-wrap:wrap;} #root .drawer{padding:20px;}
|
||||
#root :where(button,.mini,.icon-button,.side-nav-item){min-height:44px;} #root .auth-brand-copy h1{font-size:32px;} #root .auth-card{padding:24px;} #root .file-row{flex-wrap:wrap;} #root .file-import-label{white-space:normal;} #root .ai-assist-body>div{flex-wrap:wrap;} #root .ai-entry{white-space:nowrap; min-width:132px;}
|
||||
}
|
||||
@media(prefers-reduced-motion:reduce){#root *{animation:none!important;transition:none!important;}}
|
||||
|
||||
/* 微信 Bot 连接抽屉:账号列表与扫码状态 */
|
||||
#root .wechat-connect-trigger { display:inline-flex; align-items:center; gap:7px; }
|
||||
#root .wechat-drawer-mask { z-index:80; display:flex; justify-content:flex-end; background:rgba(19,43,31,.28); }
|
||||
#root .wechat-drawer { width:min(520px,100vw); padding:0; display:flex; flex-direction:column; background:var(--bg); }
|
||||
#root .wechat-drawer-head { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; padding:24px 24px 18px; border-bottom:1px solid var(--border); }
|
||||
#root .wechat-drawer-head h2 { margin:8px 0 5px; color:var(--ink-1); font-size:20px; }
|
||||
#root .wechat-drawer-head p { margin:0; max-width:390px; color:var(--ink-3); font-size:12px; line-height:1.65; }
|
||||
#root .wechat-drawer-kicker { display:inline-flex; align-items:center; gap:6px; color:var(--accent-strong); font-size:12px; font-weight:650; }
|
||||
#root .wechat-drawer-body { flex:1; overflow:auto; padding:18px 24px 28px; }
|
||||
#root .wechat-connect-panel { padding:16px; border:1px solid #cdded2; border-radius:10px; background:#f5faf6; }
|
||||
#root .wechat-connect-title,.wechat-section-heading { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
#root .wechat-connect-title strong,#root .wechat-section-heading strong { display:block; color:var(--ink-1); font-size:14px; }
|
||||
#root .wechat-connect-title span { display:block; margin-top:4px; color:var(--ink-3); font-size:12px; }
|
||||
#root .wechat-connect-title svg { color:var(--accent); }
|
||||
#root .wechat-connect-panel .field { display:grid; gap:6px; margin-top:14px; }
|
||||
#root .wechat-connect-panel .field>span { color:var(--ink-2); font-size:12px; font-weight:600; }
|
||||
#root .wechat-connect-button { display:inline-flex; align-items:center; gap:7px; margin-top:12px; }
|
||||
#root .wechat-qr-panel { display:grid; justify-items:center; gap:7px; margin-top:14px; padding:14px; border:1px dashed #9ec6ac; border-radius:8px; background:#fff; text-align:center; }
|
||||
#root .wechat-qr-title { display:flex; width:100%; justify-content:space-between; align-items:center; color:var(--ink-1); }
|
||||
#root .wechat-qr { display:block; width:180px; height:180px; object-fit:contain; border:1px solid var(--border); border-radius:6px; background:#fff; }
|
||||
#root .wechat-qr-panel span,#root .wechat-qr-panel small { color:var(--ink-3); font-size:11px; }
|
||||
#root .wechat-account-section { margin-top:22px; }
|
||||
#root .wechat-section-heading { align-items:center; padding-bottom:9px; border-bottom:1px solid var(--border); }
|
||||
#root .wechat-section-heading span { margin-left:auto; color:var(--ink-3); font-size:12px; }
|
||||
#root .wechat-section-heading .icon-button { min-height:30px; padding:5px; }
|
||||
#root .wechat-account-list { display:grid; gap:9px; margin-top:10px; }
|
||||
#root .wechat-account-row { padding:13px; border:1px solid var(--border); border-radius:9px; background:var(--bg); }
|
||||
#root .wechat-account-main { display:flex; align-items:flex-start; gap:9px; }
|
||||
#root .wechat-account-main strong { display:block; color:var(--ink-1); font-size:13px; }
|
||||
#root .wechat-account-main span:not(.wechat-status-dot) { display:block; margin-top:3px; color:var(--ink-3); font-size:11px; }
|
||||
#root .wechat-status-dot { flex:0 0 9px; width:9px; height:9px; margin-top:5px; border-radius:50%; background:#9aa9a0; }
|
||||
#root .wechat-status-dot.is-online { background:#2d9562; box-shadow:0 0 0 3px #dff1e6; }
|
||||
#root .wechat-status-dot.is-pending { background:#c88a29; box-shadow:0 0 0 3px #fff0d8; }
|
||||
#root .wechat-status-dot.is-offline { background:#a35d62; }
|
||||
#root .wechat-agent-binding { display:grid; grid-template-columns:80px minmax(0,1fr); align-items:center; gap:8px; margin-top:12px; color:var(--ink-3); font-size:11px; }
|
||||
#root .wechat-agent-binding select { min-height:34px; padding:6px 8px; font-size:12px; }
|
||||
#root .wechat-account-error { margin:8px 0 0; padding:7px 9px; border-radius:6px; background:#fff1ef; color:#a3483e; font-size:11px; line-height:1.5; }
|
||||
#root .wechat-account-foot { display:flex; align-items:center; justify-content:space-between; gap:8px; margin-top:10px; color:var(--ink-3); font-size:11px; }
|
||||
#root .wechat-account-foot button { display:inline-flex; align-items:center; gap:5px; min-height:30px; padding:4px 8px; }
|
||||
#root .wechat-empty { display:grid; justify-items:center; gap:6px; padding:28px 12px; color:var(--ink-3); text-align:center; }
|
||||
#root .wechat-empty svg { color:#9ab9a4; }
|
||||
#root .wechat-empty strong { color:var(--ink-2); font-size:13px; }
|
||||
#root .wechat-empty span { font-size:11px; }
|
||||
#root .wechat-isolation-note { display:grid; gap:4px; margin-top:18px; padding:11px 12px; border-radius:8px; background:#f1f5f2; color:var(--ink-3); font-size:11px; line-height:1.6; }
|
||||
#root .wechat-isolation-note strong { color:var(--accent-strong); font-size:12px; }
|
||||
#root .wechat-error { margin-bottom:12px; }
|
||||
#root .spin { animation:spin .8s linear infinite; }
|
||||
@keyframes spin { to { transform:rotate(360deg); } }
|
||||
@media(max-width:600px){#root .wechat-drawer-head{padding:20px 16px 15px}#root .wechat-drawer-body{padding:15px 16px 24px}}
|
||||
|
||||
/* 数字人列表:紧凑资源行,避免全局字号规则把卡片撑大。 */
|
||||
#root .digital-human-page .persona-row {
|
||||
display:flex; align-items:center; gap:10px; min-height:62px; margin-bottom:8px;
|
||||
padding:11px 14px; background:var(--bg); border:1px solid var(--border); border-radius:8px;
|
||||
box-shadow:0 1px 2px rgba(24,24,26,.03); transition:border-color .15s ease,background .15s ease,box-shadow .15s ease;
|
||||
}
|
||||
#root .digital-human-page .persona-row:hover,
|
||||
#root .digital-human-page .persona-row:focus-visible { background:#f7fbf8; border-color:#b8d3c2; box-shadow:0 3px 10px rgba(31,107,82,.08); }
|
||||
#root .digital-human-page .persona-row > b { min-width:150px; max-width:240px; overflow:hidden; color:var(--ink-1); font-size:14px !important; line-height:1.35; text-overflow:ellipsis; white-space:nowrap; }
|
||||
#root .digital-human-page .persona-row > .resource-owner { flex:0 1 120px; min-width:80px; overflow:hidden; color:var(--ink-3); font-size:12px !important; text-overflow:ellipsis; white-space:nowrap; }
|
||||
#root .digital-human-page .persona-row > .badge { flex:0 0 auto; font-size:12px !important; line-height:1.3; white-space:nowrap; }
|
||||
#root .digital-human-page .persona-row > .dim { flex:1 1 220px; min-width:0; overflow:hidden; color:var(--ink-3); font-size:12px !important; line-height:1.45; text-overflow:ellipsis; white-space:nowrap; }
|
||||
#root .digital-human-page .persona-row > .flex-spacer { display:none; }
|
||||
#root .digital-human-page .persona-row > .mini { flex:0 0 auto; min-height:32px; padding:5px 10px; font-size:12px !important; white-space:nowrap; }
|
||||
@media (max-width:760px) {
|
||||
#root .digital-human-page .persona-row { align-items:flex-start; flex-wrap:wrap; padding:11px 12px; }
|
||||
#root .digital-human-page .persona-row > b { flex:1 1 calc(100% - 90px); min-width:0; max-width:none; }
|
||||
#root .digital-human-page .persona-row > .resource-owner { order:3; flex-basis:38%; }
|
||||
#root .digital-human-page .persona-row > .badge { order:2; }
|
||||
#root .digital-human-page .persona-row > .dim { order:4; flex-basis:100%; }
|
||||
#root .digital-human-page .persona-row > .mini { order:5; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": false
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': { target: 'http://127.0.0.1:8010', changeOrigin: true }
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user