Compare commits

..
Author SHA1 Message Date
weichen 7089f768fd Make the final reply always visible and fix artifacts-panel dead clicks
Why: field report two — the final answer collapsed into the process
area (the CF0 long-reply fold swallowed 2000+ char replies and the
model inlines narration, so users saw "everything folded") and the
artifacts workspace panel preview clicks did nothing (the full-screen
overlay stayed up, hiding the rail the preview opened in).

Evidence:
- Conversation.tsx: removed the CF0 LongReplyCollapse entirely — the
  final output contract is "the reply to the user is always fully
  visible; only process parts fold"; deliverable cards stay pinned
  after the reply
- Workspaces.tsx ArtifactsPanel: row click and fullscreen action now
  close the workspace overlay before requesting the rail/fullscreen
  preview, so the preview is actually on screen
- vitest 40/40, tsc clean; no design-guard regressions (no new colors,
  keyframes, or inline font sizes)

Impact: replies are readable in place; ⌘1 panel clicks visibly open
previews; no other surfaces changed.

Refs: task feature/agent-native-deliverables-r1 (backend: artifact
backfill root fix + agent-native deliverables)
2026-10-01 03:32:55 +00:00
weichen de38791e25 Merge pull request 'Ignore the mimosa scan-hook local cache directory' (#26) from chore/ignore-mimosa-cache into main 2026-10-01 02:54:15 +00:00
weichen f054187bb0 Ignore the mimosa scan-hook local cache directory
Why: the pre-commit/push mimosa hooks write .mimosa/hook-state inside
the work tree; untracked it marks the Dev superproject dirty
(precheck SHARED_WORKSPACE_BUSY) and blocks card-close gates.

Evidence: git status showed ?? .mimosa after commits; with the ignore
the superproject returns clean. No tracked files affected.

Impact: none at runtime; cache stays local.

Refs: task feature/deliverable-docx-r1 follow-up
2026-10-01 02:54:08 +00:00
weichen b048d90de4 Merge pull request 'Render chat deliverable cards and pin live artifact projection' (#25) from feature/deliverable-docx-r1 into main 2026-10-01 02:50:19 +00:00
weichen ae761c2c4a Merge remote-tracking branch 'origin/main' into feature/deliverable-docx-r1 2026-10-01 02:50:12 +00:00
weichen e87bcf595f Render chat deliverable cards and pin live artifact projection
Why: tool artifacts were only reachable via the collapsed process
section or the artifacts workspace; users had to read model text to
learn a file existed. Deliverables should appear directly in the
conversation as an openable card (ZCode/Kimi pattern) that opens the
right-rail preview.

Evidence:
- Conversation.tsx artifact_ref renderer upgraded from a small chip to
  a deliverable card (icon + name + kind/size meta + preview affordance
  + single download action); whole card click = rail preview (the one
  preview placement, fullscreen still reachable from the rail header)
- live.ts projects artifact items as content parts pinned after the
  reply text, isomorphic with the new llm.go persisted order; card is
  visible while streaming
- styles: deliverable-card uses tokens only (radius-lg card, bg-raised,
  hairline border, mk-rise-in entrance); design-guard suite green
- vitest 40/40 (live.test pinned-tail case updated, messageparts.test
  covers new persisted order plus legacy inline order); tsc clean

Impact: every generated artifact (docx/pdf/image/…) now surfaces as an
in-chat card immediately, click-to-preview in the right rail with real
formatting for styled docx; workspace panel (⌘1) unchanged as the
file-system fallback.

Refs: task feature/deliverable-docx-r1 (backend: write_docx + pinned
artifact_ref)
2026-10-01 02:49:08 +00:00
weichen 7418e507a0 Merge pull request 'Redesign the shell on Zai tokens: panels, admin zone, artifact convergence, chat motion' (#24) from feature/ui-tokens-v3 into main 2026-10-01 02:00:47 +00:00
weichen 1fc1d2f9b4 Merge origin/main (CF3 project inbox) and re-tokenize its styles
Why: Keep the task branch on the shared baseline before acceptance.
The merged CF3 code carried a bare hex fallback (#d8d8d8) and off-scale
font size in .inbox-scope — the design guard caught it; fixed to tokens
(--border / --radius-full / --fs-ui-sm / --bg-hover) per DESIGN.md.
Evidence: clean ort merge; npx tsc -b clean; npm test -> 39 passed
(guard green after the fix); npm run build succeeds.
Impact: brings the project inbox dual view into the branch with its
styles brought onto the token system; no behavior changes.
Refs: task feature/ui-tokens-v3.
2026-09-30 19:30:22 +00:00
weichen e7fa8756db Merge remote-tracking branch 'origin/main' into feature/ui-tokens-v3 2026-09-30 19:28:35 +00:00
weichen 801e551d8d Migrate chat folds to Collapsible and adopt ToolBlock
Why: Approved redesign phase 4: process folds, long-reply folds, thinking
blocks and context-observation fold move to the @ui Collapsible primitive
(two-layer height+fade animation, delayed unmount, open-state memory);
tool calls render as ToolBlock rows with running sweep and paired results;
the live bar gains the ZCode worked-time line with sweep; input placeholder
states the backend's honest busy semantics (serialized per conversation).
Evidence: npx tsc -b clean; npm test -> 39 passed (details-guard included);
npm run build succeeds; combined make test recorded in the Dev task card.
Impact: Conversation.tsx replaces all <details> usage (guard-enforced);
ProcessParts pairs tool_call with its trailing tool_result into ToolBlock;
styles.css rewrites fold selectors for the Collapsible structure and adds
message mount animation (mk-rise-in); no contract or API changes.
Refs: Agent-System-Dev task feature/ui-tokens-v3 (phase 4).
2026-09-30 19:25:10 +00:00
weichen b3112aa412 Merge pull request 'Add project inbox dual view with live project stream (CF3)' (#23) from feature/project-inbox-r1 into main 2026-09-30 19:19:29 +00:00
weichen 93587fe813 Add project inbox dual view with live project stream (CF3)
Why: D49/D51 — aggregated project view (project cards + other
conversations' pending) so todos survive conversation switches;
D50 — subscribe the project channel via scope=project.
Evidence: api endpoints (listProjectCards/cardToProject, card.* named
SSE events — also fixes live card events previously dropped);
store projectCards/inboxScope/applyProjectEvent/cardToProject with
badge combining both scopes; Workbench scope tabs + grouped ProjectInbox;
styles for scope control.
Impact: existing conversation inbox behavior unchanged; badge now counts
both scopes.
Refs: documents proposal 2026-09-30-card-stream-artifact-unify-project-inbox.md (D48-D51).
2026-09-30 19:16:57 +00:00
weichen bfaf65ae1f Converge artifact preview to one placement and one download
Why: Approved redesign phase 3: one artifact = one preview placement
(right rail by default; full-screen overlay as the only large-window form)
plus a single download button per surface. Removes the redundant
three-action chips/rows and the nested modal chrome in AgentTracker.
Evidence: npx tsc -b clean; npm test -> 38 passed. Combined runs recorded
in the Dev task card.
Impact: new store.requestPreview() is the single preview entry (Command
semantics; placement derived there); artifact chips and workspace rows
reduce to title-click = rail preview, eye = full preview, download;
docBridge card opens route through the same entry; AgentTracker drops its
inner .modal shell (OverlayHost is the only chrome). No API changes.
Refs: Agent-System-Dev task feature/ui-tokens-v3 (phase 3).
2026-09-30 19:14:31 +00:00
weichen 48a14fb4d2 Move system capabilities into an admin-only full-screen zone
Why: Approved redesign phase 2 (IA): normal users run projects entirely
through conversation; system capabilities (user management, agent profiles,
plugin management, plugin-declared workspaces) belong behind a dedicated
admin entry, not in the user-facing sidebar or settings. The sidebar
workspace strip is removed; artifacts stay reachable via the right-rail
tab and conversation chips.
Evidence: npx tsc -b clean; npm test -> 38 passed (design-guard ratchet
still green). Full combined run recorded in the Dev task card.
Impact: new AdminZone full-screen view with a declarative section registry
(users/profiles/plugins/plugin-workspaces via UIRenderer); sections moved
out of SettingsModal (now personal preferences only) and AdminUsersModal
(now password change only); store ui.adminUsersOpen renamed adminZoneOpen;
workspaceEntries narrowed by role so plugin entries no longer take Cmd1-9
for regular users; theme toggle moved into the account menu.
Refs: Agent-System-Dev task feature/ui-tokens-v3 (phase 2).
2026-09-30 19:11:20 +00:00
weichen 8bc5b47914 Apply Zai panel layout, mono primary buttons and type scale
Why: Phase 1 of the approved frontend redesign: re-skin the shell to the
Zai look (floating rounded panels on an inset base with 4px gutters),
invert primary buttons to the mono scheme, and move every font size onto
the --fs-ui-* scale. Also wires @ui/motion.css and replaces local
keyframes with mk-* utilities.
Evidence: npx tsc -b clean; npm test -> 38 passed (new host-side
design-guard ratchet); npm run build succeeds. Combined Dev make test
recorded in the Dev task card.
Impact: styles.css fully re-tokenized (panel borders/radius, primary
button color-mix hover, login brand panel in near-mono navy); inline
fontSize literals in components replaced with scale tokens except one
decorative glyph in the ratchet baseline. No component class names or
behavior changed.
Refs: Agent-System-Dev task feature/ui-tokens-v3; DESIGN.md (parent repo).
2026-09-30 19:00:06 +00:00
weichen c5e93568a5 Merge pull request 'Wire preview capabilities into the app shell and dual-dock placement' (#21) from feature/file-preview-caps into main 2026-09-30 17:20:27 +00:00
weichen 89bfa83cae Wire preview capabilities into the app shell and dual-dock placement
Why: the adjudicated plan (D1-D4) calls for office/link previews with
loaders injected from the host, right-rail light preview alongside the
overlay viewer, and artifact deletion kept as an API capability with no
UI entry.

Evidence: previewsetup.ts injects docx (mammoth), xlsx (SheetJS, sheet
tabs, 200-row cap), pptx (pptx-preview full-fidelity; pptx2html was
dropped for a broken module entry — tXml case mismatch breaks Linux
builds — per the D2 fallback clause), and the /api/url-preview fetcher,
all via dynamic import: the main entry grows ~12KB while xlsx (430KB),
pptx (1.2MB) and jszip (98KB) load on first preview. DocViewer becomes a
FilePreview container (keeps text-family editing); Workbench gains a
preview tab with rail-preview state (store.railPreview); artifact chips
and panel rows gain a rail-preview action; the panel delete button is
removed while api.deleteArtifact and DELETE /api/artifacts/{id} remain.
tsc clean; vitest 33 passed; production build succeeds.

Impact: previews work end to end for the whole format matrix; deletion
stays plugin-reachable only.

Refs: task feature/file-preview-caps; ui fb76239; core 4d29301; backend
6d4ccfd.
2026-09-30 17:17:29 +00:00
weichen 6752b788b9 Merge pull request 'Render submit_card card parts as CardAnchor with inline full-text expansion (CF1)' (#20) from feature/cardflow-r1 into main 2026-09-30 17:12:36 +00:00
weichen 411d09b3a8 Merge remote-tracking branch 'origin/main' into feature/cardflow-r1 2026-09-30 16:55:44 +00:00
weichen af0385451a Merge pull request 'Bump vitest to 4.1.11 past the mocker path traversal advisory' (#19) from fix/dep-vuln-bumps into main 2026-09-30 16:48:48 +00:00
weichen a61bd70399 Bump vitest to 4.1.11 past the mocker path traversal advisory
Why: GHSA-82fw-gwwq-j7x9 (path traversal / arbitrary file read via @vitest/mocker redirect mock) affects vitest 2.1.0-4.1.10; the locked 3.x toolchain was inside the range. Dev-only dependency, but the fix is a routine bump — follow-up to the 2026-09-30 dependency CVE inventory.
Evidence: vitest and @vitest/mocker now at 4.1.11 (first fixed release); npm audit reports 0 vulnerabilities; npx vitest run 33/33 green on vitest 4.
Impact: package.json/package-lock.json devDependency bump only; no production dependency or source change.
Refs: Dev-side task fix/dep-vuln-bumps (gitlink pin follows after combined verification)
2026-09-30 16:47:34 +00:00
weichen b329de3bf5 Add cardflow deltas on fresh main: CardAnchor routing, D32 locate, card contract field
Why: main(含任务1 PR #18 的 parts 模型与增强版 CF0)之上补齐卡流增量:cardId→CardAnchor 锚点路由、卡→消息定位(locateMessage + data-msg-id)、Part.card.cardId 契约镜像。
Evidence: tsc 0 错误;vitest 33/33(main 的 messageparts 等测试全过)。
Impact: Conversation/Workbench/store/contracts 四文件;CF0 与过程折叠采用 main 增强版不回退。
Refs: Dev 任务卡 feature/cardflow-r1。
2026-09-30 16:37:29 +00:00
weichen b067b57975 Merge pull request 'Agent-System-Frontend: review fixes for chat-process-split-r1 (symlink, live interleaving, artifact process)' (#18) from feature/chat-process-split-r1 into main 2026-09-30 16:15:19 +00:00
26 changed files with 1703 additions and 782 deletions
+3
View File
@@ -2,3 +2,6 @@
node_modules
dist/
*.local
# mimosa 扫描钩子的本地缓存(scripts/hooks 写入,不入库)
.mimosa
+523 -194
View File
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -10,16 +10,21 @@
"preview": "vite preview"
},
"dependencies": {
"jszip": "^3.10.2",
"mammoth": "^1.13.0",
"marked": "^15.0.7",
"pptx-preview": "^1.0.7",
"react": "^19.2.0",
"react-dom": "^19.2.0"
"react-dom": "^19.2.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/node": "^22.20.4",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.3",
"vite": "^6.0.11",
"vitest": "^3.0.5"
"vitest": "^4.1.11"
}
}
+3 -2
View File
@@ -2,7 +2,8 @@ import { useEffect, useRef, useState } from 'react'
import { auth } from './auth'
import { api } from './api'
import { LoginPage, ChangePasswordGate } from './components/LoginPage'
import { AdminUsersModal, ChangePasswordModal } from './components/AdminUsersModal'
import { ChangePasswordModal } from './components/AdminUsersModal'
import { AdminZone } from './components/AdminZone'
import { Icon } from '@ui'
import { store, useStore } from './store'
import { TaskSidebar } from './components/TaskSidebar'
@@ -148,7 +149,7 @@ export function App() {
{trackerRunId && <AgentTracker />}
{ui.settingsOpen && <SettingsModal />}
{ui.projectSettingsId && <ProjectSettingsModal />}
{ui.adminUsersOpen && <AdminUsersModal onClose={() => store.setUI({ adminUsersOpen: false })} />}
{ui.adminZoneOpen && <AdminZone />}
{ui.changePwOpen && <ChangePasswordModal onClose={() => store.setUI({ changePwOpen: false })} />}
{viewingArtifact && <DocViewer artifact={viewingArtifact} />}
{toast && <div className="toast">{toast}</div>}
+12 -3
View File
@@ -70,6 +70,10 @@ export const api = {
auth.fetch(`${BASE}/api/cards/${cardId}/pin`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pinned }) }).then(r => json<{ card: ActivityCard }>(r)),
cardDismiss: (cardId: string) =>
auth.fetch(`${BASE}/api/cards/${cardId}/dismiss`, { method: 'POST' }).then(r => json<{ card: ActivityCard }>(r)),
listProjectCards: (projectId: string) =>
auth.fetch(`${BASE}/api/projects/${projectId}/cards`).then(r => json<{ cards: ActivityCard[] }>(r)).then(d => d.cards ?? []),
cardToProject: (cardId: string) =>
auth.fetch(`${BASE}/api/cards/${cardId}/to_project`, { method: 'POST' }).then(r => json<{ card: ActivityCard; origin: ActivityCard }>(r)),
upload: (projectId: string, file: File) => {
const fd = new FormData()
@@ -114,13 +118,17 @@ export interface SSEHandlers {
onError?: (err: Error) => void
}
export function connectEvents(conversationId: string, since: number, handlers: SSEHandlers): { close: () => void } {
export function connectEvents(conversationId: string, since: number, handlers: SSEHandlers, projectId?: string): { close: () => void } {
let closed = false
let es: EventSource | null = null
const connect = () => {
if (closed) return
es = new EventSource(auth.withKey(`${BASE}/api/events?conversationId=${encodeURIComponent(conversationId)}&since=${since}`))
// 项目流(CF3/D50):scope=project 订阅项目频道(伪会话键,信封带 ProjectID)。
const url = projectId
? `${BASE}/api/events?scope=project&projectId=${encodeURIComponent(projectId)}&since=${since}`
: `${BASE}/api/events?conversationId=${encodeURIComponent(conversationId)}&since=${since}`
es = new EventSource(auth.withKey(url))
es.onopen = () => handlers.onOpen?.()
es.onerror = () => {
if (closed) return
@@ -131,7 +139,8 @@ export function connectEvents(conversationId: string, since: number, handlers: S
// 同时监听具名事件与默认(服务端 event: 字段)。
const types = ['message.delta', 'thinking.delta', 'tool.call', 'tool.result', 'run.phase',
'hitl.request', 'hitl.resolved', 'plan.update', 'todo.update', 'agent.spawn', 'agent.progress',
'agent.result', 'artifact.created', 'artifact.updated', 'run.completed', 'run.failed', 'error']
'agent.result', 'artifact.created', 'artifact.updated', 'run.completed', 'run.failed', 'error',
'card.created', 'card.updated', 'card.status', 'card.resolved']
for (const t of types) es.addEventListener(t, ev => handle(t, (ev as MessageEvent).data))
}
+4 -102
View File
@@ -1,13 +1,9 @@
// 管理员用户管理弹层 + 主动改密弹窗(R2 管理员发放制)。
// AdminUsersModal:建号(用户名/显示名/初始密码/管理员位)、重置密码、列表状态。
// ChangePasswordModal:登录者主动改密(与强制改密共用 API)。
import { useEffect, useState } from 'react'
// 管理员用户管理已迁「系统管理」分区(components/adminSections.tsx UsersPanel,
// 重设计方案 阶段②)。本文件只保留登录者主动改密弹窗(与强制改密共用 API)。
import { useState } from 'react'
import { api } from '../api'
import { store, useStore } from '../store'
import { Icon, Avatar } from '@ui'
import { randomPassword } from '../passwords'
interface AdminUserRow { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string }
import { Icon } from '@ui'
function Shell({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
return (
@@ -24,100 +20,6 @@ function Shell({ title, onClose, children }: { title: string; onClose: () => voi
)
}
export function AdminUsersModal({ onClose }: { onClose: () => void }) {
const me = useStore(s => s.me)
const [users, setUsers] = useState<AdminUserRow[] | null>(null)
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [owner, setOwner] = useState('')
const [name, setName] = useState('')
const [pw, setPw] = useState('')
const [asAdmin, setAsAdmin] = useState(false)
const [busy, setBusy] = useState(false)
const [resetFor, setResetFor] = useState<string | null>(null)
const [resetPw, setResetPw] = useState('')
const refresh = async () => {
try { setUsers(await api.listAdminUsers()) } catch (e) { setErr(String(e)) }
}
useEffect(() => { refresh() }, [])
const create = async () => {
setErr(''); setNotice('')
if (pw.length < 8) { setErr('初始密码至少 8 位'); return }
setBusy(true)
try {
await api.createAdminUser(owner.trim(), name.trim(), pw, asAdmin)
setNotice(`已创建 ${owner.trim()}:首次登录须改密`)
setOwner(''); setName(''); setPw('')
await refresh()
} catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) }
}
const reset = async () => {
if (!resetFor) return
setErr('')
if (resetPw.length < 8) { setErr('新密码至少 8 位'); return }
setBusy(true)
try {
await api.resetAdminUserPassword(resetFor, resetPw)
setNotice(`已重置 ${resetFor} 的密码:其下次登录须改密(现有会话已吊销)`)
setResetFor(null); setResetPw('')
await refresh()
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
return (
<Shell title={`用户管理${me?.isAdmin ? '' : '(只读)'}`} onClose={onClose}>
{me?.isAdmin && (
<div className="ad-create">
<div className="ad-row">
<input className="lg-input" placeholder="用户名(登录名)" value={owner} onChange={e => setOwner(e.target.value)} />
<input className="lg-input" placeholder="显示名(可空)" value={name} onChange={e => setName(e.target.value)} />
</div>
<div className="ad-row">
<input className="lg-input lg-mono" placeholder="初始密码(≥8 位)" value={pw} onChange={e => setPw(e.target.value)} />
<button type="button" className="ad-mini" title="生成随机密码" onClick={() => setPw(randomPassword())}>随机</button>
<label className="ad-check"><input type="checkbox" checked={asAdmin} onChange={e => setAsAdmin(e.target.checked)} />管理员</label>
<button className="lg-submit ad-create-btn" disabled={busy || !owner.trim() || !pw} onClick={create}>创建账号</button>
</div>
<p className="ad-hint">创建后该用户首次登录必须修改此初始密码。</p>
</div>
)}
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
{resetFor ? (
<div className="ad-reset">
<span>重置 <b>{resetFor}</b> 的密码:</span>
<input className="lg-input lg-mono" placeholder="新密码(≥8 位)" value={resetPw} onChange={e => setResetPw(e.target.value)} />
<button type="button" className="ad-mini" title="生成随机密码" onClick={() => setResetPw(randomPassword())}>随机</button>
<button className="lg-submit ad-create-btn" disabled={busy || !resetPw} onClick={reset}>确认重置</button>
<button type="button" className="ad-mini" onClick={() => { setResetFor(null); setResetPw('') }}>取消</button>
</div>
) : (
<table className="ad-table">
<thead><tr><th>用户名</th><th>显示名</th><th>角色</th><th>状态</th><th>密码设置于</th><th></th></tr></thead>
<tbody>
{(users ?? []).map(u => (
<tr key={u.owner}>
<td className="ad-user-cell"><Avatar name={u.name || u.owner} size="sm" /><span className="ad-mono">{u.owner}</span></td>
<td>{u.name || '—'}</td>
<td>{u.isAdmin ? <span className="ad-chip ad-chip-admin">ADMIN</span> : <span className="ad-chip">USER</span>}</td>
<td>{u.mustChangePassword ? <span className="ad-chip ad-chip-warn">待改密</span> : u.hasPassword ? '正常' : '未设密码'}</td>
<td className="ad-dim">{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}</td>
<td>{me?.isAdmin && (
<button className="ad-mini" onClick={() => { setResetFor(u.owner); setResetPw('') }}>重置密码</button>
)}</td>
</tr>
))}
{users !== null && users.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无账号</td></tr>}
</tbody>
</table>
)}
</Shell>
)
}
/** 主动改密(账户菜单入口):当前密码 + 新密码 + 确认。 */
export function ChangePasswordModal({ onClose }: { onClose: () => void }) {
const me = useStore(s => s.me)
+66
View File
@@ -0,0 +1,66 @@
// 系统管理(重设计方案 阶段②):管理员专属的全屏独立视图(带返回,非弹窗)。
// 用户决策:系统管理 = 全屏独立视图;普通用户界面不暴露任何系统能力。
// 分区注册表:新增管理页只改 SECTIONS,不改 AdminZone 本体(声明式注册)。
import { useState } from 'react'
import { store, useStore } from '../store'
import { Icon, type IconName } from '@ui'
import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel } from './adminSections'
interface AdminSectionDef {
key: string
label: string
icon: IconName
desc: string
/** 懒渲染:进入分区才挂载(每个面板自带数据加载)。 */
render: () => React.ReactNode
}
function ProfilesPanelWrapper() {
const profiles = useStore(s => s.profiles)
return <ProfilesSection profiles={profiles} />
}
const SECTIONS: AdminSectionDef[] = [
{ key: 'users', label: '用户管理', icon: 'users', desc: '建号、发放初始密码、重置密码(管理员发放制)', render: () => <UsersPanel /> },
{ key: 'profiles', label: '智能体画像', icon: 'bot', desc: '系统级画像:系统提示、工具允许/确认/拒绝清单', render: () => <ProfilesPanelWrapper /> },
{ key: 'plugins', label: '插件管理', icon: 'puzzle', desc: '已装载插件清单与热重载(装卸不影响核心)', render: () => <PluginsSection /> },
{ key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', render: () => <PluginWorkspacesPanel /> },
]
export function AdminZone() {
const me = useStore(s => s.me)
const [sectionKey, setSectionKey] = useState('users')
// 双保险:入口已在菜单层门控(me?.isAdmin),此处再拦一次(纵深防御)。
if (!me?.isAdmin) return null
const section = SECTIONS.find(s => s.key === sectionKey) ?? SECTIONS[0]
return (
<div className="az" role="dialog" aria-label="系统管理">
<div className="az-head">
<button className="btn ghost" onClick={() => store.setUI({ adminZoneOpen: false })}>
<Icon name="chevron-down" size={14} /> 返回工作台
</button>
<span className="az-title">系统管理</span>
<span className="az-sub">仅管理员可见 · 变更即时生效</span>
<span style={{ flex: 1 }} />
<span className="az-tenant">{me.owner}</span>
</div>
<div className="az-body">
<nav className="az-side">
{SECTIONS.map(s => (
<button key={s.key} className={`az-nav-item ${s.key === sectionKey ? 'on' : ''}`} onClick={() => setSectionKey(s.key)}>
<Icon name={s.icon} size={15} />
<span>{s.label}</span>
</button>
))}
</nav>
<main className="az-main">
<div className="az-section-head">
<h3>{section.label}</h3>
<p>{section.desc}</p>
</div>
{section.render()}
</main>
</div>
</div>
)
}
+41 -47
View File
@@ -25,56 +25,50 @@ export function AgentTracker() {
const focusMsgs = focus === runId ? messagesAll.filter(m => m.runId === runId) : subMessages[focus] ?? []
return (
// 阶段③ 收敛:只保留 OverlayHost 一层壳(此前在其内又嵌 .modal 双层窗框)。
<OverlayHost title="代理追踪" icon="🤖" onClose={() => store.setUI({ trackerRunId: null })}>
<div className="modal">
<div className="modal-header">
🤖 Sub-agent 追踪
<span className="spacer" />
<button className="btn ghost" onClick={() => store.setUI({ trackerRunId: null })}>关闭</button>
</div>
<div className="modal-body">
{!root && <div>加载中…</div>}
{root && (
<div className="subagent-tree">
<div className="subagent-node">
<div className="head">
<span className={`status-dot status-${root.status}`} />
<strong>{root.kind === 'subagent' ? root.agentName || '子代理' : '主任务'}</strong>
<span style={{ color: 'var(--text-faint)' }}>{root.status}{root.phase ? ` · ${root.phase}` : ''}</span>
<span className="spacer" style={{ flex: 1 }} />
<UsageBadge usage={root.usage} />
<div className="subagent-tree">
{!root && <div>加载中…</div>}
{root && (
<>
<div className="subagent-node">
<div className="head">
<span className={`status-dot status-${root.status}`} />
<strong>{root.kind === 'subagent' ? root.agentName || '子代理' : '主任务'}</strong>
<span style={{ color: 'var(--text-faint)' }}>{root.status}{root.phase ? ` · ${root.phase}` : ''}</span>
<span className="spacer" style={{ flex: 1 }} />
<UsageBadge usage={root.usage} />
</div>
{children.length > 0 && (
<div>
{children.map(c => (
<div key={c.id} className="agent-row" style={{ background: focus === c.id ? 'var(--accent-soft)' : undefined, borderRadius: 6 }}
onClick={() => setSelected(c.id)}>
<span className={`status-dot status-${c.status}`} />
<span style={{ flex: 1 }}>🤖 {c.agentName || c.id}</span>
<span style={{ color: 'var(--text-faint)', fontSize: 'var(--fs-ui-xs)' }}>{c.status}</span>
</div>
))}
</div>
{children.length > 0 && (
<div>
{children.map(c => (
<div key={c.id} className="agent-row" style={{ background: focus === c.id ? 'var(--accent-soft)' : undefined, borderRadius: 6 }}
onClick={() => setSelected(c.id)}>
<span className={`status-dot status-${c.status}`} />
<span style={{ flex: 1 }}>🤖 {c.agentName || c.id}</span>
<span style={{ color: 'var(--text-faint)', fontSize: 11 }}>{c.status}</span>
</div>
))}
</div>
)}
</div>
<h5 style={{ margin: '12px 0 6px' }}>中间过程({focus === runId ? '主任务' : '子代理'})</h5>
<div style={{ background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 8, padding: 12, maxHeight: '40vh', overflowY: 'auto' }}>
{focusMsgs.length === 0 && <div style={{ color: 'var(--text-faint)', fontSize: 12.5 }}>(无中间过程消息)</div>}
{focusMsgs.map(m => (
<div key={m.id} className="part">
{m.parts.map((p, i) => (
<div key={i} style={{ whiteSpace: 'pre-wrap', fontSize: 13, lineHeight: 1.7 }}>
{p.type === 'text' && p.text}
{p.type === 'thinking' && <span style={{ color: 'var(--text-faint)' }}>{p.text}</span>}
{p.type === 'tool_call' && <span className="tool-chip">🛠 {p.toolCall?.name}</span>}
</div>
))}
</div>
))}
</div>
)}
</div>
)}
</div>
<h5 style={{ margin: '12px 0 6px' }}>中间过程({focus === runId ? '主任务' : '子代理'})</h5>
<div style={{ background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 8, padding: 12, maxHeight: '40vh', overflowY: 'auto' }}>
{focusMsgs.length === 0 && <div style={{ color: 'var(--text-faint)', fontSize: 'var(--fs-ui-sm)' }}>(无中间过程消息)</div>}
{focusMsgs.map(m => (
<div key={m.id} className="part">
{m.parts.map((p, i) => (
<div key={i} style={{ whiteSpace: 'pre-wrap', fontSize: 'var(--fs-ui-caption)', lineHeight: 1.7 }}>
{p.type === 'text' && p.text}
{p.type === 'thinking' && <span style={{ color: 'var(--text-faint)' }}>{p.text}</span>}
{p.type === 'tool_call' && <span className="tool-chip">🛠 {p.toolCall?.name}</span>}
</div>
))}
</div>
))}
</div>
</>
)}
</div>
</OverlayHost>
)
+157 -77
View File
@@ -1,14 +1,16 @@
// 中栏:消息流(Part 渲染器注册表)+ 流式渲染 + 过程折叠 + HITL 卡片 + 输入区(4.4)。
import { Icon, Avatar, BrandMark } from '@ui'
// 重设计方案 阶段④:折叠区全部迁 @ui Collapsible(<details> 已禁);工具调用块走
// ToolBlock 契约(运行态扫光、完成边沿收起、展开态记忆);live 行扫光 + 已工作计时。
import { Icon, Avatar, BrandMark, Collapsible, ToolBlock } from '@ui'
import { useEffect, useMemo, useRef, useState } from 'react'
import { marked } from 'marked'
import { api } from '../api'
import { store, useStore, activeRun, type LiveState, type ContextObs } from '../store'
import { liveToMessage, showProcessInline } from '../live'
import type { Message, Part, HITLCard } from '../contracts'
import { UIRenderer, InlineConfirmCard, hasOpenConfirm } from '@ui'
import type { Message, Part, HITLCard, ActivityCard } from '../contracts'
import { UIRenderer, InlineConfirmCard, hasOpenConfirm, CardAnchor } from '@ui'
import { docBridge } from './Workbench'
import { groupParts, artifactIcon } from '../messageparts'
import { groupParts, artifactIcon, humanSize } from '../messageparts'
// marked 走标准 Markdown 语义(不开 breaks):模型输出按块级排版渲染,
// 行内换行交给段落折叠,避免与 CSS 块间距叠加出松散排版。
@@ -21,12 +23,8 @@ function PartView({ part, live }: { part: Part; live?: boolean | null }) {
dangerouslySetInnerHTML={{ __html: marked.parse(part.text ?? '') }} />
case 'thinking':
// 流式期间展开可实时看到思考;run 终态后收进过程折叠区,默认收起。
return (
<details className="part-thinking" open={live ? true : undefined}>
<summary>思考过程</summary>
<div className="part-thinking-body">{part.text}</div>
</details>
)
//(此单件形态用于过程组之外散落的思考;组内思考由 ProcessParts 渲染。)
return <ThinkingBlock part={part} idKey={part.text?.slice(0, 24) ?? 't'} defaultOpen={!!live} />
case 'tool_call':
return <span className="tool-chip">🛠 {part.toolCall?.name}</span>
case 'tool_result': {
@@ -40,14 +38,22 @@ function PartView({ part, live }: { part: Part; live?: boolean | null }) {
const art = a ? findArtifact(a.artifactId) : undefined
const name = a?.name ?? '工件'
const icon = artifactIcon(name, a?.kind ?? art?.kind)
const size = humanSize(art?.size)
// 交付物卡片(对话内直接给出):整卡点击 = 右栏预览(唯一落位,全屏从
// 右栏放大);动作只保留 下载 一个——与工件面板/预览器同一动作集。
return (
<span className="artifact-chip" data-artifact-chip={a?.artifactId}>
<button className="ac-main" title={`打开 ${name}`} onClick={() => { if (art) store.viewArtifact(art) }}>
<Icon name={icon} size={14} /> {name}
</button>
<a className="ac-act" title={`下载 ${name}`} href={api.artifactDownloadUrl(a?.artifactId ?? '')}
download={name} onClick={e => e.stopPropagation()}><Icon name="download" size={13} /></a>
</span>
<div className="deliverable-card mk-rise-in" data-artifact-chip={a?.artifactId}
title={`预览 ${name}(右栏,边聊边看)`}
onClick={() => { if (art) store.requestPreview(art) }}>
<span className="dc-icon"><Icon name={icon} size={18} /></span>
<span className="dc-main">
<span className="dc-name">{name}</span>
<span className="dc-meta">{kindLabelOf(a?.kind ?? art?.kind)}{size ? ` · ${size}` : ''}{art ? '' : ' · 生成中'}</span>
</span>
<span className="dc-act">预览</span>
<a className="dc-dl" title={`下载 ${name}`} href={api.artifactDownloadUrl(a?.artifactId ?? '')}
download={name} onClick={e => e.stopPropagation()}><Icon name="download" size={14} /></a>
</div>
)
}
case 'hitl':
@@ -55,8 +61,16 @@ function PartView({ part, live }: { part: Part; live?: boolean | null }) {
case 'plan':
return null // 计划在右栏 Plan 标签呈现
case 'card': {
// cardlink 互定位(D32):点击 ↗ 定位收件箱对应卡(从 data 中解析 card:<id>)。
const m = /card:([A-Za-z0-9_]+)/.exec(JSON.stringify(part.card?.data ?? ''))
// 卡流锚点(D39):cardId 非空 = submit_card 引用部件——中栏自足(可展开全文)+ 跳收件箱。
if (part.card?.cardId) {
const ref = part.card.cardId
const card = findCard(ref)
const bodyChars = card?.body?.reduce((n, node) => n + (node.markdown?.length ?? 0), 0)
return <CardAnchor title={card?.title ?? part.card.kind} chars={bodyChars} card={card}
onLocate={() => store.openInbox(ref)} docBridge={docBridge} />
}
// 插件内嵌卡片部件(cardlink 互定位,D32):点击 ↗ 定位收件箱对应卡(从 data 解析 card:<id>)。
const m = JSON.stringify(part.card?.data ?? '').match(/card:([A-Za-z0-9_]+)/)
return (
<div className="ui-section card-link" title={m ? '点击在收件箱中定位' : undefined}
onClick={() => m && store.openInbox(m[1])}>
@@ -77,40 +91,101 @@ function findArtifact(id: string) {
return artifactCache[id]
}
// 过程折叠:run 终态后把连续 process 部件折叠为摘要行(可展开回看)。
function ProcessCollapse({ parts }: { parts: Part[] }) {
const toolCount = parts.filter(p => p.type === 'tool_call').length
const hasThinking = parts.some(p => p.type === 'thinking' && p.text)
/** 交付物卡片的类型文案(与工件面板 kind 过滤同一词汇)。 */
function kindLabelOf(kind: string | undefined): string {
switch (kind) {
case 'document': return '文档'
case 'data': return '数据'
case 'image': return '图片'
default: return '文件'
}
}
// 收件箱卡片查询缓存(store.cards 的派生索引;卡流锚点取卡数据用)。
let cardCache: Record<string, ActivityCard> = {}
function findCard(id: string) {
return cardCache[id]
}
// 思考块(Collapsible 契约):左线弱色,流式默认展开、终态默认收起;展开态记忆。
function ThinkingBlock({ part, idKey, defaultOpen }: { part: Part; idKey: string; defaultOpen?: boolean }) {
return (
<details className="process-collapse">
<summary>
<span>过程输出</span>
<span>{hasThinking ? '含思考' : ''}{toolCount > 0 ? ` · ${toolCount} 次工具调用` : ''}(点击展开)</span>
</summary>
<div className="body">
{parts.map((p, i) => <div className="part" key={i}><PartView part={p} /></div>)}
</div>
</details>
<div className="part-thinking">
<Collapsible id={`think:${idKey}`} defaultOpen={defaultOpen} triggerClassName="part-thinking-summary"
trigger={<><span className="part-thinking-chev">▸</span>思考过程</>}>
<div className="part-thinking-body">{part.text}</div>
</Collapsible>
</div>
)
}
// 长回复折叠阈值(CF0):终态后超长正文收成摘要行,点击展开全文;live 期间全文可见。
const LONG_REPLY_CHARS = 800
// 过程段渲染(配对增强):tool_call 吸收紧随其后的同名 tool_result 组成 ToolBlock
//(运行态扫光、完成边沿收起、展开看结果摘要);其余部件原位 PartView。
function ProcessParts({ parts, live, idKey }: { parts: Part[]; live: boolean; idKey: string }) {
const out: React.ReactNode[] = []
for (let i = 0; i < parts.length; i++) {
const p = parts[i]
if (p.type === 'tool_call') {
const res = parts[i + 1]?.type === 'tool_result' ? parts[i + 1] : null
if (res) i++
const st = res?.toolResult?.status
out.push(
<ToolBlock key={i} toolId={`${idKey}:${i}`} name={`🛠 ${p.toolCall?.name ?? '工具'}`}
running={live && !res} status={st === 'ok' ? 'ok' : st ? 'error' : undefined}
statusDetail={res?.toolResult?.summary}
autoCollapseOnComplete>
{res && <div className="part-text">{res.toolResult?.summary}</div>}
</ToolBlock>)
continue
}
out.push(<div className="part" key={i}><PartView part={p} live={live && p.type === 'text'} /></div>)
}
return <>{out}</>
}
function LongReplyCollapse({ text }: { text: string }) {
// 摘句取压缩空白后的前 100 字;字数按 Markdown 源码长度计。
const excerpt = text.replace(/\s+/g, ' ').trim().slice(0, 100)
// 过程折叠:run 终态后把连续 process 部件折叠为摘要行(可展开回看;Collapsible 契约)。
function ProcessCollapse({ parts, idKey }: { parts: Part[]; idKey: string }) {
const toolCount = parts.filter(p => p.type === 'tool_call').length
const hasThinking = parts.some(p => p.type === 'thinking' && p.text)
return (
<details className="process-collapse long-reply">
<summary>
<span>📄 长回复</span>
<span>{text.length} 字(按 Markdown 源码计)</span>
{excerpt && <span style={{ color: 'var(--text-faint)' }}>{excerpt}…</span>}
</summary>
<div className="body">
<div className="part-text" dangerouslySetInnerHTML={{ __html: marked.parse(text) }} />
</div>
</details>
<div className="process-collapse">
<Collapsible id={`process:${idKey}`} triggerClassName="process-collapse-summary" unmountDelay={300}
trigger={<>
<span className="process-collapse-chev">▸</span>
<span>过程输出</span>
<span className="process-collapse-meta">
{hasThinking ? '含思考' : ''}{toolCount > 0 ? ` · ${toolCount} 次工具调用` : ''}
</span>
</>}>
<div className="body">
<ProcessParts parts={parts} live={false} idKey={idKey} />
</div>
</Collapsible>
</div>
)
}
// 已工作计时(ZCode「已工作 X 分 X 秒」摘要行;1s 步进,仅 live 期间挂载)。
function useElapsed(startedAt: number): string {
const [, tick] = useState(0)
useEffect(() => {
const t = window.setInterval(() => tick(n => n + 1), 1000)
return () => window.clearInterval(t)
}, [])
const s = Math.max(0, Math.floor((Date.now() - startedAt) / 1000))
const m = Math.floor(s / 60)
return m > 0 ? `${m} 分 ${s % 60} 秒` : `${s} 秒`
}
// 流式状态行(独立组件:计时 hook 只在 live 行挂载期间运转)。
function LiveBar({ live }: { live: LiveState }) {
const elapsed = useElapsed(live.startedAt)
return (
<span className="live-bar">
<span className="live-dot" />
<span className="mk-sweep">{live.phase}</span>
<span className="live-elapsed">已工作 {elapsed}</span>
</span>
)
}
@@ -131,24 +206,23 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null
return -1
})()
return (
<div className="msg assistant">
<div className="msg assistant" data-msg-id={msg.id}>
<div className="msg-role">
<span className="role-mark role-assistant"><BrandMark size={16} /></span>
<span>助手</span>
{live && <span className="live-bar"><span className="live-dot" />{live.phase}</span>}
{live && <LiveBar live={live} />}
</div>
{blocks.map((b, i) => b.kind === 'process'
? (showProcess
? <div className="part-group" key={i}>{b.parts.map((p, j) => <div className="part" key={j}><PartView part={p} live={!!live} /></div>)}</div>
: <ProcessCollapse key={i} parts={b.parts} />)
: (() => {
// CF0:非 live 的超长文本部件折叠为摘要行;live 与短文本不受影响。
if (!live && b.part.type === 'text' && (b.part.text?.length ?? 0) > LONG_REPLY_CHARS)
return <LongReplyCollapse key={i} text={b.part.text ?? ''} />
return <div className="part" key={i}>
? <div className="part-group" key={i}><ProcessParts parts={b.parts} live={!!live} idKey={`${msg.id}:${i}`} /></div>
: <ProcessCollapse key={i} parts={b.parts} idKey={`${msg.id}:${i}`} />)
: (
// 最终输出契约:终轮正文是对用户的回复,一律完整可见(CF0 长回复折叠
// 已废除——过程进折叠区,回复不再折叠);交付卡片钉在正文之后。
<div className="part" key={i}>
<PartView part={b.part} live={!!(live && i === lastContentIdx && b.part.type === 'text')} />
</div>
})())}
))}
{live && <LiveAgentNotes live={live} />}
{!live && ctxObs && <ContextObsView obs={ctxObs} />}
</div>
@@ -158,22 +232,25 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null
// 上下文组装观测(4.10/4.17):模型实际看到什么、裁了什么——可解释性入口。
function ContextObsView({ obs }: { obs: ContextObs }) {
return (
<details className="process-collapse">
<summary>
<span>🧩 上下文构成</span>
<span>{obs.items.join(' · ')}(约 {obs.estChars} 字符)</span>
</summary>
<div className="body">
<ul className="ui-list">
{obs.items.map((it, i) => <li key={i}>{it}</li>)}
</ul>
{obs.truncations && obs.truncations.length > 0 && (
<div style={{ color: 'var(--warn)', fontSize: 12.5, marginTop: 6 }}>
{obs.truncations.map((t, i) => <div key={i}>⚠ {t}</div>)}
</div>
)}
</div>
</details>
<div className="process-collapse">
<Collapsible id={`ctxobs:${obs.estChars}`} triggerClassName="process-collapse-summary" unmountDelay={300}
trigger={<>
<span className="process-collapse-chev">▸</span>
<span>🧩 上下文构成</span>
<span className="process-collapse-meta">{obs.items.join(' · ')}(约 {obs.estChars} 字符)</span>
</>}>
<div className="body">
<ul className="ui-list">
{obs.items.map((it, i) => <li key={i}>{it}</li>)}
</ul>
{obs.truncations && obs.truncations.length > 0 && (
<div style={{ color: 'var(--warn)', fontSize: 'var(--fs-ui-sm)', marginTop: 6 }}>
{obs.truncations.map((t, i) => <div key={i}>⚠ {t}</div>)}
</div>
)}
</div>
</Collapsible>
</div>
)
}
@@ -261,6 +338,7 @@ export function Conversation() {
const bodyRef = useRef<HTMLDivElement>(null)
artifactCache = Object.fromEntries(artifacts.map(a => [a.id, a]))
cardCache = Object.fromEntries(cards.map(c => [c.id, c]))
const active = activeRun(runs)
@@ -281,7 +359,7 @@ export function Conversation() {
<div className="chat-body"><div className="empty-state">
<div style={{ fontSize: 40 }}>⌘</div>
<div>选择或创建一个会话开始</div>
<div style={{ fontSize: 12 }}>mock 演示模式可在 设置 → runtime 开启,无需模型密钥</div>
<div style={{ fontSize: 'var(--fs-ui-sm)' }}>mock 演示模式可在 设置 → runtime 开启,无需模型密钥</div>
</div></div>
</div>
)
@@ -326,7 +404,7 @@ export function Conversation() {
// 内联确认卡(T3):锚定本消息的危险命令确认,双现于对话流与收件箱。
const anchored = cards.filter(c => c.anchorMessageId === m.id && c.kind === 'confirm')
return (
<div className="msg user" key={m.id}>
<div className="msg user" key={m.id} data-msg-id={m.id}>
<div className="msg-role">
<span className="role-mark"><Avatar name={myName} size="sm" /></span>你
</div>
@@ -348,7 +426,7 @@ export function Conversation() {
{messages.length === 0 && <div className="empty-state">
<span className="es-mark"><Icon name="sparkles" size={22} /></span>
<div>发送消息开始对话</div>
<div style={{ fontSize: 12, color: 'var(--text-faint)' }}>mock 模式演示指令:#hitl · #agents · #doc · #error · 命令:/help</div>
<div style={{ fontSize: 'var(--fs-ui-sm)', color: 'var(--text-faint)' }}>mock 模式演示指令:#hitl · #agents · #doc · #error · 命令:/help</div>
</div>}
</div>
@@ -371,7 +449,9 @@ export function Conversation() {
e.target.value = ''
}} />
</label>
<textarea rows={1} value={text} placeholder={hasOpenConfirm(cards) ? '有待确认的命令——请先处理上方确认卡' : artifacts.length > 0 ? '输入消息…(/ 命令,@knowledge 检索注入)' : '输入消息…(/ 命令)'}
<textarea rows={1} value={text} placeholder={hasOpenConfirm(cards) ? '有待确认的命令——请先处理上方确认卡'
: active ? '回合进行中——等当前回合结束(或点「停止」)后再发送'
: artifacts.length > 0 ? '输入消息…(/ 命令,@knowledge 检索注入)' : '输入消息…(/ 命令)'}
onChange={e => setText(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() } }} />
{active ? (
+19 -30
View File
@@ -1,26 +1,33 @@
// 文档查看器(4.4 → 工作区文件管理升级):单层 OverlayHost——markdown 渲染 /
// 文本编辑保存 / 图片与 pdf 预览 / csv·json 等宽 / 二进制信息卡 + 下载。
// 文档查看器(4.4 → 预览能力收敛):格式渲染统一交给 @ui FilePreview
// (md/图片/pdf/代码/数据/docx/xlsx/pptx/URL 一套分派),这里只保留 OverlayHost
// 容器、工具栏与文本族的在线编辑保存。
import { useEffect, useState } from 'react'
import { marked } from 'marked'
import { api } from '../api'
import { store } from '../store'
import { OverlayHost, Icon, Btn, EmptyState } from '@ui'
import { OverlayHost, Icon, Btn, FilePreview } from '@ui'
import type { Artifact } from '../contracts'
import { artifactIcon, humanSize } from '../messageparts'
import { humanSize } from '../messageparts'
export function DocViewer({ artifact }: { artifact: Artifact }) {
const [content, setContent] = useState<string | null>(null)
const [editing, setEditing] = useState(false)
const [dirty, setDirty] = useState(false)
const textKind = isTextKind(artifact)
useEffect(() => {
if (artifact.kind === 'image' || isBinaryKind(artifact)) return
if (!textKind) return
setContent(null)
fetch(api.artifactContentUrl(artifact.id)).then(r => r.text()).then(setContent).catch(() => setContent('(读取失败)'))
}, [artifact.id])
const isPdf = /\.pdf$/i.test(artifact.name)
const isCsv = /\.csv$/i.test(artifact.name)
const isJson = /\.json$/i.test(artifact.name)
const source = {
kind: 'artifact' as const,
id: artifact.id,
name: artifact.name,
mime: artifact.mime,
contentUrl: api.artifactContentUrl(artifact.id),
downloadUrl: api.artifactDownloadUrl(artifact.id),
}
return (
<OverlayHost title={artifact.name} icon="📄" onClose={() => store.viewArtifact(null)}>
@@ -29,7 +36,7 @@ export function DocViewer({ artifact }: { artifact: Artifact }) {
<span className="docv-meta">{artifact.kind}{humanSize(artifact.size) ? ` · ${humanSize(artifact.size)}` : ''}</span>
<span className="spacer" />
{dirty && <span className="docv-dirty">未保存</span>}
{isTextKind(artifact) && (
{textKind && (
<>
<Btn variant="ghost" icon={editing ? 'eye' : 'list'} onClick={() => setEditing(e => !e)}>{editing ? '预览' : '编辑'}</Btn>
{editing && <Btn variant="primary" icon="check" disabled={!dirty} onClick={async () => {
@@ -44,25 +51,11 @@ export function DocViewer({ artifact }: { artifact: Artifact }) {
</a>
</div>
<div className="docv-body">
{artifact.kind === 'image' ? (
<img src={api.artifactContentUrl(artifact.id)} alt={artifact.name} style={{ maxWidth: '100%', borderRadius: 8 }} />
) : isPdf ? (
<iframe src={api.artifactContentUrl(artifact.id)} title={artifact.name} className="docv-pdf" />
) : isBinaryKind(artifact) ? (
<EmptyState icon={artifactIcon(artifact.name, artifact.kind)}>
<b>{artifact.name}</b>
<div>{humanSize(artifact.size) || '二进制文件'} · 浏览器不支持此格式的在线预览</div>
<a className="btn v-primary" href={api.artifactDownloadUrl(artifact.id)} download={artifact.name}>
<Icon name="download" size={14} /> 下载文件
</a>
</EmptyState>
) : editing ? (
{editing && textKind ? (
<textarea className="search-input docv-editor" value={content ?? ''}
onChange={e => { setContent(e.target.value); setDirty(true) }} />
) : isCsv || isJson ? (
<pre className="docv-pre">{content ?? '(加载中…'}</pre>
) : (
<div className="doc-viewer" dangerouslySetInnerHTML={{ __html: marked.parse(content ?? '(加载中…') as string }} />
<FilePreview source={source} />
)}
</div>
</div>
@@ -73,7 +66,3 @@ export function DocViewer({ artifact }: { artifact: Artifact }) {
function isTextKind(a: Artifact): boolean {
return a.kind === 'document' && /\.(md|txt|csv|log|json)$/i.test(a.name)
}
function isBinaryKind(a: Artifact): boolean {
return /\.(docx|pdf|xlsx|pptx|doc|xls|ppt|zip|png|jpg|jpeg|gif|webp)$/i.test(a.name)
}
+9 -107
View File
@@ -1,12 +1,14 @@
// 设置中心(4.9 → UI 能力平台迁移):@ui SettingsShell + SettingRow 统一行语义,
// 保存按钮进固定 actions 底栏(不随内容滚动);schema 驱动 + 生效时机标注 + 密钥掩码。
// 重设计方案 阶段②(IA):设置瘦身——只留个人偏好(常规);智能体画像/插件管理/
// 插件工作区迁「系统管理」(AdminZone,管理员专属全屏视图)。
import { useEffect, useMemo, useState } from 'react'
import { api } from '../api'
import { store, useStore } from '../store'
import type { AgentProfile, SettingDesc } from '../contracts'
import { SettingsShell, SettingRow, SettingsGroup, Btn, EmptyState } from '@ui'
import { store } from '../store'
import type { SettingDesc } from '../contracts'
import { SettingsShell, SettingRow, SettingsGroup, Btn } from '@ui'
type Section = 'settings' | 'profiles' | 'plugins'
type Section = 'settings'
// 分组显示名(项目设置面板共用)。
export const groupNames: Record<string, string> = {
@@ -15,8 +17,7 @@ export const groupNames: Record<string, string> = {
}
export function SettingsModal() {
const profiles = useStore(s => s.profiles)
const [section, setSection] = useState<Section>('settings')
const [section] = useState<Section>('settings')
const [schema, setSchema] = useState<SettingDesc[]>([])
const [, setUser] = useState<Record<string, any>>({})
const [draft, setDraft] = useState<Record<string, any>>({})
@@ -49,17 +50,15 @@ export function SettingsModal() {
return (
<SettingsShell
title="设置" active={section} onSelect={k => setSection(k as Section)}
title="设置" active={section} onSelect={() => undefined}
onClose={() => store.openSettings(false)}
sections={[
{ key: 'settings', label: '常规', icon: 'settings' },
{ key: 'profiles', label: '智能体画像', icon: 'bot', badge: profiles.length || undefined },
{ key: 'plugins', label: '插件', icon: 'puzzle' },
]}
footer={saved ? <span style={{ color: 'var(--ok)' }}>✓ 已保存</span> : 'Agent-System · 设置中心'}
actions={section === 'settings' && schema.length > 0 ? (
<>
{saved && <span style={{ color: 'var(--ok)', fontSize: 12.5, marginRight: 8 }}>✓ 已保存</span>}
{saved && <span style={{ color: 'var(--ok)', fontSize: 'var(--fs-ui-sm)', marginRight: 8 }}>✓ 已保存</span>}
<Btn variant="primary" icon="check" onClick={save}>保存设置</Btn>
</>
) : undefined}
@@ -73,8 +72,6 @@ export function SettingsModal() {
))}
</SettingsGroup>
))}
{section === 'profiles' && <ProfilesSection profiles={profiles} />}
{section === 'plugins' && <PluginsSection />}
</SettingsShell>
)
}
@@ -119,98 +116,3 @@ export function unflatten(flatMap: Record<string, any>): Record<string, any> {
return out
}
// ---- 画像管理(数据化:导入导出 + 编辑)----
function ProfilesSection({ profiles }: { profiles: AgentProfile[] }) {
const [editing, setEditing] = useState<AgentProfile | null>(null)
const [text, setText] = useState('')
return (
<div>
<div className="doc-toolbar">
<Btn variant="primary" icon="plus" onClick={() => { setEditing(newProfile()); setText('') }}>新建画像</Btn>
<label className="btn ghost" style={{ cursor: 'pointer' }}>
导入 JSON<input type="file" accept=".json" style={{ display: 'none' }} onChange={async e => {
const f = e.target.files?.[0]
if (!f) return
try {
const p = JSON.parse(await f.text())
p.id = p.id || 'prof_imported'
p.builtin = false
await api.saveProfile(p)
store.refreshProfiles()
store.toast('画像已导入')
} catch (err) { store.toast(`导入失败:${err}`) }
}} />
</label>
</div>
{profiles.map(p => (
<SettingRow key={p.id}
label={<>{p.name} <span style={{ color: 'var(--text-faint)', fontSize: 11 }}>{p.id}{p.builtin ? ' · 内置' : ''}</span></>}
desc={p.description}>
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
<Btn variant="ghost" icon="download" onClick={() => {
const blob = new Blob([JSON.stringify(p, null, 2)], { type: 'application/json' })
const a = document.createElement('a')
a.href = URL.createObjectURL(blob); a.download = `${p.id}.json`; a.click()
}}>导出</Btn>
{!p.builtin && <Btn variant="ghost" onClick={() => { setEditing(p); setText(p.systemPrompt) }}>编辑</Btn>}
{!p.builtin && <Btn variant="ghost" icon="trash" onClick={async () => { await api.deleteProfile(p.id); store.refreshProfiles() }}>删除</Btn>}
</div>
</SettingRow>
))}
{editing && (
<SettingsGroup title={`编辑画像:${editing.name}`}>
<div className="ui-form">
<label>名称<input value={editing.name} onChange={e => setEditing({ ...editing, name: e.target.value })} /></label>
<label>系统提示<textarea className="search-input" rows={4} style={{ height: 'auto' }} value={text} onChange={e => setText(e.target.value)} /></label>
<label>允许工具(逗号分隔,* 全部)<input value={editing.tools.allow.join(',')} onChange={e => setEditing({ ...editing, tools: { ...editing.tools, allow: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })} /></label>
<label>执行前确认(前缀. 结尾表示族)<input value={editing.tools.confirm?.join(',') ?? ''} onChange={e => setEditing({ ...editing, tools: { ...editing.tools, confirm: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })} /></label>
<label>拒绝工具<input value={editing.tools.deny?.join(',') ?? ''} onChange={e => setEditing({ ...editing, tools: { ...editing.tools, deny: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })} /></label>
</div>
<Btn variant="primary" icon="check" onClick={async () => {
try {
await api.saveProfile({ ...editing, builtin: false, source: 'user', systemPrompt: text })
await store.refreshProfiles()
setEditing(null)
store.toast('画像已保存')
} catch (e) { store.toast(`保存失败:${e}`) }
}}>保存画像</Btn>
</SettingsGroup>
)}
</div>
)
}
function newProfile(): AgentProfile {
return {
id: '', name: '新画像', systemPrompt: '', model: {}, tools: { allow: ['*'] },
context: { maxHistoryMessages: 40 }, hitl: { askOnAmbiguity: true }, budget: {},
} as AgentProfile
}
// ---- 插件管理 ----
function PluginsSection() {
const [plugins, setPlugins] = useState<any[]>([])
const refresh = () => api.listPlugins().then(setPlugins).catch(() => undefined)
useEffect(() => { refresh() }, [])
return (
<div>
<div className="doc-toolbar">
<Btn variant="ghost" icon="undo" onClick={async () => {
await api.reloadPlugins()
await refresh()
store.refreshPlugins()
store.toast('插件已热重载')
}}>热重载</Btn>
<span style={{ color: 'var(--text-faint)', fontSize: 12 }}>声明资产(工作流/意图/提示词/面板)热加载;装卸不影响核心</span>
</div>
{plugins.length === 0 && <EmptyState icon="puzzle">暂无已装载插件<br />(目录:常规 → 插件目录;示例见 Dev 仓 plugins/example)</EmptyState>}
{plugins.map(p => (
<SettingRow key={p.manifest.id}
label={`${p.manifest.name} v${p.manifest.version}`}
desc={[`工具 ${p.manifest.provides.tools?.length ?? 0}`, `面板 ${p.panels?.length ?? 0}`, `意图 ${p.intents?.length ?? 0}`, `工作流 ${p.workflows?.length ?? 0}`].join(' · ')}>
<span className="effect-badge">api {p.manifest.apiVersion}</span>
</SettingRow>
))}
</div>
)
}
+10 -14
View File
@@ -1,13 +1,13 @@
// 左栏(方案 A「任务中心」,提案 2026-09-30 R1):单一主创建入口(P6)+
// 组织视图(P8:按项目/时间线)+ 置顶/归档 + 账户区(P7)。
// 工作区快捷图标条保留(D25:⌘ 与左栏共享同一清单)。
// 重设计方案 阶段②(IA 对话优先):工件/代理/插件快捷条撤出普通用户界面——
// 工件走右栏「工件」页签,插件工作区归「系统管理」(管理员专属全屏视图)。
import { useEffect, useRef, useState } from 'react'
import { store, useStore } from '../store'
import { auth } from '../auth'
import { api } from '../api'
import { Icon, BrandMark, workspaceIcon } from '@ui'
import { Icon, BrandMark } from '@ui'
import type { Conversation } from '../contracts'
import { workspaceEntries } from './Workspaces'
import { groupByProject, groupByTimeline, relativeTime, filterTasks, type TaskOrg } from '../tasklist'
const GROUPS_KEY = 'as.taskGroups'
@@ -51,7 +51,9 @@ export function TaskSidebar() {
const org = useStore(s => s.ui.taskOrg)
const showArchived = useStore(s => s.ui.showArchived)
const theme = useStore(s => s.ui.theme)
const pendingBadge = useStore(s => s.cards.filter(c => c.status === 'action_required').length)
const pendingBadge = useStore(s =>
s.cards.filter(c => c.status === 'action_required').length +
s.projectCards.filter(c => c.status === 'action_required').length)
const [q, setQ] = useState('')
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(loadCollapsed)
@@ -192,15 +194,6 @@ export function TaskSidebar() {
))}
</div>
{/* 工作区快捷图标条(D25:与 ⌘1–9 同一清单) */}
<div className="tsb-wsstrip">
{workspaceEntries().map(e => (
<button key={e.key} title={`${e.title}(${e.shortcut})`} onClick={() => store.openWorkspace(e.key)}><Icon name={workspaceIcon(e.icon)} size={15} /></button>
))}
<span style={{ flex: 1 }} />
<button title={theme === 'dark' ? '切换浅色' : '切换深色'} onClick={() => store.setUI({ theme: theme === 'dark' ? 'light' : 'dark' })}><Icon name={theme === 'dark' ? 'sun' : 'moon'} size={15} /></button>
</div>
{/* 账户区(P7):身份常驻;阶段 1 = API key 即身份(user 即租户 owner) */}
<div className="tsb-footer">
<button className="tsb-user" onClick={e => { const el = e.currentTarget; setMenu(m => m?.kind === 'account' ? null : { kind: 'account', anchor: el }) }}>
@@ -249,7 +242,10 @@ export function TaskSidebar() {
<MenuItem icon="settings" onClick={() => { store.openSettings(true); setMenu(null) }}>偏好设置</MenuItem>
<MenuItem icon="inbox" onClick={() => { store.openInbox(); setMenu(null) }}>收件箱{pendingBadge > 0 ? `(待办 ${pendingBadge})` : ''}</MenuItem>
<MenuItem icon="key" onClick={() => { store.setUI({ changePwOpen: true }); setMenu(null) }}>修改密码…</MenuItem>
{me?.isAdmin && <MenuItem icon="users" onClick={() => { store.setUI({ adminUsersOpen: true }); setMenu(null) }}>用户管理…</MenuItem>}
<MenuItem icon={theme === 'dark' ? 'sun' : 'moon'} onClick={() => { store.setUI({ theme: theme === 'dark' ? 'light' : 'dark' }); setMenu(null) }}>
{theme === 'dark' ? '切换浅色主题' : '切换深色主题'}
</MenuItem>
{me?.isAdmin && <MenuItem icon="shield" onClick={() => { store.setUI({ adminZoneOpen: true }); setMenu(null) }}>系统管理…</MenuItem>}
{me?.auth && <>
<div className="tsb-menu-div" />
<MenuItem danger icon="logout" onClick={() => { api.logout().catch(() => undefined); auth.logout() }}>退出登录</MenuItem>
+86 -16
View File
@@ -1,41 +1,111 @@
// 右栏终态(提案 T4/D25):纯收件箱 + 「当前任务」常驻卡(计划/待办并入;工件/代理/
// 插件面板迁为工作区弹层,见 Workspaces.tsx)。
// 右栏终态(提案 T4/D25 → 预览双落位):收件箱/预览两页签 + 「当前任务」常驻卡。
// 预览页签(裁定 D4)承载右栏轻预览:FilePreview compact 模式,边聊边看。
import { store, useStore } from '../store'
import type { Run } from '../contracts'
import { InboxList, pendingCount, type ActivityCard, type DocBridge } from '@ui'
import { InboxList, pendingCount, FilePreview, type ActivityCard, type DocBridge } from '@ui'
import { Icon } from '@ui'
import { api } from '../api'
// 工件解析/打开注入:渲染器纯展示,doc-ref 词汇经此桥接本仓状态(D29)。
// 打开语义走 requestPreview 唯一入口(阶段③ 收敛:默认右栏轻预览)。
export const docBridge: DocBridge = {
resolveDoc: id => store.getState().artifacts.find(a => a.id === id),
onOpenDoc: id => {
const art = store.getState().artifacts.find(a => a.id === id)
if (art) store.viewArtifact(art)
if (art) store.requestPreview(art)
},
}
const inboxCallbacks = {
onAction: (c: ActivityCard, id: string) => store.cardAction(c, id),
onPin: (c: ActivityCard, p: boolean) => store.cardPin(c, p),
onDismiss: (c: ActivityCard) => store.cardDismiss(c),
onLocate: (c: ActivityCard) => store.locateMessage(c.anchorMessageId),
onArchive: (c: ActivityCard) => store.cardToProject(c),
}
/** 项目收件箱视图(CF3/D49/D51):项目级卡片 ∪ 其他会话待办(done 不聚合)。 */
function ProjectInbox({ cards }: { cards: ActivityCard[] }) {
const own = cards.filter(c => !c.conversationId)
const cross = cards.filter(c => !!c.conversationId)
if (cards.length === 0) {
return <div className="inbox-empty">项目收件箱为空——「留档到项目」的产物卡与其他会话的待办会聚合在这里。</div>
}
return (
<>
<div className="inbox-zone-label">📁 项目级卡片(跨会话可见)</div>
{own.length > 0
? <InboxList cards={own} callbacks={inboxCallbacks} docBridge={docBridge} />
: <div className="inbox-empty" style={{ padding: '4px 14px' }}>暂无——会话卡点 🗂 即留档至此</div>}
{cross.length > 0 && (
<>
<div className="inbox-zone-label">🕓 其他会话待办</div>
<InboxList cards={cross} callbacks={inboxCallbacks} docBridge={docBridge} showPinnedZone={false} />
</>
)}
</>
)
}
export function Workbench() {
const cards = useStore(s => s.cards)
const plan = useStore(s => s.plan)
const todos = useStore(s => s.todos)
const pending = pendingCount(cards)
const inboxScope = useStore(s => s.inboxScope)
const projectCards = useStore(s => s.projectCards)
const pending = pendingCount(cards) + pendingCount(projectCards)
const rightTab = useStore(s => s.ui.rightTab)
const railId = useStore(s => s.ui.railPreviewId)
const railArtifact = useStore(s => (railId ? s.artifacts.find(a => a.id === railId) : undefined))
const showPreview = rightTab === 'preview'
return (
<>
<div className="tabs">
<span className="tab active">收件箱{pending > 0 ? ` ${pending}` : ''}</span>
<button className={`tab ${!showPreview ? 'active' : ''}`} onClick={() => store.setUI({ rightTab: 'inbox' })}
title="收件箱">收件箱{pending > 0 ? ` ${pending}` : ''}</button>
<button className={`tab ${showPreview ? 'active' : ''}`} onClick={() => store.railPreview(railArtifact ?? null)}
title="预览(右栏轻预览)"><Icon name="eye" size={13} /> 预览</button>
<span className="spacer" style={{ flex: 1 }} />
<button className="tab tab-action" title="收纳已完成卡(done→关闭;已确认卡留痕不动)" onClick={() => store.tidyCards()}><Icon name="check" size={13} /> 收纳</button>
<button className="tab" title="收起收件箱" onClick={store.toggleRight}><Icon name="chevron-right" size={14} /></button>
</div>
<div className="tab-body">
<CurrentTaskCard plan={plan} todos={todos} />
<InboxList cards={cards} docBridge={docBridge} callbacks={{
onAction: (c: ActivityCard, id: string) => store.cardAction(c, id),
onPin: (c: ActivityCard, p: boolean) => store.cardPin(c, p),
onDismiss: (c: ActivityCard) => store.cardDismiss(c),
onLocate: (c: ActivityCard) => store.openInbox(c.id),
}} />
</div>
{showPreview ? (
<div className="tab-body" data-rail-preview="">
{railArtifact ? (
<>
<div className="rail-preview-head">
<span className="rp-name" title={railArtifact.name}>{railArtifact.name}</span>
<span className="spacer" style={{ flex: 1 }} />
<button className="icon-btn" title="全屏预览(唯一大窗落位)" onClick={() => store.requestPreview(railArtifact, 'full')}><Icon name="eye" size={14} /></button>
<a className="icon-btn" title="下载" href={api.artifactDownloadUrl(railArtifact.id)} download={railArtifact.name}><Icon name="download" size={14} /></a>
<button className="icon-btn" title="关闭预览" onClick={() => store.railPreview(null)}><Icon name="x" size={14} /></button>
</div>
<FilePreview compact source={{
kind: 'artifact',
id: railArtifact.id,
name: railArtifact.name,
mime: railArtifact.mime,
contentUrl: api.artifactContentUrl(railArtifact.id),
downloadUrl: api.artifactDownloadUrl(railArtifact.id),
}} />
</>
) : <div className="empty-note" style={{ padding: 14 }}>点消息里的工件卡片或右侧工件面板——文件会在这里边聊边看(md/图片/pdf/Office/代码均可)</div>}
</div>
) : (
<div className="tab-body">
<CurrentTaskCard plan={plan} todos={todos} />
<div className="inbox-scope" role="tablist" aria-label="收件箱范围">
<button className={inboxScope === 'conv' ? 'active' : ''} onClick={() => store.setInboxScope('conv')}>本会话</button>
<button className={inboxScope === 'project' ? 'active' : ''} onClick={() => store.setInboxScope('project')}>
项目{pendingCount(projectCards) > 0 ? ` ${pendingCount(projectCards)}` : ''}
</button>
</div>
{inboxScope === 'project'
? <ProjectInbox cards={projectCards} />
: <InboxList cards={cards} docBridge={docBridge} callbacks={inboxCallbacks} />}
</div>
)}
</>
)
}
@@ -88,7 +158,7 @@ export function AgentRow({ run, depth }: { run: Run; depth: number }) {
<div className="agent-row" style={{ paddingLeft: 8 + depth * 18 }} onClick={() => store.openTracker(run.id)}>
<span className={`status-dot status-${run.status}`} />
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{run.status}</span>
<span style={{ fontSize: 'var(--fs-ui-xs)', color: 'var(--text-faint)' }}>{run.status}</span>
</div>
)
}
+21 -25
View File
@@ -1,22 +1,26 @@
// 工作区弹层(提案 T4/D25):工件/代理/插件面板/插件声明工作区统一经 OverlayHost 呈现;
// ⌘1–9 快捷键与左栏快捷区共享同一清单(workspaceEntries)。
// 工作区弹层(提案 T4/D25):工件/代理 经 OverlayHost 呈现。
// 重设计方案 阶段②(IA 对话优先):插件面板/服务端声明工作区属系统能力,
// 只进「系统管理」分区(AdminZone),不占普通用户的 ⌘1–9;清单按角色收窄。
import { useState } from 'react'
import { store, useStore } from '../store'
import type { Run, Artifact } from '../contracts'
import type { Run } from '../contracts'
import { api } from '../api'
import { OverlayHost, Icon, type DocBridge } from '@ui'
import { docBridge } from './Workbench'
import { AgentRow } from './Workbench'
import { artifactIcon, humanSize } from '../messageparts'
/** 工作区清单(顺序即 ⌘ 序号):内置在前,插件面板/声明工作区随后。 */
/** 工作区清单(顺序即 ⌘ 序号):内置在前,插件面板/声明工作区仅管理员可见。 */
export function workspaceEntries(): { key: string; title: string; icon: string; shortcut: string }[] {
const s = store.getState()
const admin = !!s.me?.isAdmin
const out = [
{ key: 'artifacts', title: '工件', icon: '📦', shortcut: '⌘1' },
{ key: 'agents', title: '代理', icon: '🤖', shortcut: '⌘2' },
...s.pluginPanels.map((p, i) => ({ key: `plugin:${p.id}`, title: p.title, icon: '🧩', shortcut: `⌘${i + 3}` })),
...s.serverWorkspaces.map((w, i) => ({ key: `ws:${w.id}`, title: w.title, icon: w.icon || '🗂', shortcut: w.shortcut ? `⌘${w.shortcut}` : `⌘${i + 3 + s.pluginPanels.length}` })),
...(admin ? [
...s.pluginPanels.map((p, i) => ({ key: `plugin:${p.id}`, title: p.title, icon: '🧩', shortcut: `⌘${i + 3}` })),
...s.serverWorkspaces.map((w, i) => ({ key: `ws:${w.id}`, title: w.title, icon: w.icon || '🗂', shortcut: w.shortcut ? `⌘${w.shortcut}` : `⌘${i + 3 + s.pluginPanels.length}` })),
] : []),
]
return out.slice(0, 9)
}
@@ -55,26 +59,18 @@ export function Workspaces() {
return null
}
/** 工件面板(工作区文件管理):搜索 + kind 过滤 + 上传;行内预览/下载/删除(带确认)。 */
/** 工件面板(工作区文件管理):搜索 + kind 过滤 + 上传;行内 深预览/右栏预览/下载。
* 删除是保留能力:DELETE /api/artifacts/{id} 与 api.deleteArtifact 均在(插件可用),
* 系统默认不提供删除入口(提案 2026-09-30-file-preview-and-reserved-capabilities §2.2)。 */
export function ArtifactsPanel() {
const artifacts = useStore(s => s.artifacts)
const [q, setQ] = useState('')
const [kind, setKind] = useState<'all' | 'document' | 'data' | 'image'>('all')
const [confirmDel, setConfirmDel] = useState<string | null>(null)
const kinds = ['all', 'document', 'data', 'image'] as const
const visible = artifacts.filter(a =>
(kind === 'all' || a.kind === kind) && a.name.toLowerCase().includes(q.toLowerCase()))
const del = async (a: Artifact) => {
try {
await api.deleteArtifact(a.id)
if (store.getState().ui.viewingArtifact?.id === a.id) store.viewArtifact(null)
store.toast(`已删除 ${a.name}`)
} catch (e) { store.toast(`删除失败:${e}`) }
if (store.getState().currentProjectId) store.refreshArtifacts(store.getState().currentProjectId!)
}
return (
<div className="artifacts-panel">
<div className="ap-toolbar">
@@ -93,23 +89,23 @@ export function ArtifactsPanel() {
</label>
</div>
{artifacts.length === 0
? <div className="empty-note">暂无文件——上传附件或让模型产出文档(mock:#doc);这里可浏览、预览、下载与删除</div>
? <div className="empty-note">暂无文件——上传附件或让模型产出文档(mock:#doc);这里可浏览、预览、下载</div>
: visible.length === 0
? <div className="empty-note">没有匹配「{q}」的文件</div>
: visible.map(a => (
<div key={a.id} className="artifact-row" data-artifact-row={a.id}>
<span className="ar-icon" onClick={() => store.viewArtifact(a)}><Icon name={artifactIcon(a.name, a.kind)} size={15} /></span>
<span className="ar-main" onClick={() => store.viewArtifact(a)} title={a.name}>
// 预览落位在右栏——弹层必须先收起,否则预览换到被盖住的右栏,
// 用户看到的是「点了没反应」(工件面板是全屏 OverlayHost)。
<div key={a.id} className="artifact-row" data-artifact-row={a.id}
onClick={() => { store.closeWorkspace(); store.requestPreview(a) }} title={`预览 ${a.name}(右栏,边聊边看)`}>
<span className="ar-icon"><Icon name={artifactIcon(a.name, a.kind)} size={15} /></span>
<span className="ar-main">
<span className="name">{a.name}</span>
<span className="meta">{a.kind} · {humanSize(a.size) || '—'} · {shortTime(a.updatedAt)}</span>
</span>
<span className="ar-acts">
<button className="icon-btn" title="预览" onClick={() => store.viewArtifact(a)}><Icon name="eye" size={14} /></button>
<button className="icon-btn" title="全屏预览" onClick={e => { e.stopPropagation(); store.closeWorkspace(); store.requestPreview(a, 'full') }}><Icon name="eye" size={14} /></button>
<a className="icon-btn" title="下载" href={api.artifactDownloadUrl(a.id)} download={a.name}
onClick={e => e.stopPropagation()}><Icon name="download" size={14} /></a>
{confirmDel === a.id
? <button className="icon-btn danger" title="确认删除" onClick={() => { setConfirmDel(null); del(a) }}><Icon name="check" size={14} /></button>
: <button className="icon-btn" title="删除" onClick={() => { setConfirmDel(a.id); setTimeout(() => setConfirmDel(c => c === a.id ? null : c), 2500) }}><Icon name="trash" size={14} /></button>}
</span>
</div>
))}
+233
View File
@@ -0,0 +1,233 @@
// 系统管理分区面板(重设计方案 阶段②):普通用户界面不再暴露的系统能力
// 全部收进这里,由 AdminZone 以分区注册表呈现。数据入口与原弹窗完全一致
//(api.* 不变),只换呈现位置;插件面板/声明工作区经 UIRenderer 内联渲染。
import { useEffect, useState } from 'react'
import { api } from '../api'
import { store, useStore } from '../store'
import type { AgentProfile } from '../contracts'
import { Btn, EmptyState, Icon, SettingRow, SettingsGroup, UIRenderer, type DocBridge } from '@ui'
import { docBridge } from './Workbench'
import { randomPassword } from '../passwords'
// ---- 用户管理(原 AdminUsersModal 主体,R2 管理员发放制)----
interface AdminUserRow { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string }
export function UsersPanel() {
const me = useStore(s => s.me)
const [users, setUsers] = useState<AdminUserRow[] | null>(null)
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [owner, setOwner] = useState('')
const [name, setName] = useState('')
const [pw, setPw] = useState('')
const [asAdmin, setAsAdmin] = useState(false)
const [busy, setBusy] = useState(false)
const [resetFor, setResetFor] = useState<string | null>(null)
const [resetPw, setResetPw] = useState('')
const refresh = async () => {
try { setUsers(await api.listAdminUsers()) } catch (e) { setErr(String(e)) }
}
useEffect(() => { refresh() }, [])
const create = async () => {
setErr(''); setNotice('')
if (pw.length < 8) { setErr('初始密码至少 8 位'); return }
setBusy(true)
try {
await api.createAdminUser(owner.trim(), name.trim(), pw, asAdmin)
setNotice(`已创建 ${owner.trim()}:首次登录须改密`)
setOwner(''); setName(''); setPw('')
await refresh()
} catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) }
}
const reset = async () => {
if (!resetFor) return
setErr('')
if (resetPw.length < 8) { setErr('新密码至少 8 位'); return }
setBusy(true)
try {
await api.resetAdminUserPassword(resetFor, resetPw)
setNotice(`已重置 ${resetFor} 的密码:其下次登录须改密(现有会话已吊销)`)
setResetFor(null); setResetPw('')
await refresh()
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
}
return (
<div>
{me?.isAdmin && (
<div className="ad-create">
<div className="ad-row">
<input className="lg-input" placeholder="用户名(登录名)" value={owner} onChange={e => setOwner(e.target.value)} />
<input className="lg-input" placeholder="显示名(可空)" value={name} onChange={e => setName(e.target.value)} />
</div>
<div className="ad-row">
<input className="lg-input lg-mono" placeholder="初始密码(≥8 位)" value={pw} onChange={e => setPw(e.target.value)} />
<button type="button" className="ad-mini" title="生成随机密码" onClick={() => setPw(randomPassword())}>随机</button>
<label className="ad-check"><input type="checkbox" checked={asAdmin} onChange={e => setAsAdmin(e.target.checked)} />管理员</label>
<button className="lg-submit ad-create-btn" disabled={busy || !owner.trim() || !pw} onClick={create}>创建账号</button>
</div>
<p className="ad-hint">创建后该用户首次登录必须修改此初始密码。</p>
</div>
)}
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
{resetFor ? (
<div className="ad-reset">
<span>重置 <b>{resetFor}</b> 的密码:</span>
<input className="lg-input lg-mono" placeholder="新密码(≥8 位)" value={resetPw} onChange={e => setResetPw(e.target.value)} />
<button type="button" className="ad-mini" title="生成随机密码" onClick={() => setResetPw(randomPassword())}>随机</button>
<button className="lg-submit ad-create-btn" disabled={busy || !resetPw} onClick={reset}>确认重置</button>
<button type="button" className="ad-mini" onClick={() => { setResetFor(null); setResetPw('') }}>取消</button>
</div>
) : (
<table className="ad-table">
<thead><tr><th>用户名</th><th>显示名</th><th>角色</th><th>状态</th><th>密码设置于</th><th></th></tr></thead>
<tbody>
{(users ?? []).map(u => (
<tr key={u.owner}>
<td className="ad-user-cell"><span className="ad-mono">{u.owner}</span></td>
<td>{u.name || '—'}</td>
<td>{u.isAdmin ? <span className="ad-chip ad-chip-admin">ADMIN</span> : <span className="ad-chip">USER</span>}</td>
<td>{u.mustChangePassword ? <span className="ad-chip ad-chip-warn">待改密</span> : u.hasPassword ? '正常' : '未设密码'}</td>
<td className="ad-dim">{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}</td>
<td>{me?.isAdmin && (
<button className="ad-mini" onClick={() => { setResetFor(u.owner); setResetPw('') }}>重置密码</button>
)}</td>
</tr>
))}
{users !== null && users.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无账号</td></tr>}
</tbody>
</table>
)}
</div>
)
}
// ---- 智能体画像(原 SettingsModal ProfilesSection:数据化导入导出 + 编辑)----
export function ProfilesSection({ profiles }: { profiles: AgentProfile[] }) {
const [editing, setEditing] = useState<AgentProfile | null>(null)
const [text, setText] = useState('')
return (
<div>
<div className="doc-toolbar">
<Btn variant="primary" icon="plus" onClick={() => { setEditing(newProfile()); setText('') }}>新建画像</Btn>
<label className="btn ghost" style={{ cursor: 'pointer' }}>
导入 JSON<input type="file" accept=".json" style={{ display: 'none' }} onChange={async e => {
const f = e.target.files?.[0]
if (!f) return
try {
const p = JSON.parse(await f.text())
p.id = p.id || 'prof_imported'
p.builtin = false
await api.saveProfile(p)
store.refreshProfiles()
store.toast('画像已导入')
} catch (err) { store.toast(`导入失败:${err}`) }
}} />
</label>
</div>
{profiles.map(p => (
<SettingRow key={p.id}
label={<>{p.name} <span style={{ color: 'var(--text-faint)', fontSize: 'var(--fs-ui-xs)' }}>{p.id}{p.builtin ? ' · 内置' : ''}</span></>}
desc={p.description}>
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
<Btn variant="ghost" icon="download" onClick={() => {
const blob = new Blob([JSON.stringify(p, null, 2)], { type: 'application/json' })
const a = document.createElement('a')
a.href = URL.createObjectURL(blob); a.download = `${p.id}.json`; a.click()
}}>导出</Btn>
{!p.builtin && <Btn variant="ghost" onClick={() => { setEditing(p); setText(p.systemPrompt) }}>编辑</Btn>}
{!p.builtin && <Btn variant="ghost" icon="trash" onClick={async () => { await api.deleteProfile(p.id); store.refreshProfiles() }}>删除</Btn>}
</div>
</SettingRow>
))}
{editing && (
<SettingsGroup title={`编辑画像:${editing.name}`}>
<div className="ui-form">
<label>名称<input value={editing.name} onChange={e => setEditing({ ...editing, name: e.target.value })} /></label>
<label>系统提示<textarea className="search-input" rows={4} style={{ height: 'auto' }} value={text} onChange={e => setText(e.target.value)} /></label>
<label>允许工具(逗号分隔,* 全部)<input value={editing.tools.allow.join(',')} onChange={e => setEditing({ ...editing, tools: { ...editing.tools, allow: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })} /></label>
<label>执行前确认(前缀. 结尾表示族)<input value={editing.tools.confirm?.join(',') ?? ''} onChange={e => setEditing({ ...editing, tools: { ...editing.tools, confirm: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })} /></label>
<label>拒绝工具<input value={editing.tools.deny?.join(',') ?? ''} onChange={e => setEditing({ ...editing, tools: { ...editing.tools, deny: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })} /></label>
</div>
<Btn variant="primary" icon="check" onClick={async () => {
try {
await api.saveProfile({ ...editing, builtin: false, source: 'user', systemPrompt: text })
await store.refreshProfiles()
setEditing(null)
store.toast('画像已保存')
} catch (e) { store.toast(`保存失败:${e}`) }
}}>保存画像</Btn>
</SettingsGroup>
)}
</div>
)
}
function newProfile(): AgentProfile {
return {
id: '', name: '新画像', systemPrompt: '', model: {}, tools: { allow: ['*'] },
context: { maxHistoryMessages: 40 }, hitl: { askOnAmbiguity: true }, budget: {},
} as AgentProfile
}
// ---- 插件管理(原 SettingsModal PluginsSection:清单 + 热重载)----
export function PluginsSection() {
const [plugins, setPlugins] = useState<any[]>([])
const refresh = () => api.listPlugins().then(setPlugins).catch(() => undefined)
useEffect(() => { refresh() }, [])
return (
<div>
<div className="doc-toolbar">
<Btn variant="ghost" icon="undo" onClick={async () => {
await api.reloadPlugins()
await refresh()
store.refreshPlugins()
store.toast('插件已热重载')
}}>热重载</Btn>
<span style={{ color: 'var(--text-faint)', fontSize: 'var(--fs-ui-sm)' }}>声明资产(工作流/意图/提示词/面板)热加载;装卸不影响核心</span>
</div>
{plugins.length === 0 && <EmptyState icon="puzzle">暂无已装载插件<br />(插件目录在 设置 → 常规 → 插件目录;示例见 Dev 仓 plugins/example)</EmptyState>}
{plugins.map(p => (
<SettingRow key={p.manifest.id}
label={`${p.manifest.name} v${p.manifest.version}`}
desc={[`工具 ${p.manifest.provides.tools?.length ?? 0}`, `面板 ${p.panels?.length ?? 0}`, `意图 ${p.intents?.length ?? 0}`, `工作流 ${p.workflows?.length ?? 0}`].join(' · ')}>
<span className="effect-badge">api {p.manifest.apiVersion}</span>
</SettingRow>
))}
</div>
)
}
// ---- 插件工作区(插件声明面板 + 服务端声明工作区;声明体经 UIRenderer 内联渲染)----
export function PluginWorkspacesPanel() {
const pluginPanels = useStore(s => s.pluginPanels)
const serverWorkspaces = useStore(s => s.serverWorkspaces)
const [selected, setSelected] = useState<string | null>(null)
const entries = [
...pluginPanels.map(p => ({ key: `plugin:${p.id}`, title: p.title, icon: '🧩', body: p.body ?? [] })),
...serverWorkspaces.map(w => ({ key: `ws:${w.id}`, title: w.title, icon: w.icon || '🗂', body: (w.body ?? []) as any[] })),
]
const current = entries.find(e => e.key === selected) ?? entries[0]
if (entries.length === 0) {
return <EmptyState icon="grid">暂无插件声明工作区<br />(插件可在 manifest 里声明 panels / workspaces)</EmptyState>
}
return (
<div>
<div className="doc-toolbar" style={{ flexWrap: 'wrap' }}>
{entries.map(e => (
<button key={e.key} className={`kind-pill ${current?.key === e.key ? 'on' : ''}`} onClick={() => setSelected(e.key)}>
{e.icon} {e.title}
</button>
))}
<span style={{ flex: 1 }} />
<Btn variant="ghost" icon="undo" onClick={() => store.refreshPlugins()}>刷新</Btn>
</div>
{current && <UIRenderer nodes={current.body} docBridge={docBridge as DocBridge} />}
</div>
)
}
+1 -1
View File
@@ -33,7 +33,7 @@ export interface Part {
artifact?: { artifactId: ID; name: string; kind?: string };
hitl?: { cardId: ID; question: string; resolved: boolean; answer?: string };
plan?: PlanSnapshot;
card?: { kind: string; data: UINode[] };
card?: { kind: string; data: UINode[]; cardId?: ID }; // cardId 非空 = 收件箱卡片引用部件(submit_card)
error?: { code: string; message: string };
process?: boolean; // run 终态后折叠为摘要行
}
+7 -2
View File
@@ -42,7 +42,8 @@ export function applyToolCall(items: LiveItem[], name: string, callId?: string):
return [...items, { kind: 'tool', callId, name }]
}
// applyArtifactCreated 追加工件引用(artifact.created 事件;与落库一致标记为过程)。
// applyArtifactCreated 追加工件引用(artifact.created 事件)。投影时作为
// 交付物钉在消息尾部(内容部件,不进过程折叠)——与落库序同构。
export function applyArtifactCreated(items: LiveItem[], artifact: { artifactId: string; name: string; kind?: string }): LiveItem[] {
return [...items, { kind: 'artifact', artifact }]
}
@@ -73,6 +74,7 @@ export function liveToMessage(live: LiveState): Message {
textProcess.set(i, hasCalls)
}
const parts: Part[] = []
const deliverables: Part[] = []
for (let i = 0; i < live.items.length; i++) {
const it = live.items[i]
if (it.kind === 'thinking') {
@@ -84,11 +86,14 @@ export function liveToMessage(live: LiveState): Message {
toolResult: { callId: it.callId ?? '', status: it.status === 'error' ? 'error' : 'ok', summary: it.summary } })
}
} else if (it.kind === 'artifact') {
parts.push({ type: 'artifact_ref', process: true, artifact: it.artifact })
// 交付物卡片:内容部件,投影钉尾(落库侧 llm.go 同序——最终正文之后),
// 流式期间即可在消息底部看到"文件已生成"卡片。
deliverables.push({ type: 'artifact_ref', artifact: it.artifact })
} else {
parts.push({ type: 'text', text: it.text, process: textProcess.get(i) || undefined })
}
}
parts.push(...deliverables)
return { id: `live:${live.runId}`, conversationId: '', role: 'assistant', runId: live.runId, parts, createdAt: '' }
}
+5
View File
@@ -1,10 +1,15 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { App } from './App'
import { wirePreviewCapabilities } from './previewsetup'
import '@ui/tokens.css'
import '@ui/motion.css'
import '@ui/ui.css'
import './styles.css'
// 预览能力装配(D1–D4):Office 解析器与链接预览 fetcher 注入 @ui(动态加载,不进主包)。
wirePreviewCapabilities()
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
+85
View File
@@ -0,0 +1,85 @@
// 预览能力装配(裁定 D1–D4):把重解析器经 configureFilePreviewLoaders 注入 @ui。
// 全部 dynamic import——对应格式首次预览时才下载 chunk,主 bundle 不含解析器。
// 链接预览 fetcher 走服务端 /api/url-preview(SSRF 防护在服务端执行端)。
import { configureFilePreviewLoaders, configureLinkPreviewFetcher } from '@ui'
import { auth } from './auth'
import type { LinkPreviewData } from '@ui'
const fetchBuf = async (url: string | undefined): Promise<ArrayBuffer> => {
const r = await fetch(url!)
if (!r.ok) throw new Error(`HTTP ${r.status}`)
return r.arrayBuffer()
}
const docxLoader = async (el: HTMLElement, source: { contentUrl?: string }) => {
const mammoth = await import('mammoth')
const { value } = await mammoth.convertToHtml({ arrayBuffer: await fetchBuf(source.contentUrl) })
el.innerHTML = value
}
const xlsxLoader = async (el: HTMLElement, source: { contentUrl?: string }) => {
const XLSX = await import('xlsx')
const wb = XLSX.read(await fetchBuf(source.contentUrl), { type: 'array' })
el.innerHTML = ''
const tabs = document.createElement('div')
tabs.className = 'ui-fp-sheet-tabs'
const wrap = document.createElement('div')
wrap.className = 'ui-fp-sheet-wrap'
el.append(tabs, wrap)
wb.SheetNames.forEach((name, i) => {
const tab = document.createElement('button')
tab.className = `ui-fp-sheet-tab ${i === 0 ? 'on' : ''}`
tab.textContent = name
tab.onclick = () => {
tabs.querySelectorAll('.ui-fp-sheet-tab').forEach(t => t.classList.remove('on'))
tab.classList.add('on')
renderSheet(wrap, XLSX.utils.sheet_to_json<unknown[]>(wb.Sheets[name], { header: 1, defval: '' }))
}
tabs.append(tab)
if (i === 0) renderSheet(wrap, XLSX.utils.sheet_to_json<unknown[]>(wb.Sheets[name], { header: 1, defval: '' }))
})
}
function renderSheet(wrap: HTMLElement, rows: unknown[][]) {
wrap.innerHTML = ''
if (rows.length > 200) {
const note = document.createElement('div')
note.className = 'ui-fp-note'
note.textContent = `仅显示前 200 行(共 ${rows.length} 行)——完整内容请下载`
wrap.append(note)
}
const table = document.createElement('table')
table.className = 'ui-fp-sheet'
for (const row of rows.slice(0, 200)) {
const tr = document.createElement('tr')
for (const cell of row) {
const td = document.createElement('td')
td.textContent = cell == null ? '' : String(cell)
td.title = td.textContent
tr.append(td)
}
table.append(tr)
}
wrap.append(table)
}
const pptxLoader = async (el: HTMLElement, source: { contentUrl?: string }) => {
// pptx2html@0.3.4 的 module 入口引用了大小写错配的 ./txml(Linux 打包必炸),
// 按 D2 备选条款改用 pptx-preview(init(dom).preview(arrayBuffer) 全保真渲染)。
const { init } = await import('pptx-preview')
el.innerHTML = ''
const stage = document.createElement('div')
stage.className = 'ui-fp-slide-stage'
el.append(stage)
const previewer = init(stage, { width: stage.clientWidth || 900 })
await previewer.preview(await fetchBuf(source.contentUrl))
}
export function wirePreviewCapabilities() {
configureFilePreviewLoaders({ docx: docxLoader, xlsx: xlsxLoader, pptx: pptxLoader })
configureLinkPreviewFetcher(async href => {
const r = await fetch(auth.withKey(`/api/url-preview?url=${encodeURIComponent(href)}`))
if (!r.ok) return { url: href, error: `HTTP ${r.status}` }
return (await r.json()) as LinkPreviewData
})
}
+103 -4
View File
@@ -35,11 +35,14 @@ export interface UIState {
settingsOpen: boolean
trackerRunId: string | null
viewingArtifact: Artifact | null
/** 右栏轻预览的工件(rail 预览页签内容;与弹窗深预览 viewingArtifact 并存)。 */
railPreviewId: string | null
activeWorkspace: string | null
taskOrg: 'project' | 'timeline'
showArchived: boolean
lastProjectId: string | null
adminUsersOpen: boolean
/** 系统管理全屏视图(阶段② IA:仅管理员;入口账户菜单,纵深防御在 AdminZone 再拦一次)。 */
adminZoneOpen: boolean
changePwOpen: boolean
projectSettingsId: string | null
}
@@ -62,6 +65,8 @@ interface AppState {
todos: TodoList | null
artifacts: Artifact[]
cards: ActivityCard[]
projectCards: ActivityCard[]
inboxScope: 'conv' | 'project'
profiles: AgentProfile[]
pluginPanels: PluginPanel[]
serverWorkspaces: { id: string; title: string; icon?: string; shortcut?: string; body?: any[] }[]
@@ -83,10 +88,11 @@ const DEFAULT_UI: UIState = {
settingsOpen: false,
trackerRunId: null,
viewingArtifact: null,
railPreviewId: null,
taskOrg: 'project',
showArchived: false,
lastProjectId: null,
adminUsersOpen: false,
adminZoneOpen: false,
changePwOpen: false,
projectSettingsId: null,
}
@@ -103,7 +109,7 @@ let state: AppState = {
contextObs: {},
projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null,
messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
plan: null, todos: null, artifacts: [], cards: [], profiles: [], pluginPanels: [],
plan: null, todos: null, artifacts: [], cards: [], projectCards: [], inboxScope: 'conv', profiles: [], pluginPanels: [],
serverWorkspaces: [],
ui: loadUI(), connected: false, error: null, toast: null,
}
@@ -128,6 +134,8 @@ function applyTheme(theme: string) {
}
let sse: { close: () => void } | null = null
let projectSse: { close: () => void } | null = null
let projectSeq = 0
export function useStore<T>(selector: (s: AppState) => T): T {
return useSyncExternalStore(
@@ -170,6 +178,10 @@ export const store = {
async openProject(projectId: string) {
set({ currentProjectId: projectId })
if (store.getState().inboxScope === 'project') {
store.loadProjectCards()
store.connectProject()
}
store.setUI({ lastProjectId: projectId })
await store.refreshArtifacts(projectId)
},
@@ -221,7 +233,7 @@ export const store = {
async openConversation(convId: string) {
if (sse) { sse.close(); sse = null }
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [] })
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], inboxScope: 'conv' })
await store.reconcile()
await store.connectLive()
},
@@ -489,6 +501,72 @@ export const store = {
toggleRight() { setUI({ rightCollapsed: !state.ui.rightCollapsed }) },
openWorkspace(id: string) { setUI({ activeWorkspace: id }) },
/** 收纳策略(T5):一键收纳已完成卡(done→dismissed;resolved 盖章留痕不动)。 */
// 项目收件箱(CF3/D48-D51):加载聚合视图 + 项目流订阅 + 留档。
async loadProjectCards() {
const pid = store.getState().currentProjectId
if (!pid) return
try { set({ projectCards: await api.listProjectCards(pid) }) } catch { /* 静默:切项目竞态 */ }
},
setInboxScope(scope: 'conv' | 'project') {
set({ inboxScope: scope })
if (scope === 'project') {
store.loadProjectCards()
store.connectProject()
} else if (projectSse) {
projectSse.close()
projectSse = null
}
},
connectProject() {
const pid = store.getState().currentProjectId
if (!pid) return
projectSse?.close()
projectSse = connectEvents('', projectSeq, { onEvent: e => store.applyProjectEvent(e) }, pid)
},
applyProjectEvent(e: StreamEvent) {
projectSeq = Math.max(projectSeq, e.seq)
const upsert = (card: ActivityCard) => {
const list = store.getState().projectCards
const i = list.findIndex(c => c.id === card.id)
set({ projectCards: i >= 0 ? list.map(c => (c.id === card.id ? card : c)) : [card, ...list] })
}
switch (e.type) {
case 'card.created':
case 'card.updated':
if ((e.payload as { card?: ActivityCard })?.card) upsert((e.payload as { card: ActivityCard }).card)
break
case 'card.status': {
const p = e.payload as { cardId: string; status: ActivityCard['status']; tone?: string }
const list = store.getState().projectCards
set({ projectCards: list.map(c => (c.id === p.cardId ? { ...c, status: p.status, tone: p.tone ?? c.tone } : c)) })
break
}
case 'card.resolved': {
const p = e.payload as { cardId: string }
const list = store.getState().projectCards
set({ projectCards: list.map(c => (c.id === p.cardId ? { ...c, status: 'resolved' as const } : c)) })
break
}
}
},
async cardToProject(card: ActivityCard) {
try {
const out = await api.cardToProject(card.id)
store.upsertCard(out.origin) // 会话视图:原卡 dismissed 留痕
const list = store.getState().projectCards
const i = list.findIndex(c => c.id === out.card.id)
set({ projectCards: i >= 0 ? list.map(c => (c.id === out.card.id ? out.card : c)) : [out.card, ...list] })
store.toast('已留档到项目收件箱')
store.setInboxScope('project')
} catch (e) {
store.toast(`留档失败:${e}`)
}
},
async tidyCards() {
const done = state.cards.filter(c => c.status === 'done')
for (const c of done) {
@@ -503,8 +581,29 @@ export const store = {
document.querySelector(`[data-card-id="${cardId}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, 60)
},
// 卡→消息互定位另一半(D32):从收件箱卡滚回产生它的对话消息。
locateMessage(messageId?: string) {
if (!messageId) return
setTimeout(() => {
document.querySelector(`[data-msg-id="${messageId}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, 60)
},
openTracker(runId: string) { setUI({ trackerRunId: runId }) },
viewArtifact(a: Artifact | null) { setUI({ viewingArtifact: a }) },
/** 右栏轻预览:打开指定工件并切到 rail 预览页签(双落位中的"轻"侧)。 */
railPreview(a: Artifact | null) {
if (!a) { setUI({ railPreviewId: null, rightTab: 'inbox' }); return }
setUI({ railPreviewId: a.id, rightTab: 'preview', rightCollapsed: false })
},
/**
* 工件预览唯一入口(重设计方案 阶段③ 收敛,Command 语义):一切预览请求只经这里,
* 落位由本方法派生——默认右栏轻预览(不打断对话),hint 'full' 落位全屏深读
* (DocViewer,全项目唯一"大窗"形态)。下载语义同理由各落位只保留一个按钮。
*/
requestPreview(a: Artifact, hint?: 'full') {
if (hint === 'full') { setUI({ viewingArtifact: a }); return }
setUI({ railPreviewId: a.id, rightTab: 'preview', rightCollapsed: false })
},
openSettings(open: boolean) { setUI({ settingsOpen: open }) },
openProjectSettings(id: string | null) { setUI({ projectSettingsId: id }) },
toast(msg: string) {
+196 -144
View File
@@ -1,43 +1,44 @@
/* 设计令牌与 UI 组件样式已迁入 Agent-System-UI(@ui/tokens.css、@ui/ui.css,main.tsx 引入)。
R4 视觉收敛(2026-09-30):三级表面阶梯 + 发丝边框 + 圆角标尺 + 克制阴影 + 统一线性图标。 */
/* 宿主样式(R5/Zai 复刻,2026-09-30;权威规则见父仓 DESIGN.md):
- 颜色/圆角/字号只消费 @ui/tokens.css 令牌(--fs-ui-* 标尺);
- 动效只消费 @ui/motion.css 的 mk-* 类(本文件不得出现 @keyframes,守护测试拦截);
- 布局面板化:三栏为浮在 --bg-inset 底座上的圆角面板,间隙 4px; */
* { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; }
body {
font-family: var(--font); background: var(--bg); color: var(--text);
font-size: 13.5px; line-height: 1.55;
font-family: var(--font); background: var(--bg-inset); color: var(--text);
font-size: var(--fs-ui-base); line-height: 1.6;
-webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility;
}
button { font-family: inherit; font-size: inherit; color: inherit; }
::selection { background: var(--accent-soft-2); }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-radius: 2px; }
.app { display: flex; height: 100vh; overflow: hidden; }
/* ---- 三栏布局:层次靠背景阶梯(bg-panel 侧栏 / bg 主区),边框只发丝勾边 ---- */
.left-nav { background: var(--bg-panel); border-right: 1px solid var(--border); display: flex; flex-direction: column; min-width: 0; flex-shrink: 0; transition: width .15s var(--ease); }
/* ---- 面板化三栏:独立圆角面板 + 4px 间隙(DESIGN.md §6) ---- */
.app { display: flex; height: 100vh; overflow: hidden; gap: 4px; padding: 4px; background: var(--bg-inset); }
.left-nav { background: var(--bg-panel); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); display: flex; flex-direction: column; min-width: 0; flex-shrink: 0; transition: width .15s var(--ease); }
.left-nav.collapsed { width: 52px !important; }
.divider { width: 4px; cursor: col-resize; background: transparent; flex-shrink: 0; position: relative; }
.divider::before { content: ''; position: absolute; left: 1.5px; top: 0; bottom: 0; width: 1px; background: transparent; transition: background .12s; }
.divider:hover::before, .divider.dragging::before { background: var(--accent); }
.divider::before { content: ''; position: absolute; left: 1px; top: 12px; bottom: 12px; width: 2px; border-radius: 2px; background: transparent; transition: background .12s; }
.divider:hover::before, .divider.dragging::before { background: var(--border-strong); }
.divider::after { content: ''; position: absolute; inset: 0 -3px; } /* 拖拽热区加宽 */
.main { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--bg); }
.right-panel { background: var(--bg-panel); border-left: 1px solid var(--border); display: flex; flex-direction: column; flex-shrink: 0; min-width: 0; }
.main { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--bg); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); }
.right-panel { background: var(--bg-panel); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); display: flex; flex-direction: column; flex-shrink: 0; min-width: 0; }
.right-panel.collapsed { width: 44px !important; }
/* ---- 通用控件 ---- */
.btn {
background: var(--accent); color: var(--text-on-accent); border: none;
border-radius: var(--radius); padding: 8px 16px; cursor: pointer;
font-size: 13px; font-weight: 550; white-space: nowrap;
box-shadow: inset 0 1px 0 rgba(255,255,255,.14), 0 1px 2px rgba(8,9,12,.3);
transition: filter .12s var(--ease), transform .12s var(--ease);
background: var(--primary); color: var(--primary-foreground); border: none;
border-radius: var(--radius-sm); padding: 8px 16px; cursor: pointer;
font-size: var(--fs-ui-caption); font-weight: 550; white-space: nowrap;
box-shadow: 0 1px 2px rgba(0, 0, 0, .25);
transition: background .12s var(--ease), transform .12s var(--ease);
}
.btn:hover:not(:disabled) { filter: brightness(1.09); }
.btn:hover:not(:disabled) { background: color-mix(in srgb, var(--primary) 86%, var(--bg)); }
.btn:active:not(:disabled) { transform: translateY(1px); }
.btn:disabled { opacity: .42; cursor: not-allowed; box-shadow: none; }
.btn.ghost { background: var(--bg-raised); border: 1px solid var(--border-strong); color: var(--text-dim); box-shadow: none; }
.btn.ghost:hover:not(:disabled) { color: var(--text); border-color: var(--text-faint); filter: none; }
.btn.ghost:hover:not(:disabled) { color: var(--text); border-color: var(--text-faint); background: var(--bg-raised); }
.btn.danger { background: var(--err); }
.icon-btn {
background: none; border: none; color: var(--text-faint);
@@ -48,7 +49,7 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.icon-btn:hover { background: var(--bg-hover); color: var(--text); }
.search-input, .cmd-menu input {
background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius);
color: var(--text); padding: 7px 11px; font-family: inherit; font-size: 13px; width: 100%;
color: var(--text); padding: 7px 11px; font-family: inherit; font-size: var(--fs-ui-caption); width: 100%;
transition: border-color .12s, box-shadow .12s;
}
.search-input:focus { border-color: var(--accent); box-shadow: var(--ring); outline: none; }
@@ -57,24 +58,27 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
/* min-height:0 须随列向 flex 链下传(.main→.chat→.chat-body):flex 项的自动最小
高度是内容高度,缺失时消息会撑破 .chat——chat-body 永不滚动、输入栏被顶出视口。 */
.chat { flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0; }
.chat-header { padding: 12px 22px; border-bottom: 1px solid var(--border-soft); display: flex; align-items: center; gap: 10px; background: var(--bg); }
.chat-title { font-weight: 600; font-size: 14.5px; letter-spacing: -0.01em; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chat-header { padding: 12px 22px; border-bottom: 1px solid var(--border-soft); display: flex; align-items: center; gap: 10px; background: var(--bg); border-radius: var(--radius-lg) var(--radius-lg) 0 0; }
.chat-title { font-weight: 600; font-size: var(--fs-ui-base); letter-spacing: -0.01em; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chat-body { flex: 1; overflow-y: auto; min-height: 0; padding: 22px max(22px, 13%); }
.empty-state { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--text-faint); gap: 10px; font-size: 13.5px; }
.empty-state .es-mark { width: 44px; height: 44px; border-radius: 12px; background: var(--bg-raised); border: 1px solid var(--border-soft); display: flex; align-items: center; justify-content: center; color: var(--text-faint); margin-bottom: 4px; }
.empty-state { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--text-faint); gap: 10px; font-size: var(--fs-ui-caption); }
.empty-state .es-mark { width: 44px; height: 44px; border-radius: var(--radius-lg); background: var(--bg-raised); border: 1px solid var(--border-soft); display: flex; align-items: center; justify-content: center; color: var(--text-faint); margin-bottom: 4px; }
.empty-state svg { opacity: .5; }
.msg { margin-bottom: 20px; }
.msg-role { font-size: 12px; color: var(--text-faint); margin-bottom: 6px; display: flex; gap: 8px; align-items: center; }
.msg { margin-bottom: 20px; animation: mk-rise-in .18s var(--ease-out); }
.msg-role { font-size: var(--fs-ui-sm); color: var(--text-faint); margin-bottom: 6px; display: flex; gap: 8px; align-items: center; }
.msg-role .role-mark { width: 18px; height: 18px; border-radius: 5px; display: inline-flex; align-items: center; justify-content: center; color: var(--text-dim); background: var(--bg-raised); border: 1px solid var(--border); }
.msg.user .bubble {
background: var(--accent-soft); border: 1px solid transparent;
border-radius: var(--radius-lg); padding: 11px 16px; white-space: pre-wrap;
}
.part { margin: 4px 0; }
.part-thinking { color: var(--text-dim); font-size: 12.5px; border-left: 2px solid var(--border); padding-left: 10px; margin: 4px 0; }
.part-thinking summary { cursor: pointer; color: var(--text-faint); user-select: none; }
/* 思考块(Collapsible 契约:触发行 + mk-col 动画体) */
.part-thinking { color: var(--text-dim); font-size: var(--fs-ui-sm); border-left: 2px solid var(--border); padding-left: 10px; margin: 4px 0; }
.part-thinking .part-thinking-summary { color: var(--text-faint); user-select: none; }
.part-thinking .part-thinking-chev { display: inline-flex; color: var(--text-faint); font-size: var(--fs-ui-xs); transition: transform .12s; }
.part-thinking .mk-col-trigger[aria-expanded="true"] .part-thinking-chev { transform: rotate(90deg); }
.part-thinking .part-thinking-body { white-space: pre-wrap; margin-top: 4px; }
.part-tool { font-size: 12.5px; }
.part-tool { font-size: var(--fs-ui-sm); }
.tool-chip { display: inline-flex; gap: 6px; align-items: center; background: var(--bg-inset); border: 1px solid var(--border-soft); border-radius: var(--radius-sm); padding: 4px 9px; margin: 2px 4px 2px 0; font-family: var(--mono); }
.tool-chip .status-ok { color: var(--ok); }
.tool-chip .status-error { color: var(--err); }
@@ -91,8 +95,8 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.part-text :is(ul,ol) { margin: 0.4em 0; padding-left: 1.6em; }
.part-text li { margin: 0.15em 0; }
.part-text li > p { margin: 0.2em 0; }
.part-text pre { background: var(--bg-inset); border: 1px solid var(--border-soft); border-radius: var(--radius); padding: 11px 13px; overflow-x: auto; font-family: var(--mono); font-size: 12.5px; }
.part-text code { font-family: var(--mono); background: var(--bg-raised); border-radius: var(--radius-xs); padding: 1px 5px; font-size: 12.5px; }
.part-text pre { background: var(--bg-inset); border: 1px solid var(--border-soft); border-radius: var(--radius); padding: 11px 13px; overflow-x: auto; font-family: var(--mono); font-size: var(--fs-ui-sm); }
.part-text code { font-family: var(--mono); background: var(--bg-raised); border-radius: var(--radius-xs); padding: 1px 5px; font-size: var(--fs-ui-sm); }
.part-text pre code { background: none; padding: 0; }
.part-text blockquote { margin: 0.5em 0; padding: 0.1em 0 0.1em 10px; border-left: 3px solid var(--border-strong); color: var(--text-dim); }
.part-text table { border-collapse: collapse; margin: 0.6em 0; font-size: 0.95em; display: block; max-width: 100%; overflow-x: auto; }
@@ -101,27 +105,47 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.part-text hr { border: none; border-top: 1px solid var(--border); margin: 0.8em 0; }
.part-text a { color: var(--accent); }
.part-error { color: var(--err); background: var(--err-soft); border: 1px solid var(--err); border-radius: var(--radius-sm); padding: 8px 12px; }
.artifact-chip { display: inline-flex; align-items: stretch; background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; font-size: 13px; margin: 2px 0; }
.artifact-chip { display: inline-flex; align-items: stretch; background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; font-size: var(--fs-ui-caption); margin: 2px 0; }
.artifact-chip .ac-main { display: inline-flex; align-items: center; gap: 7px; color: var(--accent); background: none; border: none; padding: 5px 10px; cursor: pointer; font: inherit; max-width: 340px; }
.artifact-chip .ac-main span, .artifact-chip .ac-main { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.artifact-chip:hover { border-color: var(--accent); }
.artifact-chip .ac-act { display: inline-flex; align-items: center; padding: 5px 8px; color: var(--text-dim); border-left: 1px solid var(--border-soft); background: none; cursor: pointer; }
.artifact-chip .ac-act:hover { color: var(--accent); background: var(--bg-hover); }
/* 交付物卡片(对话内直接给出;整卡点击 = 右栏预览,动作只剩下载) */
.deliverable-card { display: flex; align-items: center; gap: 12px; width: fit-content; max-width: 100%; padding: 10px 12px 10px 10px; margin: 6px 0 2px;
background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-lg); cursor: pointer; user-select: none;
transition: border-color .12s, background .12s; }
.deliverable-card:hover { border-color: var(--accent); background: var(--bg-hover); }
.deliverable-card .dc-icon { display: inline-flex; align-items: center; justify-content: center; width: 38px; height: 38px; border-radius: var(--radius); flex: none;
background: var(--bg-inset); color: var(--accent); border: 1px solid var(--border-soft); }
.deliverable-card .dc-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.deliverable-card .dc-name { color: var(--text); font-weight: 600; font-size: var(--fs-ui-base); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 380px; }
.deliverable-card .dc-meta { color: var(--text-faint); font-size: var(--fs-ui-sm); white-space: nowrap; }
.deliverable-card .dc-act { flex: none; margin-left: auto; padding: 5px 12px; border-radius: var(--radius-sm); color: var(--accent);
font-size: var(--fs-ui-caption); border: 1px solid var(--border); background: var(--bg-panel); }
.deliverable-card:hover .dc-act { border-color: var(--accent); }
.deliverable-card .dc-dl { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: var(--radius-sm);
color: var(--text-dim); border: 1px solid var(--border-soft); }
.deliverable-card .dc-dl:hover { color: var(--accent); border-color: var(--accent); background: var(--bg-hover); }
/* 过程段内联组(流式/关闭自动折叠时,按原位渲染的过程容器) */
.part-group { margin: 2px 0; }
/* 过程折叠(run 终态后) */
/* 过程折叠(run 终态后;Collapsible 契约:触发行 + mk-col 动画体) */
.process-collapse { border: 1px solid var(--border-soft); border-radius: var(--radius); background: var(--bg-panel); margin: 6px 0; }
.process-collapse summary { list-style: none; cursor: pointer; padding: 7px 12px; color: var(--text-faint); font-size: 12.5px; display: flex; align-items: center; gap: 8px; user-select: none; }
.process-collapse summary::before { content: '▸'; transition: transform .12s; font-size: 10px; }
.process-collapse[open] summary::before { transform: rotate(90deg); }
.process-collapse .process-collapse-summary { padding: 7px 12px; color: var(--text-faint); font-size: var(--fs-ui-sm); user-select: none; }
.process-collapse .process-collapse-summary:hover { color: var(--text-dim); }
.process-collapse .process-collapse-chev { display: inline-flex; color: var(--text-faint); font-size: var(--fs-ui-xs); transition: transform .12s; }
.process-collapse .mk-col-trigger[aria-expanded="true"] .process-collapse-chev { transform: rotate(90deg); }
.process-collapse .process-collapse-meta { color: var(--text-faint); }
.process-collapse .process-collapse-excerpt { color: var(--text-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 40%; }
.process-collapse .body { padding: 4px 12px 10px; }
/* 流式状态行 */
.live-bar { display: flex; align-items: center; gap: 8px; color: var(--text-faint); font-size: 12.5px; margin-bottom: 6px; }
.live-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); animation: pulse 1.2s infinite; }
@keyframes pulse { 0%,100% { opacity: .4 } 50% { opacity: 1 } }
.cursor-blink::after { content: '▍'; animation: pulse 1s infinite; color: var(--accent); }
/* 流式状态行(呼吸/扫光走 mk-* 类;已工作计时弱色等宽) */
.live-bar { display: flex; align-items: center; gap: 8px; color: var(--text-faint); font-size: var(--fs-ui-sm); margin-bottom: 6px; }
.live-elapsed { color: var(--text-faint); font-variant-numeric: tabular-nums; }
.live-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); animation: mk-breathe 1.2s infinite; }
.cursor-blink::after { content: '▍'; animation: mk-breathe 1s infinite; color: var(--accent); }
/* HITL 卡片(确认类交互统一绿色语言) */
.hitl-card { border: 1px solid var(--ok-border); background: var(--ok-soft); border-radius: var(--radius-lg); padding: 14px; margin: 10px 0; }
@@ -131,31 +155,31 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.hitl-choice { text-align: left; border: 1px solid var(--border); background: var(--bg-raised); color: var(--text); border-radius: var(--radius); padding: 10px 12px; cursor: pointer; transition: border-color .12s, background .12s; }
.hitl-choice:hover { border-color: var(--accent); background: var(--accent-soft); }
.hitl-choice .label { font-weight: 600; display: flex; gap: 8px; align-items: center; }
.hitl-choice .desc { color: var(--text-dim); font-size: 12.5px; margin-top: 3px; }
.hitl-choice .impact { color: var(--warn); font-size: 12px; margin-top: 3px; }
.hitl-choice .rec { font-size: 10.5px; color: var(--accent); border: 1px solid var(--accent); padding: 0 5px; border-radius: var(--radius-full); }
.hitl-choice .desc { color: var(--text-dim); font-size: var(--fs-ui-sm); margin-top: 3px; }
.hitl-choice .impact { color: var(--warn); font-size: var(--fs-ui-sm); margin-top: 3px; }
.hitl-choice .rec { font-size: var(--fs-ui-xs); color: var(--accent); border: 1px solid var(--accent); padding: 0 5px; border-radius: var(--radius-full); }
.hitl-free { display: flex; gap: 8px; margin-top: 10px; }
/* 输入区:悬浮卡片式输入壳(外圆角大、聚焦发光) */
.input-bar { padding: 8px max(16px, 6%) 16px; background: var(--bg); }
/* 输入区:悬浮卡片式输入壳(16px 大圆角 = DESIGN.md 特批例外) */
.input-bar { padding: 8px max(16px, 6%) 16px; background: var(--bg); border-radius: 0 0 var(--radius-lg) var(--radius-lg); }
.attachments { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
.attach-chip { display: inline-flex; gap: 6px; align-items: center; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 3px 8px; font-size: 12px; }
.attach-chip { display: inline-flex; gap: 6px; align-items: center; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 3px 8px; font-size: var(--fs-ui-sm); }
.input-row { display: flex; gap: 8px; align-items: flex-end; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 8px 8px 8px 14px; box-shadow: var(--shadow-md); transition: border-color .12s, box-shadow .12s; }
.input-row:focus-within { border-color: var(--accent); box-shadow: var(--shadow-md), var(--ring); }
.input-row textarea { flex: 1; resize: none; background: transparent; border: none; color: var(--text); padding: 6px 0; font-family: inherit; font-size: 13.5px; max-height: 160px; outline: none; }
.input-row textarea { flex: 1; resize: none; background: transparent; border: none; color: var(--text); padding: 6px 0; font-family: inherit; font-size: var(--fs-ui-caption); max-height: 160px; outline: none; }
.input-row textarea::placeholder { color: var(--text-faint); }
.cmd-menu { border: 1px solid var(--border); background: var(--bg-raised); border-radius: var(--radius); margin-bottom: 6px; max-height: 180px; overflow-y: auto; box-shadow: var(--shadow-md); }
.cmd-item { padding: 7px 12px; cursor: pointer; display: flex; gap: 10px; font-size: 13px; }
.cmd-item { padding: 7px 12px; cursor: pointer; display: flex; gap: 10px; font-size: var(--fs-ui-caption); }
.cmd-item:hover, .cmd-item.active { background: var(--bg-hover); }
.cmd-item .name { color: var(--accent); font-family: var(--mono); }
/* ---- 右栏 ---- */
.tabs { display: flex; border-bottom: 1px solid var(--border-soft); overflow-x: auto; padding: 0 8px; }
.tab { background: none; border: none; color: var(--text-faint); padding: 10px 10px; cursor: pointer; font-size: 12.5px; font-weight: 550; border-bottom: 2px solid transparent; margin-bottom: -1px; white-space: nowrap; }
.tab { background: none; border: none; color: var(--text-faint); padding: 10px 10px; cursor: pointer; font-size: var(--fs-ui-sm); font-weight: 550; border-bottom: 2px solid transparent; margin-bottom: -1px; white-space: nowrap; }
.tab:hover { color: var(--text-dim); }
.tab.active { color: var(--text); border-bottom-color: var(--accent); }
.tab-body { flex: 1; overflow-y: auto; padding: 12px; }
.step { display: flex; gap: 8px; align-items: flex-start; padding: 6px 4px; font-size: 13px; }
.step { display: flex; gap: 8px; align-items: flex-start; padding: 6px 4px; font-size: var(--fs-ui-caption); }
.step-icon { width: 16px; text-align: center; flex-shrink: 0; }
.step.pending .step-icon { color: var(--text-faint); }
.step.in_progress .step-icon { color: var(--accent); }
@@ -164,8 +188,8 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
/* ---- 工件面板(工作区文件管理)---- */
.artifacts-panel { display: flex; flex-direction: column; gap: 4px; }
.ap-toolbar { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; flex-wrap: wrap; }
.ap-toolbar .search-input { flex: 1; min-width: 120px; height: 30px; font-size: 12.5px; }
.kind-pill { border: 1px solid var(--border); background: none; color: var(--text-dim); border-radius: var(--radius-full); padding: 3px 10px; font-size: 12px; cursor: pointer; }
.ap-toolbar .search-input { flex: 1; min-width: 120px; height: 30px; font-size: var(--fs-ui-sm); }
.kind-pill { border: 1px solid var(--border); background: none; color: var(--text-dim); border-radius: var(--radius-full); padding: 3px 10px; font-size: var(--fs-ui-sm); cursor: pointer; }
.kind-pill:hover { color: var(--text); background: var(--bg-hover); }
.kind-pill.on { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
.ap-upload { cursor: pointer; }
@@ -173,30 +197,28 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.artifact-row:hover { background: var(--bg-hover); }
.artifact-row .ar-icon { display: flex; color: var(--text-dim); cursor: pointer; flex: none; }
.artifact-row .ar-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; cursor: pointer; }
.artifact-row .name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 13px; }
.artifact-row .meta { font-size: 11px; color: var(--text-faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.artifact-row .name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: var(--fs-ui-caption); }
.artifact-row .meta { font-size: var(--fs-ui-xs); color: var(--text-faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.artifact-row .ar-acts { display: none; align-items: center; gap: 2px; flex: none; }
.artifact-row:hover .ar-acts { display: inline-flex; }
.artifact-row .ar-acts .icon-btn { width: 26px; height: 26px; }
.artifact-row .ar-acts .icon-btn.danger { color: var(--err); }
.empty-note { color: var(--text-faint); font-size: 12.5px; padding: 14px 6px; }
.agent-row { display: flex; align-items: center; gap: 8px; padding: 8px; border-radius: var(--radius-sm); cursor: pointer; font-size: 13px; }
.empty-note { color: var(--text-faint); font-size: var(--fs-ui-sm); padding: 14px 6px; }
.agent-row { display: flex; align-items: center; gap: 8px; padding: 8px; border-radius: var(--radius-sm); cursor: pointer; font-size: var(--fs-ui-caption); }
.agent-row:hover { background: var(--bg-hover); }
.status-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.status-running { background: var(--accent); animation: pulse 1.2s infinite; }
.status-running { background: var(--accent); animation: mk-breathe 1.2s infinite; }
.status-completed { background: var(--ok); }
.status-failed, .status-cancelled, .status-interrupted { background: var(--err); }
.status-pending, .status-waiting_hitl { background: var(--warn); }
/* ---- 弹窗 / 模态 ---- */
.overlay { position: fixed; inset: 0; background: rgba(8, 9, 12, .6); backdrop-filter: blur(8px); display: flex; align-items: center; justify-content: center; z-index: 50; animation: overlay-in .14s var(--ease); }
@keyframes overlay-in { from { opacity: 0 } }
.modal { background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-xl); width: min(860px, 92vw); max-height: 86vh; display: flex; flex-direction: column; box-shadow: var(--shadow-lg); animation: modal-in .16s var(--ease); }
@keyframes modal-in { from { opacity: 0; transform: translateY(6px) scale(.99) } }
.modal-header { padding: 16px 20px; border-bottom: 1px solid var(--border-soft); display: flex; align-items: center; font-weight: 600; font-size: 14.5px; letter-spacing: -0.01em; }
/* ---- 弹窗 / 模态(动画走 mk-* 类) ---- */
.overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, .55); backdrop-filter: blur(8px); display: flex; align-items: center; justify-content: center; z-index: 50; animation: mk-overlay-in .14s var(--ease); }
.modal { background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-xl); width: min(860px, 92vw); max-height: 86vh; display: flex; flex-direction: column; box-shadow: var(--shadow-lg); animation: mk-modal-in .16s var(--ease); }
.modal-header { padding: 16px 20px; border-bottom: 1px solid var(--border-soft); display: flex; align-items: center; font-weight: 600; font-size: var(--fs-ui-base); letter-spacing: -0.01em; }
.modal-header .spacer { flex: 1; }
.modal-body { flex: 1; overflow-y: auto; padding: 18px 20px; }
.subagent-tree { font-size: 13px; }
.subagent-tree { font-size: var(--fs-ui-caption); }
.subagent-node { border: 1px solid var(--border); border-radius: var(--radius); padding: 10px; margin-bottom: 8px; background: var(--bg-panel); }
.subagent-node .head { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
@@ -205,7 +227,7 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.ui-setting-control input, .ui-setting-control select { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text); padding: 7px 10px; font-family: inherit; width: 100%; max-width: 340px; }
.ui-setting-control input:focus, .ui-setting-control select:focus { border-color: var(--accent); box-shadow: var(--ring); outline: none; }
.ui-setting-control input[type="checkbox"] { width: auto; }
.effect-badge { font-size: 10.5px; padding: 2px 7px; border-radius: var(--radius-full); border: 1px solid var(--border); color: var(--text-faint); white-space: nowrap; }
.effect-badge { font-size: var(--fs-ui-xs); padding: 2px 7px; border-radius: var(--radius-full); border: 1px solid var(--border); color: var(--text-faint); white-space: nowrap; }
.effect-hot { color: var(--ok); border-color: var(--ok); }
.effect-new-run { color: var(--warn); border-color: var(--warn); }
@@ -213,16 +235,16 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.docv { display: flex; flex-direction: column; min-height: 0; }
.docv-toolbar { display: flex; align-items: center; gap: 8px; padding: 0 0 10px; border-bottom: 1px solid var(--border-soft); margin-bottom: 10px; }
.docv-toolbar .spacer { flex: 1; }
.docv-meta { font-size: 11.5px; color: var(--text-faint); text-transform: capitalize; }
.docv-dirty { color: var(--warn); font-size: 12px; }
.docv-meta { font-size: var(--fs-ui-xs); color: var(--text-faint); text-transform: capitalize; }
.docv-dirty { color: var(--warn); font-size: var(--fs-ui-sm); }
.docv-dl { text-decoration: none; }
.docv-body { flex: 1; min-height: 0; overflow: auto; }
.docv-pdf { width: 100%; height: 100%; min-height: 60vh; border: 1px solid var(--border-soft); border-radius: var(--radius); background: var(--bg); }
.docv-editor { width: 100%; height: 56vh; font-family: var(--mono); font-size: 12.5px; resize: vertical; }
.docv-pre { font-family: var(--mono); font-size: 12.5px; white-space: pre-wrap; word-break: break-word; background: var(--bg-inset); border: 1px solid var(--border-soft); border-radius: var(--radius); padding: 12px 14px; }
.docv-editor { width: 100%; height: 56vh; font-family: var(--mono); font-size: var(--fs-ui-sm); resize: vertical; }
.docv-pre { font-family: var(--mono); font-size: var(--fs-ui-sm); white-space: pre-wrap; word-break: break-word; background: var(--bg-inset); border: 1px solid var(--border-soft); border-radius: var(--radius); padding: 12px 14px; }
/* ---- 杂项 ---- */
.toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); background: var(--bg-raised); border: 1px solid var(--border); color: var(--text); padding: 9px 16px; border-radius: var(--radius-full); box-shadow: var(--shadow-lg); z-index: 90; font-size: 13px; }
.toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); background: var(--bg-raised); border: 1px solid var(--border); color: var(--text); padding: 9px 16px; border-radius: var(--radius-full); box-shadow: var(--shadow-lg); z-index: 90; font-size: var(--fs-ui-caption); }
.conn-dot { width: 8px; height: 8px; border-radius: 50%; }
.conn-dot.on { background: var(--ok); }
.conn-dot.off { background: var(--err); }
@@ -232,49 +254,49 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
::-webkit-scrollbar-thumb:hover { background: var(--border-strong); background-clip: content-box; border: 3px solid transparent; }
::-webkit-scrollbar-track { background: transparent; }
/* ---- 品牌标(侧栏 / 登录页 / 头像共用的星芒渐变底) ---- */
.brand-mark { background: linear-gradient(135deg, #6d8dff, #9d7bff); color: #fff; display: inline-flex; align-items: center; justify-content: center; flex: none; box-shadow: inset 0 1px 0 rgba(255,255,255,.22); }
/* ---- 品牌标(侧栏 / 登录页 / 头像共用的星芒渐变底;辅色为品牌令牌 --brand-2) ---- */
.brand-mark { background: linear-gradient(135deg, var(--accent), var(--brand-2)); color: var(--text-on-accent); display: inline-flex; align-items: center; justify-content: center; flex: none; box-shadow: inset 0 1px 0 rgba(255,255,255,.22); }
/* ---- 左栏:任务中心(方案 A;R4 重造型) ---- */
/* ---- 左栏:任务中心(方案 A;Zai 复刻重造型) ---- */
.tsb-brand { display: flex; align-items: center; gap: 9px; padding: 14px 14px 8px; }
.tsb-logo { width: 24px; height: 24px; border-radius: 8px; background: linear-gradient(135deg, #6d8dff, #9d7bff); color: #fff; font-size: 12px; font-weight: 700; display: flex; align-items: center; justify-content: center; flex: none; }
.tsb-name { font-weight: 650; font-size: 13.5px; letter-spacing: -0.01em; }
.tsb-iconbtn { width: 26px; height: 26px; border-radius: var(--radius-sm); background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: 13px; display: inline-flex; align-items: center; justify-content: center; position: relative; }
.tsb-logo { width: 24px; height: 24px; border-radius: var(--radius); background: linear-gradient(135deg, var(--accent), var(--brand-2)); color: var(--text-on-accent); font-size: var(--fs-ui-sm); font-weight: 700; display: flex; align-items: center; justify-content: center; flex: none; }
.tsb-name { font-weight: 650; font-size: var(--fs-ui-caption); letter-spacing: -0.01em; }
.tsb-iconbtn { width: 26px; height: 26px; border-radius: var(--radius-sm); background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: var(--fs-ui-caption); display: inline-flex; align-items: center; justify-content: center; position: relative; }
.tsb-iconbtn:hover { background: var(--bg-hover); color: var(--text); }
.tsb-iconbtn.active { color: var(--accent); background: var(--accent-soft); }
.tsb-actions { padding: 6px 12px 8px; display: flex; flex-direction: column; gap: 7px; }
.tsb-newtask { display: flex; height: 36px; border-radius: var(--radius); background: var(--accent); color: var(--text-on-accent); overflow: hidden; box-shadow: inset 0 1px 0 rgba(255,255,255,.16), 0 2px 6px rgba(8,9,12,.28); transition: filter .12s; }
.tsb-newtask:hover { filter: brightness(1.08); }
.tsb-newtask-main { flex: 1; display: flex; align-items: center; gap: 7px; padding: 0 11px; background: none; border: none; color: inherit; font-weight: 600; font-size: 13px; cursor: pointer; min-width: 0; }
.tsb-newtask { display: flex; height: 36px; border-radius: var(--radius); background: var(--primary); color: var(--primary-foreground); overflow: hidden; box-shadow: 0 1px 3px rgba(0, 0, 0, .3); transition: background .12s; }
.tsb-newtask:hover { background: color-mix(in srgb, var(--primary) 86%, var(--bg)); }
.tsb-newtask-main { flex: 1; display: flex; align-items: center; gap: 7px; padding: 0 11px; background: none; border: none; color: inherit; font-weight: 600; font-size: var(--fs-ui-caption); cursor: pointer; min-width: 0; }
.tsb-newtask-main svg { flex: none; }
.tsb-newtask-proj { font-weight: 450; font-size: 11.5px; opacity: .75; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tsb-newtask-caret { width: 30px; border: none; border-left: 1px solid rgba(255,255,255,.18); background: none; color: inherit; cursor: pointer; display: flex; align-items: center; justify-content: center; }
.tsb-newtask-caret:hover { background: rgba(255,255,255,.08); }
.tsb-newtask-proj { font-weight: 450; font-size: var(--fs-ui-xs); opacity: .75; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tsb-newtask-caret { width: 30px; border: none; border-left: 1px solid color-mix(in srgb, var(--primary-foreground) 18%, transparent); background: none; color: inherit; cursor: pointer; display: flex; align-items: center; justify-content: center; }
.tsb-newtask-caret:hover { background: color-mix(in srgb, var(--primary-foreground) 8%, transparent); }
.tsb-search { display: flex; align-items: center; gap: 7px; height: 32px; border-radius: var(--radius); border: 1px solid var(--border); background: var(--bg-raised); color: var(--text-faint); padding: 0 10px; transition: border-color .12s, box-shadow .12s; }
.tsb-search:focus-within { border-color: var(--accent); box-shadow: var(--ring); }
.tsb-search svg { flex: none; }
.tsb-search input { flex: 1; border: none; background: none; color: var(--text); font-size: 12.5px; outline: none; min-width: 0; }
.tsb-search input { flex: 1; border: none; background: none; color: var(--text); font-size: var(--fs-ui-sm); outline: none; min-width: 0; }
.tsb-search input::placeholder { color: var(--text-faint); }
.tsb-orgrow { display: flex; align-items: center; gap: 6px; padding: 0 12px 8px; }
.tsb-pills { flex: 1; display: flex; background: var(--bg-inset); border: 1px solid var(--border-soft); border-radius: var(--radius-sm); padding: 2px; gap: 2px; }
.tsb-pill { flex: 1; height: 22px; border: none; border-radius: var(--radius-xs); background: none; color: var(--text-faint); font-size: 12px; cursor: pointer; transition: all .12s; }
.tsb-pill { flex: 1; height: 22px; border: none; border-radius: var(--radius-xs); background: none; color: var(--text-faint); font-size: var(--fs-ui-sm); cursor: pointer; transition: all .12s; }
.tsb-pill:hover { color: var(--text-dim); }
.tsb-pill.on { background: var(--bg-raised); color: var(--text); font-weight: 550; box-shadow: 0 1px 2px rgba(8,9,12,.25); }
.tsb-pill.on { background: var(--bg-raised); color: var(--text); font-weight: 550; box-shadow: 0 1px 2px rgba(0,0,0,.25); }
.tsb-scroll { flex: 1; overflow-y: auto; padding: 0 10px 8px; min-height: 0; }
.tsb-seclabel { font-size: 11px; color: var(--text-faint); padding: 12px 8px 4px; letter-spacing: .05em; font-weight: 600; }
.tsb-empty { padding: 20px 14px; color: var(--text-faint); font-size: 12.5px; line-height: 1.9; text-align: center; }
.tsb-seclabel { font-size: var(--fs-ui-xs); color: var(--text-faint); padding: 12px 8px 4px; letter-spacing: .05em; font-weight: 600; }
.tsb-empty { padding: 20px 14px; color: var(--text-faint); font-size: var(--fs-ui-sm); line-height: 1.9; text-align: center; }
.tsb-newproject { padding: 0 2px 8px; }
.tsb-group { margin-bottom: 2px; }
.tsb-group-head { display: flex; align-items: center; gap: 6px; height: 30px; padding: 0 6px 0 4px; border-radius: var(--radius-sm); color: var(--text-dim); font-weight: 550; font-size: 13px; cursor: pointer; user-select: none; }
.tsb-group-head { display: flex; align-items: center; gap: 6px; height: 30px; padding: 0 6px 0 4px; border-radius: var(--radius-sm); color: var(--text-dim); font-weight: 550; font-size: var(--fs-ui-caption); cursor: pointer; user-select: none; }
.tsb-group-head:hover { background: var(--bg-hover); }
.tsb-group.closed .tsb-chev { transform: rotate(0deg); }
.tsb-chev { display: inline-flex; color: var(--text-faint); transform: rotate(90deg); transition: transform .12s; }
.tsb-group-name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tsb-group-cnt { font-size: 11px; color: var(--text-faint); font-weight: 400; font-variant-numeric: tabular-nums; }
.tsb-group-cnt { font-size: var(--fs-ui-xs); color: var(--text-faint); font-weight: 400; font-variant-numeric: tabular-nums; }
.tsb-group-add { visibility: hidden; width: 22px; height: 22px; border: none; border-radius: var(--radius-xs); background: none; color: var(--text-faint); display: inline-flex; align-items: center; justify-content: center; cursor: pointer; }
.tsb-group-head:hover .tsb-group-add { visibility: visible; }
.tsb-group-add:hover { background: var(--bg-active); color: var(--text); }
@@ -284,105 +306,118 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.tsb-task.active { background: var(--bg-active); color: var(--text); }
.tsb-task.active::before { content: ''; position: absolute; left: -2px; top: 6px; bottom: 6px; width: 2.5px; border-radius: 2px; background: var(--accent); }
.tsb-task.archived .tsb-task-t { text-decoration: line-through; opacity: .6; }
.tsb-task-t { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 13px; }
.tsb-task-tm { font-size: 11px; color: var(--text-faint); font-variant-numeric: tabular-nums; }
.tsb-pbadge { font-size: 10.5px; color: var(--text-faint); border: 1px solid var(--border); border-radius: var(--radius-xs); padding: 0 5px; line-height: 1.5; white-space: nowrap; }
.tsb-task-t { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: var(--fs-ui-caption); }
.tsb-task-tm { font-size: var(--fs-ui-xs); color: var(--text-faint); font-variant-numeric: tabular-nums; }
.tsb-pbadge { font-size: var(--fs-ui-xs); color: var(--text-faint); border: 1px solid var(--border); border-radius: var(--radius-xs); padding: 0 5px; line-height: 1.5; white-space: nowrap; }
.tsb-row-acts { display: none; gap: 2px; }
.tsb-task:hover .tsb-row-acts { display: inline-flex; }
.tsb-task:hover .tsb-task-tm { display: none; }
.tsb-row-act { border: none; background: none; color: var(--text-faint); cursor: pointer; padding: 3px; border-radius: var(--radius-xs); display: inline-flex; }
.tsb-row-act:hover { background: var(--bg-active); color: var(--text); }
.tsb-wsstrip { display: flex; align-items: center; gap: 2px; padding: 6px 12px; border-top: 1px solid var(--border-soft); }
.tsb-wsstrip button { border: none; background: none; color: var(--text-faint); cursor: pointer; padding: 5px; border-radius: var(--radius-sm); display: inline-flex; }
.tsb-wsstrip button:hover { background: var(--bg-hover); color: var(--text); }
.tsb-footer { display: flex; align-items: center; gap: 4px; padding: 10px 12px; border-top: 1px solid var(--border-soft); }
.tsb-footer { display: flex; align-items: center; gap: 4px; padding: 10px 12px; border-top: 1px solid var(--border-soft); border-radius: 0 0 var(--radius-lg) var(--radius-lg); }
.tsb-user { flex: 1; display: flex; align-items: center; gap: 9px; min-width: 0; height: 40px; padding: 0 7px; border: none; border-radius: var(--radius); background: none; cursor: pointer; text-align: left; transition: background .12s; }
.tsb-user:hover { background: var(--bg-hover); }
.tsb-avatar { width: 27px; height: 27px; border-radius: 50%; background: linear-gradient(135deg, #6d8dff, #9d7bff); color: #fff; font-size: 12px; font-weight: 600; display: flex; align-items: center; justify-content: center; flex: none; box-shadow: inset 0 1px 0 rgba(255,255,255,.2); }
.tsb-avatar { width: 27px; height: 27px; border-radius: 50%; background: linear-gradient(135deg, var(--accent), var(--brand-2)); color: var(--text-on-accent); font-size: var(--fs-ui-sm); font-weight: 600; display: flex; align-items: center; justify-content: center; flex: none; box-shadow: inset 0 1px 0 rgba(255,255,255,.2); }
.tsb-user-info { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.tsb-user-name { font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tsb-user-sub { font-size: 11px; color: var(--text-faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tsb-user-name { font-size: var(--fs-ui-caption); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tsb-user-sub { font-size: var(--fs-ui-xs); color: var(--text-faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tsb-user-chev { color: var(--text-faint); display: inline-flex; }
.tsb-menu { position: fixed; z-index: 300; min-width: 216px; background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); box-shadow: var(--shadow-lg); padding: 5px; display: flex; flex-direction: column; animation: modal-in .12s var(--ease); }
.tsb-mi { display: flex; align-items: center; gap: 9px; min-height: 31px; padding: 4px 9px; border: none; border-radius: var(--radius-sm); background: none; color: var(--text-dim); font-size: 13px; cursor: pointer; text-align: left; }
.tsb-menu { position: fixed; z-index: 300; min-width: 216px; background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: var(--radius); box-shadow: var(--shadow-lg); padding: 5px; display: flex; flex-direction: column; animation: mk-modal-in .12s var(--ease); }
.tsb-mi { display: flex; align-items: center; gap: 9px; min-height: 31px; padding: 4px 9px; border: none; border-radius: var(--radius-sm); background: none; color: var(--text-dim); font-size: var(--fs-ui-caption); cursor: pointer; text-align: left; }
.tsb-mi svg { color: var(--text-faint); flex: none; }
.tsb-mi:hover { background: var(--bg-hover); color: var(--text); }
.tsb-mi:hover svg { color: var(--text-dim); }
.tsb-mi.danger { color: var(--err); }
.tsb-mi.danger:hover { background: var(--err); color: #fff; }
.tsb-mi.danger:hover svg { color: #fff; }
.tsb-mi kbd { margin-left: auto; font-size: 10.5px; font-family: var(--mono); color: var(--text-faint); }
.tsb-mi.danger:hover { background: var(--err); color: var(--text-on-accent); }
.tsb-mi.danger:hover svg { color: var(--text-on-accent); }
.tsb-mi kbd { margin-left: auto; font-size: var(--fs-ui-xs); font-family: var(--mono); color: var(--text-faint); }
.tsb-menu-div { height: 1px; background: var(--border-soft); margin: 5px 4px; }
.tsb-acc-head { display: flex; gap: 10px; align-items: center; padding: 8px 9px 6px; }
.tsb-acc-name { font-size: 13.5px; font-weight: 600; }
.tsb-acc-mail { font-size: 11.5px; color: var(--text-faint); }
.tsb-acc-name { font-size: var(--fs-ui-caption); font-weight: 600; }
.tsb-acc-mail { font-size: var(--fs-ui-xs); color: var(--text-faint); }
/* ---- 登录页与改密门(R4 重造型:宽屏右侧品牌面板) ---- */
.lg-wrap { position: fixed; inset: 0; display: flex; align-items: stretch; background: var(--bg); z-index: 500; }
.lg-panel { flex: none; width: 46%; max-width: 620px; position: relative; overflow: hidden; display: flex; flex-direction: column; justify-content: center; padding: 64px; color: #dfe4f2; background: linear-gradient(160deg, #171e38 0%, #1d2145 55%, #251d4d 100%); }
.lg-panel::before { content: ''; position: absolute; width: 560px; height: 560px; border-radius: 50%; background: radial-gradient(closest-side, rgba(109,141,255,.22), transparent 70%); top: -140px; right: -160px; }
.lg-panel::after { content: ''; position: absolute; width: 440px; height: 440px; border-radius: 50%; background: radial-gradient(closest-side, rgba(157,123,255,.18), transparent 70%); bottom: -150px; left: -120px; }
/* ---- 登录页与改密门(Zai 复刻:近单色深底 + 深海军品牌面;品牌面板固定深底浅字) ---- */
.lg-wrap { position: fixed; inset: 0; display: flex; align-items: stretch; background: var(--bg-inset); z-index: 500; }
.lg-panel { flex: none; width: 46%; max-width: 620px; position: relative; overflow: hidden; display: flex; flex-direction: column; justify-content: center; padding: 64px; color: #dfe4f2; background: linear-gradient(160deg, #0e1116 0%, #101826 55%, #0b1f38 100%); }
.lg-panel::before { content: ''; position: absolute; width: 560px; height: 560px; border-radius: 50%; background: radial-gradient(closest-side, rgba(64,153,255,.16), transparent 70%); top: -140px; right: -160px; }
.lg-panel::after { content: ''; position: absolute; width: 440px; height: 440px; border-radius: 50%; background: radial-gradient(closest-side, rgba(123,92,229,.12), transparent 70%); bottom: -150px; left: -120px; }
.lg-panel h2 { font-size: 26px; font-weight: 700; letter-spacing: -0.02em; line-height: 1.35; margin: 0 0 12px; position: relative; }
.lg-panel p { font-size: 14px; opacity: .72; line-height: 1.75; margin: 0; position: relative; }
.lg-panel p { font-size: var(--fs-ui-base); opacity: .72; line-height: 1.75; margin: 0; position: relative; }
.lg-panel ul { margin: 28px 0 0; list-style: none; display: flex; flex-direction: column; gap: 13px; position: relative; }
.lg-panel li { display: flex; gap: 10px; align-items: flex-start; font-size: 13.5px; opacity: .88; }
.lg-panel li svg { margin-top: 2px; color: #8fa8ff; flex: none; }
.lg-panel .lg-foot { position: absolute; bottom: 28px; left: 64px; right: 64px; display: flex; justify-content: space-between; font-size: 11.5px; opacity: .45; }
.lg-panel li { display: flex; gap: 10px; align-items: flex-start; font-size: var(--fs-ui-caption); opacity: .88; }
.lg-panel li svg { margin-top: 2px; color: var(--accent-strong); flex: none; }
.lg-panel .lg-foot { position: absolute; bottom: 28px; left: 64px; right: 64px; display: flex; justify-content: space-between; font-size: var(--fs-ui-xs); opacity: .45; }
@media (max-width: 960px) { .lg-panel { display: none; } }
.lg-formside { flex: 1; display: flex; align-items: center; justify-content: center; padding: 24px; min-width: 0; }
.lg-card { width: 392px; max-width: 100%; }
.lg-brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 15.5px; letter-spacing: -0.01em; margin-bottom: 26px; }
.lg-title { margin: 0 0 4px; font-size: 22px; font-weight: 700; letter-spacing: -0.02em; }
.lg-sub { margin: 0 0 18px; font-size: 12.5px; color: var(--text-faint); line-height: 1.6; }
.lg-brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: var(--fs-ui-base); letter-spacing: -0.01em; margin-bottom: 26px; }
.lg-title { margin: 0 0 4px; font-size: var(--fs-ui-xl); font-weight: 700; letter-spacing: -0.02em; }
.lg-sub { margin: 0 0 18px; font-size: var(--fs-ui-sm); color: var(--text-faint); line-height: 1.6; }
.lg-tabs { display: flex; background: var(--bg-inset); border: 1px solid var(--border-soft); border-radius: var(--radius); padding: 3px; gap: 3px; margin-bottom: 16px; }
.lg-tab { flex: 1; height: 30px; border: none; border-radius: var(--radius-xs); background: none; color: var(--text-faint); font-size: 12.5px; cursor: pointer; transition: all .12s; }
.lg-tab { flex: 1; height: 30px; border: none; border-radius: var(--radius-xs); background: none; color: var(--text-faint); font-size: var(--fs-ui-sm); cursor: pointer; transition: all .12s; }
.lg-tab:hover { color: var(--text-dim); }
.lg-tab.on { background: var(--bg-raised); color: var(--text); font-weight: 550; box-shadow: 0 1px 2px rgba(8,9,12,.25); }
.lg-label { display: block; font-size: 12px; font-weight: 550; color: var(--text-dim); margin: 12px 0 6px; }
.lg-input { width: 100%; height: 38px; border-radius: var(--radius); border: 1px solid var(--border); background: var(--bg-raised); color: var(--text); padding: 0 12px; font-size: 13.5px; transition: border-color .12s, box-shadow .12s; }
.lg-tab.on { background: var(--bg-raised); color: var(--text); font-weight: 550; box-shadow: 0 1px 2px rgba(0,0,0,.25); }
.lg-label { display: block; font-size: var(--fs-ui-sm); font-weight: 550; color: var(--text-dim); margin: 12px 0 6px; }
.lg-input { width: 100%; height: 38px; border-radius: var(--radius); border: 1px solid var(--border); background: var(--bg-raised); color: var(--text); padding: 0 12px; font-size: var(--fs-ui-caption); transition: border-color .12s, box-shadow .12s; }
.lg-input:focus { border-color: var(--accent); box-shadow: var(--ring); outline: none; }
.lg-mono { font-family: var(--mono); font-size: 12.5px; }
.lg-err { margin-top: 12px; font-size: 12.5px; color: var(--err); background: var(--err-soft); border: 1px solid color-mix(in srgb, var(--err) 35%, transparent); border-radius: var(--radius); padding: 8px 11px; display: flex; gap: 7px; align-items: flex-start; }
.lg-mono { font-family: var(--mono); font-size: var(--fs-ui-sm); }
.lg-err { margin-top: 12px; font-size: var(--fs-ui-sm); color: var(--err); background: var(--err-soft); border: 1px solid color-mix(in srgb, var(--err) 35%, transparent); border-radius: var(--radius); padding: 8px 11px; display: flex; gap: 7px; align-items: flex-start; }
.lg-err svg { flex: none; margin-top: 1px; }
.lg-submit {
width: 100%; height: 38px; margin-top: 16px; border: none; border-radius: var(--radius);
background: var(--accent); color: var(--text-on-accent); font-weight: 600; font-size: 13.5px; cursor: pointer;
box-shadow: inset 0 1px 0 rgba(255,255,255,.16), 0 2px 8px var(--accent-glow);
transition: filter .12s, transform .12s;
background: var(--primary); color: var(--primary-foreground); font-weight: 600; font-size: var(--fs-ui-caption); cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, .3);
transition: background .12s, transform .12s;
}
.lg-submit:disabled { opacity: .45; cursor: default; box-shadow: none; }
.lg-submit:not(:disabled):hover { filter: brightness(1.09); }
.lg-submit:not(:disabled):hover { background: color-mix(in srgb, var(--primary) 86%, var(--bg)); }
.lg-submit:not(:disabled):active { transform: translateY(1px); }
.lg-cancel { width: 100%; margin-top: 8px; border: none; background: none; color: var(--text-faint); font-size: 12.5px; cursor: pointer; }
.lg-cancel { width: 100%; margin-top: 8px; border: none; background: none; color: var(--text-faint); font-size: var(--fs-ui-sm); cursor: pointer; }
.lg-cancel:hover { color: var(--text-dim); text-decoration: underline; }
.lg-hint { margin: 14px 0 0; font-size: 11.5px; color: var(--text-faint); line-height: 1.7; }
.lg-hint code { font-family: var(--mono); font-size: 10.5px; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-xs); padding: 1px 5px; }
.lg-hint { margin: 14px 0 0; font-size: var(--fs-ui-xs); color: var(--text-faint); line-height: 1.7; }
.lg-hint code { font-family: var(--mono); font-size: var(--fs-ui-xs); background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-xs); padding: 1px 5px; }
/* ---- 系统管理(AdminZone,重设计方案 阶段②):管理员专属全屏视图 ---- */
.az { position: fixed; inset: 4px; z-index: 70; display: flex; flex-direction: column; background: var(--bg); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); overflow: hidden; animation: mk-modal-in .16s var(--ease); }
.az-head { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border-bottom: 1px solid var(--border-soft); background: var(--bg-panel); }
.az-title { font-weight: 650; font-size: var(--fs-ui-base); letter-spacing: -0.01em; }
.az-sub { font-size: var(--fs-ui-xs); color: var(--text-faint); }
.az-tenant { font-size: var(--fs-ui-xs); color: var(--text-faint); font-family: var(--mono); border: 1px solid var(--border); border-radius: var(--radius-full); padding: 2px 10px; }
.az-body { flex: 1; min-height: 0; display: flex; }
.az-side { width: 220px; flex: none; border-right: 1px solid var(--border-soft); background: var(--bg-panel); padding: 10px 8px; display: flex; flex-direction: column; gap: 2px; }
.az-nav-item { display: flex; align-items: center; gap: 9px; padding: 9px 11px; border: none; background: none; color: var(--text-dim); font-size: var(--fs-ui-caption); border-radius: var(--radius-sm); cursor: pointer; text-align: left; }
.az-nav-item:hover { background: var(--bg-hover); color: var(--text); }
.az-nav-item.on { background: var(--bg-active); color: var(--text); font-weight: 550; }
.az-nav-item.on svg { color: var(--accent); }
.az-main { flex: 1; min-width: 0; overflow-y: auto; padding: 20px 24px; }
.az-section-head { margin-bottom: 14px; }
.az-section-head h3 { margin: 0 0 4px; font-size: var(--fs-ui-lg); font-weight: 600; letter-spacing: -0.01em; }
.az-section-head p { margin: 0; font-size: var(--fs-ui-sm); color: var(--text-faint); }
/* ---- 用户管理 / 改密弹层 ---- */
.ad-body { padding: 16px 20px 20px; display: flex; flex-direction: column; gap: 12px; max-height: 70vh; overflow-y: auto; }
.ad-create { border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 14px; display: flex; flex-direction: column; gap: 8px; background: var(--bg-panel); }
.ad-row { display: flex; gap: 8px; align-items: center; }
.ad-row .lg-input { flex: 1; min-width: 0; height: 34px; }
.ad-check { display: flex; align-items: center; gap: 5px; font-size: 12.5px; color: var(--text-dim); white-space: nowrap; }
.ad-create-btn { width: auto; padding: 0 14px; margin-top: 0; height: 34px; flex: none; box-shadow: inset 0 1px 0 rgba(255,255,255,.14); }
.ad-mini { border: 1px solid var(--border); background: var(--bg-raised); color: var(--text-dim); border-radius: var(--radius-sm); padding: 4px 9px; font-size: 12px; cursor: pointer; white-space: nowrap; display: inline-flex; align-items: center; gap: 4px; }
.ad-check { display: flex; align-items: center; gap: 5px; font-size: var(--fs-ui-sm); color: var(--text-dim); white-space: nowrap; }
.ad-create-btn { width: auto; padding: 0 14px; margin-top: 0; height: 34px; flex: none; }
.ad-mini { border: 1px solid var(--border); background: var(--bg-raised); color: var(--text-dim); border-radius: var(--radius-sm); padding: 4px 9px; font-size: var(--fs-ui-sm); cursor: pointer; white-space: nowrap; display: inline-flex; align-items: center; gap: 4px; }
.ad-mini:hover { color: var(--text); border-color: var(--accent); }
.ad-hint { margin: 0; font-size: 11.5px; color: var(--text-faint); }
.ad-notice { font-size: 12.5px; color: var(--ok); background: var(--ok-soft); border: 1px solid var(--ok-border); border-radius: var(--radius); padding: 8px 11px; display: flex; gap: 7px; align-items: flex-start; }
.ad-hint { margin: 0; font-size: var(--fs-ui-xs); color: var(--text-faint); }
.ad-notice { font-size: var(--fs-ui-sm); color: var(--ok); background: var(--ok-soft); border: 1px solid var(--ok-border); border-radius: var(--radius); padding: 8px 11px; display: flex; gap: 7px; align-items: flex-start; }
.ad-notice svg { flex: none; margin-top: 1px; }
.ad-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
.ad-table th { text-align: left; font-weight: 600; color: var(--text-faint); font-size: 11px; text-transform: uppercase; letter-spacing: .06em; padding: 8px; border-bottom: 1px solid var(--border); }
.ad-table { width: 100%; border-collapse: collapse; font-size: var(--fs-ui-sm); }
.ad-table th { text-align: left; font-weight: 600; color: var(--text-faint); font-size: var(--fs-ui-xs); text-transform: uppercase; letter-spacing: .06em; padding: 8px; border-bottom: 1px solid var(--border); }
.ad-table td { padding: 9px 8px; border-bottom: 1px solid var(--border-soft); }
.ad-table tr:last-child td { border-bottom: none; }
.ad-mono { font-family: var(--mono); font-size: 12px; }
.ad-mono { font-family: var(--mono); font-size: var(--fs-ui-sm); }
.ad-dim { color: var(--text-faint); }
.ad-chip { font-size: 10px; border-radius: var(--radius-xs); padding: 2px 7px; font-weight: 700; letter-spacing: .5px; background: var(--bg-hover); color: var(--text-faint); }
.ad-chip { font-size: var(--fs-ui-xs); border-radius: var(--radius-xs); padding: 2px 7px; font-weight: 700; letter-spacing: .5px; background: var(--bg-hover); color: var(--text-faint); }
.ad-chip-admin { background: var(--accent-soft); color: var(--accent); }
.ad-chip-warn { background: var(--warn-soft); color: var(--warn); }
.ad-reset { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; border: 1px solid color-mix(in srgb, var(--warn) 40%, transparent); background: var(--warn-soft); border-radius: var(--radius-lg); padding: 12px; font-size: 12.5px; }
.ad-reset { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; border: 1px solid color-mix(in srgb, var(--warn) 40%, transparent); background: var(--warn-soft); border-radius: var(--radius-lg); padding: 12px; font-size: var(--fs-ui-sm); }
.ad-reset .lg-input { flex: 1; min-width: 160px; height: 32px; }
.ad-changepw { display: flex; flex-direction: column; gap: 2px; max-width: 340px; }
@@ -390,10 +425,27 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.icon-only { display: flex; flex-direction: column; align-items: center; gap: 6px; padding-top: 12px; }
.icon-only button { background: none; border: none; color: var(--text-faint); cursor: pointer; padding: 0; width: 34px; height: 34px; border-radius: var(--radius); display: inline-flex; align-items: center; justify-content: center; position: relative; }
.icon-only button:hover { background: var(--bg-hover); color: var(--text); }
.rail-badge { position: absolute; top: 3px; right: 3px; min-width: 14px; height: 14px; border-radius: 7px; background: var(--warn); color: #fff; font-size: 9.5px; font-weight: 700; display: flex; align-items: center; justify-content: center; padding: 0 4px; }
.rail-badge { position: absolute; top: 3px; right: 3px; min-width: 14px; height: 14px; border-radius: 7px; background: var(--warn); color: var(--text-on-accent); font-size: var(--fs-ui-xs); font-weight: 700; display: flex; align-items: center; justify-content: center; padding: 0 4px; }
/* ---- UI 能力平台消费(消息头像列 / 设置外壳 / 管理弹层头像) ---- */
.role-mark { width: 20px; height: 20px; border-radius: 6px; display: inline-flex; align-items: center; justify-content: center; color: var(--text-dim); background: var(--bg-raised); border: 1px solid var(--border); }
.role-mark.role-assistant { background: none; border: none; padding: 0; }
.msg.user .msg-role { align-items: center; }
.ad-user-cell { display: flex; align-items: center; gap: 8px; }
/* ---- 右栏轻预览(裁定 D4:右栏 + 弹窗双落位)---- */
.rail-preview-head { display: flex; align-items: center; gap: 6px; padding: 0 2px 8px; border-bottom: 1px solid var(--border-soft); margin-bottom: 8px; }
.rail-preview-head .rp-name { font-size: var(--fs-ui-caption); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 55%; }
.tab-body[data-rail-preview] { display: flex; flex-direction: column; }
.tab-body[data-rail-preview] .ui-fp { flex: 1; }
.artifact-chip .ac-act { border: none; background: none; }
/* 项目收件箱(CF3/D51):会话/项目范围分段控件(令牌对齐 DESIGN.md,阶段④合入主线时归一) */
.inbox-scope {
display: flex; gap: 4px; padding: 6px 14px 2px;
}
.inbox-scope button {
border: 1px solid var(--border); background: transparent; color: inherit;
border-radius: var(--radius-full); padding: 2px 12px; font-size: var(--fs-ui-sm); cursor: pointer; opacity: .72;
}
.inbox-scope button.active { opacity: 1; font-weight: 600; background: var(--bg-hover); }
+88
View File
@@ -0,0 +1,88 @@
// 设计系统守护测试(父仓 DESIGN.md §11 禁止清单 · 宿主侧,2026-09-30)。
// 棘轮基线:现存豁免登记在下方;新增同类违规直接失败。收紧 = 修掉违规后删基线条目。
// 范围:src/styles.css 与 src/**/*.tsx。@ui 组件库侧由 Agent-System-UI 的守护测试覆盖。
import { describe, it, expect } from 'vitest'
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'
import { join, relative, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const HERE = dirname(fileURLToPath(import.meta.url)) // test/
const FE_ROOT = join(HERE, '..')
const SRC = join(FE_ROOT, 'src')
const DEV_ROOT = join(FE_ROOT, '..', '..') // Dev 父仓根(DESIGN.md;单独检出本仓时不存在)
function walk(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
const p = join(dir, name)
if (statSync(p).isDirectory()) walk(p, out)
else out.push(p)
}
return out
}
const read = (p: string) => readFileSync(p, 'utf8')
const stripCssComments = (t: string) => t.replace(/\/\*[\s\S]*?\*\//g, '')
const collectHex = (t: string) => [...t.matchAll(/#[0-9a-fA-F]{3,8}\b/g)].map(m => m[0].toLowerCase())
// ---- 1. 宪法在位(组合检出时) ----
describe('DESIGN.md 宪法在位', () => {
it('DESIGN.md 存在且 AGENTS.md 带强制阅读门禁', () => {
const designPath = join(DEV_ROOT, 'DESIGN.md')
if (!existsSync(designPath)) { console.warn('(单独检出 Frontend 仓:跳过宪法检查)'); return }
expect(read(designPath)).toContain('Agent-System-Dev 设计系统')
const agents = read(join(DEV_ROOT, 'AGENTS.md'))
expect(agents).toContain('DESIGN.md')
})
})
// ---- 2. 动效唯一所有者 ----
describe('动效唯一所有者', () => {
it('styles.css 无 @keyframes(全部走 @ui/motion.css 的 mk-* 类)', () => {
expect(stripCssComments(read(join(SRC, 'styles.css')))).not.toContain('@keyframes')
})
})
// ---- 3. 裸色值棘轮 ----
const HEX_BASELINE_STYLES = [
'#0e1116', '#101826', '#0b1f38', // 登录品牌面板固定深底渐变(面板自带深底,主题无关)
'#dfe4f2', // 同上面板的固定浅字
] as const
describe('裸色值棘轮', () => {
it('styles.css 不引入基线外裸色值', () => {
const found = collectHex(read(join(SRC, 'styles.css')))
const allow = new Set<string>(HEX_BASELINE_STYLES)
const extra = found.filter(h => !allow.has(h))
expect(extra, `新增裸色值 ${[...new Set(extra)].join(', ')}——改用 tokens 令牌;确需豁免更新 HEX_BASELINE_STYLES 并写明理由`).toEqual([])
})
it('组件 TSX 无裸色值', () => {
const extra: string[] = []
for (const p of walk(SRC).filter(p => /\.(tsx|ts)$/.test(p) && !p.endsWith('.d.ts'))) {
for (const h of collectHex(read(p))) extra.push(`${relative(SRC, p)}: ${h}`)
}
expect(extra, '组件里出现裸色值——走 tokens 令牌').toEqual([])
})
})
// ---- 4. 行内字号棘轮(--fs-ui-* 标尺) ----
const FONTSIZE_LITERAL_BASELINE = ['40'] as const // Conversation 空态装饰字形(⌘ 展示字号,非 UI 层级)
describe('行内字号棘轮', () => {
it('组件 TSX 无基线外 fontSize 数值字面量', () => {
const allow = new Set<string>(FONTSIZE_LITERAL_BASELINE)
const extra: string[] = []
for (const p of walk(SRC).filter(p => p.endsWith('.tsx'))) {
const lines = read(p).split('\n')
lines.forEach((l: string, i: number) => {
const m = l.match(/fontSize:\s*['"`]?([\d.]+)/)
if (m && !allow.has(m[1])) extra.push(`${relative(SRC, p)}:${i + 1} (${m[1]})`)
})
}
expect(extra, '行内 fontSize 数值——改用 var(--fs-ui-*);确需豁免更新基线并写明理由').toEqual([])
})
it('组件 TSX 无 <details> 折叠(走 @ui Collapsible,DESIGN.md §11.7)', () => {
const extra: string[] = []
for (const p of walk(SRC).filter(p => p.endsWith('.tsx'))) {
const code = read(p).replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
if (/<details[\s>]/.test(code)) extra.push(relative(SRC, p))
}
expect(extra, '<details> 折叠——改用 @ui Collapsible').toEqual([])
})
})
+4 -3
View File
@@ -122,7 +122,8 @@ describe('liveToMessage', () => {
const msg = liveToMessage(live({ items }))
for (const p of msg.parts.filter(p => p.type !== 'text')) expect(p.process).toBe(true)
})
it('artifact refs land after their tool call and are process', () => {
it('deliverable cards are content parts pinned after the reply text', () => {
// 与落库序同构(llm.go 交付物钉尾):artifact_ref 在终轮正文之后、不折叠。
let items: LiveItem[] = []
items = applyTextDelta(items, 0, '生成报表。')
items = applyToolCall(items, 'make_report', 'c1')
@@ -130,8 +131,8 @@ describe('liveToMessage', () => {
items = applyArtifactCreated(items, { artifactId: 'a1', name: 'report.csv', kind: 'csv' })
items = applyTextDelta(items, 1, '完成。')
const msg = liveToMessage(live({ items }))
expect(msg.parts.map(p => p.type)).toEqual(['text', 'tool_call', 'tool_result', 'artifact_ref', 'text'])
expect(msg.parts.find(p => p.type === 'artifact_ref')).toMatchObject({ process: true })
expect(msg.parts.map(p => p.type)).toEqual(['text', 'tool_call', 'tool_result', 'text', 'artifact_ref'])
expect(msg.parts.find(p => p.type === 'artifact_ref')?.process).toBeFalsy() // 内容部件,不折叠
expect(msg.parts.filter(p => p.type === 'text').map(p => p.process)).toEqual([true, undefined])
})
})
+18 -7
View File
@@ -9,20 +9,31 @@ function p(partial: Partial<Part> & { type: Part['type'] }): Part {
describe('groupParts(顺序保真)', () => {
it('interleaves tool blocks at their original positions', () => {
// 后端 llm.go 的真实交错序:text → tool → tool_result → artifact_ref → text
// 后端 llm.go 的真实落库序(交付物钉尾):text(过程解说) → tool → tool_result → text(终轮回复) → artifact_ref
const parts: Part[] = [
p({ type: 'text', text: '先找资料' }),
p({ type: 'text', text: '先找资料', process: true }),
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'search' } }),
p({ type: 'tool_result', process: true, toolResult: { callId: 'c1', status: 'ok' } }),
p({ type: 'artifact_ref', artifact: { artifactId: 'a1', name: '会议纪要.docx' } }),
p({ type: 'text', text: '文档已生成,请查收' }),
p({ type: 'artifact_ref', artifact: { artifactId: 'a1', name: '会议纪要.docx' } }),
]
const blocks = groupParts(parts)
expect(blocks.map(b => b.kind)).toEqual(['content', 'process', 'content', 'content'])
expect((blocks[0] as { part: Part }).part.text).toBe('先找资料')
expect((blocks[1] as { parts: Part[] }).parts.map(x => x.type)).toEqual(['tool_call', 'tool_result'])
expect(blocks.map(b => b.kind)).toEqual(['process', 'content', 'content'])
expect((blocks[0] as { parts: Part[] }).parts.map(x => x.type)).toEqual(['text', 'tool_call', 'tool_result'])
expect((blocks[1] as { part: Part }).part.text).toBe('文档已生成,请查收')
expect((blocks[2] as { part: Part }).part.type).toBe('artifact_ref')
expect((blocks[3] as { part: Part }).part.text).toBe('文档已生成,请查收')
})
it('renders deliverable cards as content in legacy inline order too', () => {
// 旧落库序(artifact_ref 夹在过程与正文之间):仍是内容部件,原位渲染不折叠。
const parts: Part[] = [
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'search' } }),
p({ type: 'artifact_ref', artifact: { artifactId: 'a1', name: '会议纪要.docx' } }),
p({ type: 'text', text: '文档已生成' }),
]
const blocks = groupParts(parts)
expect(blocks.map(b => b.kind)).toEqual(['process', 'content', 'content'])
expect((blocks[1] as { part: Part }).part.type).toBe('artifact_ref')
})
it('merges only consecutive process parts into one block', () => {
+1 -1
View File
@@ -12,7 +12,7 @@
"skipLibCheck": true,
"isolatedModules": true,
"noEmit": true,
"types": ["vite/client"],
"types": ["vite/client", "node"],
"baseUrl": ".",
"paths": {
"@ui": ["../ui/src/index.ts"],
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/api.ts","./src/auth.ts","./src/contracts.ts","./src/live.ts","./src/main.tsx","./src/messageparts.ts","./src/passwords.ts","./src/store.ts","./src/tasklist.ts","./src/components/AdminUsersModal.tsx","./src/components/AgentTracker.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/LoginPage.tsx","./src/components/ProjectSettingsModal.tsx","./src/components/SettingsModal.tsx","./src/components/TaskSidebar.tsx","./src/components/Workbench.tsx","./src/components/Workspaces.tsx","./test/auth.test.ts","./test/contracts.test.ts","./test/live.test.ts","./test/messageparts.test.ts","./test/tasklist.test.ts"],"version":"5.9.3"}
{"root":["./src/App.tsx","./src/api.ts","./src/auth.ts","./src/contracts.ts","./src/live.ts","./src/main.tsx","./src/messageparts.ts","./src/passwords.ts","./src/previewsetup.ts","./src/store.ts","./src/tasklist.ts","./src/components/AdminUsersModal.tsx","./src/components/AdminZone.tsx","./src/components/AgentTracker.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/LoginPage.tsx","./src/components/ProjectSettingsModal.tsx","./src/components/SettingsModal.tsx","./src/components/TaskSidebar.tsx","./src/components/Workbench.tsx","./src/components/Workspaces.tsx","./src/components/adminSections.tsx","./test/auth.test.ts","./test/contracts.test.ts","./test/design-guard.test.ts","./test/live.test.ts","./test/messageparts.test.ts","./test/tasklist.test.ts"],"version":"5.9.3"}