Compare commits

...
Author SHA1 Message Date
weichen a3c556cd0a Surface cross-session controls in the UI with a project settings panel
Why: the layered permission model (project gate + per-conversation
outbound/inbound flags) shipped in Backend PR #18 needs visible
controls; project scope also had no settings UI yet though the API
existed.

Evidence: npm run build (tsc -b + vite build) and npm test (15/15)
pass.

Impact: new schema-driven ProjectSettingsModal (first project-scope
settings surface, writes putProjectSettings, reuses SettingsModal
group/input helpers); TaskSidebar gains a private shield on rows and
in the row menu, per-conversation allowCrossRead in the row menu, and
a project-settings gear on project group heads; Conversation header
gains a cross-read toggle; contracts/api/store carry the new flags.

Refs: proposals/2026-09-30-cross-session-reading.md; Backend PR #18;
Dev task feature/cross-session-reading.
2026-09-30 15:19:46 +00:00
weichen b2fbcf72bc Merge pull request 'Consume the UI capability platform (avatars, settings shell)' (#14) from feature/ui-capability-platform into main 2026-09-30 14:23:17 +00:00
weichen 59ffe044b5 Consume the UI capability platform: shared icons/avatars, settings shell
Why: avatars, cards, and settings are base capabilities now (user
decision 2026-09-30); the host must eat its own dogfood so plugins get
the identical surfaces.

Evidence:
- Local icons.tsx removed; Icon/Avatar/BrandMark/primitives/Settings
  Shell imported from @ui; message stream gains avatar columns (user
  initial from /api/me name, assistant brand mark); SettingsModal
  rebuilt on SettingsShell (side-nav sections with badges, EmptyState
  for plugins, iconified actions); AdminUsersModal rows show shared
  avatars.
- tsconfig paths pin react/react-dom types to the frontend copy — the
  source-composed ui package otherwise resolves a second @types/react
  through its own node_modules during make frontend (npm ci), which
  breaks type identity (surfaced by SVGProps crossing the boundary);
  vite dedupe already guarantees one runtime copy.
- Headless on the real binary: avatar columns render, settings shell
  switches sections with badges, admin rows show avatars, zero console
  errors; build + vitest green.

Impact: visual/structural only; no API or state changes.

Refs: Agent-System-Documents proposal 2026-09-30-ui-redesign-and-auth.md
2026-09-30 14:22:37 +00:00
weichen 01672deec5 Merge pull request 'Restyle the whole UI: linear icons, layered surfaces, refined chrome (R4)' (#13) from feature/visual-refresh-r4 into main 2026-09-30 12:44:34 +00:00
weichen 9fc1387235 Restyle the whole UI: linear icon set, layered surfaces, refined chrome
Why: user feedback (2026-09-30) — the UI still lacked the polish and
modern feel of ZCode; per its design system (calm/dense/operational)
this is the R4 visual convergence pass.

Evidence:
- New icons.tsx: 32 Lucide-style linear SVG icons replacing every emoji/
  glyph (grep for the old glyph set now returns 0 in components); brand
  mark + workspace-icon mapping shared by sidebar, rail, login, menus.
- Login/change gates gain a wide-screen brand panel (gradient + radial
  glows + feature list), segmented tabs, focus-ring inputs, glowing
  submit; sidebar gets a solid accent New Task CTA, inline-icon search,
  selected task with accent bar, hover-reveal row actions, account
  footer chips; chat area gains floating rounded composer with focus
  glow, calmer header/tool blocks, greener HITL confirmations, empty
  state mark; modals gain backdrop blur + spring-in.
- Two headless review iterations on the real binary (--auth on, both
  themes): fixed bubble padding, headline break, and lifted the panel
  step for clearer three-column separation; final walk shows 21 SVGs,
  zero leftover glyphs, zero console errors, build + vitest + full make
  test green.

Impact: pure visual layer (CSS + icon components + class-level JSX);
no behavior, API, or state changes; both themes driven by the new token
scale in the ui repo.

Refs: Agent-System-Documents proposal 2026-09-30-ui-redesign-and-auth.md (R4)
2026-09-30 12:43:39 +00:00
weichen 2516df5b3b Merge pull request 'Login page, forced password change, admin user management (R2)' (#12) from feature/auth-admin-accounts into main 2026-09-30 11:17:04 +00:00
weichen 1d6b8a64a6 Add login page, forced password change, and admin user management
Why: accounts are admin-issued (user decision 2026-09-30, no self
registration); initial and admin-reset passwords must force a change on
first login.

Evidence:
- LoginPage: password (primary) + API key tabs; ChangePasswordGate
  blocks the app until the forced change completes (mismatch/length
  validation inline); App auth state machine checking -> login ->
  mustchange -> ok, session cookie flows through fetch automatically.
- Account menu gains change-password and admin-only user management;
  AdminUsersModal: create account (random initial password generator,
  admin checkbox), reset password, status table (ADMIN/USER, pending
  change chips).
- Fixed a real bug found in the headless walk: menu toggles passed
  e.currentTarget into the setState updater where React nulls it by
  render time, so anchor was null and the menu never rendered; capture
  the element before the updater.
- Headless journey on the real binary with --auth on: admin bootstrap
  via CLI -> first-login forced change -> app; admin creates bob (bob
  sees no user management); alice reset via CLI -> forced change on
  next login; API-key tab login; logout; zero console errors. npm run
  build + vitest green; make test full suite exit 0.

Impact: replaces the old API-key-only LoginGate; embedded no-auth mode
unchanged (no login page shown).

Refs: proposal 2026-09-30-ui-redesign-and-auth.md section 5 (R2,
admin-issued variant)
2026-09-30 11:16:47 +00:00
weichen 6cfc1059c5 Merge pull request 'Rebuild left nav as task-centric sidebar (option A, R1)' (#11) from feature/task-sidebar-r1 into main 2026-09-30 10:45:33 +00:00
weichen dc70399d45 Rebuild the left nav as a task-centric sidebar (option A, R1)
Why: creation entries were split (project + at sidebar top-left, new
session at bottom-left) and the project tree forced per-project
navigation; the approved option A design makes one primary New Task
entry, organization a view mode, and identity always present.

Evidence:
- New TaskSidebar: single New Task split button (current-project context,
  Cmd-N, secondary menu for new project / new task in project), project
  <-> timeline organization with persisted collapse, pinned section,
  archive view toggle, hover row actions, account footer fed by
  /api/me (embedded mode shows local tenant; logout clears the key),
  workspace icon strip retained (D25 shared list with Cmd-1..9).
- store: tasks = cross-project list via GET /api/conversations; openTask
  switches project context; pin/archive optimistic + refresh; last
  project context persisted; stale conversations field removed.
- tasklist.ts pure helpers + 6 vitest cases; npm run build passes;
  headless walk on the real binary: cross-project open, pinned-first,
  archive hide/show/restore, new-task flow, zero console errors.

Impact: ProjectNav removed; contracts.ts regenerated from core
(pinned/archived); no composer/right-pane changes (D25/D26/D31 intact).

Refs: Agent-System-Documents docs/proposals/2026-09-30-ui-redesign-and-auth.md
2026-09-30 10:41:12 +00:00
weichen 3aeafc4440 Merge pull request 'feature/prompt-library' (#10) from feature/prompt-library into main 2026-09-30 10:36:34 +00:00
weichen 4abf13d33d Mirror the prompt module contract and add the library API
Why: the backend prompt library ships new contract shapes and an
endpoint; the frontend mirror and api binding keep the TS side in sync
for upcoming management UI.

Evidence: tsc + vite build green as part of composite make test.

Impact: contracts.ts gains PromptModule/Trigger and
AgentProfile.promptModules; api gains listPromptModules. No UI change
yet.

Refs: feature/prompt-library task card.
2026-09-30 10:35:13 +00:00
weichen 0eb9066510 Merge pull request 'feature/chat-live-quality: live streaming bubble, markdown spacing, document-task quality' (#9) from feature/chat-live-quality into main 2026-09-30 09:59:24 +00:00
weichen 616795be41 Show the live streaming bubble and fix markdown spacing
Why: during a run nothing was rendered (the assistant message only lands
at completion, so the LiveState projection never hit the screen), and
white-space:pre-wrap on .part-text turned marked's inter-block newlines
into blank lines, giving ordered lists a huge per-item gap. A latent
race also wiped thinking/tool/phase state that arrived before the send
POST resolved, and thinking text ran inline with the answer.

Evidence: headless playwright against the mock backend shows the live
bar, an expanded thinking panel, tool chips and incremental text with a
blinking cursor mid-run; list item gaps measure 2.1px with
white-space:normal; make test (frontend 9 tests) green.

Impact: users watch thinking, tool calls and the answer stream in real
time; markdown typography (lists, tables, quotes, headings) is compact;
terminal runs fold the process trail behind a summary.

Refs: feature/chat-live-quality task card.
2026-09-30 09:57:35 +00:00
weichen a54022c438 Constrain the chat column height so the message stream scrolls
Why: .chat is a column-flex item of .main without min-height:0, so its
content-based automatic minimum height grew past the viewport; .chat-body
never overflowed (never scrolled) and the input bar was pushed off-screen.
Evidence: headless Chromium on mock serve, 1024x620: before .chat=2130px
and input-bar bottom=717 (>viewport), chat-body scrollable=false; after
.chat=620, chat-body clientH 501 / scrollH 2011 with wheel scroll moving
600px up and back to bottom, input-bar bottom=620. Variants (empty
conversation, single short message, 1024x400, 720x600) keep the input
visible. Full combo make test green.
Impact: chat page was unusable beyond one screen of content; single CSS
rule chain, no behavior change elsewhere.
Refs: Agent-System-Dev task card feature/chat-scroll-fix.
2026-09-30 09:15:45 +00:00
weichen a617504268 Merge pull request 'Add the tidy action and collapsed-rail pending badge (T5)' (#8) from feature/polish-r1 into main 2026-09-30 08:18:01 +00:00
weichen 4f4711a22a Add the tidy action and collapsed-rail pending badge (T5)
Why: T5 notification strategy - the collapsed right rail must still surface the pending count (badge), and the inbox needs a one-key tidy (done cards dismissed; resolved verdicts keep their stamp).
Evidence: tsc clean; vitest 9/9; vite build ok; delegation-only logic.
Impact: App collapsed rail badge, store tidyCards (done->dismiss via existing cardDismiss), Workbench tidy button, styles.
Refs: docs/proposals/2026-09-30-ui-inbox-command-workspace.md T5; task card feature/polish-r1
2026-09-30 08:13:13 +00:00
weichen e5b65ca79a Merge pull request 'Make the right rail pure inbox and move deep views into overlay workspaces (T4)' (#7) from feature/workspaces-r1 into main 2026-09-30 08:08:25 +00:00
weichen 58f3599660 Sync main before merge (stale-candidate guard) 2026-09-30 08:08:01 +00:00
weichen 298c41ff67 Make the right rail pure inbox and move deep views into overlay workspaces (T4)
Why: D25 final state - the right rail answers only "what needs me now" (inbox + a pinned current-task card); artifacts/agents/plugin panels become overlay workspaces with Cmd shortcuts and a left-rail quick section; DocViewer/AgentTracker migrate onto the shared OverlayHost.
Evidence: tsc clean; vitest 9/9; vite build ok; delegation-only logic (overlay semantics covered by ui OverlayHost suite).
Impact: Workbench drops classic tabs and gains CurrentTaskCard (plan+todos merged); new Workspaces.tsx (workspaceEntries, Cmd1-9 handler, builtin/plugin/server workspace rendering); store gains activeWorkspace/serverWorkspaces + open/close; ProjectNav quick section; Conversation project badge (D26); DocViewer/AgentTracker render through OverlayHost.
Refs: docs/proposals/2026-09-30-ui-inbox-command-workspace.md T4; task card feature/workspaces-r1
2026-09-30 08:04:21 +00:00
weichen 6a2b21a28d Merge pull request 'Wire inline confirm, cardlink locate, drop zone, and symmetric command menu (T3)' (#6) from feature/command-bus-r1 into main 2026-09-30 07:55:00 +00:00
weichen ee85556584 Wire inline confirm, cardlink locate, drop zone, and symmetric command menu (T3)
Why: the chat column must host the danger-command confirm interaction inline (anchored, blocking hint, collapsed once resolved), cardlinks must locate their inbox card (D32), files dropped on the conversation must attach, and the slash menu must reflect the command registry (same source as buttons/cards/chat, D27).
Evidence: vitest 9/9; tsc clean; delegation-only logic (rendering covered by ui InlineConfirmCard suite, dispatch by backend e2e).
Impact: Conversation renders anchored confirm cards via @ui InlineConfirmCard with openInbox locate (store helper), PartCard cardlink click locates the inbox card, chat-body accepts file drops through the existing upload path, composer hints while a confirm is open, CommandMenu tolerates commands without a slash alias and marks danger=high.
Refs: docs/proposals/2026-09-30-ui-inbox-command-workspace.md T3; task card feature/command-bus-r1
2026-09-30 07:54:41 +00:00
weichen 801a684c26 Merge pull request 'Wire inbox components (frontend)' (#4) from feature/inbox-r2 into main 2026-09-30 06:58:17 +00:00
22 changed files with 1585 additions and 372 deletions
+48 -38
View File
@@ -1,57 +1,48 @@
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 { Icon } from '@ui'
import { store, useStore } from './store'
import { ProjectNav } from './components/ProjectNav'
import { TaskSidebar } from './components/TaskSidebar'
import { Conversation } from './components/Conversation'
import { Workbench } from './components/Workbench'
import { Workspaces, workspaceShortcuts } from './components/Workspaces'
import { AgentTracker } from './components/AgentTracker'
import { SettingsModal } from './components/SettingsModal'
import { ProjectSettingsModal } from './components/ProjectSettingsModal'
import { DocViewer } from './components/DocViewer'
// 三栏布局:左(项目会话)/ 中(对话)/ 右(工作台);分隔条拖拽与超调收起(4.4)。
const LEFT_MIN = 220, LEFT_MAX = 420, LEFT_OVERSHOOT = 40
const RIGHT_MIN = 280, RIGHT_MAX = 560
// 登录门:服务端开启 API key 认证时(PG 档默认)要求输入密钥;嵌入式档无认证直接放行。
function LoginGate({ onSuccess }: { onSuccess: () => void }) {
const [key, setKey] = useState('')
const [err, setErr] = useState('')
const submit = async () => {
auth.setKey(key)
try {
const r = await auth.fetch('/api/health')
if (r.ok) { onSuccess(); return }
} catch { /* 网络错误按认证失败处理,提示重试 */ }
auth.clear()
setErr('密钥无效或已撤销,请重试')
}
return (
<div className="login-gate">
<div className="login-card">
<h2>Agent-System</h2>
<p className="login-hint">本服务已启用 API key 认证。密钥由服务端创建:<code>agent-system apikey create --owner &lt;name&gt;</code></p>
<input type="password" value={key} onChange={e => setKey(e.target.value)}
placeholder="ask_…" onKeyDown={e => e.key === 'Enter' && submit()} autoFocus />
<button onClick={submit}>进入</button>
{err && <div className="login-err">{err}</div>}
</div>
</div>
)
}
export function App() {
useEffect(() => {
const h = (e: KeyboardEvent) => workspaceShortcuts(e)
window.addEventListener('keydown', h, true)
return () => window.removeEventListener('keydown', h, true)
}, [])
const ui = useStore(s => s.ui)
const toast = useStore(s => s.toast)
const trackerRunId = useStore(s => s.ui.trackerRunId)
const viewingArtifact = useStore(s => s.ui.viewingArtifact)
// authState:checking → ok(放行)| required(登录门)。
const [authState, setAuthState] = useState<'checking' | 'required' | 'ok'>('checking')
const pendingBadge = useStore(s => s.cards.filter(c => c.status === 'action_required').length)
// authState:checking → login(登录)→ mustchange(首登强制改密)→ ok(放行)。
const [authState, setAuthState] = useState<'checking' | 'login' | 'mustchange' | 'ok'>('checking')
const [mustChangeOwner, setMustChangeOwner] = useState('')
useEffect(() => {
;(async () => {
try {
const r = await auth.fetch('/api/health')
setAuthState(r.status === 401 ? 'required' : 'ok')
if (r.status === 401) { setAuthState('login'); return }
// 已持有有效会话 cookie(或密钥)时直接放行;会话携带 must_change 时先进改密门。
const me = await api.me().catch(() => null)
if (me?.mustChangePassword) { setMustChangeOwner(me.owner); setAuthState('mustchange'); return }
setAuthState('ok')
} catch {
setAuthState('ok') // 网络异常不是认证问题;放行交给业务层报错
}
@@ -95,9 +86,19 @@ export function App() {
window.addEventListener('mouseup', up)
}
if (authState === 'login') {
return <LoginPage
onAuthed={() => setAuthState('ok')}
onMustChange={owner => { setMustChangeOwner(owner); setAuthState('mustchange') }} />
}
if (authState === 'mustchange') {
return <ChangePasswordGate
owner={mustChangeOwner}
onDone={async () => { await store.refreshMe().catch(() => undefined); setAuthState('ok') }}
onCancel={() => { api.logout().catch(() => undefined); setAuthState('login') }} />
}
if (authState !== 'ok') {
return authState === 'required' ? <LoginGate onSuccess={() => setAuthState('ok')} />
: <div className="login-gate"><div className="login-card"><p>连接服务中…</p></div></div>
return <div className="login-gate"><div className="login-card"><p>连接服务中…</p></div></div>
}
return (
@@ -105,16 +106,19 @@ export function App() {
{!ui.leftCollapsed ? (
<>
<div className="left-nav" style={{ width: ui.leftWidth }}>
<ProjectNav />
<TaskSidebar />
</div>
<div className="divider" onMouseDown={onDividerDown('left')} onDoubleClick={store.toggleLeft} title="拖动调整宽度;继续向左超过最小宽度后松开可收起;双击切换" />
</>
) : (
<div className="left-nav collapsed" style={{ width: 52 }}>
<div className="icon-only">
<button title="展开侧栏" onClick={store.toggleLeft}>☰</button>
<button title="新会话" onClick={() => store.createConversation()}>✚</button>
<button title="设置" onClick={() => store.openSettings(true)}>⚙</button>
<button title="展开侧栏(⌘B)" onClick={store.toggleLeft}><Icon name="panel-left" /></button>
<button title="新建任务(⌘N)" onClick={() => store.createTask()}><Icon name="plus" /></button>
<button title="收件箱" onClick={() => store.openInbox()}><Icon name="inbox" />
{pendingBadge > 0 && <span className="rail-badge" data-testid="rail-badge">{pendingBadge}</span>}
</button>
<button title="设置" onClick={() => store.openSettings(true)}><Icon name="settings" /></button>
</div>
</div>
)}
@@ -133,13 +137,19 @@ export function App() {
) : (
<div className="right-panel collapsed" style={{ width: 44 }}>
<div className="icon-only">
<button title="展开工作台" onClick={store.toggleRight}>◧</button>
<button title="展开收件箱" onClick={store.toggleRight}>◧
{pendingBadge > 0 && <span className="rail-badge" data-testid="rail-badge">{pendingBadge}</span>}
</button>
</div>
</div>
)}
<Workspaces />
{trackerRunId && <AgentTracker />}
{ui.settingsOpen && <SettingsModal />}
{ui.projectSettingsId && <ProjectSettingsModal />}
{ui.adminUsersOpen && <AdminUsersModal onClose={() => store.setUI({ adminUsersOpen: false })} />}
{ui.changePwOpen && <ChangePasswordModal onClose={() => store.setUI({ changePwOpen: false })} />}
{viewingArtifact && <DocViewer artifact={viewingArtifact} />}
{toast && <div className="toast">{toast}</div>}
</div>
+21 -2
View File
@@ -3,7 +3,7 @@
import { auth } from './auth'
import type {
Project, Conversation, Message, Run, Artifact, HITLCard, HITLResponse,
AgentProfile, SettingDesc, StreamEvent, PlanSnapshot, TodoList, UploadInfo,
AgentProfile, SettingDesc, StreamEvent, PlanSnapshot, TodoList, UploadInfo, PromptModule,
ActivityCard,
} from './contracts'
@@ -24,6 +24,23 @@ export const api = {
deleteProject: (id: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'DELETE' }),
listConversations: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`).then(r => json<Conversation[]>(r)),
// 跨项目任务列表(方案 A 侧栏):默认仅未归档;includeArchived 含归档。
listTasks: (includeArchived?: boolean) => auth.fetch(`${BASE}/api/conversations${includeArchived ? '?archived=1' : ''}`).then(r => json<Conversation[]>(r)),
setConversationFlags: (id: string, flags: { pinned?: boolean; archived?: boolean; allowCrossRead?: boolean; crossPrivate?: boolean }) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(flags) }).then(r => { if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) }),
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; isAdmin?: boolean; mustChangePassword?: boolean }>(r)),
// ---- 认证与会话(管理员发放制,R2)----
login: (owner: string, password: string) => auth.fetch(`${BASE}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, password }) }).then(async r => {
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean }>
}),
logout: () => auth.fetch(`${BASE}/api/auth/logout`, { method: 'POST' }),
changePassword: (current: string, next: string) => auth.fetch(`${BASE}/api/auth/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ current, next }) }).then(async r => {
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean }>
}),
listAdminUsers: () => auth.fetch(`${BASE}/api/admin/users`).then(r => json<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string }[]>(r)),
createAdminUser: (owner: string, name: string, password: string, isAdmin: boolean) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, name, password, isAdmin }) }).then(r => json<{ owner: string; mustChangePassword: boolean }>(r)),
resetAdminUserPassword: (owner: string, password: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) }).then(r => json<{ owner: string; mustChangePassword: boolean }>(r)),
createConversation: (projectId: string, title?: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: title ?? '' }) }).then(r => json<Conversation>(r)),
deleteConversation: (id: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'DELETE' }),
setConversationProfile: (id: string, profileId: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profileId }) }).then(r => json<Conversation>(r)),
@@ -67,6 +84,7 @@ export const api = {
getTodos: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/todos`).then(r => r.ok ? r.json() : null) as Promise<TodoList | null>,
listProfiles: () => auth.fetch(`${BASE}/api/profiles`).then(r => json<AgentProfile[]>(r)),
listPromptModules: () => auth.fetch(`${BASE}/api/prompts`).then(r => json<PromptModule[]>(r)),
saveProfile: (p: AgentProfile) => auth.fetch(`${BASE}/api/profiles`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(p) }).then(r => { if (!r.ok) throw new Error(r.statusText); return r.json() }),
deleteProfile: (id: string) => auth.fetch(`${BASE}/api/profiles/${id}`, { method: 'DELETE' }),
@@ -78,7 +96,8 @@ export const api = {
listTools: () => auth.fetch(`${BASE}/api/tools`).then(r => r.json()),
listPlugins: () => auth.fetch(`${BASE}/api/plugins`).then(r => r.json()),
reloadPlugins: () => auth.fetch(`${BASE}/api/plugins/reload`, { method: 'POST' }).then(r => r.json()),
listCommands: () => auth.fetch(`${BASE}/api/commands`).then(r => r.json()) as Promise<{ name: string; description: string }[]>,
listCommands: () => auth.fetch(`${BASE}/api/commands`).then(r => r.json()) as Promise<{ name?: string; id?: string; title?: string; description?: string; danger?: string }[]>,
listWorkspaces: () => auth.fetch(`${BASE}/api/workspaces`).then(r => r.json()) as Promise<{ id: string; title: string; icon?: string; shortcut?: string; badgeTool?: string; body?: any[]; refreshTool?: string }[]>,
}
// ---- SSE 客户端:Last-Event-ID 游标续传;断线自动重连 ----
+5
View File
@@ -30,6 +30,11 @@ export const auth = {
headers.set('Authorization', `Bearer ${key}`)
return fetch(input, { ...init, headers })
},
// 退出登录:清凭证并整页回登录门(服务端会话/密钥均为无状态凭证,无需服务端注销)。
logout() {
this.clear()
try { location.reload() } catch { /* 非 DOM 环境(测试)忽略 */ }
},
// 无法带头的资源 URL(EventSource / <img> / 下载):追加 ?key=。
withKey(url: string): string {
const key = this.apiKey()
+162
View File
@@ -0,0 +1,162 @@
// 管理员用户管理弹层 + 主动改密弹窗(R2 管理员发放制)。
// AdminUsersModal:建号(用户名/显示名/初始密码/管理员位)、重置密码、列表状态。
// ChangePasswordModal:登录者主动改密(与强制改密共用 API)。
import { useEffect, 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 }
function Shell({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
return (
<div className="overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="modal ad-card" role="dialog" aria-label={title} style={{ maxWidth: 640 }}>
<div className="modal-header">
<b>{title}</b>
<span className="spacer" />
<button className="ad-mini" onClick={onClose} aria-label="关闭"><Icon name="x" size={13} /></button>
</div>
<div className="ad-body">{children}</div>
</div>
</div>
)
}
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)
const [current, setCurrent] = useState('')
const [next, setNext] = useState('')
const [confirm, setConfirm] = useState('')
const [err, setErr] = useState('')
const [ok, setOk] = useState(false)
const [busy, setBusy] = useState(false)
const submit = async () => {
setErr('')
if (next !== confirm) { setErr('两次输入的新密码不一致'); return }
if (next.length < 8) { setErr('新密码至少 8 位'); return }
setBusy(true)
try {
await api.changePassword(current, next)
setOk(true)
store.toast('密码已更新')
setTimeout(onClose, 900)
} catch (e) {
setErr(String(e).includes('401') ? '当前密码不正确' : String(e))
} finally { setBusy(false) }
}
return (
<Shell title="修改密码" onClose={onClose}>
{ok ? <div className="ad-notice">密码已更新,其他设备的会话已注销。</div> : (
<div className="ad-changepw">
<label className="lg-label">当前密码({me?.owner ?? ''})</label>
<input className="lg-input" type="password" value={current} onChange={e => setCurrent(e.target.value)} autoFocus />
<label className="lg-label">新密码(≥8 位)</label>
<input className="lg-input" type="password" value={next} onChange={e => setNext(e.target.value)} />
<label className="lg-label">确认新密码</label>
<input className="lg-input" type="password" value={confirm} onChange={e => setConfirm(e.target.value)} />
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
<button className="lg-submit" disabled={busy || !current || !next || !confirm} onClick={submit}>{busy ? '提交中…' : '更新密码'}</button>
</div>
)}
</Shell>
)
}
+3 -2
View File
@@ -2,6 +2,7 @@
import { useEffect, useState } from 'react'
import { api } from '../api'
import { store, useStore } from '../store'
import { OverlayHost } from '@ui'
import type { Message, Run, Usage } from '../contracts'
export function AgentTracker() {
@@ -24,7 +25,7 @@ export function AgentTracker() {
const focusMsgs = focus === runId ? messagesAll.filter(m => m.runId === runId) : subMessages[focus] ?? []
return (
<div className="overlay" onClick={e => { if (e.target === e.currentTarget) store.setUI({ trackerRunId: null }) }}>
<OverlayHost title="代理追踪" icon="🤖" onClose={() => store.setUI({ trackerRunId: null })}>
<div className="modal">
<div className="modal-header">
🤖 Sub-agent 追踪
@@ -75,7 +76,7 @@ export function AgentTracker() {
)}
</div>
</div>
</div>
</OverlayHost>
)
}
+90 -23
View File
@@ -1,13 +1,15 @@
// 中栏:消息流(Part 渲染器注册表)+ 流式渲染 + 过程折叠 + HITL 卡片 + 输入区(4.4)。
import { Icon, Avatar, BrandMark } 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 type { Message, Part, HITLCard } from '../contracts'
import { UIRenderer } from '@ui'
import { UIRenderer, InlineConfirmCard, hasOpenConfirm } from '@ui'
import { docBridge } from './Workbench'
marked.setOptions({ breaks: true })
// marked 走标准 Markdown 语义(不开 breaks):模型输出按块级排版渲染,
// 行内换行交给段落折叠,避免与 CSS 块间距叠加出松散排版。
// Part 渲染器注册表:按类型查表;未知类型静默忽略(前向兼容)。
function PartView({ part, live }: { part: Part; live?: boolean | null }) {
@@ -16,7 +18,13 @@ function PartView({ part, live }: { part: Part; live?: boolean | null }) {
return <div className={live ? 'part-text cursor-blink' : 'part-text'}
dangerouslySetInnerHTML={{ __html: marked.parse(part.text ?? '') }} />
case 'thinking':
return <div className="part-thinking">{part.text}</div>
// 流式期间展开可实时看到思考;run 终态后收进过程折叠区,默认收起。
return (
<details className="part-thinking" open={live ? true : undefined}>
<summary>思考过程</summary>
<div className="part-thinking-body">{part.text}</div>
</details>
)
case 'tool_call':
return <span className="tool-chip">🛠 {part.toolCall?.name}</span>
case 'tool_result': {
@@ -31,11 +39,19 @@ function PartView({ part, live }: { part: Part; live?: boolean | null }) {
if (art) store.viewArtifact(art)
}}>📄 {part.artifact?.name}</span>
case 'hitl':
return <div className="tool-chip">❓ {part.hitl?.question} → {part.hitl?.answer || '…'}</div>
return <div className="tool-chip"><Icon name="message" size={12} /> {part.hitl?.question} → {part.hitl?.answer || '…'}</div>
case 'plan':
return null // 计划在右栏 Plan 标签呈现
case 'card':
return <div className="ui-section"><UIRenderer nodes={part.card?.data ?? []} docBridge={docBridge} /></div>
case 'card': {
// cardlink 互定位(D32):点击 ↗ 定位收件箱对应卡(从 data 中解析 card:<id>)。
const m = /card:([A-Za-z0-9_]+)/.exec(JSON.stringify(part.card?.data ?? ''))
return (
<div className="ui-section card-link" title={m ? '点击在收件箱中定位' : undefined}
onClick={() => m && store.openInbox(m[1])}>
<UIRenderer nodes={part.card?.data ?? []} docBridge={docBridge} />
</div>
)
}
case 'error':
return <div className="part-error">{part.error?.code}: {part.error?.message}</div>
default:
@@ -56,7 +72,7 @@ function ProcessCollapse({ parts }: { parts: Part[] }) {
return (
<details className="process-collapse">
<summary>
<span>⚙ 处程输出</span>
<span>过程输出</span>
<span>{hasThinking ? '含思考' : ''}{toolCount > 0 ? ` · ${toolCount} 次工具调用` : ''}(点击展开)</span>
</summary>
<div className="body">
@@ -81,11 +97,12 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null
return (
<div className="msg assistant">
<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>}
</div>
{split.process.length > 0 && (showProcessInline
? split.process.map((p, i) => <div className="part" key={i}><PartView part={p} /></div>)
? split.process.map((p, i) => <div className="part" key={i}><PartView part={p} live={!!live} /></div>)
: <ProcessCollapse parts={split.process} />)}
{split.content.map((p, i) => <div className="part" key={i}><PartView part={p} live={!!(live && i === split.content.length - 1 && p.type === 'text')} /></div>)}
{live && <LiveAgentNotes live={live} />}
@@ -94,6 +111,22 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null
)
}
// live 投影:run 进行中没有已落库的助手消息,把 LiveState 合成为同构的临时消息
// 复用 AssistantMessage 渲染(思考/工具/文本实时上屏);run 终态对账后由真消息替代。
function liveToMessage(live: LiveState): Message {
const parts: Part[] = []
if (live.thinking) parts.push({ type: 'thinking', text: live.thinking, process: true })
for (const tc of live.toolCalls) {
parts.push({ type: 'tool_call', process: true, toolCall: { id: '', name: tc.name } })
if (tc.status !== undefined) {
parts.push({ type: 'tool_result', process: true, toolCall: { id: '', name: tc.name },
toolResult: { callId: '', status: tc.status === 'error' ? 'error' : 'ok', summary: tc.summary } })
}
}
if (live.text) parts.push({ type: 'text', text: live.text })
return { id: `live:${live.runId}`, conversationId: '', role: 'assistant', runId: live.runId, parts, createdAt: '' }
}
// 上下文组装观测(4.10/4.17):模型实际看到什么、裁了什么——可解释性入口。
function ContextObsView({ obs }: { obs: ContextObs }) {
return (
@@ -136,7 +169,7 @@ function HITLCardView({ card }: { card: HITLCard }) {
}
return (
<div className="hitl-card">
<div className="hitl-q">❓ {card.question}</div>
<div className="hitl-q"><Icon name="check" size={15} />{card.question}</div>
{card.choices && (
<div className="hitl-choices">
{card.choices.map(c => (
@@ -161,18 +194,19 @@ function HITLCardView({ card }: { card: HITLCard }) {
}
function CommandMenu({ text, onPick }: { text: string; onPick: (cmd: string) => void }) {
const [commands, setCommands] = useState<{ name: string; description: string }[]>([])
const [commands, setCommands] = useState<{ name?: string; id?: string; title?: string; description?: string; danger?: string }[]>([])
const [active, setActive] = useState(0)
useEffect(() => { api.listCommands().then(setCommands).catch(() => undefined) }, [])
const q = text.slice(1).toLowerCase()
const hits = commands.filter(c => c.name.slice(1).toLowerCase().startsWith(q))
// 四入口同源(D27):菜单即注册表——有 `/` 别名的可直接补全;无别名的展示说明(对称提示)。
const hits = commands.filter(c => (c.name ?? '').slice(1).toLowerCase().startsWith(q))
if (hits.length === 0) return null
return (
<div className="cmd-menu">
{hits.map((c, i) => (
<div key={c.name} className={`cmd-item ${i === active ? 'active' : ''}`}
onMouseEnter={() => setActive(i)} onClick={() => onPick(c.name + ' ')}>
<span className="name">{c.name}</span><span>{c.description}</span>
onMouseEnter={() => setActive(i)} onClick={() => onPick(c.name! + ' ')}>
<span className="name">{c.name}{c.danger === 'high' ? ' ⚠' : ''}</span><span>{c.description}</span>
</div>
))}
</div>
@@ -181,14 +215,17 @@ function CommandMenu({ text, onPick }: { text: string; onPick: (cmd: string) =>
export function Conversation() {
const currentConvId = useStore(s => s.currentConvId)
const conversation = useStore(s => s.conversations.find(c => c.id === s.currentConvId))
const conversation = useStore(s => s.tasks.find(c => c.id === s.currentConvId))
const messages = useStore(s => s.messages)
const myName = useStore(s => s.me?.name || s.me?.owner || '你')
const activeHitl = useStore(s => s.activeHitl)
const live = useStore(s => s.live)
const runs = useStore(s => s.runs)
const connected = useStore(s => s.connected)
const artifacts = useStore(s => s.artifacts)
const profiles = useStore(s => s.profiles)
const cards = useStore(s => s.cards)
const projectName = useStore(s => s.projects.find(p => p.id === s.currentProjectId)?.name ?? '项目')
const [text, setText] = useState('')
const [attachmentIds, setAttachmentIds] = useState<string[]>([])
@@ -201,7 +238,7 @@ export function Conversation() {
useEffect(() => {
bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight })
}, [messages.length, live?.text, live?.thinking, activeHitl.length])
}, [messages.length, live?.text, live?.thinking, live?.toolCalls.length, live?.phase, activeHitl.length])
const send = async () => {
if (!text.trim() && attachmentIds.length === 0) return
@@ -225,6 +262,7 @@ export function Conversation() {
return (
<div className="chat">
<div className="chat-header">
<span className="project-badge" title="当前项目">{projectName}</span>
<div className="chat-title">{conversation?.title ?? '会话'}</div>
<select className="effect-badge" value={conversation?.profileId || 'default'} title="切换会话画像"
onChange={async e => {
@@ -234,25 +272,54 @@ export function Conversation() {
}}>
{profiles.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
<button className="btn ghost" style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}
title={conversation?.allowCrossRead === false
? '本会话不读取其他会话记录(点击开启)'
: '本会话可读取同项目其他会话记录(需项目设置开启跨会话读取;点击关闭)'}
onClick={() => currentConvId && store.setCrossFlags(currentConvId, { allowCrossRead: conversation?.allowCrossRead === false })}>
<Icon name="users" size={13} />跨读{conversation?.allowCrossRead === false ? '·关' : '·开'}
</button>
<span title={connected ? '事件流已连接' : '事件流重连中'} className={`conn-dot ${connected ? 'on' : 'off'}`} />
{active && <button className="btn ghost" onClick={() => store.cancelActive()}>■ 停止</button>}
{active && <button className="btn ghost" onClick={() => store.cancelActive()}>停止</button>}
</div>
<div className="chat-body" ref={bodyRef}>
<div className="chat-body" ref={bodyRef}
onDragOver={e => { e.preventDefault(); e.currentTarget.classList.add('drag-over') }}
onDragLeave={e => e.currentTarget.classList.remove('drag-over')}
onDrop={async e => {
e.preventDefault(); e.currentTarget.classList.remove('drag-over')
for (const f of Array.from(e.dataTransfer.files ?? [])) {
const id = await store.uploadAttachment(f)
if (id) { setAttachmentIds(a => [...a, id]); setAttachmentNames(a => [...a, f.name]) }
}
}}>
{messages.filter(m => m.role !== 'assistant' || !m.runId || runs.find(r => r.id === m.runId)?.kind !== 'subagent').map(m => {
if (m.role === 'user') {
// 内联确认卡(T3):锚定本消息的危险命令确认,双现于对话流与收件箱。
const anchored = cards.filter(c => c.anchorMessageId === m.id && c.kind === 'confirm')
return (
<div className="msg user" key={m.id}>
<div className="msg-role">你</div>
<div className="msg-role">
<span className="role-mark"><Avatar name={myName} size="sm" /></span>你
</div>
<div className="bubble">{m.parts.filter(p => p.type === 'text').map(p => p.text).join('\n')}</div>
{anchored.map(c => (
<InlineConfirmCard key={c.id} card={c} callbacks={{
onAction: (card, actionId) => store.cardAction(card, actionId),
onLocate: card => store.openInbox(card.id),
}} />
))}
</div>
)
}
return <AssistantMessage key={m.id} msg={m} live={live?.runId === m.runId ? live : null} />
})}
{live && !messages.some(m => m.runId === live.runId) &&
<AssistantMessage key={`live-${live.runId}`} msg={liveToMessage(live)} live={live} />}
{activeHitl.map(c => <HITLCardView key={c.id} card={c} />)}
{messages.length === 0 && <div className="empty-state">
<div>发送消息开始对话</div>
<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>}
</div>
@@ -260,15 +327,15 @@ export function Conversation() {
<div className="input-bar">
{attachmentNames.length > 0 && (
<div className="attachments">
{attachmentNames.map((n, i) => <span className="attach-chip" key={i}>📎 {n}
<span style={{ cursor: 'pointer' }} onClick={() => { setAttachmentIds(a => a.filter((_, j) => j !== i)); setAttachmentNames(a => a.filter((_, j) => j !== i)) }}>✕</span>
{attachmentNames.map((n, i) => <span className="attach-chip" key={i}><Icon name='paperclip' size={12} /> {n}
<span style={{ cursor: 'pointer', display: 'inline-flex' }} onClick={() => { setAttachmentIds(a => a.filter((_, j) => j !== i)); setAttachmentNames(a => a.filter((_, j) => j !== i)) }}><Icon name="x" size={11} /></span>
</span>)}
</div>
)}
{text.startsWith('/') && <CommandMenu text={text} onPick={c => setText(c)} />}
<div className="input-row">
<label className="icon-btn" title="上传附件(文档解析后注入上下文)">
📎<input type="file" multiple style={{ display: 'none' }} onChange={async e => {
<Icon name="paperclip" size={16} /><input type="file" multiple style={{ display: 'none' }} onChange={async e => {
for (const f of Array.from(e.target.files ?? [])) {
const id = await store.uploadAttachment(f)
if (id) { setAttachmentIds(a => [...a, id]); setAttachmentNames(a => [...a, f.name]) }
@@ -276,7 +343,7 @@ export function Conversation() {
e.target.value = ''
}} />
</label>
<textarea rows={1} value={text} placeholder={artifacts.length > 0 ? '输入消息…(/ 命令,@knowledge 检索注入)' : '输入消息…(/ 命令)'}
<textarea rows={1} value={text} placeholder={hasOpenConfirm(cards) ? '有待确认的命令——请先处理上方确认卡' : artifacts.length > 0 ? '输入消息…(/ 命令,@knowledge 检索注入)' : '输入消息…(/ 命令)'}
onChange={e => setText(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() } }} />
{active ? (
+3 -2
View File
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'
import { marked } from 'marked'
import { api } from '../api'
import { store } from '../store'
import { OverlayHost } from '@ui'
import type { Artifact } from '../contracts'
export function DocViewer({ artifact }: { artifact: Artifact }) {
@@ -16,7 +17,7 @@ export function DocViewer({ artifact }: { artifact: Artifact }) {
}, [artifact.id])
return (
<div className="overlay" onClick={e => { if (e.target === e.currentTarget) store.viewArtifact(null) }}>
<OverlayHost title={artifact.name} icon="📄" onClose={() => store.viewArtifact(null)}>
<div className="modal">
<div className="modal-header">
📄 {artifact.name}
@@ -49,7 +50,7 @@ export function DocViewer({ artifact }: { artifact: Artifact }) {
)}
</div>
</div>
</div>
</OverlayHost>
)
}
+154
View File
@@ -0,0 +1,154 @@
// 登录与改密(管理员发放制,R2):
// LoginPage —— 用户名+密码(主)/ API 密钥(兼容程序化客户端)双入口;
// ChangePasswordGate —— 初始/重置密码后的首登强制改密(不完成不放行)。
import { useState } from 'react'
import { auth } from '../auth'
import { api } from '../api'
import { Icon, BrandMark } from '@ui'
function Err({ msg }: { msg: string }) {
if (!msg) return null
return <div className="lg-err" role="alert"><Icon name="alert" size={14} />{msg}</div>
}
/** 宽屏右侧品牌面板(叙事 + 特性清单);窄屏隐藏。 */
function BrandPanel() {
return (
<aside className="lg-panel">
<h2>把研发记忆,<br />变成组织的第二大脑</h2>
<p>文档、决策与知识的沉淀、检索与生成——面向团队的自托管智能体工作台。</p>
<ul>
<li><Icon name="message" size={15} />项目化多任务工作区,收件箱驱动待办</li>
<li><Icon name="shield" size={15} />管理员发放账号,密码策略与密钥分级</li>
<li><Icon name="search" size={15} />知识库混合检索(全文 + 向量)</li>
<li><Icon name="package" size={15} />文档生成流水线与工件管理</li>
</ul>
<div className="lg-foot"><span>自托管 · 多租户</span><span>数据不出域</span></div>
</aside>
)
}
/** 登录页:认证开启(PG 档/–-auth on)时的唯一入口。 */
export function LoginPage({ onAuthed, onMustChange }: { onAuthed: () => void; onMustChange: (owner: string) => void }) {
const [tab, setTab] = useState<'password' | 'apikey'>('password')
const [owner, setOwner] = useState('')
const [password, setPassword] = useState('')
const [key, setKey] = useState('')
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
const submitPassword = async () => {
setErr(''); setBusy(true)
try {
const me = await api.login(owner.trim(), password)
if (me.mustChangePassword) {
// 初始/重置密码:先完成强制改密再放行。
onMustChange(me.owner)
return
}
onAuthed()
} catch (e) {
setErr(humanLoginError(String(e)))
} finally { setBusy(false) }
}
const submitKey = async () => {
setErr(''); setBusy(true)
auth.setKey(key)
try {
const r = await auth.fetch('/api/health')
if (r.ok) { onAuthed(); return }
throw new Error(String(r.status))
} catch {
auth.clear()
setErr('密钥无效或已撤销,请重试')
} finally { setBusy(false) }
}
return (
<div className="lg-wrap">
<BrandPanel />
<div className="lg-formside">
<form className="lg-card" onSubmit={e => { e.preventDefault(); tab === 'password' ? submitPassword() : submitKey() }}>
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
<h2 className="lg-title">登录</h2>
<p className="lg-sub">账号由管理员发放;首次登录需修改初始密码</p>
<div className="lg-tabs" role="tablist">
<button type="button" role="tab" aria-selected={tab === 'password'} className={`lg-tab ${tab === 'password' ? 'on' : ''}`} onClick={() => setTab('password')}>账号密码</button>
<button type="button" role="tab" aria-selected={tab === 'apikey'} className={`lg-tab ${tab === 'apikey' ? 'on' : ''}`} onClick={() => setTab('apikey')}>API 密钥</button>
</div>
{tab === 'password' ? (
<>
<label className="lg-label">用户名</label>
<input className="lg-input" value={owner} onChange={e => setOwner(e.target.value)} autoFocus autoComplete="username" placeholder="由管理员分配" />
<label className="lg-label">密码</label>
<input className="lg-input" type="password" value={password} onChange={e => setPassword(e.target.value)} autoComplete="current-password" placeholder="初始密码或常用密码" />
<Err msg={err} />
<button className="lg-submit" type="submit" disabled={busy || !owner.trim() || !password}>{busy ? '验证中…' : '登录'}</button>
</>
) : (
<>
<label className="lg-label">API 访问密钥</label>
<input className="lg-input lg-mono" value={key} onChange={e => setKey(e.target.value)} autoFocus placeholder="ask_…" />
<Err msg={err} />
<button className="lg-submit" type="submit" disabled={busy || !key}>{busy ? '验证中…' : '进入'}</button>
<p className="lg-hint">密钥由服务端创建:<code>agent-system apikey create --owner &lt;name&gt;</code>。程序化客户端也可直接带 Authorization 头。</p>
</>
)}
</form>
</div>
</div>
)
}
/** 强制改密门:初始密码/管理员重置后的首次登录必须完成改密才可进入系统。 */
export function ChangePasswordGate({ owner, onDone, onCancel }: { owner: string; onDone: () => void; onCancel?: () => void }) {
const [current, setCurrent] = useState('')
const [next, setNext] = useState('')
const [confirm, setConfirm] = useState('')
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
const submit = async () => {
setErr('')
if (next !== confirm) { setErr('两次输入的新密码不一致'); return }
if (next.length < 8) { setErr('新密码至少 8 位'); return }
setBusy(true)
try {
await api.changePassword(current, next)
onDone()
} catch (e) {
setErr(humanLoginError(String(e)).replace('401: ', ''))
} finally { setBusy(false) }
}
return (
<div className="lg-wrap">
<BrandPanel />
<div className="lg-formside">
<form className="lg-card" onSubmit={e => { e.preventDefault(); submit() }}>
<div className="lg-brand"><BrandMark size={26} />Agent-System</div>
<h2 className="lg-title">修改密码</h2>
<p className="lg-sub">账号 <b>{owner}</b> 使用初始/重置密码首次登录,须设置新密码后继续</p>
<label className="lg-label">当前密码(初始/重置密码)</label>
<input className="lg-input" type="password" value={current} onChange={e => setCurrent(e.target.value)} autoFocus autoComplete="current-password" />
<label className="lg-label">新密码(≥8 位)</label>
<input className="lg-input" type="password" value={next} onChange={e => setNext(e.target.value)} autoComplete="new-password" />
<label className="lg-label">确认新密码</label>
<input className="lg-input" type="password" value={confirm} onChange={e => setConfirm(e.target.value)} autoComplete="new-password" />
<Err msg={err} />
<button className="lg-submit" type="submit" disabled={busy || !current || !next || !confirm}>{busy ? '提交中…' : '设置新密码并进入'}</button>
{onCancel && <button type="button" className="lg-cancel" onClick={onCancel}>返回登录</button>}
</form>
</div>
</div>
)
}
/** 错误文案:把 HTTP 状态码翻成人话。 */
export function humanLoginError(raw: string): string {
if (raw.includes('401')) return '用户名或密码不正确'
if (raw.includes('429')) return '失败次数过多,请稍后再试'
if (raw.includes('412')) return '本服务未开启登录(嵌入式单租户档)'
return raw.replace(/^\d{3}:\s*/, '')
}
-62
View File
@@ -1,62 +0,0 @@
// 左栏:项目折叠组 → 会话列表;底部账户/设置入口(4.4)。
import { useEffect, useState } from 'react'
import { store, useStore } from '../store'
export function ProjectNav() {
const projects = useStore(s => s.projects)
const currentProjectId = useStore(s => s.currentProjectId)
const conversations = useStore(s => s.conversations)
const currentConvId = useStore(s => s.currentConvId)
const [collapsedProjects, setCollapsedProjects] = useState<Record<string, boolean>>({})
const [creating, setCreating] = useState(false)
const [newName, setNewName] = useState('')
useEffect(() => { setNewName(`项目 ${projects.length + 1}`) }, [projects.length])
return (
<>
<div className="nav-header">
<span className="nav-label">Agent-System</span>
<button title="新建项目" onClick={() => setCreating(v => !v)}>✚</button>
</div>
{creating && (
<div style={{ padding: '0 10px 8px', display: 'flex', gap: 6 }}>
<input className="search-input" value={newName} onChange={e => setNewName(e.target.value)}
onKeyDown={async e => {
if (e.key === 'Enter' && newName.trim()) { await store.createProject(newName.trim()); setCreating(false) }
}} placeholder="项目名称" autoFocus />
<button className="btn" onClick={async () => { if (newName.trim()) { await store.createProject(newName.trim()); setCreating(false) } }}>建</button>
</div>
)}
<div className="nav-scroll">
{projects.length === 0 && <div style={{ padding: 12, color: 'var(--text-faint)', fontSize: 12.5 }}>尚无项目——点击 ✚ 创建</div>}
{projects.map(p => {
const open = !collapsedProjects[p.id]
return (
<div className="nav-project" key={p.id}>
<div className={`nav-project-name ${open ? 'open' : ''} ${p.id === currentProjectId ? '' : ''}`}
style={p.id === currentProjectId ? { color: 'var(--text)' } : undefined}
onClick={async () => {
setCollapsedProjects(c => ({ ...c, [p.id]: !(c[p.id] ?? p.id === currentProjectId ? false : true) }))
await store.openProject(p.id)
}}>
<span className="chev">▶</span>
<span style={{ flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{p.name}</span>
</div>
{open && p.id === currentProjectId && conversations.map(c => (
<div key={c.id} className={`nav-conv ${c.id === currentConvId ? 'active' : ''}`}
onClick={() => store.openConversation(c.id)} title={c.title}>
{c.title}
</div>
))}
</div>
)
})}
</div>
<div className="nav-footer">
<button className="nav-label" onClick={() => store.createConversation()}>+ 新会话</button>
<button className="nav-label" onClick={() => store.openSettings(true)}>⚙ 设置</button>
</div>
</>
)
}
+86
View File
@@ -0,0 +1,86 @@
// 项目设置面板(提案 2026-09-30-cross-session-reading):首个项目级设置 UI。
// schema 驱动渲染 project scope(4.9 覆盖链:system → user → project),
// 写 putProjectSettings;后续项目级设置项经 settings schema 自动出现,零开发。
import { useEffect, useMemo, useState } from 'react'
import { api } from '../api'
import { store, useStore } from '../store'
import type { SettingDesc } from '../contracts'
import { SettingsShell, Btn, EmptyState } from '@ui'
import { SettingInput, flatten, unflatten, groupNames } from './SettingsModal'
export function ProjectSettingsModal() {
const projectId = useStore(s => s.ui.projectSettingsId)
const projects = useStore(s => s.projects)
const project = projects.find(p => p.id === projectId)
const [schema, setSchema] = useState<SettingDesc[]>([])
const [draft, setDraft] = useState<Record<string, any>>({})
const [loaded, setLoaded] = useState(false)
const [saved, setSaved] = useState(false)
useEffect(() => {
if (!projectId) return
setLoaded(false)
api.getSettings(projectId).then(s => {
setSchema(s.schema)
setDraft(flatten(s.project))
setLoaded(true)
}).catch(() => undefined)
}, [projectId])
const groups = useMemo(() => {
const g: Record<string, SettingDesc[]> = {}
for (const d of schema) (g[d.group] ??= []).push(d)
return g
}, [schema])
if (!projectId) return null
const save = async () => {
if (!projectId) return
try {
await api.putProjectSettings(projectId, unflatten(draft))
setSaved(true)
setTimeout(() => setSaved(false), 1500)
} catch (e) {
store.toast(`项目设置保存失败:${e}`)
}
}
return (
<SettingsShell
title={`项目设置 · ${project?.name ?? projectId}`}
active="project"
onSelect={() => undefined}
onClose={() => store.openProjectSettings(null)}
sections={[{ key: 'project', label: '本项目', icon: 'folder' }]}
footer={saved ? <span style={{ color: 'var(--ok)' }}>✓ 已保存</span> : '项目设置覆盖用户设置(new-run 类对新任务生效)'}
>
<div>
{!loaded ? (
<EmptyState icon="settings">加载中…</EmptyState>
) : (
<>
<div className="empty-note" style={{ marginBottom: 10 }}>
此处仅保存本项目的覆盖值;留空即沿用用户/系统设置。跨会话读取在「会话协作」组。
</div>
{Object.entries(groups).map(([g, descs]) => (
<div className="settings-group" key={g}>
<h4>{groupNames[g] ?? g}</h4>
{descs.map(d => (
<div className="setting-row" key={d.key}>
<label title={d.key}>{d.label}</label>
<SettingInput desc={d} value={draft[d.key] ?? ''} onChange={v => setDraft(x => ({ ...x, [d.key]: v }))} />
<span className={`effect-badge effect-${d.effect}`} title={`生效时机:${d.effect}`}>
{d.effect === 'hot' ? '立即' : d.effect === 'new-run' ? '新任务' : '需重启'}
</span>
</div>
))}
</div>
))}
<Btn variant="primary" icon="check" onClick={save}>保存项目设置</Btn>
</>
)}
</div>
</SettingsShell>
)
}
+27 -31
View File
@@ -3,9 +3,16 @@ import { useEffect, useMemo, useState } from 'react'
import { api } from '../api'
import { store, useStore } from '../store'
import type { AgentProfile, SettingDesc } from '../contracts'
import { SettingsShell, Btn, EmptyState } from '@ui'
type Section = 'settings' | 'profiles' | 'plugins'
// 分组显示名(项目设置面板共用)。
export const groupNames: Record<string, string> = {
models: '模型与密钥', runtime: '运行时', context: '上下文', retrieval: '检索',
ui: '界面', plugins: '插件目录', crossSession: '会话协作', tools: '工具',
}
export function SettingsModal() {
const profiles = useStore(s => s.profiles)
const [section, setSection] = useState<Section>('settings')
@@ -28,11 +35,6 @@ export function SettingsModal() {
return g
}, [schema])
const groupNames: Record<string, string> = {
models: '模型与密钥', runtime: '运行时', context: '上下文', retrieval: '检索',
ui: '界面', plugins: '插件目录',
}
const save = async () => {
const values = unflatten(draft)
await api.putUserSettings(values)
@@ -45,22 +47,17 @@ export function SettingsModal() {
}
return (
<div className="overlay" onClick={e => { if (e.target === e.currentTarget) store.openSettings(false) }}>
<div className="modal">
<div className="modal-header">
⚙ 设置
<div className="tabs" style={{ margin: '0 16px', borderBottom: 'none' }}>
{(['settings', 'profiles', 'plugins'] as Section[]).map(s => (
<button key={s} className={`tab ${section === s ? 'active' : ''}`} onClick={() => setSection(s)}>
{s === 'settings' ? '设置' : s === 'profiles' ? '智能体画像' : '插件'}
</button>
))}
</div>
<span className="spacer" />
{saved && <span style={{ color: 'var(--ok)', marginRight: 10, fontSize: 12.5 }}>已保存</span>}
<button className="btn ghost" onClick={() => store.openSettings(false)}>关闭</button>
</div>
<div className="modal-body">
<SettingsShell
title="设置" active={section} onSelect={k => setSection(k as Section)}
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 · 设置中心'}
>
<div>
{section === 'settings' && (
<>
{Object.entries(groups).map(([g, descs]) => (
@@ -77,18 +74,17 @@ export function SettingsModal() {
))}
</div>
))}
<button className="btn" onClick={save}>保存设置</button>
<Btn variant="primary" icon="check" onClick={save}>保存设置</Btn>
</>
)}
{section === 'profiles' && <ProfilesSection profiles={profiles} />}
{section === 'plugins' && <PluginsSection />}
</div>
</div>
</div>
</SettingsShell>
)
}
function SettingInput({ desc, value, onChange }: { desc: SettingDesc; value: any; onChange: (v: any) => void }) {
export function SettingInput({ desc, value, onChange }: { desc: SettingDesc; value: any; onChange: (v: any) => void }) {
if (desc.type === 'boolean') {
return <input type="checkbox" checked={value === true || value === 'true'} onChange={e => onChange(e.target.checked)} />
}
@@ -111,12 +107,12 @@ function flat(m: Record<string, any>, prefix: string, out: Record<string, any>)
else out[key] = v
}
}
function flatten(m: Record<string, any>): Record<string, any> {
export function flatten(m: Record<string, any>): Record<string, any> {
const out: Record<string, any> = {}
flat(m, '', out)
return out
}
function unflatten(flatMap: Record<string, any>): Record<string, any> {
export function unflatten(flatMap: Record<string, any>): Record<string, any> {
const out: Record<string, any> = {}
for (const [k, v] of Object.entries(flatMap)) {
if (v === '' || v === undefined) continue // 空串不覆盖(密钥未修改)
@@ -135,7 +131,7 @@ function ProfilesSection({ profiles }: { profiles: AgentProfile[] }) {
return (
<div>
<div className="doc-toolbar">
<button className="btn" onClick={() => { setEditing(newProfile()); setText('') }}>+ 新建画像</button>
<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]
@@ -207,15 +203,15 @@ function PluginsSection() {
return (
<div>
<div className="doc-toolbar">
<button className="btn" onClick={async () => {
<Btn variant="ghost" icon="undo" onClick={async () => {
await api.reloadPlugins()
await refresh()
store.refreshPlugins()
store.toast('插件已热重载')
}}>⟳ 热重载</button>
}}>热重载</Btn>
<span style={{ color: 'var(--text-faint)', fontSize: 12 }}>声明资产(工作流/意图/提示词/面板)热加载;装卸不影响核心</span>
</div>
{plugins.length === 0 && <div style={{ color: 'var(--text-faint)' }}>暂无已装载插件(目录:设置 → 插件目录;示例插件见 Dev 仓 plugins/example)</div>}
{plugins.length === 0 && <EmptyState icon="puzzle">暂无已装载插件<br/>(目录:常规 → 插件目录;示例见 Dev 仓 plugins/example)</EmptyState>}
{plugins.map(p => (
<div className="setting-row" key={p.manifest.id}>
<label>{p.manifest.name} <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>v{p.manifest.version}</span></label>
+260
View File
@@ -0,0 +1,260 @@
// 左栏(方案 A「任务中心」,提案 2026-09-30 R1):单一主创建入口(P6)+
// 组织视图(P8:按项目/时间线)+ 置顶/归档 + 账户区(P7)。
// 工作区快捷图标条保留(D25:⌘ 与左栏共享同一清单)。
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 type { Conversation } from '../contracts'
import { workspaceEntries } from './Workspaces'
import { groupByProject, groupByTimeline, relativeTime, filterTasks, type TaskOrg } from '../tasklist'
const GROUPS_KEY = 'as.taskGroups'
function loadCollapsed(): Record<string, boolean> {
try { return JSON.parse(localStorage.getItem(GROUPS_KEY) ?? '{}') } catch { return {} }
}
/** 小型弹出菜单:锚点下方渲染,点击外部关闭。 */
function Menu({ anchor, onClose, children }: { anchor: HTMLElement | null; onClose: () => void; children: React.ReactNode }) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!anchor || !ref.current) return
const r = anchor.getBoundingClientRect()
const el = ref.current
let x = r.left, y = r.bottom + 6
if (x + el.offsetWidth > window.innerWidth - 8) x = window.innerWidth - el.offsetWidth - 8
if (y + el.offsetHeight > window.innerHeight - 8) y = r.top - el.offsetHeight - 6
el.style.left = `${Math.max(8, x)}px`
el.style.top = `${y}px`
const h = (e: MouseEvent) => { if (!el.contains(e.target as Node)) onClose() }
window.addEventListener('mousedown', h)
return () => window.removeEventListener('mousedown', h)
}, [anchor])
if (!anchor) return null
return <div ref={ref} className="tsb-menu" onClick={e => e.stopPropagation()}>{children}</div>
}
function MenuItem({ onClick, danger, icon, children }: { onClick: () => void; danger?: boolean; icon?: Parameters<typeof Icon>[0]['name']; children: React.ReactNode }) {
return <button className={`tsb-mi ${danger ? 'danger' : ''}`} onClick={onClick}>
{icon && <Icon name={icon} size={15} />}{children}
</button>
}
export function TaskSidebar() {
const projects = useStore(s => s.projects)
const tasks = useStore(s => s.tasks)
const me = useStore(s => s.me)
const currentConvId = useStore(s => s.currentConvId)
const currentProjectId = useStore(s => s.currentProjectId)
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 [q, setQ] = useState('')
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(loadCollapsed)
const [menu, setMenu] = useState<{ kind: 'new' | 'filter' | 'account' | `row:${string}`; anchor: HTMLElement } | null>(null)
const [creatingProject, setCreatingProject] = useState(false)
const [newProjectName, setNewProjectName] = useState('')
// ⌘N/Ctrl+N 新建任务(P6:创建入口唯一 + 快捷键)。
useEffect(() => {
const h = (e: KeyboardEvent) => {
const k = e.key.toLowerCase()
if ((e.metaKey || e.ctrlKey) && k === 'n') { e.preventDefault(); store.createTask() }
if ((e.metaKey || e.ctrlKey) && k === 'b') { e.preventDefault(); store.toggleLeft() }
}
window.addEventListener('keydown', h, true)
return () => window.removeEventListener('keydown', h, true)
}, [])
const tglGroup = (key: string) => {
setCollapsed(c => {
const next = { ...c, [key]: !c[key] }
try { localStorage.setItem(GROUPS_KEY, JSON.stringify(next)) } catch { /* ignore */ }
return next
})
}
const setOrg = (o: TaskOrg) => store.setUI({ taskOrg: o })
const visible = filterTasks(tasks, q)
const pinned = visible.filter(t => t.pinned)
const rest = visible.filter(t => !t.pinned)
const groups = org === 'project' ? groupByProject(rest, projects) : groupByTimeline(rest)
const currentProject = projects.find(p => p.id === currentProjectId)
const rowTask = menu?.kind.startsWith('row:') ? tasks.find(t => t.id === menu.kind.slice(4)) : null
const rowMenuAnchor = menu && menu.kind.startsWith('row:') ? menu.anchor : null
const rowActions = (t: Conversation) => (
<span className="tsb-row-acts">
<button className="tsb-row-act" title="置顶 / 取消置顶" onClick={e => { e.stopPropagation(); store.pinTask(t.id, !t.pinned) }}><Icon name="pin" size={13} /></button>
<button className="tsb-row-act" title={t.archived ? '恢复' : '归档'} onClick={e => { e.stopPropagation(); store.archiveTask(t.id, !t.archived) }}>
<Icon name={t.archived ? 'undo' : 'archive'} size={13} />
</button>
<button className={`tsb-row-act ${t.crossPrivate ? 'active' : ''}`} title={t.crossPrivate ? '私有会话:不被其他会话读取(点击取消)' : '设为私有(不被其他会话读取)'}
onClick={e => { e.stopPropagation(); store.setCrossFlags(t.id, { crossPrivate: !t.crossPrivate }) }}>
<Icon name="shield" size={13} />
</button>
</span>
)
const taskRow = (t: Conversation, badge?: string) => (
<div key={t.id} className={`tsb-task ${t.id === currentConvId ? 'active' : ''} ${t.archived ? 'archived' : ''}`}
onClick={() => store.openTask(t.id)}
onContextMenu={e => { e.preventDefault(); setMenu({ kind: `row:${t.id}`, anchor: e.currentTarget as HTMLElement }) }}>
<span className="tsb-task-t" title={t.title}>{t.title || '未命名任务'}</span>
{t.crossPrivate && <span className="tsb-pbadge" title="私有:不被其他会话读取"><Icon name="shield" size={11} /></span>}
{badge && <span className="tsb-pbadge">{badge}</span>}
<span className="tsb-task-tm">{relativeTime(t.updatedAt)}</span>
{rowActions(t)}
</div>
)
return (
<>
<div className="tsb-brand">
<BrandMark size={24} />
<span className="tsb-name">Agent-System</span>
<span style={{ flex: 1 }} />
<button className="tsb-iconbtn" title="收起侧栏(⌘B)" onClick={store.toggleLeft}><Icon name="panel-left" /></button>
</div>
{/* 单一主创建入口(P6):新建任务 + 次级菜单(新建项目/指定项目新建) */}
<div className="tsb-actions">
<div className="tsb-newtask">
<button className="tsb-newtask-main" title="在当前项目新建任务(⌘N)" onClick={() => store.createTask()}>
<Icon name="plus" size={15} />新建任务{currentProject ? <span className="tsb-newtask-proj">{currentProject.name}</span> : null}
</button>
<button className="tsb-newtask-caret" title="更多创建选项"
onClick={e => { const el = e.currentTarget; setMenu(m => m?.kind === 'new' ? null : { kind: 'new', anchor: el }) }}><Icon name="chevron-down" size={14} /></button>
</div>
<div className="tsb-search">
<Icon name="search" size={14} />
<input placeholder="搜索任务…" value={q} onChange={e => setQ(e.target.value)} />
</div>
</div>
{/* 组织视图切换(P8)+ 过滤 */}
<div className="tsb-orgrow">
<div className="tsb-pills">
<button className={`tsb-pill ${org === 'project' ? 'on' : ''}`} onClick={() => setOrg('project')}>按项目</button>
<button className={`tsb-pill ${org === 'timeline' ? 'on' : ''}`} onClick={() => setOrg('timeline')}>时间线</button>
</div>
<button className={`tsb-iconbtn ${showArchived ? 'active' : ''}`} title={showArchived ? '隐藏归档' : '查看归档'}
onClick={() => { store.setUI({ showArchived: !showArchived }); store.refreshTasks(!showArchived) }}><Icon name="archive" size={15} /></button>
</div>
<div className="tsb-scroll">
{creatingProject && (
<div className="tsb-newproject">
<input className="tsb-search" autoFocus value={newProjectName}
onChange={e => setNewProjectName(e.target.value)}
onKeyDown={async e => {
if (e.key === 'Enter' && newProjectName.trim()) {
await store.createProject(newProjectName.trim())
setCreatingProject(false); setNewProjectName('')
}
if (e.key === 'Escape') setCreatingProject(false)
}}
placeholder="项目名称,回车创建" />
</div>
)}
{pinned.length > 0 && (
<>
<div className="tsb-seclabel">置顶</div>
{pinned.map(t => taskRow(t, org === 'timeline' ? projects.find(p => p.id === t.projectId)?.name : undefined))}
</>
)}
{groups.length === 0 && pinned.length === 0 && (
<div className="tsb-empty">尚无任务——点击「新建任务」开始<br />(项目内素材上传、检索与生成全程可在对话中完成)</div>
)}
{groups.map(g => (
<div key={g.key} className={`tsb-group ${collapsed[g.key] ? 'closed' : ''}`}>
{org === 'project' ? (
<div className="tsb-group-head" onClick={() => tglGroup(g.key)}>
<span className="tsb-chev"><Icon name="chevron-right" size={13} /></span>
<span className="tsb-group-name" title={g.label}>{g.label}</span>
<span className="tsb-group-cnt">{g.tasks.length}</span>
<span style={{ flex: 1 }} />
<button className="tsb-group-add" title="项目设置(含跨会话读取)" onClick={e => { e.stopPropagation(); if (g.projectId) store.openProjectSettings(g.projectId) }}><Icon name="settings" size={13} /></button>
<button className="tsb-group-add" title="在此项目新建任务" onClick={e => { e.stopPropagation(); store.createTask(g.projectId) }}><Icon name="plus" size={13} /></button>
</div>
) : (
<div className="tsb-seclabel">{g.label}</div>
)}
{!collapsed[g.key] && g.tasks.map(t => taskRow(t, org === 'timeline' ? projects.find(p => p.id === t.projectId)?.name : undefined))}
</div>
))}
</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 }) }}>
<span className="tsb-avatar">{(me?.name || me?.owner || '?').slice(0, 1).toUpperCase()}</span>
<span className="tsb-user-info">
<span className="tsb-user-name">{me?.name || me?.owner || '未连接'}</span>
<span className="tsb-user-sub">{me ? (me.auth ? (me.isAdmin ? '管理员 · 已认证' : '已认证') : '本地模式 · 默认租户') : '…'}</span>
</span>
<span className="tsb-user-chev"><Icon name="chevron-down" size={13} /></span>
</button>
<button className="tsb-iconbtn" title="设置" onClick={() => store.openSettings(true)}><Icon name="settings" size={15} /></button>
{pendingBadge > 0 && <button className="tsb-iconbtn active" title={`待办 ${pendingBadge}`} onClick={() => store.openInbox()}><Icon name="inbox" size={15} />{pendingBadge}</button>}
</div>
{/* 弹出菜单们 */}
<Menu anchor={menu?.kind === 'new' ? menu.anchor : null} onClose={() => setMenu(null)}>
<MenuItem icon="plus" onClick={() => { store.createTask(); setMenu(null) }}>新建任务 · 当前项目<kbd>⌘N</kbd></MenuItem>
<div className="tsb-menu-div" />
<MenuItem icon="folder" onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
{projects.filter(p => p.id !== currentProjectId).slice(0, 6).map(p => (
<MenuItem key={p.id} onClick={() => { store.createTask(p.id); setMenu(null) }}>在「{p.name}」新建任务</MenuItem>
))}
</Menu>
<Menu anchor={rowMenuAnchor} onClose={() => setMenu(null)}>
{rowTask && <>
<MenuItem icon="pin" onClick={() => { store.pinTask(rowTask.id, !rowTask.pinned); setMenu(null) }}>{rowTask.pinned ? '取消置顶' : '置顶'}</MenuItem>
<MenuItem icon={rowTask.archived ? 'undo' : 'archive'} onClick={() => { store.archiveTask(rowTask.id, !rowTask.archived); setMenu(null) }}>{rowTask.archived ? '恢复(移出归档)' : '归档'}</MenuItem>
<div className="tsb-menu-div" />
<MenuItem icon="shield" onClick={() => { store.setCrossFlags(rowTask.id, { crossPrivate: !rowTask.crossPrivate }); setMenu(null) }}>{rowTask.crossPrivate ? '取消私有(可被其他会话读取)' : '设为私有(不被其他会话读取)'}</MenuItem>
<MenuItem icon="users" onClick={() => { store.setCrossFlags(rowTask.id, { allowCrossRead: rowTask.allowCrossRead === false }); setMenu(null) }}>{rowTask.allowCrossRead === false ? '开启读取其他会话' : '关闭读取其他会话'}</MenuItem>
<div className="tsb-menu-div" />
<MenuItem icon="clock" onClick={() => { store.setUI({ taskOrg: 'timeline' }); setMenu(null) }}>按时间线查看</MenuItem>
</>}
</Menu>
<Menu anchor={menu?.kind === 'account' ? menu.anchor : null} onClose={() => setMenu(null)}>
<div className="tsb-acc-head">
<span className="tsb-avatar">{(me?.name || me?.owner || '?').slice(0, 1).toUpperCase()}</span>
<span>
<div className="tsb-acc-name">{me?.name || '未连接'}</div>
<div className="tsb-acc-mail">{me?.owner ? `租户:${me.owner}` : '—'}</div>
</span>
</div>
<div className="tsb-menu-div" />
<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>}
{me?.auth && <>
<div className="tsb-menu-div" />
<MenuItem danger icon="logout" onClick={() => { api.logout().catch(() => undefined); auth.logout() }}>退出登录</MenuItem>
</>}
</Menu>
</>
)
}
+49 -94
View File
@@ -1,7 +1,9 @@
// 右栏工作台:收件箱(D25 置首)+ Plan / Todos / Artifacts / Agents + 插件面板;tab 显隐排序见设置。
import { store, useStore, isTerminal } from '../store'
// 右栏终态(提案 T4/D25):纯收件箱 + 「当前任务」常驻卡(计划/待办并入;工件/代理/
// 插件面板迁为工作区弹层,见 Workspaces.tsx)。
import { store, useStore } from '../store'
import type { Run } from '../contracts'
import { UIRenderer, InboxList, pendingCount, type DocBridge, type ActivityCard } from '@ui'
import { InboxList, pendingCount, type ActivityCard, type DocBridge } from '@ui'
import { Icon } from '@ui'
// 工件解析/打开注入:渲染器纯展示,doc-ref 词汇经此桥接本仓状态(D29)。
export const docBridge: DocBridge = {
@@ -13,127 +15,80 @@ export const docBridge: DocBridge = {
}
export function Workbench() {
const ui = useStore(s => s.ui)
const cards = useStore(s => s.cards)
const plan = useStore(s => s.plan)
const todos = useStore(s => s.todos)
const artifacts = useStore(s => s.artifacts)
const runs = useStore(s => s.runs)
const pluginPanels = useStore(s => s.pluginPanels)
const cards = useStore(s => s.cards)
const pending = pendingCount(cards)
const tabs: { id: string; label: string }[] = [
{ id: 'inbox', label: pending > 0 ? `收件箱 ${pending}` : '收件箱' },
{ id: 'plan', label: '计划' },
{ id: 'todos', label: '待办' },
{ id: 'artifacts', label: `工件${artifacts.length ? ` ${artifacts.length}` : ''}` },
{ id: 'agents', label: '代理' },
...pluginPanels.map(p => ({ id: `plugin:${p.id}`, label: p.title })),
]
return (
<>
<div className="tabs">
{tabs.map(t => (
<button key={t.id} className={`tab ${ui.rightTab === t.id ? 'active' : ''}`}
onClick={() => store.setUI({ rightTab: t.id })}>{t.label}</button>
))}
<button className="tab" title="收起工作台" onClick={store.toggleRight}>»</button>
<span className="tab active">收件箱{pending > 0 ? ` ${pending}` : ''}</span>
<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">
{ui.rightTab === 'inbox' && <InboxTab cards={cards} />}
{ui.rightTab === 'plan' && <PlanTab plan={plan} />}
{ui.rightTab === 'todos' && <TodosTab todos={todos} />}
{ui.rightTab === 'artifacts' && <ArtifactsTab />}
{ui.rightTab === 'agents' && <AgentsTab runs={runs} />}
{ui.rightTab.startsWith('plugin:') && (
<UIRenderer nodes={pluginPanels.find(p => `plugin:${p.id}` === ui.rightTab)?.body ?? []} docBridge={docBridge} />
)}
<CurrentTaskCard plan={plan} todos={todos} />
<InboxList cards={cards} 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>
</>
)
}
function InboxTab({ cards }: { cards: ActivityCard[] }) {
return <InboxList cards={cards} callbacks={{
onAction: (c, id) => store.cardAction(c, id),
onPin: (c, p) => store.cardPin(c, p),
onDismiss: c => store.cardDismiss(c),
}} />
}
function StatusIcon({ status }: { status?: string }) {
if (status === 'done' || status === 'completed') return <span style={{ color: 'var(--ok)' }}>●</span>
if (status === 'in_progress' || status === 'running') return <span style={{ color: 'var(--accent)' }}>◐</span>
if (status === 'skipped') return <span>◌</span>
return <span style={{ color: 'var(--text-faint)' }}>○</span>
}
function PlanTab({ plan }: { plan: { title: string; steps: { id: string; text: string; status: string; agentRun?: string }[] } | null }) {
if (!plan?.steps?.length) return <Empty text="暂无计划——模型可通过 update_plan 工具创建(mock:#agents)" />
/** 当前任务常驻卡(D25 R4):计划+待办合成一张卡,钉在收件箱顶部——右栏只回答"现在要我做什么"。 */
export function CurrentTaskCard({ plan, todos }: {
plan: { title: string; steps: { id: string; text: string; status: string; agentRun?: string }[] } | null
todos: { items: { id: string; text: string; status: string }[] } | null
}) {
const steps = plan?.steps ?? []
const items = todos?.items ?? []
if (steps.length === 0 && items.length === 0) return null
const done = [...steps, ...items].filter(x => x.status === 'done' || x.status === 'completed').length
const total = steps.length + items.length
return (
<div>
<h5 style={{ margin: '4px 0 10px' }}>{plan.title}</h5>
{plan.steps.map(s => (
<div className="current-task" data-testid="current-task">
<div className="ct-head">
<span className="lamp lamp-ok">●</span>
<span className="ct-title">{plan?.title ?? '当前任务'}</span>
<span className="ct-progress">{done}/{total}</span>
</div>
{steps.map(s => (
<div key={s.id} className={`step ${s.status}`}>
<span className="step-icon"><StatusIcon status={s.status} /></span>
<StatusIcon status={s.status} />
<span style={{ flex: 1 }}>{s.text}</span>
{s.agentRun && <span className="status-dot status-completed" title="子代理执行" />}
</div>
))}
{items.map(t => (
<div key={t.id} className={`step ${t.status}`}>
<StatusIcon status={t.status} />
<span>{t.text}</span>
</div>
))}
</div>
)
}
function TodosTab({ todos }: { todos: { items: { id: string; text: string; status: string }[] } | null }) {
if (!todos?.items?.length) return <Empty text="暂无待办(mock:#agents)" />
return <>{todos.items.map(t => (
<div key={t.id} className={`step ${t.status}`}>
<span className="step-icon"><StatusIcon status={t.status} /></span>
<span>{t.text}</span>
</div>
))}</>
export function StatusIcon({ status }: { status?: string }) {
if (status === 'done' || status === 'completed') return <span style={{ color: 'var(--ok)' }}>●</span>
if (status === 'in_progress' || status === 'running') return <span style={{ color: 'var(--accent)' }}>◐</span>
if (status === 'skipped') return <span>◌</span>
return <span style={{ color: 'var(--text-faint)' }}>○</span>
}
function ArtifactsTab() {
const artifacts = useStore(s => s.artifacts)
if (artifacts.length === 0) return <Empty text="暂无工件——上传附件或让模型产出文档(mock:#doc)" />
return <>{artifacts.map(a => (
<div key={a.id} className="artifact-row" onClick={() => store.viewArtifact(a)}>
<span>{a.kind === 'image' ? '🖼' : a.kind === 'document' ? '📄' : '🗂'}</span>
<span className="name" title={a.name}>{a.name}</span>
<span className="kind">{a.kind}</span>
</div>
))}</>
}
function AgentsTab({ runs }: { runs: Run[] }) {
const convRuns = runs.filter(r => r.kind !== 'tool')
if (convRuns.length === 0) return <Empty text="暂无运行——多代理任务会在此形成任务树(mock:#agents)" />
// 任务树:顶层 run + 其子代理。
const roots = convRuns.filter(r => !r.parentRunId)
return <div className="subagent-tree">
{roots.map(r => (
<div key={r.id}>
<AgentRow run={r} depth={0} />
{convRuns.filter(c => c.parentRunId === r.id).map(c => <AgentRow key={c.id} run={c} depth={1} />)}
</div>
))}
</div>
}
function AgentRow({ run, depth }: { run: Run; depth: number }) {
/** 代理行(工作区弹层复用;点击进 AgentTracker)。 */
export function AgentRow({ run, depth }: { run: Run; depth: number }) {
const label = run.kind === 'subagent' ? `🤖 ${run.agentName || '子代理'}` : '💬 对话任务'
const terminal = isTerminal(run.status)
return (
<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 }}>{label}</span>
<span style={{ color: 'var(--text-faint)', fontSize: 11 }}>{terminal ? run.status : (run.phase || run.status)}</span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{run.status}</span>
</div>
)
}
function Empty({ text }: { text: string }) {
return <div style={{ color: 'var(--text-faint)', fontSize: 12.5, padding: '10px 4px', lineHeight: 1.8 }}>{text}</div>
}
+92
View File
@@ -0,0 +1,92 @@
// 工作区弹层(提案 T4/D25):工件/代理/插件面板/插件声明工作区统一经 OverlayHost 呈现;
// ⌘1–9 快捷键与左栏快捷区共享同一清单(workspaceEntries)。
import { store, useStore } from '../store'
import type { Run } from '../contracts'
import { OverlayHost, type DocBridge } from '@ui'
import { docBridge } from './Workbench'
import { AgentRow } from './Workbench'
/** 工作区清单(顺序即 ⌘ 序号):内置在前,插件面板/声明工作区随后。 */
export function workspaceEntries(): { key: string; title: string; icon: string; shortcut: string }[] {
const s = store.getState()
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}` })),
]
return out.slice(0, 9)
}
export function Workspaces() {
const active = useStore(s => s.ui.activeWorkspace)
const artifacts = useStore(s => s.artifacts)
const runs = useStore(s => s.runs)
const pluginPanels = useStore(s => s.pluginPanels)
const serverWorkspaces = useStore(s => s.serverWorkspaces)
if (!active) return null
const entry = workspaceEntries().find(e => e.key === active)
const close = () => store.closeWorkspace()
if (active === 'artifacts') {
return <OverlayHost title="工件" icon="📦" shortcut="⌘1" badge={artifacts.length || undefined} onClose={close}>
<ArtifactsPanel />
</OverlayHost>
}
if (active === 'agents') {
const convRuns = runs.filter(r => r.kind !== 'tool')
return <OverlayHost title="代理" icon="🤖" shortcut="⌘2" onClose={close}>
<AgentsPanel runs={convRuns} />
</OverlayHost>
}
if (active.startsWith('plugin:')) {
const panel = pluginPanels.find(p => `plugin:${p.id}` === active)
return <OverlayHost title={panel?.title ?? '插件面板'} icon="🧩" onClose={close}
nodes={panel?.body ?? []} docBridge={docBridge as DocBridge} onRefresh={() => store.refreshPlugins()} />
}
if (active.startsWith('ws:')) {
const ws = serverWorkspaces.find(w => `ws:${w.id}` === active)
return <OverlayHost title={ws?.title ?? '工作区'} icon={ws?.icon || '🗂'} shortcut={entry?.shortcut} onClose={close}
nodes={ws?.body ?? []} docBridge={docBridge as DocBridge} onRefresh={() => store.refreshPlugins()} />
}
return null
}
/** 工件面板(自 Workbench tabs 迁入;点击进 DocViewer 深读)。 */
export function ArtifactsPanel() {
const artifacts = useStore(s => s.artifacts)
if (artifacts.length === 0) return <div className="empty-note">暂无工件——上传附件或让模型产出文档(mock:#doc)</div>
return <>{artifacts.map(a => (
<div key={a.id} className="artifact-row" onClick={() => store.viewArtifact(a)}>
<span>{a.kind === 'image' ? '🖼' : a.kind === 'document' ? '📄' : '🗂'}</span>
<span className="name" title={a.name}>{a.name}</span>
<span className="kind">{a.kind}</span>
</div>
))}</>
}
/** 代理面板(任务树;点击进 AgentTracker 深读)。 */
export function AgentsPanel({ runs }: { runs: Run[] }) {
if (runs.length === 0) return <div className="empty-note">暂无运行——多代理任务会在此形成任务树(mock:#agents)</div>
const roots = runs.filter(r => !r.parentRunId)
return <div className="subagent-tree">
{roots.map(r => (
<div key={r.id}>
<AgentRow run={r} depth={0} />
{runs.filter(c => c.parentRunId === r.id).map(c => <AgentRow key={c.id} run={c} depth={1} />)}
</div>
))}
</div>
}
/** ⌘1–9 / Ctrl+1–9 全局快捷键(挂载于 App)。 */
export function workspaceShortcuts(e: KeyboardEvent) {
if (!(e.metaKey || e.ctrlKey)) return
const n = parseInt(e.key, 10)
if (!Number.isInteger(n) || n < 1 || n > 9) return
const entries = workspaceEntries()
const hit = entries[n - 1]
if (!hit) return
e.preventDefault()
store.openWorkspace(hit.key)
}
+25 -1
View File
@@ -14,6 +14,8 @@ export interface Project {
export interface Conversation {
id: ID; projectId: ID; owner?: ID; title: string; profileId?: ID;
pinned?: boolean; archived?: boolean;
allowCrossRead?: boolean; crossPrivate?: boolean;
createdAt: string; updatedAt: string;
}
@@ -152,7 +154,7 @@ export interface HITLResponse {
export type UINodeType =
| 'section' | 'list' | 'kv' | 'form' | 'markdown' | 'doc-ref' | 'actions' | 'chart-lite'
| 'progress' | 'compare' | 'tree' | 'table' | 'steps' | 'tabs' | 'timeline';
| 'avatar' | 'progress' | 'compare' | 'tree' | 'table' | 'steps' | 'tabs' | 'timeline';
export interface UINode {
type: UINodeType;
@@ -166,6 +168,7 @@ export interface UINode {
chart?: { kind: 'bar' | 'line'; labels: string[]; series: number[] };
children?: UINode[];
avatar?: { name?: string; sub?: string; tone?: string; stack?: { name?: string; sub?: string; tone?: string }[] };
progress?: { label?: string; value: number; tone?: string };
compare?: {
left: { title?: string; tone?: string; nodes?: UINode[] };
@@ -192,11 +195,32 @@ export interface UIField {
options?: string[]; required?: boolean; placeholder?: string; help?: string; default?: string;
}
// ---- 提示词库(prompt-library)----
export interface PromptTrigger {
kind: 'intent' | 'command' | 'attachment' | 'manual';
keywords?: string[]; command?: string;
}
export interface PromptModule {
id: string; layer: 'base' | 'env' | 'task' | 'agent' | 'tool' | 'aux' | 'style';
version: number; title: string; description?: string; priority?: number;
triggers?: PromptTrigger[]; requiresAttachments?: boolean;
body: string; outputContract?: string;
source?: 'builtin' | 'plugin' | 'user'; owner?: string;
basedOnVersion?: number; drifted?: boolean; disabled?: boolean;
}
export interface PromptBlock { moduleId: string; version: number; layer: string; body: string; }
export interface PromptHitRecord { moduleId: string; layer: string; reason: string; injected: boolean; }
// ---- 画像 / 设置 ----
export interface AgentProfile {
id: string; owner?: string; name: string; description?: string; builtin?: boolean; source?: string;
systemPrompt: string;
/** 提示词库模块引用(有序;空 = 默认 base+env 全集)。 */
promptModules?: string[];
model: { chat?: string; lightweight?: string; embedding?: string; rerank?: string };
tools: { allow: string[]; deny?: string[]; confirm?: string[] };
context: {
+11
View File
@@ -0,0 +1,11 @@
// 浏览器端随机初始密码(管理员未显式给定密码时):
// crypto.getRandomValues 拒绝易混淆字符(0/O/1/l/I),保证可抄写。
const ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789'
export function randomPassword(length = 12): string {
const buf = new Uint32Array(length)
crypto.getRandomValues(buf)
let out = ''
for (let i = 0; i < length; i++) out += ALPHABET[buf[i] % ALPHABET.length]
return out
}
+105 -13
View File
@@ -34,13 +34,23 @@ export interface UIState {
settingsOpen: boolean
trackerRunId: string | null
viewingArtifact: Artifact | null
activeWorkspace: string | null
taskOrg: 'project' | 'timeline'
showArchived: boolean
lastProjectId: string | null
adminUsersOpen: boolean
changePwOpen: boolean
projectSettingsId: string | null
}
export interface Me { owner: string; name: string; auth: boolean; isAdmin?: boolean; mustChangePassword?: boolean }
interface AppState {
contextObs: Record<string, ContextObs>
projects: Project[]
currentProjectId: string | null
conversations: Conversation[]
tasks: Conversation[]
me: Me | null
currentConvId: string | null
messages: Message[]
runs: Run[]
@@ -53,6 +63,7 @@ interface AppState {
cards: ActivityCard[]
profiles: AgentProfile[]
pluginPanels: PluginPanel[]
serverWorkspaces: { id: string; title: string; icon?: string; shortcut?: string; body?: any[] }[]
ui: UIState
connected: boolean
error: string | null
@@ -65,11 +76,18 @@ const DEFAULT_UI: UIState = {
rightWidth: 340,
rightCollapsed: false,
rightTab: 'inbox',
activeWorkspace: null,
theme: 'dark',
autoCollapseProcess: true,
settingsOpen: false,
trackerRunId: null,
viewingArtifact: null,
taskOrg: 'project',
showArchived: false,
lastProjectId: null,
adminUsersOpen: false,
changePwOpen: false,
projectSettingsId: null,
}
function loadUI(): UIState {
@@ -82,9 +100,10 @@ function loadUI(): UIState {
let state: AppState = {
contextObs: {},
projects: [], currentProjectId: null, conversations: [], currentConvId: null,
projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null,
messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
plan: null, todos: null, artifacts: [], cards: [], profiles: [], pluginPanels: [],
serverWorkspaces: [],
ui: loadUI(), connected: false, error: null, toast: null,
}
@@ -122,12 +141,23 @@ export const store = {
async refreshProjects() {
try {
const projects = await api.listProjects()
const current = projects.find(p => p.id === state.currentProjectId) ?? projects[0]
const current = projects.find(p => p.id === state.currentProjectId) ?? projects.find(p => p.id === state.ui.lastProjectId) ?? projects[0]
set({ projects, currentProjectId: current?.id ?? null, error: null })
if (current) await store.openProject(current.id)
} catch (e) {
set({ error: String(e) })
}
store.refreshTasks()
store.refreshMe()
},
// 跨项目任务列表(方案 A 侧栏数据源):归档视图切换时带 includeArchived 重取。
async refreshTasks(includeArchived = state.ui.showArchived) {
try { set({ tasks: await api.listTasks(includeArchived) }) } catch { /* 列表竞态,静默 */ }
},
async refreshMe() {
try { set({ me: await api.me() }) } catch { /* 身份不可用时侧栏只显示通用入口 */ }
},
async createProject(name: string) {
@@ -138,16 +168,54 @@ export const store = {
},
async openProject(projectId: string) {
const conversations = await api.listConversations(projectId)
set({ currentProjectId: projectId, conversations })
set({ currentProjectId: projectId })
store.setUI({ lastProjectId: projectId })
await store.refreshArtifacts(projectId)
},
/** 新建任务(=会话):默认当前项目上下文;显式 projectId 用于"新建于指定项目"。 */
async createTask(projectId?: string) {
const pid = projectId ?? state.currentProjectId
if (!pid) { store.toast('请先创建项目'); return }
const c = await api.createConversation(pid)
await store.refreshTasks()
await store.openTask(c.id)
},
/** 兼容旧入口(⌘/命令总线) */
async createConversation(title?: string) {
if (!state.currentProjectId) return
const c = await api.createConversation(state.currentProjectId, title)
await store.openProject(state.currentProjectId)
await store.openConversation(c.id)
if (title) store.toast('已在当前项目创建任务')
await store.createTask()
},
/** 打开任务:跨项目点击时切换项目上下文(工件/上传归属正确)。 */
async openTask(convId: string) {
const t = state.tasks.find(x => x.id === convId)
if (t && t.projectId !== state.currentProjectId) await store.openProject(t.projectId)
await store.openConversation(convId)
},
async pinTask(convId: string, pinned: boolean) {
const tasks = state.tasks.map(t => t.id === convId ? { ...t, pinned } : t)
set({ tasks })
try { await api.setConversationFlags(convId, { pinned }) } catch (e) { store.toast(`置顶失败:${e}`); store.refreshTasks() }
},
async archiveTask(convId: string, archived: boolean) {
const tasks = state.tasks.map(t => t.id === convId ? { ...t, archived } : t)
set({ tasks })
try {
await api.setConversationFlags(convId, { archived })
if (state.currentConvId === convId && archived) store.toast('任务已归档')
} catch (e) { store.toast(`归档失败:${e}`) }
store.refreshTasks()
},
// 跨会话标记(提案 2026-09-30-cross-session-reading):出站 allowCrossRead / 入站 crossPrivate。
async setCrossFlags(convId: string, flags: { allowCrossRead?: boolean; crossPrivate?: boolean }) {
const tasks = state.tasks.map(t => t.id === convId ? { ...t, ...flags } : t)
set({ tasks })
try { await api.setConversationFlags(convId, flags) } catch (e) { store.toast(`跨会话设置失败:${e}`); store.refreshTasks() }
},
async openConversation(convId: string) {
@@ -174,6 +242,7 @@ export const store = {
const [plan, todos] = await Promise.all([api.getPlan(d.conversation.id), api.getTodos(d.conversation.id)])
set({ plan, todos })
store.refreshCards(d.conversation.id)
store.refreshTasks()
} catch (e) {
set({ error: String(e) })
}
@@ -304,10 +373,12 @@ export const store = {
return
}
if (out.run) {
set({
live: { runId: out.run.id, phase: 'thinking', thinking: '', text: '', toolCalls: [], agentNotes: {}, startedAt: Date.now() },
messages: [...state.messages, out.message],
})
// 竞态保护:POST 返回前 SSE 事件可能已先行到达并累积(同 run),
// 此时保留已累积的 live,只补挂用户消息;否则初始化空 live。
if (!state.live || state.live.runId !== out.run.id) {
set({ live: { runId: out.run.id, phase: 'thinking', thinking: '', text: '', toolCalls: [], agentNotes: {}, startedAt: Date.now() } })
}
set({ messages: [...state.messages, out.message] })
store.connectIfNeeded()
}
} catch (e) {
@@ -391,6 +462,10 @@ export const store = {
const panels = list.flatMap(p => p.panels ?? [])
set({ pluginPanels: panels })
} catch { /* ignore */ }
try {
const ws = await api.listWorkspaces()
set({ serverWorkspaces: ws ?? [] })
} catch { /* ignore */ }
},
async uploadAttachment(file: File): Promise<string | null> {
@@ -409,9 +484,26 @@ export const store = {
setUI,
toggleLeft() { setUI({ leftCollapsed: !state.ui.leftCollapsed }) },
toggleRight() { setUI({ rightCollapsed: !state.ui.rightCollapsed }) },
openWorkspace(id: string) { setUI({ activeWorkspace: id }) },
/** 收纳策略(T5):一键收纳已完成卡(done→dismissed;resolved 盖章留痕不动)。 */
async tidyCards() {
const done = state.cards.filter(c => c.status === 'done')
for (const c of done) {
try { const out = await api.cardDismiss(c.id); store.upsertCard(out.card) } catch { /* 单卡失败不阻断 */ }
}
if (done.length > 0) store.toast(`已收纳 ${done.length} 张完成卡`)
},
closeWorkspace() { setUI({ activeWorkspace: null }) },
openInbox(cardId?: string) {
setUI({ rightCollapsed: false, rightTab: 'inbox' })
if (cardId) setTimeout(() => {
document.querySelector(`[data-card-id="${cardId}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, 60)
},
openTracker(runId: string) { setUI({ trackerRunId: runId }) },
viewArtifact(a: Artifact | null) { setUI({ viewingArtifact: a }) },
openSettings(open: boolean) { setUI({ settingsOpen: open }) },
openProjectSettings(id: string | null) { setUI({ projectSettingsId: id }) },
toast(msg: string) {
set({ toast: msg })
setTimeout(() => { if (state.toast === msg) set({ toast: null }) }, 2600)
+286 -102
View File
@@ -1,70 +1,112 @@
/* 设计令牌与 UI 组件样式已迁入 Agent-System-UI(@ui/tokens.css、@ui/ui.css,main.tsx 引入)。 */
/* 设计令牌与 UI 组件样式已迁入 Agent-System-UI(@ui/tokens.css、@ui/ui.css,main.tsx 引入)。
R4 视觉收敛(2026-09-30):三级表面阶梯 + 发丝边框 + 圆角标尺 + 克制阴影 + 统一线性图标。 */
* { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; }
body { font-family: var(--font); background: var(--bg); color: var(--text); font-size: 14px; }
button { font-family: inherit; }
body {
font-family: var(--font); background: var(--bg); color: var(--text);
font-size: 13.5px; line-height: 1.55;
-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; }
/* ---- 三栏布局 ---- */
.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 .08s ease; }
/* ---- 三栏布局:层次靠背景阶梯(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); }
.left-nav.collapsed { width: 52px !important; }
.divider { width: 4px; cursor: col-resize; background: transparent; flex-shrink: 0; position: relative; }
.divider:hover, .divider.dragging { background: var(--accent); }
.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::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; }
.right-panel.collapsed { width: 44px !important; }
/* ---- 左栏 ---- */
.nav-header { padding: 14px 14px 8px; font-weight: 600; display: flex; align-items: center; justify-content: space-between; }
.nav-header button { background: none; border: none; color: var(--text-dim); cursor: pointer; font-size: 16px; padding: 4px; border-radius: var(--radius-sm); }
.nav-header button:hover { background: var(--bg-hover); color: var(--text); }
.nav-scroll { flex: 1; overflow-y: auto; padding: 4px 8px 8px; }
.nav-project { margin-bottom: 4px; }
.nav-project-name { display: flex; align-items: center; gap: 6px; padding: 6px 8px; border-radius: var(--radius-sm); color: var(--text-dim); font-weight: 500; cursor: pointer; user-select: none; }
.nav-project-name:hover { background: var(--bg-hover); }
.nav-project-name .chev { font-size: 10px; transition: transform .12s; }
.nav-project-name.open .chev { transform: rotate(90deg); }
.nav-conv { padding: 5px 8px 5px 26px; border-radius: var(--radius-sm); color: var(--text-dim); cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.nav-conv:hover { background: var(--bg-hover); color: var(--text); }
.nav-conv.active { background: var(--accent-soft); color: var(--accent); }
.nav-footer { border-top: 1px solid var(--border); padding: 8px; display: flex; gap: 6px; }
.nav-footer button { flex: 1; background: none; border: none; color: var(--text-dim); padding: 7px; border-radius: var(--radius-sm); cursor: pointer; font-size: 12px; }
.nav-footer button:hover { background: var(--bg-hover); color: var(--text); }
.left-nav.collapsed .nav-label { display: none; }
.icon-only { display: flex; flex-direction: column; align-items: center; gap: 10px; padding-top: 10px; }
.icon-only button { background: none; border: none; color: var(--text-dim); font-size: 16px; cursor: pointer; padding: 8px; border-radius: var(--radius-sm); }
.icon-only button:hover { background: var(--bg-hover); color: var(--text); }
/* ---- 通用控件 ---- */
.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);
}
.btn:hover:not(:disabled) { filter: brightness(1.09); }
.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.danger { background: var(--err); }
.icon-btn {
background: none; border: none; color: var(--text-faint);
border-radius: var(--radius-sm); padding: 0; width: 28px; height: 28px;
display: inline-flex; align-items: center; justify-content: center; cursor: pointer;
transition: background .12s, color .12s;
}
.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%;
transition: border-color .12s, box-shadow .12s;
}
.search-input:focus { border-color: var(--accent); box-shadow: var(--ring); outline: none; }
/* ---- 中栏对话 ---- */
.chat { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.chat-header { padding: 10px 18px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 10px; background: var(--bg-raised); }
.chat-title { font-weight: 600; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chat-body { flex: 1; overflow-y: auto; padding: 18px max(18px, 12%); }
.empty-state { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--text-faint); gap: 8px; }
.msg { margin-bottom: 18px; }
.msg-role { font-size: 12px; color: var(--text-faint); margin-bottom: 4px; display: flex; gap: 8px; align-items: center; }
.msg.user .bubble { background: var(--accent-soft); border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 14px; white-space: pre-wrap; }
/* 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-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 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-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-faint); font-size: 13px; border-left: 2px solid var(--border); padding-left: 10px; white-space: pre-wrap; }
.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; }
.part-thinking .part-thinking-body { white-space: pre-wrap; margin-top: 4px; }
.part-tool { font-size: 12.5px; }
.tool-chip { display: inline-flex; gap: 6px; align-items: center; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 4px 8px; margin: 2px 4px 2px 0; font-family: var(--mono); }
.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); }
.part-text { white-space: pre-wrap; line-height: 1.7; }
.part-text :is(h1,h2,h3) { margin: 0.6em 0 0.3em; }
.part-text pre { background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 10px; overflow-x: auto; font-family: var(--mono); font-size: 12.5px; }
.part-text code { font-family: var(--mono); background: var(--bg-raised); border-radius: 4px; padding: 1px 5px; font-size: 12.5px; }
.part-text :is(p,ul,ol) { margin: 0.4em 0; }
.part-error { color: var(--err); background: color-mix(in srgb, var(--err) 8%, transparent); border: 1px solid var(--err); border-radius: var(--radius-sm); padding: 8px 12px; }
.artifact-chip { display: inline-flex; align-items: center; gap: 6px; color: var(--accent); background: var(--accent-soft); border-radius: var(--radius-sm); padding: 4px 10px; cursor: pointer; font-size: 13px; }
.artifact-chip:hover { filter: brightness(1.15); }
.part-text { line-height: 1.7; overflow-wrap: break-word; }
/* 块级间距只由标签自身控制:禁用 pre-wrap(marked 输出的块间换行会被渲染成空行,
叠加出"列表逐项空一行"的松散排版);行内换行语义交给 Markdown 段落。 */
.part-text > *:first-child { margin-top: 0; }
.part-text > *:last-child { margin-bottom: 0; }
.part-text p { margin: 0.5em 0; }
.part-text :is(h1,h2,h3,h4) { margin: 0.8em 0 0.35em; line-height: 1.35; font-weight: 600; letter-spacing: -0.01em; }
.part-text h1 { font-size: 1.3em; }
.part-text h2 { font-size: 1.18em; }
.part-text h3 { font-size: 1.06em; }
.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 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; }
.part-text th, .part-text td { border: 1px solid var(--border); padding: 4px 10px; text-align: left; }
.part-text th { background: var(--bg-raised); font-weight: 600; }
.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: center; gap: 6px; color: var(--accent); background: var(--accent-soft); border: 1px solid transparent; border-radius: var(--radius-sm); padding: 5px 10px; cursor: pointer; font-size: 13px; transition: background .12s; }
.artifact-chip:hover { background: var(--accent-soft-2); }
/* 过程折叠(run 终态后) */
.process-collapse { border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-raised); margin: 6px 0; }
.process-collapse summary { list-style: none; cursor: pointer; padding: 6px 12px; color: var(--text-faint); font-size: 12.5px; display: flex; align-items: center; gap: 8px; user-select: none; }
.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 .body { padding: 4px 12px 10px; }
@@ -75,41 +117,37 @@ button { font-family: inherit; }
@keyframes pulse { 0%,100% { opacity: .4 } 50% { opacity: 1 } }
.cursor-blink::after { content: '▍'; animation: pulse 1s infinite; color: var(--accent); }
/* HITL 卡片 */
.hitl-card { border: 1px solid var(--accent); background: var(--bg-raised); border-radius: var(--radius); padding: 14px; margin: 10px 0; }
.hitl-q { font-weight: 600; margin-bottom: 10px; }
/* HITL 卡片(确认类交互统一绿色语言) */
.hitl-card { border: 1px solid var(--ok-border); background: var(--ok-soft); border-radius: var(--radius-lg); padding: 14px; margin: 10px 0; }
.hitl-q { font-weight: 600; margin-bottom: 10px; display: flex; gap: 8px; align-items: flex-start; }
.hitl-q svg { color: var(--ok); flex: none; margin-top: 1px; }
.hitl-choices { display: flex; flex-direction: column; gap: 8px; }
.hitl-choice { text-align: left; border: 1px solid var(--border); background: var(--bg); color: var(--text); border-radius: var(--radius-sm); padding: 10px 12px; cursor: pointer; }
.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: 8px; }
.hitl-choice .rec { font-size: 10.5px; 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 { border-top: 1px solid var(--border); padding: 12px 18px; background: var(--bg-raised); }
/* 输入区:悬浮卡片式输入壳(外圆角大、聚焦发光) */
.input-bar { padding: 8px max(16px, 6%) 16px; background: var(--bg); }
.attachments { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
.attach-chip { display: inline-flex; gap: 6px; align-items: center; background: var(--bg-hover); border-radius: var(--radius-sm); padding: 3px 8px; font-size: 12px; }
.input-row { display: flex; gap: 8px; align-items: flex-end; }
.input-row textarea { flex: 1; resize: none; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); padding: 10px 12px; font-family: inherit; font-size: 14px; max-height: 160px; outline: none; }
.input-row textarea:focus { border-color: var(--accent); }
.btn { background: var(--accent); color: #fff; border: none; border-radius: var(--radius-sm); padding: 9px 16px; cursor: pointer; font-size: 13px; white-space: nowrap; }
.btn:disabled { opacity: .45; cursor: not-allowed; }
.btn.ghost { background: none; border: 1px solid var(--border); color: var(--text-dim); }
.btn.ghost:hover { color: var(--text); border-color: var(--text-faint); }
.btn.danger { background: var(--err); }
.icon-btn { background: none; border: 1px solid var(--border); color: var(--text-dim); border-radius: var(--radius-sm); padding: 8px 10px; cursor: pointer; font-size: 14px; }
.icon-btn:hover { color: var(--text); border-color: var(--text-faint); }
.cmd-menu { border: 1px solid var(--border); background: var(--bg-raised); border-radius: var(--radius-sm); margin-bottom: 6px; max-height: 180px; overflow-y: auto; }
.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; }
.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::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:hover, .cmd-item.active { background: var(--accent-soft); }
.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); overflow-x: auto; }
.tab { background: none; border: none; color: var(--text-dim); padding: 9px 12px; cursor: pointer; font-size: 12.5px; border-bottom: 2px solid transparent; white-space: nowrap; }
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.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: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-icon { width: 16px; text-align: center; flex-shrink: 0; }
@@ -130,55 +168,201 @@ button { font-family: inherit; }
.status-pending, .status-waiting_hitl { background: var(--warn); }
/* ---- 弹窗 / 模态 ---- */
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,.55); display: flex; align-items: center; justify-content: center; z-index: 50; }
.modal { background: var(--bg-raised); border: 1px solid var(--border); border-radius: 12px; width: min(860px, 92vw); max-height: 86vh; display: flex; flex-direction: column; box-shadow: 0 18px 60px rgba(0,0,0,.4); }
.modal-header { padding: 14px 18px; border-bottom: 1px solid var(--border); display: flex; align-items: center; font-weight: 600; }
.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; }
.modal-header .spacer { flex: 1; }
.modal-body { flex: 1; overflow-y: auto; padding: 16px 18px; }
.modal-body { flex: 1; overflow-y: auto; padding: 18px 20px; }
.subagent-tree { font-size: 13px; }
.subagent-node { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 10px; margin-bottom: 8px; }
.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; }
/* ---- 设置(schema 驱动)---- */
.settings-group { margin-bottom: 18px; }
.settings-group h4 { margin: 0 0 8px; font-size: 13px; color: var(--text-dim); text-transform: uppercase; letter-spacing: .04em; }
.setting-row { display: grid; grid-template-columns: 200px 1fr auto; gap: 10px; align-items: center; padding: 7px 0; border-bottom: 1px dashed var(--border); font-size: 13px; }
.settings-group h4 { margin: 0 0 8px; font-size: 11.5px; color: var(--text-faint); text-transform: uppercase; letter-spacing: .07em; font-weight: 600; }
.setting-row { display: grid; grid-template-columns: 200px 1fr auto; gap: 10px; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--border-soft); font-size: 13px; }
.setting-row label { color: var(--text-dim); }
.setting-row input, .setting-row select { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text); padding: 7px 10px; font-family: inherit; }
.effect-badge { font-size: 10.5px; padding: 2px 7px; border-radius: 8px; border: 1px solid var(--border); color: var(--text-faint); white-space: nowrap; }
.setting-row input:focus, .setting-row select:focus { border-color: var(--accent); box-shadow: var(--ring); outline: none; }
.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-hot { color: var(--ok); border-color: var(--ok); }
.effect-new-run { color: var(--warn); border-color: var(--warn); }
/* UI schema 渲染器样式(.ui-*)与 .doc-viewer 已迁入 @ui/ui.css。 */
/* ---- 杂项 ---- */
.toast { position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%); background: var(--bg-raised); border: 1px solid var(--border); color: var(--text); padding: 9px 16px; border-radius: 20px; box-shadow: 0 8px 30px rgba(0,0,0,.35); 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: 13px; }
.conn-dot { width: 8px; height: 8px; border-radius: 50%; }
.conn-dot.on { background: var(--ok); }
.conn-dot.off { background: var(--err); }
.doc-toolbar { display: flex; gap: 8px; margin-bottom: 10px; align-items: center; }
.search-input { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text); padding: 6px 10px; font-family: inherit; width: 100%; }
::-webkit-scrollbar { width: 9px; height: 9px; }
::-webkit-scrollbar-thumb { background: var(--bg-active); border-radius: 5px; }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: var(--bg-active); border-radius: 6px; border: 3px solid transparent; background-clip: content-box; }
::-webkit-scrollbar-thumb:hover { background: var(--border-strong); background-clip: content-box; border: 3px solid transparent; }
::-webkit-scrollbar-track { background: transparent; }
/* ---- 登录门(API key 认证,PG 档默认开启) ---- */
.login-gate {
position: fixed; inset: 0; display: flex; align-items: center; justify-content: center;
background: var(--bg, #14161a); z-index: 1000;
/* ---- 品牌标(侧栏 / 登录页 / 头像共用的星芒渐变底) ---- */
.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); }
/* ---- 左栏:任务中心(方案 A;R4 重造型) ---- */
.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-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-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-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::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: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-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-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: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-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); }
.tsb-task { display: flex; align-items: center; gap: 8px; height: 31px; padding: 0 8px; margin: 1px 0; border-radius: var(--radius-sm); color: var(--text-dim); cursor: pointer; position: relative; transition: background .1s, color .1s; }
.tsb-task:hover { background: var(--bg-hover); color: var(--text); }
.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-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-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-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-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-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-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); }
/* ---- 登录页与改密门(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; }
.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 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; }
@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-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: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-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-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;
}
.login-card {
width: 360px; padding: 28px; border-radius: 12px; text-align: center;
background: #1d2026; border: 1px solid #2c313a; box-shadow: 0 8px 32px rgba(0,0,0,.45);
}
.login-card h2 { margin: 0 0 8px; }
.login-hint { font-size: 12px; color: #9aa3b2; margin-bottom: 16px; line-height: 1.6; }
.login-card input {
width: 100%; box-sizing: border-box; padding: 9px 12px; border-radius: 8px;
border: 1px solid #2c313a; background: #14161a; color: inherit; font-size: 14px;
}
.login-card button {
margin-top: 12px; width: 100%; padding: 9px 0; border: none; border-radius: 8px;
background: #3b82f6; color: #fff; font-size: 14px; cursor: pointer;
}
.login-err { margin-top: 10px; font-size: 12px; color: #f87171; }
.lg-submit:disabled { opacity: .45; cursor: default; box-shadow: none; }
.lg-submit:not(:disabled):hover { filter: brightness(1.09); }
.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: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; }
/* ---- 用户管理 / 改密弹层 ---- */
.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-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-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 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-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-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 .lg-input { flex: 1; min-width: 160px; height: 32px; }
.ad-changepw { display: flex; flex-direction: column; gap: 2px; max-width: 340px; }
/* ---- 收起轨道(左栏收起态) ---- */
.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; }
/* ---- 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; }
+82
View File
@@ -0,0 +1,82 @@
// 任务列表组织(方案 A,提案 2026-09-30):纯函数,供 TaskSidebar 与测试消费。
// 组织是本端视图偏好(P8),不改数据模型——数据源恒为跨项目 tasks(updated_at 倒序)。
import type { Conversation, Project } from './contracts'
export type TaskOrg = 'project' | 'timeline'
export interface TaskGroup {
key: string
label: string
/** 项目分组时携带(组头「在此项目新建」入口与计数)。 */
projectId?: string
tasks: Conversation[]
}
/** 按项目分组:项目按服务端序(更新倒序),组内任务按列表序(置顶已在服务端排首)。 */
export function groupByProject(tasks: Conversation[], projects: Project[]): TaskGroup[] {
const byProject = new Map<string, Conversation[]>()
for (const t of tasks) {
const list = byProject.get(t.projectId) ?? []
list.push(t)
byProject.set(t.projectId, list)
}
const groups: TaskGroup[] = []
for (const p of projects) {
const list = byProject.get(p.id)
if (!list || list.length === 0) continue
groups.push({ key: `prj:${p.id}`, label: p.name, projectId: p.id, tasks: list })
byProject.delete(p.id)
}
// 项目已删但会话仍在(竞态残留):收进"未分组",不静默丢弃。
for (const [pid, list] of byProject) {
groups.push({ key: `prj:${pid}`, label: '未分组', projectId: pid, tasks: list })
}
return groups
}
/** 按时间线分组:今天 / 昨天 / 本周更早 / 更早(按 updatedAt 本地时区)。 */
export function groupByTimeline(tasks: Conversation[], now = Date.now()): TaskGroup[] {
const startOfDay = (ts: number) => { const d = new Date(ts); d.setHours(0, 0, 0, 0); return d.getTime() }
const today0 = startOfDay(now)
const dayMs = 86_400_000
const buckets: TaskGroup[] = [
{ key: 'today', label: '今天', tasks: [] },
{ key: 'yesterday', label: '昨天', tasks: [] },
{ key: 'week', label: '本周更早', tasks: [] },
{ key: 'older', label: '更早', tasks: [] },
]
const weekday = (new Date(now).getDay() + 6) % 7 // 周一=0
const weekStart = today0 - weekday * dayMs
for (const t of tasks) {
const ts = Date.parse(t.updatedAt)
if (Number.isNaN(ts)) { buckets[3].tasks.push(t); continue }
if (ts >= today0) buckets[0].tasks.push(t)
else if (ts >= today0 - dayMs) buckets[1].tasks.push(t)
else if (ts >= weekStart) buckets[2].tasks.push(t)
else buckets[3].tasks.push(t)
}
return buckets.filter(g => g.tasks.length > 0)
}
/** 相对时间(侧栏行尾):刚刚 / N 分钟 / N 小时 / 昨天 / N 天 / 月-日。 */
export function relativeTime(iso: string, now = Date.now()): string {
const ts = Date.parse(iso)
if (Number.isNaN(ts)) return ''
const diff = now - ts
if (diff < 60_000) return '刚刚'
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} 分钟`
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)} 小时`
if (diff < 172_800_000) return '昨天'
if (diff < 7 * 86_400_000) return `${Math.floor(diff / 86_400_000)} 天`
const d = new Date(ts)
const nowD = new Date(now)
const md = `${d.getMonth() + 1}/${d.getDate()}`
return d.getFullYear() === nowD.getFullYear() ? md : `${d.getFullYear()}/${md}`
}
/** 标题过滤(不区分大小写;空串返回原列表)。 */
export function filterTasks(tasks: Conversation[], q: string): Conversation[] {
const needle = q.trim().toLowerCase()
if (!needle) return tasks
return tasks.filter(t => t.title.toLowerCase().includes(needle))
}
+71
View File
@@ -0,0 +1,71 @@
// 任务列表组织纯函数测试(方案 A R1)。
import { describe, it, expect } from 'vitest'
import { groupByProject, groupByTimeline, relativeTime, filterTasks } from '../src/tasklist'
import type { Conversation, Project } from '../src/contracts'
const NOW = new Date('2026-09-30T15:00:00').getTime()
const H = 3_600_000
function conv(id: string, projectId: string, updatedAt: string, extra: Partial<Conversation> = {}): Conversation {
return { id, projectId, title: id, createdAt: updatedAt, updatedAt, ...extra }
}
describe('groupByProject', () => {
it('groups tasks under projects in project order and never drops orphans', () => {
const projects: Project[] = [
{ id: 'p2', name: '乙', createdAt: '', updatedAt: '' },
{ id: 'p1', name: '甲', createdAt: '', updatedAt: '' },
]
const tasks = [conv('a', 'p1', '2026-09-30T10:00:00Z'), conv('b', 'p2', '2026-09-29T10:00:00Z'), conv('c', 'pX', '2026-09-28T10:00:00Z')]
const groups = groupByProject(tasks, projects)
expect(groups.map(g => g.label)).toEqual(['乙', '甲', '未分组'])
expect(groups[0].projectId).toBe('p2')
expect(groups[2].tasks.map(t => t.id)).toEqual(['c'])
})
it('skips projects with zero visible tasks', () => {
const projects: Project[] = [{ id: 'p1', name: '甲', createdAt: '', updatedAt: '' }]
expect(groupByProject([], projects)).toEqual([])
})
})
describe('groupByTimeline', () => {
it('buckets by local day and week boundaries', () => {
// NOW=周三 15:00(本地);构造今天/昨天/本周更早(周一)/更早 各一条。
const tasks = [
conv('today', 'p', new Date(NOW - H).toISOString()),
conv('yesterday', 'p', new Date(NOW - 26 * H).toISOString()),
conv('week', 'p', new Date(NOW - 50 * H).toISOString()),
conv('older', 'p', new Date(NOW - 10 * 24 * H).toISOString()),
]
const groups = groupByTimeline(tasks, NOW)
expect(groups.map(g => g.label)).toEqual(['今天', '昨天', '本周更早', '更早'])
expect(groups[0].tasks[0].id).toBe('today')
})
it('hides empty buckets and tolerates bad timestamps', () => {
const groups = groupByTimeline([conv('x', 'p', 'not-a-date')], NOW)
expect(groups).toHaveLength(1)
expect(groups[0].label).toBe('更早')
})
})
describe('relativeTime', () => {
it('formats gradients and year rollover', () => {
expect(relativeTime(new Date(NOW - 30_000).toISOString(), NOW)).toBe('刚刚')
expect(relativeTime(new Date(NOW - 5 * 60_000).toISOString(), NOW)).toBe('5 分钟')
expect(relativeTime(new Date(NOW - 3 * H).toISOString(), NOW)).toBe('3 小时')
expect(relativeTime(new Date(NOW - 30 * H).toISOString(), NOW)).toBe('昨天')
expect(relativeTime(new Date(NOW - 3 * 24 * H).toISOString(), NOW)).toBe('3 天')
expect(relativeTime('2026-05-01T00:00:00', NOW)).toBe('5/1')
expect(relativeTime('2025-12-31T00:00:00', NOW)).toBe('2025/12/31')
expect(relativeTime('bad')).toBe('')
})
})
describe('filterTasks', () => {
it('matches title case-insensitively and passes through on empty query', () => {
const tasks = [conv('a', 'p', '', { title: '周报 Draft' }), conv('b', 'p', '', { title: '接口评审' })]
expect(filterTasks(tasks, '周报')).toEqual([tasks[0]])
expect(filterTasks(tasks, ' ')).toEqual(tasks)
expect(filterTasks(tasks, 'DRAFT')).toEqual([tasks[0]])
})
})
+4 -1
View File
@@ -16,7 +16,10 @@
"baseUrl": ".",
"paths": {
"@ui": ["../ui/src/index.ts"],
"@ui/*": ["../ui/src/*"]
"@ui/*": ["../ui/src/*"],
"react": ["./node_modules/@types/react"],
"react-dom": ["./node_modules/@types/react-dom"],
"react/jsx-runtime": ["./node_modules/@types/react/jsx-runtime"]
}
},
"include": ["src", "test"]
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/api.ts","./src/auth.ts","./src/contracts.ts","./src/main.tsx","./src/store.ts","./src/components/AgentTracker.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/ProjectNav.tsx","./src/components/SettingsModal.tsx","./src/components/Workbench.tsx","./test/auth.test.ts","./test/contracts.test.ts"],"version":"5.9.3"}
{"root":["./src/App.tsx","./src/api.ts","./src/auth.ts","./src/contracts.ts","./src/main.tsx","./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/tasklist.test.ts"],"version":"5.9.3"}