('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 (
+
+ {children}
+ {active && (
+ {
+ if (event.target === event.currentTarget) close(null)
+ }}>
+
+ {active.tone === 'danger' ? '!' : active.tone === 'success' ? '✓' : '?'}
+
+
{active.title}
+ {active.description &&
{active.description}
}
+
+ {active.kind === 'prompt' && }
+
+ {active.kind !== 'notice' && }
+
+
+
+
+ )}
+
+ )
+}
+
+export function useDialog(): DialogApi {
+ const dialog = useContext(DialogContext)
+ if (!dialog) throw new Error('useDialog 必须在 DialogProvider 内使用')
+ return dialog
+}
diff --git a/web/src/lazyRoute.tsx b/web/src/lazyRoute.tsx
new file mode 100644
index 0000000..3dfcd9b
--- /dev/null
+++ b/web/src/lazyRoute.tsx
@@ -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
+
+ {slow ? '正在加载页面数据,请稍候…' : label}
+ {slow ? '公网连接较慢,页面会自动继续加载,请不要重复点击。' : '正在准备页面资源'}
+
+
+}
+
+function loadWithRecovery(load: () => Promise<{ default: ComponentType
}>) {
+ 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
}>(() => {})
+ }
+ 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
(load: () => Promise<{ default: ComponentType
}>) {
+ return function LazyRoute(props: P) {
+ const [attempt, setAttempt] = useState(() => ({ Page: lazy(() => loadWithRecovery(load)), key: 0 }))
+ const Page = attempt.Page as ComponentType
+ return setAttempt(a => ({ Page: lazy(() => loadWithRecovery(load)), key: a.key + 1 }))}>
+ }>
+
+ }
+}
diff --git a/web/src/main.tsx b/web/src/main.tsx
new file mode 100644
index 0000000..f54fb38
--- /dev/null
+++ b/web/src/main.tsx
@@ -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(
+
+)
diff --git a/web/src/nodeRegistry.ts b/web/src/nodeRegistry.ts
new file mode 100644
index 0000000..541438c
--- /dev/null
+++ b/web/src/nodeRegistry.ts
@@ -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 = {
+ 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
+}
diff --git a/web/src/pages/AccessPage.tsx b/web/src/pages/AccessPage.tsx
new file mode 100644
index 0000000..d998977
--- /dev/null
+++ b/web/src/pages/AccessPage.tsx
@@ -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([])
+ const [adminKbs, setAdminKbs] = useState([])
+ const [adminCanvases, setAdminCanvases] = useState([])
+ const [adminMemories, setAdminMemories] = useState([])
+ const [adminDigitalHumans, setAdminDigitalHumans] = useState([])
+ const [adminSkills, setAdminSkills] = useState([])
+ const [adminAgents, setAdminAgents] = useState([])
+ const [roles, setRoles] = useState([])
+ const [newRoleLabel, setNewRoleLabel] = useState('')
+ const [newRoleDescription, setNewRoleDescription] = useState('')
+ const [newRolePermissions, setNewRolePermissions] = useState(['read'])
+ // 资源授权批处理选择:键由资源类型与 id 组成,跨知识库、画布、记忆和数字人统一管理。
+ const [selectedResources, setSelectedResources] = useState([])
+ const [selectedGrantUsers, setSelectedGrantUsers] = useState([])
+ 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>({})
+ 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('/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('/admin/knowledge-bases'), api.get('/admin/canvases'),
+ api.get('/admin/memories'), api.get('/admin/digital-humans'), api.get('/admin/skills'), api.get('/admin/agents'), api.get('/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) => {
+ 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()
+ 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()
+ 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
+ }
+ 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
+
+ {tabs.map(t => )}
+
+ {error &&
{error}
}
+ {notice &&
{notice}
}
+
+ {tab === 'members' && <>
+
+
+
+
+
{users.map(user =>
+
{user.display_name.slice(0, 1) || user.username.slice(0, 1)}{user.display_name}{user.username} · 创建于 {user.created_at}
+
+
+
setResetPasswords(current => ({ ...current, [user.id]: event.target.value }))} disabled={user.username.startsWith('deleted_')} />
+
+
)}
+
停用成员会立即令其现有会话失效;删除成员会永久移除账号和资源授权,历史审计与用量记录仍会保留。系统始终保留至少一个启用管理员。重设密码请输入新密码后点「重设」(≥10 字符)。
+ >}
+
+ {tab === 'roles' &&
+ {roles.map(roleRow => deleteRole(roleRow)} />)}
+ 新建自定义角色
先按左侧功能栏选择功能,再勾选该功能允许的操作。
+ }
+
+ {tab === 'grants' &&
+
+
批量授权与发布跨分类选择资源后,一次授权给多位成员或发布全局{bulkProgress && 处理中 {bulkProgress.done}/{bulkProgress.total} · 成功 {bulkProgress.success} · 失败 {bulkProgress.failure}}
+
+
授权成员{grantableUsers.map(user => )}{grantableUsers.length === 0 && 暂无可授权成员}
+
全局资源允许成员修改自己的副本,成员不提供导出权限。
+
+
+ {adminKbs.map(resource => )}
+
+
+ {adminAgents.map(resource => )}
+
+
+ {adminCanvases.map(resource => )}
+
+
+ {adminMemories.map(resource => )}
+
+
+ {adminSkills.map(resource => )}
+
+
+ {adminDigitalHumans.map(resource => )}
+
+ [`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]),
+ ])} />
+ }
+
+ {tab === 'security' &&
+ 登录与安全策略(当前生效)
+
+
+
防爆破同一(IP + 用户名)5 次失败锁定 15 分钟;单 IP 30 次失败锁定 15 分钟
+
密码规则长度 ≥10 字符;成员密码仅管理员可重设
+
传输安全全站 HTTPS(Cloudflare 边缘证书);API 凭据仅存服务端钥匙串
+
响应安全头X-Frame-Options / nosniff / CSP frame-ancestors 已启用
+
+ }
+
+ {/* 新增成员弹窗 */}
+ {showCreate &&
setShowCreate(false)} />}
+ {showCreate &&
+
新增成员
+
setCreateDraft(d => ({ ...d, username: event.target.value }))} />
+
setCreateDraft(d => ({ ...d, displayName: event.target.value }))} />
+
setCreateDraft(d => ({ ...d, password: event.target.value }))} />
+
+
+
}
+
+}
+
+const VISIBILITY_LABEL: Record
= { private: '私有', shared: '指定成员', global: '全局' }
+const GRANT_LABEL: Record = { 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 0}>
+
+ ›
+ {title}{selected ? `已选 ${selected} 项` : '点击展开资源列表'}
+ {rows.length}
+
+
+ {children}
+
+}
+
+/** 资源卡片内的授权选择器:采用消费记录相同的抽屉、卡片和选中态,避免原生 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
+
setOpen(event.currentTarget.open)}>
+ ›{selectedUser ? selectedUser.display_name || selectedUser.username : '选择授权成员'}{selectedUser ? (GRANT_LABEL[permission] || '可使用') : '点击展开成员与权限'}{grantable.length}
+
+
选择一个成员,再设置他在此资源上的权限。
+
+ {grantable.map((user: any) => {
+ const active = String(user.id) === userId
+ return
+ })}
+ {!grantable.length && 暂无可授权成员}
+
+
+ {(['read', 'use', ...(kind !== 'digital-humans' ? ['manage'] : [])] as string[]).map(level => )}
+
+
+
+
+ {currentGrant &&
当前首条授权:{currentGrant.display_name || currentGrant.username} · {GRANT_LABEL[currentGrant.permission] || '可使用'}}
+
+}
+
+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
+
+
+ {kind === 'memories' && {resource.layer}}
+ {kind === 'digital-humans' && {resource.status === 'active' ? '已生效' : '待确认'}}
+ {VISIBILITY_LABEL[resource.visibility] || '私有'}{detail} · {resource.grants?.length || 0} 条授权
+
+ {resource.visibility !== 'global' && }
+
+ {grantControls(kind, resource)}
+ {resource.grants?.map((item: any) =>
{item.display_name || item.username} · {GRANT_LABEL[item.permission] || '可使用'}
)}
+
+}
+
+/* 权限目录和左侧功能栏保持一一对应。每组就是侧栏中的一个分组,组内先控制入口,再控制具体操作。 */
+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
+
+ {group &&
}
+
+}
+
+function RolePermissionEditor({ roleRow, busy, onSaved, onDelete }: { roleRow: any; busy: boolean; onSaved: () => void; onDelete: () => void }) {
+ const [permissions, setPermissions] = useState(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
+
{roleRow.label}
{roleRow.description || '未填写角色说明'} · {count} 项权限
+
{!roleRow.builtin && }
+
setPermissions(current => current.includes(permission) ? current.filter(item => item !== permission) : [...current, permission])} />
+ {isAdmin && 管理员保留全部权限,避免锁死管理入口。}
+
+}
diff --git a/web/src/pages/AgentWorkbench.tsx b/web/src/pages/AgentWorkbench.tsx
new file mode 100644
index 0000000..8725a0e
--- /dev/null
+++ b/web/src/pages/AgentWorkbench.tsx
@@ -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([])
+ const [tags, setTags] = useState([])
+ const [providers, setProviders] = useState([])
+ const [kbs, setKbs] = useState([])
+ const [kbCategories, setKbCategories] = useState>({})
+ const [skills, setSkills] = useState([])
+ const [digitalHumans, setDigitalHumans] = useState([])
+ const [toolCatalog, setToolCatalog] = useState([])
+ const [cur, setCur] = useState(null)
+ const [me, setMe] = useState(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(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('/models/available')
+ const grouped: Record = {}
+ 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('/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('/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(`/kbs/${kid}`); setKbCategories(prev => ({ ...prev, [kid]: d.categories || [] })) } catch {} }
+ setCur({ ...cur, kb_ids: nextIds })
+ }
+ // 可搜索分组标签多选器
+ const tagGroups = useMemo(() => {
+ const groups: Record = {}
+ 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
+
配置中心} />
+ {/* 模块内页签 */}
+
+ {moduleTabs.map(t => (
+
+ ))}
+
+
+ {moduleTab === 'agent' && }
+
+ {moduleTab === 'skills' && }
+ {moduleTab === 'memories' && }
+ {aiOpen && setAiOpen(false)} />}
+
+}
diff --git a/web/src/pages/AgentsPage.tsx b/web/src/pages/AgentsPage.tsx
new file mode 100644
index 0000000..c90a639
--- /dev/null
+++ b/web/src/pages/AgentsPage.tsx
@@ -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([])
+ const [tags, setTags] = useState([])
+ const [providers, setProviders] = useState([])
+ const [kbs, setKbs] = useState([])
+ const [kbCategories, setKbCategories] = useState>({})
+ const [skills, setSkills] = useState([])
+ const [promptTemplates, setPromptTemplates] = useState([])
+ const [toolCatalog, setToolCatalog] = useState([])
+ const [cur, setCur] = useState(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('/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('/models/available')
+ const grouped: Record = {}
+ 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(`/agents/${id}`)
+ const selected = agent.kb_ids || []
+ const entries = await Promise.all(selected.map(async (kid: number) => [kid, (await api.get(`/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('/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 (
+
+
+ Agent 列表
每个 Agent 负责一种内容节点或质量检查。
+
+ {agents.map(a => (
+ void selectAgent(a.id)}>
+ {a.name}
+
+ {{ production: '内容生产', training_map: '导图生成', qc: '质检', service: '服务', coordinator: '主控', composer: '组合' }[a.role] || a.role}
+ {a.content_tags.join('/') || '—'}
+ {a.fusion_agent_names?.length > 0 && 融合:{a.fusion_agent_names.join(' + ')}}
+ 提示:{a.prompt_template_name || '系统默认'} · v{a.prompt_template_version || 1}
+ {a.model_label}
+ {a.status === 'disabled' && 停用}
+ {a.builtin && 内置}
+
+ ))}
+
+
+ {cur ? `编辑:${cur.name}` : '选择一个 Agent'}
{cur ? '先套用模板,再按实际业务补充少量规则。' : '从左侧选择 Agent,即可查看配置。'}
+ {error && {error}
}
+ {cur && (
+
+
不知道怎么写规则?选择模板会填入基础规则,之后仍可自由修改。
+
+
+
+
+
+
+
+
+
模型
+
+ {cur.model_provider_id && (
+ )}
+
+
提示模板模板负责角色、任务、示例和输出协议;保存任务时会冻结当前版本。
+
+
+
状态内容生产 Agent 必须先挂接知识库,导图生成 Agent 的资料范围由训练营导图单独选择。
+
+
规则(rules.md)用来约束这个 Agent 的职责、输出方式和禁止事项。系统会把规则连同当前任务一起发送给模型。
+
+
+
+
+
+
+ )}
+
+ {aiOpen &&
setAiOpen(false)} />}
+
+ )
+}
+
+/* ---------------- 技能库 ---------------- */
diff --git a/web/src/pages/AuditLogsPage.tsx b/web/src/pages/AuditLogsPage.tsx
new file mode 100644
index 0000000..efc5f49
--- /dev/null
+++ b/web/src/pages/AuditLogsPage.tsx
@@ -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 = {
+ 'http.get': '查看',
+ 'http.post': '创建 / 提交',
+ 'http.put': '更新',
+ 'http.patch': '更新',
+ 'http.delete': '删除',
+ login: '登录',
+ logout: '退出登录',
+ 'points.grant': '发放点数',
+ 'feature_flags.update': '更新功能开关',
+ 'tags.update': '更新标签',
+}
+const RESULT_LABEL: Record = {
+ success: '成功',
+ ok: '成功',
+ failure: '失败',
+ failed: '失败',
+ error: '错误',
+}
+const RESOURCE_LABEL: Record = {
+ '/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)[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 = {
+ '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 = { 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)[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([]); 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 操作日志
管理员业务变更审计(不记录密钥、令牌或请求正文)。共 {total} 条。
+ 操作人动作资源结果时间
{rows.map(r =>
{r.display_name || '系统'}{labelAction(r.action, r.resource)}{labelResource(r.resource)}{labelResult(r.result)}{r.created_at}
)}{!rows.length &&
暂无操作日志
}
+
+
+}
diff --git a/web/src/pages/ContentTabs.tsx b/web/src/pages/ContentTabs.tsx
new file mode 100644
index 0000000..514b034
--- /dev/null
+++ b/web/src/pages/ContentTabs.tsx
@@ -0,0 +1,5 @@
+import { Link } from 'react-router-dom'
+import './training-map.css'
+export function ContentTabs({ map = false }: { map?: boolean }) {
+ return
+}
diff --git a/web/src/pages/DigitalHumansPage.tsx b/web/src/pages/DigitalHumansPage.tsx
new file mode 100644
index 0000000..24198fd
--- /dev/null
+++ b/web/src/pages/DigitalHumansPage.tsx
@@ -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 = {
+ 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([])
+ const [sel, setSel] = useState(null)
+ const [grantOpen, setGrantOpen] = useState(false)
+ const [grantList, setGrantList] = useState([])
+ const [grantUserId, setGrantUserId] = useState('')
+ const [grantPermission, setGrantPermission] = useState('use')
+ const [members, setMembers] = useState([])
+ 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([])
+ const [redoBusy, setRedoBusy] = useState(false)
+ const fileRef = useRef(null)
+ const [editingPersona, setEditingPersona] = useState(false)
+ const [personaDraft, setPersonaDraft] = useState>({})
+ const [personaBusy, setPersonaBusy] = useState(false)
+ const [forkBusy, setForkBusy] = useState(null)
+ const [testVersion, setTestVersion] = useState(null)
+ // 微信 Bot 连接(每个登录用户独立,可绑定多个微信账号)
+ const [wechatOpen, setWechatOpen] = useState(false)
+ const [wechatAccounts, setWechatAccounts] = useState([])
+ const [wechatAgents, setWechatAgents] = useState([])
+ const [wechatLoading, setWechatLoading] = useState(false)
+ const [wechatAction, setWechatAction] = useState(null)
+ const [wechatError, setWechatError] = useState('')
+ const [wechatAgentId, setWechatAgentId] = useState('')
+ const [wechatQr, setWechatQr] = useState(null)
+ const [wechatQrImage, setWechatQrImage] = useState('')
+
+ // iLink 的 qrcode_img_content 是扫码链接(HTML 页面),不是可直接作为
+ //
的图片地址;在浏览器本地生成二维码,避免把登录链接转发给第三方。
+ 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(' { api.get('/auth/users').then((r: any) => setMembers(Array.isArray(r) ? r : (r.users || []))).catch(() => {}) }, [])
+
+ const refresh = () => api.get('/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('/wechat/bot/accounts'),
+ api.get('/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('/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('/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 (
+ <>
+
+
人格资产} />
+ row.status === "active").length},{label:"使用流程",value:"素材 → 蒸馏 → 审核"}]} />
+
+
setQuery(e.target.value)} />
+
+
+
+ {error && {error}
}
+ {(wBusy || testBusy || redoBusy) && {wBusy ? 'AI 正在蒸馏数字人' : redoBusy ? 'AI 正在按建议重新蒸馏' : 'AI 正在生成测试文稿'}正在处理资料,预计需要 1–2 分钟,请不要关闭页面
读取资料 → 分析表达风格 → 保存新版本 }
+ {filtered.map(row => {
+ const st = STATUS_BADGE[row.status] || STATUS_BADGE.distilling
+ return (
+ select(row)} role="button" tabIndex={0} onKeyDown={e => { if (e.key === 'Enter') select(row) }} style={{ cursor: 'pointer' }}>
+ {row.name}
+
+ {st.label}
+ v{row.version} · {row.persona_name || '未命名人格'} · 来源 {row.source_count} 份 · {row.visibility === 'private' ? '私有' : row.visibility === 'shared' ? '共享' : '全局'}
+
+
+ {row.can_manage && }
+ {!row.can_manage && isGlobalResource(row) && <>>}
+
+ )
+ })}
+ {!filtered.length && 还没有数字人点「蒸馏新数字人」,上传这个人的直播稿/聊天记录,AI 提取文稿风格、情感表达、叙事与思考方式,蒸馏出可绑定 Agent 的人格档案。
}
+
+ {/* ---------- 蒸馏向导 ---------- */}
+ {wizardOpen && !wBusy && setWizardOpen(false)}>
+
e.stopPropagation()}>
+
✦
+
+
蒸馏新数字人
+
上传或粘贴这个人的直播稿、聊天记录,提取本人文稿风格、情感变化、叙事结构和思考方式。也可在蒸馏后补充表达提示与规则。支持 docx / doc / pdf / txt / md / srt,单个大文件可通过分片上传至 20GB;多个普通文件单次总量 ≤ 90MB。扫描版 PDF / 图片不含文字,无法识别。
+
+
+
+
}
+
+ {/* ---------- 详情 ---------- */}
+ {sel &&
+
+
+
{sel.name} {(STATUS_BADGE[sel.status] || {}).label} · v{sel.version}
+
+
{sel.desc || '—'} · 来源:{(sel.source_files || []).join('、') || '—'}
+
+ {sel.status === 'active' &&
可被 Agent 绑定}
+
+
+
人物表达统一在这份档案维护。绑定此数字人的 Agent 会继承其口吻、情感、文稿与叙事方式,并按任务导图和上文衔接生成。
+ {sel.persona_meta &&
+ 蒸馏质量与证据说明查看素材覆盖、证据边界和人工审核要求
+
+
已累计投喂 {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 || ''}
+ {sel.persona_meta.source_history?.length > 1 &&
最近一批新增:{(sel.persona_meta.source_history.at(-1)?.source_names || []).join('、') || '仅按建议修订'}。每次投喂都会生成新版本,需重新测试并审核。
}
+
证据规则:{sel.persona_meta.evidence_policy || '每个结论需有原文依据;原话逐字引用。'}
+
不确定性:{sel.persona_meta.uncertainty || '证据不足内容需人工确认。'}
+
使用边界:{sel.persona_meta.context_rules || '人格只影响表达,不改变任务和知识范围。'}
+
+ }
+ {sel.bound_agents?.length > 0 &&
已绑定:{sel.bound_agents.map((agent: any) => agent.name).join('、')}。新版本审核生效后用于后续配置;已保存任务继续使用当时的版本。
}
+
+ {canManage && !editingPersona && }
+ {editingPersona && <>
+
+
+ >}
+ {!canManage && isGlobalResource(sel) ? <>全局数字人不能直接修改;副本的蒸馏、删除和版本只影响你的账号。> : !canManage && 当前为只读档案,表达设定由创建者或管理员维护。}
+
+
+
+ {canManage &&
}
+
}
+
+ {wechatOpen && !wechatAction && setWechatOpen(false)}>
+
+
}
+ {grantOpen && sel && (
+ setGrantOpen(false)}>
+
e.stopPropagation()}>
+
授权成员 · {sel.name}
+
当前已授权:{grantList.map((g: any) => g.display_name || g.username).join('、') || '(无)'}
+
选择成员
+
+
+
权限
+
+
+
+
+
+
+ {grantList.length > 0 && (
+
+ {grantList.map((g: any) => (
+
+ {g.display_name || g.username} · {g.permission === 'read' ? '只读' : '可使用'}
+
+
+ ))}
+
+ )}
+
+
+ )}
+ >
+ )
+}
diff --git a/web/src/pages/FeatureFlagsPage.tsx b/web/src/pages/FeatureFlagsPage.tsx
new file mode 100644
index 0000000..f552289
--- /dev/null
+++ b/web/src/pages/FeatureFlagsPage.tsx
@@ -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>({})
+ 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 }>('/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
+
+
功能开关
+
+ {dirty &&
未保存}{status &&
{status}}{saved &&
已保存}
+
+
+ {error &&
{error}
}
+ {FLAG_META.map(meta => (
+
+ {meta.label}
{meta.hint}
+
+ toggle(meta.key)} disabled={busy} />
+ {(flags[meta.key] ?? true) ? '开启' : '关闭'}
+
+
+ ))}
+
开关立即生效(保存后下一次请求起应用);关闭的功能仅隐藏入口或跳过对应环节,不影响历史数据。
+
+}
diff --git a/web/src/pages/KBsPage.tsx b/web/src/pages/KBsPage.tsx
new file mode 100644
index 0000000..a24400b
--- /dev/null
+++ b/web/src/pages/KBsPage.tsx
@@ -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 {
+ 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 {
+ 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([])
+ const [kbMetrics, setKbMetrics] = useState(null)
+ const [name, setName] = useState(''); const [desc, setDesc] = useState('')
+ const [q, setQ] = useState(''); const [hits, setHits] = useState([])
+ const [sel, setSel] = useState(null)
+ const [editing, setEditing] = useState(null)
+ const [detail, setDetail] = useState(null)
+ const [preview, setPreview] = useState(null)
+ const [usages, setUsages] = useState([])
+ const [categoryId, setCategoryId] = useState('')
+ const [categoryParentId, setCategoryParentId] = useState('')
+ const [categoryName, setCategoryName] = useState('')
+ const [busy, setBusy] = useState(false)
+ const [error, setError] = useState('')
+ const [uploadPct, setUploadPct] = useState(null)
+ const [folderJob, setFolderJob] = useState(null)
+ const [folderInfo, setFolderInfo] = useState('')
+ const dragRef = useRef(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(`/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(`/kbs/${id}`),
+ api.get(`/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 (
+
+ {/* 左:窄导航(库列表 + 建库),也是拖拽上传的落点 */}
+
{ 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('先在左侧选中一个知识库,再拖入文件夹')
+ }}>
+
+
+ {editing &&
编辑知识库:{editing.name}
}
+
setName(e.target.value)} />
+
setDesc(e.target.value)} />
+ {error &&
{error}
}
+
{editing
+ ? <>>
+ : }
+
+
+ {kbs.map(k => (
+
selectKb(k.id)}>
+ {k.name}
+ {KB_STATUS_LABEL[k.index_status] || k.index_status}{k.file_count} 文件 / {k.chunk_count} 块
+
+ ))}
+
+
+
+ {/* 右:宽工作区 */}
+
+ {sel ? `${kbs.find(k => k.id === sel)?.name || ''}工作区` : '知识库工作区'}
{preview ? `预览:${preview.name} · ${preview.characters} 字${preview.truncated ? '(仅前 50,000 字)' : ''}` : '上传素材、管理分类、测试检索——Agent 生成时自动引用这里的内容。'}
{preview &&
}
+
+ {!sel && (
+ 先在左侧选择一个知识库选中后即可上传文件或整个文件夹(支持音视频自动转写)、管理分类并测试检索。
+ )}
+
+ {sel && preview && (
+ {preview.text || '该文件没有可解析的文字内容。'}
+ )}
+
+ {sel && !preview && (
+ <>
+ {/* 工具行:上传组 | 分类组 | 维护组 */}
+
+
+ 上传到
+
+
+
+
+ 📁 整个文件夹
+ { if (e.target.files?.length) await uploadFolder(e.target.files) }} />
+ 📤 文件
+ { 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) } } }} />
+
+
+
+
+
+
+
+
+ {folderInfo && {folderInfo}
}
+
+ {uploadPct !== null && !folderJob && (
+
+ )}
+
+ {folderJob && (
+
+ {folderJob.status === 'running' &&
⏳ 正在处理:{folderJob.current || '准备中…'}{folderJob.total ? `(${folderJob.files_done}/${folderJob.total})` : ''}
}
+ {folderJob.status === 'done' &&
✓ 导入完成:{folderJob.result?.files ?? '?'} 个文件 / {folderJob.result?.chunks ?? '?'} 个知识块
}
+ {folderJob.status === 'error' &&
导入失败:{folderJob.error}
}
+
+
+ )}
+
+ {/* 分类管理 */}
+
+
分类管理
+
+ setCategoryName(e.target.value)} />
+
+
+
+
+
+ {/* 已导入文件 */}
+ {detail?.files?.length > 0 && (
+
+
已导入文件({detail.files.length})
+ {detail.files.map((file: any) => (
+
+
+
+
+ {file.classification_status === 'ai_suggested' && }
+ {(file.name.toLowerCase().endsWith('.txt') || file.name.toLowerCase().endsWith('.md')) && }
+ {kbs.length > 1 && (
+
+ )}
+
+
+
+ ))}
+
+ )}
+
+ {/* Agent 已消费知识 */}
+
+
Agent 已消费知识({usages.filter(usage => usage.status === 'consumed').length} 条锁定)
+
锁定仅针对已使用它的 Agent;其他 Agent 仍可检索。重新开放后,该 Agent 才能再次使用。
+ {usages.length ? usages.map(usage => (
+
+
{usage.file_name || '未记录来源文件'}{usage.heading ? ` / ${usage.heading}` : ''} · {usage.agent_name} · {usage.used_at}
+ {usage.status === 'consumed' ?
:
已开放}
+
+ )) :
尚未有 Agent 消费本知识库的片段。}
+
+
+ {busy && 处理中(分块+向量化可能需要几分钟)…
}
+
+ {/* 检索测试 */}
+
+ setQ(e.target.value)} />
+
+
+ {hits.map((h, i) => (
+
+
来源:{h.meta?.file || '?'}{h.meta?.heading ? ' / ' + h.meta.heading : ''}
+ score {(h.score || 0).toFixed(2)}
+
{h.text}
+
+ ))}
+ {!hits.length && q && 没有检索到内容换个问法试试,或确认素材已导入本库。
}
+ >
+ )}
+
+
+ )
+}
+
+/* ---------------- 记忆库 ---------------- */
diff --git a/web/src/pages/KnowledgePage.tsx b/web/src/pages/KnowledgePage.tsx
new file mode 100644
index 0000000..1809a04
--- /dev/null
+++ b/web/src/pages/KnowledgePage.tsx
@@ -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 {
+ 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 {
+ 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([])
+ const [me, setMe] = useState(null)
+ const [forkBusy, setForkBusy] = useState(null)
+ const [sel, setSel] = useState(id ? Number(id) : null)
+ const selRef = useRef(id ? Number(id) : null)
+ const [detail, setDetail] = useState(null)
+ const [detailLoading, setDetailLoading] = useState(false)
+ const [usages, setUsages] = useState([])
+ const [allUsages, setAllUsages] = useState([])
+ const [allUsageLoading, setAllUsageLoading] = useState(false)
+ const [allUsageError, setAllUsageError] = useState('')
+ const [usageSummary, setUsageSummary] = useState(null)
+ const [selectedUsageIds, setSelectedUsageIds] = useState>(new Set())
+ const [openCategoryIds, setOpenCategoryIds] = useState>(new Set())
+ const [tab, setTab] = useState('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('')
+ const [chunkMode, setChunkMode] = useState<'auto' | 'heading'>('auto')
+ const [uploadPct, setUploadPct] = useState(null)
+ const [folderJob, setFolderJob] = useState(null)
+ const [importMessages, setImportMessages] = useState([])
+ const uploadAlive = useRef(false)
+ useEffect(() => { uploadAlive.current = true; return () => { uploadAlive.current = false } }, [])
+ const [fileInputRef, setFileInputRef] = useState(null)
+ const [folderInputRef, setFolderInputRef] = useState(null)
+ // 检索
+ const [q, setQ] = useState('')
+ const [hits, setHits] = useState([])
+ // 分类
+ 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(null)
+ const [previewLoading, setPreviewLoading] = useState(false)
+ const [previewTarget, setPreviewTarget] = useState('')
+ // 路由切换或开发模式 StrictMode 可能让详情请求重叠;仅允许最后一次请求更新视图。
+ const selectRequestRef = useRef(0)
+
+ const refresh = () => api.get('/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('/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(`/kbs/${id}`)
+ if (requestId !== selectRequestRef.current) return
+ setDetail(kbDetail)
+ setSelectedUsageIds(new Set())
+ void Promise.all([
+ api.get(`/kbs/${id}/usage`),
+ api.get(`/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('/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 => {
+ while (uploadAlive.current) {
+ const job = await api.get(`/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 => {
+ 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(`/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
+
+ {children.length ? : }
+ {category.name}{String(category.file_count ?? 0)} 个文件{children.length > 0 ? '(含子分类)' : ''}
+
+
+ {open && children.length ?
{renderCategoryTree(children, depth + 1)}
: null}
+
+ })
+ 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('/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(`/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
+ if (moduleTab === 'usage') return
+
知识资产} />
+
+
+
+
+
+ {error && {error}
}
+ {allUsageError && 消费记录加载失败:{allUsageError}
}
+ {allUsageLoading ? 正在读取所有知识库消费记录…
: !allUsageError && void releaseAllUsages(true)} onReleaseSelected={() => void releaseAllUsages(false)} onRelease={usage => void releaseAnyUsage(usage)} />}
+ {!allUsageLoading && !allUsageError && !allUsages.length && 暂无消费记录——生成内容任务后,这里会显示所有知识库被引用的片段。
}
+
+
+ return
+
知识资产} />
+
+
+
+
+
+
+
+
+
+
+ {error && {error}
}
+ {!detail && detailLoading && 正在打开知识库…正在读取文件和分类信息,请稍候。
}
+ {!detail && !detailLoading && }
+ {detail && <>
+
+
+
{detail.name}
+
+
+ {detail.index_status === 'ready' ? '索引就绪' : detail.index_status}
+ {files.length} 文件 · {detail.chunk_count ?? 0} 块
+
+
+
+ {detailEditable ? <>
+ > : null}
+ e.target.files && uploadFolder(e.target.files)} />
+ e.target.files && uploadFiles(e.target.files)} />
+ {canManageResource(detail, me) ? : isGlobalResource(detail) && canWriteKnowledge ? <>> : 当前知识库为只读资源}
+
+
+ {/* @ts-ignore webkitdirectory */}
+ { if (el) { (el as any).webkitdirectory = true; setFolderInputRef(el) } }} type="file" multiple style={{ display: 'none' }} onChange={e => e.target.files && uploadFolder(e.target.files)} />
+ 单个文件超过 80MB 会自动分片上传,公网单文件支持至 20GB;文件夹上传请先压缩并受公网通道限制。同一知识库的重复文件会自动跳过,文件内完全重复的同标题片段只索引一次。
+
+ {importMessages.length > 0 && 上传处理结果{importMessages.map((message, index) => - {message}
)}
}
+ {uploadPct !== null && }
+ {folderJob &&
+ {folderJob.status === 'running' && ⏳ 正在处理:{folderJob.current || '准备中'}{folderJob.total ? `(${folderJob.files_done}/${folderJob.total})` : ''}}
+ {folderJob.status === 'done' && ✓ 导入完成}
+ {folderJob.status === 'error' && 失败:{folderJob.error}}
+
}
+
+
+ {tabs.map(t => )}
+
+
+ {tab === 'files' &&
+
+
setQuery(e.target.value)} />
+
+ {detailEditable && <>
+ >}
+
+
+ {classifyBusy &&
正在处理分类建议请等待本次操作完成
AI 推荐会读取文件并匹配分类;采用建议后会更新文件归属。}
+ {(classifyNotice || classifyError) &&
{classifyError || classifyNotice}{classifyError && }
}
+ {!!aiSuggestedFiles.length &&
✦待确认的 AI 分类建议({aiSuggestedFiles.length})AI 只生成建议,不会自动改变文件归属;请逐条采用或一次采用全部建议。
}
+ {filteredFiles.map((f: any) =>
+
+ {f.chunk_count ?? 0} 块
+ {f.classification_status === 'ai_suggested' && ✦ AI 推荐:{f.category_path || '待确认'}{f.ai_confidence ? `(${Math.round(f.ai_confidence * 100)}%)` : ''}{f.ai_reason ? ` · ${f.ai_reason}` : ''}}
+
+
+ {f.classification_status === 'ai_suggested' && }
+ {detailEditable && }
+
)}
+ {!filteredFiles.length &&
没有文件。用顶部「上传文件夹」导入。
}
+
}
+
+ {tab === 'categories' &&
+
+ setCategoryName(e.target.value)} style={{ maxWidth: 200 }} />
+
+ {detailEditable && }
+
+
{renderCategoryTree(detail.categories || [])}
+ {!categories.length &&
暂无分类。
}
+
}
+
+ {tab === 'search' &&
+
+ setQ(e.target.value)} onKeyDown={e => e.key === 'Enter' && search()} style={{ flex: 1 }} />
+
+
+ {hits.map((h: any, i: number) =>
{`#${i + 1} · ${(h.meta && h.meta.file) || h.file || '—'}`}{String(h.text || h).slice(0, 220)}
)}
+
}
+
+ {tab === 'usage' &&
+ {usageSummary && (() => {
+ const total = usageSummary.total_chunks || 0
+ const consumed = usageSummary.consumed || 0
+ const pct = total ? Math.round(consumed / total * 100) : 0
+ return <>
+
+
+ {total} 总块
+ {consumed} 已被消费
+ {usageSummary.released || 0} 已重新开放
+ {usageSummary.remaining ?? 0} 剩余可用
+
+
+
同一片段只对消费它的 Agent 锁定;其他 Agent 仍可用。「重新开放」后片段会回到可用池。
+
+ {!!usageSummary.by_agent?.length &&
+
按 Agent 统计
+ {usageSummary.by_agent.map((a: any) => (
+
+ {a.agent_name}
+ 已消费 {a.used} 段{a.released ? ` · 已重新开放 ${a.released}` : ''}
+
+ ))}
+
}
+
+ >
+ })()}
+
void releaseSelectedUsages(true)} onReleaseSelected={() => void releaseSelectedUsages(false)} onRelease={usage => void releaseUsage(usage as KbUsageResponse)} />
+ {!usages.length && 暂无消费记录——生成任务后这里会显示每个 Agent 用了哪些知识。
}
+ }
+
+ {tab === 'settings' &&
+
+
块数{String(detail.chunk_count ?? 0)}
+
索引状态{detail.index_status}
+
+
+
+
}
+ >}
+
+
+ {preview && setPreview(null)}>
+
+
}
+
+}
diff --git a/web/src/pages/KnowledgeUsageList.tsx b/web/src/pages/KnowledgeUsageList.tsx
new file mode 100644
index 0000000..1f3df29
--- /dev/null
+++ b/web/src/pages/KnowledgeUsageList.tsx
@@ -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; busy: boolean; onSelect: (next: Set) => 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>({ consumed: true })
+ // 每个知识库在状态抽屉内独立折叠,避免消费记录很多时页面过长。
+ const [openKb, setOpenKb] = useState>({})
+ 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
+ 消费明细 {usages.length}
+
+
+
+
+ {groups.map(group => { const expanded = event.currentTarget.open; setOpen(current => ({ ...current, [group.id]: expanded })) }}>
+ {group.title}{group.rows.length}{open[group.id] ? '收起' : '展开'}
+ {!!open[group.id] && {group.hint}
+ {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
)).map(([kbKey, kbRows], kbIndex) => {
+ const drawerKey = `${group.id}:${kbKey}`
+ const kbExpanded = openKb[drawerKey] ?? (group.id === 'consumed' && kbIndex === 0)
+ return { const expanded = event.currentTarget.open; setOpenKb(current => ({ ...current, [drawerKey]: expanded })) }}>{kbRows[0].kb_name || '未指定知识库'}{kbRows.length}{kbExpanded ? '收起' : '展开'}
{kbRows.map(row => {
+ const selectable = row.status === 'consumed' && row.can_release !== false, active = selectable && selected.has(row.id)
+ const text =
{row.kb_name && {row.kb_name}}{row.agent_name || '未命名 Agent'}{row.heading || row.file_name || '知识片段'}{row.excerpt || row.chunk_id}{(row.campaign_name || row.used_at) && {row.campaign_name ? `任务:${row.campaign_name}${row.segment_title ? ` · ${row.segment_title}` : ''}` : ''}{row.used_at ? ` · ${row.used_at}` : ''}}
+ return
+ {selectable ? toggle(row.id)} aria-label={`选择记录 ${row.id}:${row.heading || row.file_name || '知识片段'}`} />{text} : {row.status === 'available' ? : }{text}
}
+ {selectable && row.can_release !== false && }
+
+ })}
})}
+ {!group.rows.length && 暂无{group.title}记录
}
+ }
+ )}
+
+}
diff --git a/web/src/pages/LogsPage.tsx b/web/src/pages/LogsPage.tsx
new file mode 100644
index 0000000..f056e97
--- /dev/null
+++ b/web/src/pages/LogsPage.tsx
@@ -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 = { alert: '告警', failure: '失败', review: '待审查', success: '成功', info: '信息', reminder: '提醒' }
+
+function errorText(error: unknown) {
+ return localizeError(error)
+}
+
+export default function LogsPage() {
+ const [traces, setTraces] = useState([])
+ const [notifs, setNotifs] = useState([])
+ const [mems, setMems] = useState([])
+ const [runStatus, setRunStatus] = useState('')
+ const [selectedTrace, setSelectedTrace] = useState('')
+ const [notifBusy, setNotifBusy] = useState(false)
+ const [detail, setDetail] = useState(null)
+ const [traceError, setTraceError] = useState('')
+ const refresh = () => Promise.all([
+ api.get(`/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(`/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 (
+
+
+
+
+ {notifs.map(n => (
+
+ {NOTIFICATION_TYPE_LABEL[n.type] || n.type}{n.message}
+ {n.created_at}
+ {n.status === 'unread' && }
+
))}
+ {!notifs.length && 暂无通知任务失败、审查提醒和系统告警会出现在这里。
}
+ 记忆待确认池(确认后才沉淀)
+ {mems.map(m => (
+
+
{m.type}{m.agent_name || `Agent#${m.target_agent_id}`} · {m.source_ref}
+
{m.content}
+
+
+
+
+
+
))}
+
+
+ LLM Trace({traces.length})
一次运行的模型调用、工具事件、QC 与成稿配置在同一条 Trace 内回放。
+ {traceError && {traceError}
}
+ {traces.map(trace => )}
+ {!traces.length && 暂无可追踪的运行创建并运行内容任务后,这里会出现完整的调用链。
}
+ {detail && Trace 详情:{detail.trace_id}
+ 运行配置{detail.run ? `运行 #${detail.run.id} · ${detail.run.runtime_version} · ${STATUS_LABEL[detail.run.status] || detail.run.status}` : '无关联运行'}
质检{detail.qc_reports.map(report => `${report.gate}:${STATUS_LABEL[report.verdict] || report.verdict}`).join(' · ') || '尚无'}
成稿{detail.artifacts.map(artifact => `#${artifact.id} ${STATUS_LABEL[artifact.final_status] || artifact.final_status}`).join(' · ') || '尚无'}
+ {detail.calls.map(call =>
{call.agent_name || '系统'}{call.provider}/{call.model} · 第 {call.attempt} 次 · 段 {call.segment_id || '—'}{call.duration_ms}ms
{call.prompt_tokens}+{call.completion_tokens} tok{call.error ? errorText(call.error) : `输入 ${call.prompt_hash.slice(0, 10)} · 输出 ${call.output_hash.slice(0, 10)}`}
)}
+ {detail.events.slice().reverse().map(event =>
{event.created_at?.slice(11, 19) || '--:--:--'}{RUNTIME_EVENT_LABEL[event.event_type] || event.event_type}{event.actor_name || '系统'}{event.tool_name ? ` · ${RUNTIME_TOOL_LABEL[event.tool_name] || event.tool_name}` : ''}
)}
+ {detail.run && 冻结配置快照
{JSON.stringify(detail.run.config_snapshot, null, 2)} }
+ }
+
+
+ )
+}
diff --git a/web/src/pages/MemoriesPage.tsx b/web/src/pages/MemoriesPage.tsx
new file mode 100644
index 0000000..3011fb0
--- /dev/null
+++ b/web/src/pages/MemoriesPage.tsx
@@ -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 = {
+ 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([])
+ const [forkBusy, setForkBusy] = useState(null)
+ const [memories, setMemories] = useState([])
+ const [pending, setPending] = useState([])
+ 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(null)
+ const [form, setForm] = useState(emptyForm)
+ const [baseline, setBaseline] = useState(JSON.stringify(emptyForm()))
+ const [replacementOptions, setReplacementOptions] = useState([])
+ const [replacementLoading, setReplacementLoading] = useState(false)
+ const [replacementError, setReplacementError] = useState('')
+ const inFlight = useRef(false)
+ const requestVersion = useRef(0)
+ const editorVersion = useRef(0)
+ const editorElement = useRef(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(`/memories?${params}`),
+ api.get(`/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('/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(`/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, 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
+
+
长期记忆
这里管理人工确认的长期经验。智能体画像和当前任务上下文由各自的配置与任务提供。
+
内容段首次生成时,会按任务召回合适的记忆并冻结快照。同一运行的重试沿用原快照;本页修改供后续首次生成的内容段使用。
+
+
+
+
+
+ setQ(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') void refresh() }} />
+
+
+ {feedback &&
{feedback}
}
+ {error &&
{error}
}
+ {editor &&
+ {editorTitle}
+ {editor.kind === 'metadata' ? '已确认的记忆正文保持只读。要纠正正文,请新增候选记忆,审核时选择替换旧记忆以保留历史。' : editor.kind === 'approve' ? '请核对并修改正文、类型、标题和说明,确认后才会成为可召回的长期经验。' : '手动补充的候选记忆默认私有,确认保存后才会生效。'}
+
+ }
+
已确认记忆({memories.length})
生效记忆供后续任务召回;过期记忆保留历史。已有替代版本生效时,需先停用新版本才能恢复旧版本。
{managedMemories.length > 0 &&
}
+ {memories.map(memory =>
+
+
{memory.agent_name}{MEMORY_TYPES[memory.type] || memory.type}{memory.status === 'active' ? '已生效' : '已过期'}{!memory.can_manage && 只读}
+
{titleOf(memory)}
{memory.description || '尚未填写说明'}
{memory.content}
+
使用 {memory.usage_count ?? 0} 次 · 最近使用:{dateLabel(memory.last_used_at)}
+
来源:{memory.source_ref || '未记录'} · 创建于 {dateLabel(memory.created_at)}
+
+ {memory.can_manage ?
: isGlobalResource(memory) &&
}
+
)}
+ {!loading && !memories.length &&
当前筛选下没有已确认记忆审核通过的候选记忆会出现在这里,也可以调整筛选条件。
}
+
待确认记忆({pending.length})
先审核内容,再确认保存。候选记忆在确认前不会参与内容生成。
{managedPending.length > 0 &&
}
+ {pending.map(memory =>
+
{memory.agent_name}{MEMORY_TYPES[memory.type] || memory.type}待确认{!memory.can_manage && 只读}
{titleOf(memory)}
{memory.description &&
{memory.description}
}
{memory.content}
来源:{memory.source_ref || '未记录'} · 创建于 {dateLabel(memory.created_at)}
+ {memory.can_manage &&
}
+
)}
+ {!loading && !pending.length &&
暂无待确认记忆可以手动补充一条长期经验,再审核确认。
}
+
+}
diff --git a/web/src/pages/ModelsPage.tsx b/web/src/pages/ModelsPage.tsx
new file mode 100644
index 0000000..fd5008c
--- /dev/null
+++ b/web/src/pages/ModelsPage.tsx
@@ -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([])
+ const [form, setForm] = useState(emptyProvider)
+ const [editing, setEditing] = useState(null)
+ const [error, setError] = useState('')
+ const [busy, setBusy] = useState(false)
+ const busyRef = useRef(false)
+ const [status, setStatus] = useState('')
+ const [defaultModel, setDefaultModel] = useState(null)
+ const providerError = (error: unknown, fallback: string) => {
+ const message = localizeError(error)
+ return message && message !== '请求失败,请稍后重试' ? message : fallback
+ }
+ const refresh = () => readOnly
+ ? api.get('/models/available').then(rows => {
+ const grouped: Record = {}
+ 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 (
+
+
+ {readOnly ? '可用模型目录' : editing ? `编辑 Provider:${editing.name}` : '新增 Provider(OpenAI 兼容)'}
+ {readOnly && 模型连接、发现、能力测试和费率由管理端统一维护。用户端只能选择已发布的模型用于自己的 Agent。
}
+ {!readOnly && <>
+
+ >}
+
+
+ {readOnly ? '已发布模型' : '已注册 Provider'}
+ {defaultModel && 全局默认模型{ps.find(p => p.id === defaultModel.provider_id)?.name || '—'} / {defaultModel.model_name || '—'}
}
+ {ps.map(p => (
+
+ {p.name}{p.builtin && 内置}{p.enabled ? '启用' : '停用'}
+ {p.base_url} · {p.api_key || '未配置 Key'}
+ 默认:{p.default_model || '—'} · {p.models.join(', ')}
+ {!readOnly && <>
+
+ {p.enabled && }
+ >}
+
+ ))}
+
+
+ )
+}
+
+/* ---------------- 知识库 ---------------- */
diff --git a/web/src/pages/OpsPage.tsx b/web/src/pages/OpsPage.tsx
new file mode 100644
index 0000000..a735057
--- /dev/null
+++ b/web/src/pages/OpsPage.tsx
@@ -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 }; 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(null)
+ const [error, setError] = useState('')
+ const [busy, setBusy] = useState(false)
+ const [performance, setPerformance] = useState(null)
+ const load = () => api.get('/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('/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 {error}
+ if (!data) return 看板加载中…
+ const money = (minor: number) => new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format((minor || 0) / 100)
+ const isAdmin = data?.scope !== 'self'
+ return (
+
+ {error &&
{error}
}
+
+
{isAdmin ? '队列执行中' : '我的任务进行中'}▶
{number(data.campaigns.active)}{isAdmin ? `待执行 ${number(data.queue.pending)} · 已完成 ${number(data.queue.done)}` : `已交付 ${number(data.campaigns.done)} · 有失败 ${number(data.campaigns.failed)}`}
+ {isAdmin &&
死信任务☠
{number(data.queue.dead)}{data.queue.dead ? '存在需要处理的重试失败任务' : '队列健康,无死信'}}
+
近 7 天 Token∑
{number(data.cost_7d.tokens)}{isAdmin ? `费用 ${money(data.cost_7d.amount_minor)}` : '我的用量(全部服务合计)'}
+
{isAdmin ? '任务总量' : '我的任务'}▦
{number(data.campaigns.total)}{isAdmin ? '组织内全部任务' : '只统计你自己创建的任务'}
+
+
+
+ {isAdmin ? '任务与质量' : '我的任务概览'}
{isAdmin ? '内容生产概览与知识库消费质量' : '只显示你自己创建的任务'}
{isAdmin &&
}
+
+
进行中任务{number(data.campaigns.active)}
+
已交付{number(data.campaigns.done)}
+
存在失败{number(data.campaigns.failed)}
+
检索命中率{(data.knowledge.hit_rate * 100).toFixed(0)}%
+
重复消费率{(data.knowledge.repeat_rate * 100).toFixed(0)}%
+
引用覆盖率{(data.knowledge.reference_coverage * 100).toFixed(0)}%
+
+ {data.knowledge.by_kb.length > 0 && (
+ {data.knowledge.by_kb.map(kb => (
+
+
{kb.name}
+
+
{number(kb.consumed)}/{number(kb.chunk_count)} 块
+
+ ))}
+ )}
+
+ {isAdmin &&
+
+ {data.providers.map(p => (
+
+ {p.name}
+ {p.enabled ? '启用' : '停用'}
+ {p.backend === 'keychain' ? '钥匙串' : '数据库'}
+ {p.models} 个模型
+
+ ))}
+
+ {data.recent_alerts.length ? {data.recent_alerts.map(a => (
+
⚠{a.message}{a.created_at}
+ ))}
: 暂无告警,一切正常。
}
+ }
+
+ {isAdmin && performance &&
+ 性能基线
最近 {number(performance.samples)} 次 API 请求;重启后重新采样
+
+
API P50{performance.api.p50_ms.toFixed(0)} ms
+
API P95{performance.api.p95_ms.toFixed(0)} ms
+
API P99{performance.api.p99_ms.toFixed(0)} ms
+
RAG P95{performance.rag.p95_ms.toFixed(0)} ms
+
RAG 缓存命中{(performance.rag.cache_hit_rate * 100).toFixed(0)}%
+ {performance.rag.embedding?.embedding_encode_ms &&
Embedding 推理 P95{performance.rag.embedding.embedding_encode_ms.p95_ms.toFixed(0)} ms
}
+ {performance.rag.embedding?.embedding_model_load_ms &&
模型加载 P95{performance.rag.embedding.embedding_model_load_ms.p95_ms.toFixed(0)} ms
}
+ {performance.rag.embedding?.embedding_ipc_ms &&
进程通信 P95{performance.rag.embedding.embedding_ipc_ms.p95_ms.toFixed(0)} ms
}
+
+ }
+ {data.dead_letter.length > 0 && (
+
+ 死信队列({data.dead_letter.length})
重试耗尽的任务,可手动重放
+ {data.dead_letter.map(item => (
+
+
{item.task_key}{item.error || '无错误信息'} · 第 {item.attempt} 次尝试 · {item.updated_at}
+
+
+ ))}
+
+ )}
+ {isAdmin &&
+ 备份与恢复
启动与每 6 小时自动备份,保留最近 {data.backup.keep_count} 份
+
+
+
+
+ {data.backup.latest ? (
+
+
备份份数{data.backup.count}
+
最近备份{data.backup.latest_at.replace('T', ' ').slice(0, 16)}
+
大小{data.backup.latest_size_kb} KB
+
恢复演练
+ {data.backup.last_drill
+ ? {data.backup.last_drill.ok ? '✓ 通过' : '⚠ 未通过'}
+ : 未执行}
+
+ {data.backup.last_drill &&
演练详情:{data.backup.last_drill.detail}({data.backup.last_drill.checked_at.replace('T', ' ').slice(0, 16)})
}
+
+ ) : 尚无备份,点击「立即备份」创建第一份。
}
+ }
+
i{isAdmin ? '看板每 15 秒自动刷新;告警由系统每 5 分钟巡检一次,同一问题 10 分钟内不重复通知。' : '看板只显示你自己的任务与用量,每 15 秒自动刷新。'}
+
+ )
+}
diff --git a/web/src/pages/OverviewPage.tsx b/web/src/pages/OverviewPage.tsx
new file mode 100644
index 0000000..4e61250
--- /dev/null
+++ b/web/src/pages/OverviewPage.tsx
@@ -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([])
+ const [agents, setAgents] = useState([])
+ const [kbs, setKbs] = useState([])
+ const [notifications, setNotifications] = useState([])
+ 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
+
{loading ? "刷新中…" : "刷新数据"}} />
+