Compare commits
50
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae761c2c4a | ||
|
|
e87bcf595f | ||
|
|
7418e507a0 | ||
|
|
1fc1d2f9b4 | ||
|
|
e7fa8756db | ||
|
|
801e551d8d | ||
|
|
b3112aa412 | ||
|
|
93587fe813 | ||
|
|
bfaf65ae1f | ||
|
|
48a14fb4d2 | ||
|
|
8bc5b47914 | ||
|
|
c5e93568a5 | ||
|
|
89bfa83cae | ||
|
|
6752b788b9 | ||
|
|
411d09b3a8 | ||
|
|
af0385451a | ||
|
|
a61bd70399 | ||
|
|
b329de3bf5 | ||
|
|
b067b57975 | ||
|
|
c6464bc4b0 | ||
|
|
65f7753a79 | ||
|
|
8712fadb49 | ||
|
|
64837575a9 | ||
|
|
765789f34d | ||
|
|
754ce83ffd | ||
|
|
22a87232d7 | ||
|
|
fb219e04a3 | ||
|
|
edb5dbe73e | ||
|
|
9c87953877 | ||
|
|
a3c556cd0a | ||
|
|
642299ddd4 | ||
|
|
b2fbcf72bc | ||
|
|
59ffe044b5 | ||
|
|
01672deec5 | ||
|
|
9fc1387235 | ||
|
|
2516df5b3b | ||
|
|
1d6b8a64a6 | ||
|
|
6cfc1059c5 | ||
|
|
dc70399d45 | ||
|
|
3aeafc4440 | ||
|
|
4abf13d33d | ||
|
|
0eb9066510 | ||
|
|
616795be41 | ||
|
|
a54022c438 | ||
|
|
a617504268 | ||
|
|
4f4711a22a | ||
|
|
e5b65ca79a | ||
|
|
58f3599660 | ||
|
|
298c41ff67 | ||
|
|
6a2b21a28d |
+2
-1
@@ -1,3 +1,4 @@
|
||||
node_modules/
|
||||
# 无斜杠形式同时忽略目录与符号链接(目录式规则不匹配 symlink,曾致链接被提交)。
|
||||
node_modules
|
||||
dist/
|
||||
*.local
|
||||
|
||||
@@ -17,3 +17,20 @@
|
||||
- 模块边界、公开契约与隔离档位见 `policy/modules/`(如有)与 PROJECT.md。
|
||||
- 跨仓 gitlink 变更须经组合验证后在 Dev 仓推进;受保护分支经 PR 合入。
|
||||
- 验证命令见 PROJECT.md「验证」节;完成声明须附实际命令输出证据。
|
||||
|
||||
## Gitea 与 Git 访问事实(2026-09-30 实测)
|
||||
|
||||
历史文档与旧链接里的 `git-panel.u1680832.nyat.app:30319` 已失效(TLS 握手失败),
|
||||
见到一律按过期处理,不要再试。当前权威通道:
|
||||
|
||||
- **Web / API(HTTPS)**:`https://git-home.u1680832.nyat.app:30319`(Gitea 1.27.3,
|
||||
API 前缀 `/api/v1`;偶发 broken pipe / 超时,重试即可)。
|
||||
- **Git over SSH**:`ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/<Repo>.git`
|
||||
(各仓 origin 与 gitlink 均为此格式)。
|
||||
- **API token**:`~/.secrets/gitea`,两行键值 `URL_PREFIX=…` 与
|
||||
`ACCESS_TOKEN=<40 位 hex>`;取值 `grep ACCESS_TOKEN ~/.secrets/gitea | cut -d= -f2-`,
|
||||
用作 `Authorization: token <值>` 请求头。**token 值不入仓、不回显、不写进产出物**
|
||||
(baseline 硬边界 2:只记录来源、字段与可用状态)。
|
||||
- **SSH key**:`~/.ssh/home_gitea`(ED25519,公钥指纹
|
||||
`SHA256:GYXaOXIMeloQ1fKSfdJ/ndgPQzs/Ix7Hk62ekfkfQ20`,Gitea 侧密钥名 GMKtec);
|
||||
`~/.ssh/config` 已有 `git-ssh.u1680832.nyat.app` 主机条目,克隆/推送直接可用。
|
||||
|
||||
Generated
+523
-194
File diff suppressed because it is too large
Load Diff
+7
-2
@@ -10,16 +10,21 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"jszip": "^3.10.2",
|
||||
"mammoth": "^1.13.0",
|
||||
"marked": "^15.0.7",
|
||||
"pptx-preview": "^1.0.7",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
"react-dom": "^19.2.0",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.20.4",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.0.11",
|
||||
"vitest": "^3.0.5"
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
}
|
||||
|
||||
+49
-38
@@ -1,57 +1,49 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { auth } from './auth'
|
||||
import { api } from './api'
|
||||
import { LoginPage, ChangePasswordGate } from './components/LoginPage'
|
||||
import { ChangePasswordModal } from './components/AdminUsersModal'
|
||||
import { AdminZone } from './components/AdminZone'
|
||||
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 <name></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 +87,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 +107,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 +138,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.adminZoneOpen && <AdminZone />}
|
||||
{ui.changePwOpen && <ChangePasswordModal onClose={() => store.setUI({ changePwOpen: false })} />}
|
||||
{viewingArtifact && <DocViewer artifact={viewingArtifact} />}
|
||||
{toast && <div className="toast">{toast}</div>}
|
||||
</div>
|
||||
|
||||
+39
-5
@@ -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)),
|
||||
@@ -53,6 +70,10 @@ export const api = {
|
||||
auth.fetch(`${BASE}/api/cards/${cardId}/pin`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pinned }) }).then(r => json<{ card: ActivityCard }>(r)),
|
||||
cardDismiss: (cardId: string) =>
|
||||
auth.fetch(`${BASE}/api/cards/${cardId}/dismiss`, { method: 'POST' }).then(r => json<{ card: ActivityCard }>(r)),
|
||||
listProjectCards: (projectId: string) =>
|
||||
auth.fetch(`${BASE}/api/projects/${projectId}/cards`).then(r => json<{ cards: ActivityCard[] }>(r)).then(d => d.cards ?? []),
|
||||
cardToProject: (cardId: string) =>
|
||||
auth.fetch(`${BASE}/api/cards/${cardId}/to_project`, { method: 'POST' }).then(r => json<{ card: ActivityCard; origin: ActivityCard }>(r)),
|
||||
|
||||
upload: (projectId: string, file: File) => {
|
||||
const fd = new FormData()
|
||||
@@ -61,12 +82,19 @@ export const api = {
|
||||
},
|
||||
listArtifacts: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/artifacts`).then(r => json<Artifact[]>(r)),
|
||||
artifactContentUrl: (id: string) => auth.withKey(`${BASE}/api/artifacts/${id}/content`),
|
||||
// 下载直链:后端按 ?download=1 回 Content-Disposition: attachment(RFC 5987 文件名)。
|
||||
artifactDownloadUrl: (id: string) => {
|
||||
const url = auth.withKey(`${BASE}/api/artifacts/${id}/content`)
|
||||
return url.includes('?') ? `${url}&download=1` : `${url}?download=1`
|
||||
},
|
||||
saveArtifactContent: (id: string, content: string) => auth.fetch(`${BASE}/api/artifacts/${id}/content`, { method: 'PUT', body: content }),
|
||||
deleteArtifact: (id: string) => auth.fetch(`${BASE}/api/artifacts/${id}`, { method: 'DELETE' }),
|
||||
|
||||
getPlan: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/plan`).then(r => r.ok ? r.json() : null) as Promise<PlanSnapshot | null>,
|
||||
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 +106,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 游标续传;断线自动重连 ----
|
||||
@@ -89,13 +118,17 @@ export interface SSEHandlers {
|
||||
onError?: (err: Error) => void
|
||||
}
|
||||
|
||||
export function connectEvents(conversationId: string, since: number, handlers: SSEHandlers): { close: () => void } {
|
||||
export function connectEvents(conversationId: string, since: number, handlers: SSEHandlers, projectId?: string): { close: () => void } {
|
||||
let closed = false
|
||||
let es: EventSource | null = null
|
||||
|
||||
const connect = () => {
|
||||
if (closed) return
|
||||
es = new EventSource(auth.withKey(`${BASE}/api/events?conversationId=${encodeURIComponent(conversationId)}&since=${since}`))
|
||||
// 项目流(CF3/D50):scope=project 订阅项目频道(伪会话键,信封带 ProjectID)。
|
||||
const url = projectId
|
||||
? `${BASE}/api/events?scope=project&projectId=${encodeURIComponent(projectId)}&since=${since}`
|
||||
: `${BASE}/api/events?conversationId=${encodeURIComponent(conversationId)}&since=${since}`
|
||||
es = new EventSource(auth.withKey(url))
|
||||
es.onopen = () => handlers.onOpen?.()
|
||||
es.onerror = () => {
|
||||
if (closed) return
|
||||
@@ -106,7 +139,8 @@ export function connectEvents(conversationId: string, since: number, handlers: S
|
||||
// 同时监听具名事件与默认(服务端 event: 字段)。
|
||||
const types = ['message.delta', 'thinking.delta', 'tool.call', 'tool.result', 'run.phase',
|
||||
'hitl.request', 'hitl.resolved', 'plan.update', 'todo.update', 'agent.spawn', 'agent.progress',
|
||||
'agent.result', 'artifact.created', 'artifact.updated', 'run.completed', 'run.failed', 'error']
|
||||
'agent.result', 'artifact.created', 'artifact.updated', 'run.completed', 'run.failed', 'error',
|
||||
'card.created', 'card.updated', 'card.status', 'card.resolved']
|
||||
for (const t of types) es.addEventListener(t, ev => handle(t, (ev as MessageEvent).data))
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// 管理员用户管理已迁「系统管理」分区(components/adminSections.tsx UsersPanel,
|
||||
// 重设计方案 阶段②)。本文件只保留登录者主动改密弹窗(与强制改密共用 API)。
|
||||
import { useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { store, useStore } from '../store'
|
||||
import { Icon } from '@ui'
|
||||
|
||||
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 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// 系统管理(重设计方案 阶段②):管理员专属的全屏独立视图(带返回,非弹窗)。
|
||||
// 用户决策:系统管理 = 全屏独立视图;普通用户界面不暴露任何系统能力。
|
||||
// 分区注册表:新增管理页只改 SECTIONS,不改 AdminZone 本体(声明式注册)。
|
||||
import { useState } from 'react'
|
||||
import { store, useStore } from '../store'
|
||||
import { Icon, type IconName } from '@ui'
|
||||
import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel } from './adminSections'
|
||||
|
||||
interface AdminSectionDef {
|
||||
key: string
|
||||
label: string
|
||||
icon: IconName
|
||||
desc: string
|
||||
/** 懒渲染:进入分区才挂载(每个面板自带数据加载)。 */
|
||||
render: () => React.ReactNode
|
||||
}
|
||||
|
||||
function ProfilesPanelWrapper() {
|
||||
const profiles = useStore(s => s.profiles)
|
||||
return <ProfilesSection profiles={profiles} />
|
||||
}
|
||||
|
||||
const SECTIONS: AdminSectionDef[] = [
|
||||
{ key: 'users', label: '用户管理', icon: 'users', desc: '建号、发放初始密码、重置密码(管理员发放制)', render: () => <UsersPanel /> },
|
||||
{ key: 'profiles', label: '智能体画像', icon: 'bot', desc: '系统级画像:系统提示、工具允许/确认/拒绝清单', render: () => <ProfilesPanelWrapper /> },
|
||||
{ key: 'plugins', label: '插件管理', icon: 'puzzle', desc: '已装载插件清单与热重载(装卸不影响核心)', render: () => <PluginsSection /> },
|
||||
{ key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', render: () => <PluginWorkspacesPanel /> },
|
||||
]
|
||||
|
||||
export function AdminZone() {
|
||||
const me = useStore(s => s.me)
|
||||
const [sectionKey, setSectionKey] = useState('users')
|
||||
// 双保险:入口已在菜单层门控(me?.isAdmin),此处再拦一次(纵深防御)。
|
||||
if (!me?.isAdmin) return null
|
||||
const section = SECTIONS.find(s => s.key === sectionKey) ?? SECTIONS[0]
|
||||
return (
|
||||
<div className="az" role="dialog" aria-label="系统管理">
|
||||
<div className="az-head">
|
||||
<button className="btn ghost" onClick={() => store.setUI({ adminZoneOpen: false })}>
|
||||
<Icon name="chevron-down" size={14} /> 返回工作台
|
||||
</button>
|
||||
<span className="az-title">系统管理</span>
|
||||
<span className="az-sub">仅管理员可见 · 变更即时生效</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span className="az-tenant">{me.owner}</span>
|
||||
</div>
|
||||
<div className="az-body">
|
||||
<nav className="az-side">
|
||||
{SECTIONS.map(s => (
|
||||
<button key={s.key} className={`az-nav-item ${s.key === sectionKey ? 'on' : ''}`} onClick={() => setSectionKey(s.key)}>
|
||||
<Icon name={s.icon} size={15} />
|
||||
<span>{s.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<main className="az-main">
|
||||
<div className="az-section-head">
|
||||
<h3>{section.label}</h3>
|
||||
<p>{section.desc}</p>
|
||||
</div>
|
||||
{section.render()}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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,58 +25,52 @@ 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 }) }}>
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
🤖 Sub-agent 追踪
|
||||
<span className="spacer" />
|
||||
<button className="btn ghost" onClick={() => store.setUI({ trackerRunId: null })}>关闭</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{!root && <div>加载中…</div>}
|
||||
{root && (
|
||||
<div className="subagent-tree">
|
||||
<div className="subagent-node">
|
||||
<div className="head">
|
||||
<span className={`status-dot status-${root.status}`} />
|
||||
<strong>{root.kind === 'subagent' ? root.agentName || '子代理' : '主任务'}</strong>
|
||||
<span style={{ color: 'var(--text-faint)' }}>{root.status}{root.phase ? ` · ${root.phase}` : ''}</span>
|
||||
<span className="spacer" style={{ flex: 1 }} />
|
||||
<UsageBadge usage={root.usage} />
|
||||
// 阶段③ 收敛:只保留 OverlayHost 一层壳(此前在其内又嵌 .modal 双层窗框)。
|
||||
<OverlayHost title="代理追踪" icon="🤖" onClose={() => store.setUI({ trackerRunId: null })}>
|
||||
<div className="subagent-tree">
|
||||
{!root && <div>加载中…</div>}
|
||||
{root && (
|
||||
<>
|
||||
<div className="subagent-node">
|
||||
<div className="head">
|
||||
<span className={`status-dot status-${root.status}`} />
|
||||
<strong>{root.kind === 'subagent' ? root.agentName || '子代理' : '主任务'}</strong>
|
||||
<span style={{ color: 'var(--text-faint)' }}>{root.status}{root.phase ? ` · ${root.phase}` : ''}</span>
|
||||
<span className="spacer" style={{ flex: 1 }} />
|
||||
<UsageBadge usage={root.usage} />
|
||||
</div>
|
||||
{children.length > 0 && (
|
||||
<div>
|
||||
{children.map(c => (
|
||||
<div key={c.id} className="agent-row" style={{ background: focus === c.id ? 'var(--accent-soft)' : undefined, borderRadius: 6 }}
|
||||
onClick={() => setSelected(c.id)}>
|
||||
<span className={`status-dot status-${c.status}`} />
|
||||
<span style={{ flex: 1 }}>🤖 {c.agentName || c.id}</span>
|
||||
<span style={{ color: 'var(--text-faint)', fontSize: 'var(--fs-ui-xs)' }}>{c.status}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{children.length > 0 && (
|
||||
<div>
|
||||
{children.map(c => (
|
||||
<div key={c.id} className="agent-row" style={{ background: focus === c.id ? 'var(--accent-soft)' : undefined, borderRadius: 6 }}
|
||||
onClick={() => setSelected(c.id)}>
|
||||
<span className={`status-dot status-${c.status}`} />
|
||||
<span style={{ flex: 1 }}>🤖 {c.agentName || c.id}</span>
|
||||
<span style={{ color: 'var(--text-faint)', fontSize: 11 }}>{c.status}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<h5 style={{ margin: '12px 0 6px' }}>中间过程({focus === runId ? '主任务' : '子代理'})</h5>
|
||||
<div style={{ background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 8, padding: 12, maxHeight: '40vh', overflowY: 'auto' }}>
|
||||
{focusMsgs.length === 0 && <div style={{ color: 'var(--text-faint)', fontSize: 12.5 }}>(无中间过程消息)</div>}
|
||||
{focusMsgs.map(m => (
|
||||
<div key={m.id} className="part">
|
||||
{m.parts.map((p, i) => (
|
||||
<div key={i} style={{ whiteSpace: 'pre-wrap', fontSize: 13, lineHeight: 1.7 }}>
|
||||
{p.type === 'text' && p.text}
|
||||
{p.type === 'thinking' && <span style={{ color: 'var(--text-faint)' }}>{p.text}</span>}
|
||||
{p.type === 'tool_call' && <span className="tool-chip">🛠 {p.toolCall?.name}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<h5 style={{ margin: '12px 0 6px' }}>中间过程({focus === runId ? '主任务' : '子代理'})</h5>
|
||||
<div style={{ background: 'var(--bg)', border: '1px solid var(--border)', borderRadius: 8, padding: 12, maxHeight: '40vh', overflowY: 'auto' }}>
|
||||
{focusMsgs.length === 0 && <div style={{ color: 'var(--text-faint)', fontSize: 'var(--fs-ui-sm)' }}>(无中间过程消息)</div>}
|
||||
{focusMsgs.map(m => (
|
||||
<div key={m.id} className="part">
|
||||
{m.parts.map((p, i) => (
|
||||
<div key={i} style={{ whiteSpace: 'pre-wrap', fontSize: 'var(--fs-ui-caption)', lineHeight: 1.7 }}>
|
||||
{p.type === 'text' && p.text}
|
||||
{p.type === 'thinking' && <span style={{ color: 'var(--text-faint)' }}>{p.text}</span>}
|
||||
{p.type === 'tool_call' && <span className="tool-chip">🛠 {p.toolCall?.name}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</OverlayHost>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+237
-65
@@ -1,13 +1,19 @@
|
||||
// 中栏:消息流(Part 渲染器注册表)+ 流式渲染 + 过程折叠 + HITL 卡片 + 输入区(4.4)。
|
||||
// 重设计方案 阶段④:折叠区全部迁 @ui Collapsible(<details> 已禁);工具调用块走
|
||||
// ToolBlock 契约(运行态扫光、完成边沿收起、展开态记忆);live 行扫光 + 已工作计时。
|
||||
import { Icon, Avatar, BrandMark, Collapsible, ToolBlock } from '@ui'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { marked } from 'marked'
|
||||
import { api } from '../api'
|
||||
import { store, useStore, activeRun, type LiveState, type ContextObs } from '../store'
|
||||
import type { Message, Part, HITLCard } from '../contracts'
|
||||
import { UIRenderer, InlineConfirmCard, hasOpenConfirm } from '@ui'
|
||||
import { liveToMessage, showProcessInline } from '../live'
|
||||
import type { Message, Part, HITLCard, ActivityCard } from '../contracts'
|
||||
import { UIRenderer, InlineConfirmCard, hasOpenConfirm, CardAnchor } from '@ui'
|
||||
import { docBridge } from './Workbench'
|
||||
import { groupParts, artifactIcon, humanSize } from '../messageparts'
|
||||
|
||||
marked.setOptions({ breaks: true })
|
||||
// marked 走标准 Markdown 语义(不开 breaks):模型输出按块级排版渲染,
|
||||
// 行内换行交给段落折叠,避免与 CSS 块间距叠加出松散排版。
|
||||
|
||||
// Part 渲染器注册表:按类型查表;未知类型静默忽略(前向兼容)。
|
||||
function PartView({ part, live }: { part: Part; live?: boolean | null }) {
|
||||
@@ -16,7 +22,9 @@ 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 终态后收进过程折叠区,默认收起。
|
||||
//(此单件形态用于过程组之外散落的思考;组内思考由 ProcessParts 渲染。)
|
||||
return <ThinkingBlock part={part} idKey={part.text?.slice(0, 24) ?? 't'} defaultOpen={!!live} />
|
||||
case 'tool_call':
|
||||
return <span className="tool-chip">🛠 {part.toolCall?.name}</span>
|
||||
case 'tool_result': {
|
||||
@@ -25,18 +33,44 @@ function PartView({ part, live }: { part: Part; live?: boolean | null }) {
|
||||
{part.toolCall?.name ?? part.toolResult?.callId} <span className={`status-${st}`}>{st === 'ok' ? '✓' : '✗'}</span>
|
||||
</span>
|
||||
}
|
||||
case 'artifact_ref':
|
||||
return <span className="artifact-chip" onClick={() => {
|
||||
const art = findArtifact(part.artifact!.artifactId)
|
||||
if (art) store.viewArtifact(art)
|
||||
}}>📄 {part.artifact?.name}</span>
|
||||
case 'artifact_ref': {
|
||||
const a = part.artifact
|
||||
const art = a ? findArtifact(a.artifactId) : undefined
|
||||
const name = a?.name ?? '工件'
|
||||
const icon = artifactIcon(name, a?.kind ?? art?.kind)
|
||||
const size = humanSize(art?.size)
|
||||
// 交付物卡片(对话内直接给出):整卡点击 = 右栏预览(唯一落位,全屏从
|
||||
// 右栏放大);动作只保留 下载 一个——与工件面板/预览器同一动作集。
|
||||
return (
|
||||
<div className="deliverable-card mk-rise-in" data-artifact-chip={a?.artifactId}
|
||||
title={`预览 ${name}(右栏,边聊边看)`}
|
||||
onClick={() => { if (art) store.requestPreview(art) }}>
|
||||
<span className="dc-icon"><Icon name={icon} size={18} /></span>
|
||||
<span className="dc-main">
|
||||
<span className="dc-name">{name}</span>
|
||||
<span className="dc-meta">{kindLabelOf(a?.kind ?? art?.kind)}{size ? ` · ${size}` : ''}{art ? '' : ' · 生成中'}</span>
|
||||
</span>
|
||||
<span className="dc-act">预览</span>
|
||||
<a className="dc-dl" title={`下载 ${name}`} href={api.artifactDownloadUrl(a?.artifactId ?? '')}
|
||||
download={name} onClick={e => e.stopPropagation()}><Icon name="download" size={14} /></a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
case 'hitl':
|
||||
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': {
|
||||
// cardlink 互定位(D32):点击 ↗ 定位收件箱对应卡(从 data 中解析 card:<id>)。
|
||||
const m = /card:([A-Za-z0-9_]+)/.exec(JSON.stringify(part.card?.data ?? ''))
|
||||
// 卡流锚点(D39):cardId 非空 = submit_card 引用部件——中栏自足(可展开全文)+ 跳收件箱。
|
||||
if (part.card?.cardId) {
|
||||
const ref = part.card.cardId
|
||||
const card = findCard(ref)
|
||||
const bodyChars = card?.body?.reduce((n, node) => n + (node.markdown?.length ?? 0), 0)
|
||||
return <CardAnchor title={card?.title ?? part.card.kind} chars={bodyChars} card={card}
|
||||
onLocate={() => store.openInbox(ref)} docBridge={docBridge} />
|
||||
}
|
||||
// 插件内嵌卡片部件(cardlink 互定位,D32):点击 ↗ 定位收件箱对应卡(从 data 解析 card:<id>)。
|
||||
const m = JSON.stringify(part.card?.data ?? '').match(/card:([A-Za-z0-9_]+)/)
|
||||
return (
|
||||
<div className="ui-section card-link" title={m ? '点击在收件箱中定位' : undefined}
|
||||
onClick={() => m && store.openInbox(m[1])}>
|
||||
@@ -57,20 +91,124 @@ function findArtifact(id: string) {
|
||||
return artifactCache[id]
|
||||
}
|
||||
|
||||
// 过程折叠:run 终态后把连续 process 部件折叠为摘要行(可展开回看)。
|
||||
function ProcessCollapse({ parts }: { parts: Part[] }) {
|
||||
/** 交付物卡片的类型文案(与工件面板 kind 过滤同一词汇)。 */
|
||||
function kindLabelOf(kind: string | undefined): string {
|
||||
switch (kind) {
|
||||
case 'document': return '文档'
|
||||
case 'data': return '数据'
|
||||
case 'image': return '图片'
|
||||
default: return '文件'
|
||||
}
|
||||
}
|
||||
|
||||
// 收件箱卡片查询缓存(store.cards 的派生索引;卡流锚点取卡数据用)。
|
||||
let cardCache: Record<string, ActivityCard> = {}
|
||||
function findCard(id: string) {
|
||||
return cardCache[id]
|
||||
}
|
||||
|
||||
// 思考块(Collapsible 契约):左线弱色,流式默认展开、终态默认收起;展开态记忆。
|
||||
function ThinkingBlock({ part, idKey, defaultOpen }: { part: Part; idKey: string; defaultOpen?: boolean }) {
|
||||
return (
|
||||
<div className="part-thinking">
|
||||
<Collapsible id={`think:${idKey}`} defaultOpen={defaultOpen} triggerClassName="part-thinking-summary"
|
||||
trigger={<><span className="part-thinking-chev">▸</span>思考过程</>}>
|
||||
<div className="part-thinking-body">{part.text}</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 过程段渲染(配对增强):tool_call 吸收紧随其后的同名 tool_result 组成 ToolBlock
|
||||
//(运行态扫光、完成边沿收起、展开看结果摘要);其余部件原位 PartView。
|
||||
function ProcessParts({ parts, live, idKey }: { parts: Part[]; live: boolean; idKey: string }) {
|
||||
const out: React.ReactNode[] = []
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const p = parts[i]
|
||||
if (p.type === 'tool_call') {
|
||||
const res = parts[i + 1]?.type === 'tool_result' ? parts[i + 1] : null
|
||||
if (res) i++
|
||||
const st = res?.toolResult?.status
|
||||
out.push(
|
||||
<ToolBlock key={i} toolId={`${idKey}:${i}`} name={`🛠 ${p.toolCall?.name ?? '工具'}`}
|
||||
running={live && !res} status={st === 'ok' ? 'ok' : st ? 'error' : undefined}
|
||||
statusDetail={res?.toolResult?.summary}
|
||||
autoCollapseOnComplete>
|
||||
{res && <div className="part-text">{res.toolResult?.summary}</div>}
|
||||
</ToolBlock>)
|
||||
continue
|
||||
}
|
||||
out.push(<div className="part" key={i}><PartView part={p} live={live && p.type === 'text'} /></div>)
|
||||
}
|
||||
return <>{out}</>
|
||||
}
|
||||
|
||||
// 过程折叠:run 终态后把连续 process 部件折叠为摘要行(可展开回看;Collapsible 契约)。
|
||||
function ProcessCollapse({ parts, idKey }: { parts: Part[]; idKey: string }) {
|
||||
const toolCount = parts.filter(p => p.type === 'tool_call').length
|
||||
const hasThinking = parts.some(p => p.type === 'thinking' && p.text)
|
||||
return (
|
||||
<details className="process-collapse">
|
||||
<summary>
|
||||
<span>⚙ 处程输出</span>
|
||||
<span>{hasThinking ? '含思考' : ''}{toolCount > 0 ? ` · ${toolCount} 次工具调用` : ''}(点击展开)</span>
|
||||
</summary>
|
||||
<div className="body">
|
||||
{parts.map((p, i) => <div className="part" key={i}><PartView part={p} /></div>)}
|
||||
</div>
|
||||
</details>
|
||||
<div className="process-collapse">
|
||||
<Collapsible id={`process:${idKey}`} triggerClassName="process-collapse-summary" unmountDelay={300}
|
||||
trigger={<>
|
||||
<span className="process-collapse-chev">▸</span>
|
||||
<span>过程输出</span>
|
||||
<span className="process-collapse-meta">
|
||||
{hasThinking ? '含思考' : ''}{toolCount > 0 ? ` · ${toolCount} 次工具调用` : ''}
|
||||
</span>
|
||||
</>}>
|
||||
<div className="body">
|
||||
<ProcessParts parts={parts} live={false} idKey={idKey} />
|
||||
</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 长回复折叠阈值(CF0):终态后超长正文收成摘要行,点击展开全文;live 期间全文可见。
|
||||
const LONG_REPLY_CHARS = 800
|
||||
|
||||
function LongReplyCollapse({ text, idKey }: { text: string; idKey: string }) {
|
||||
// 摘句取压缩空白后的前 100 字;字数按 Markdown 源码长度计。
|
||||
const excerpt = text.replace(/\s+/g, ' ').trim().slice(0, 100)
|
||||
return (
|
||||
<div className="process-collapse long-reply">
|
||||
<Collapsible id={`long:${idKey}`} triggerClassName="process-collapse-summary" unmountDelay={300}
|
||||
trigger={<>
|
||||
<span className="process-collapse-chev">▸</span>
|
||||
<span>📄 长回复</span>
|
||||
<span className="process-collapse-meta">{text.length} 字(按 Markdown 源码计)</span>
|
||||
{excerpt && <span className="process-collapse-excerpt">{excerpt}…</span>}
|
||||
</>}>
|
||||
<div className="body">
|
||||
<div className="part-text" dangerouslySetInnerHTML={{ __html: marked.parse(text) }} />
|
||||
</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 已工作计时(ZCode「已工作 X 分 X 秒」摘要行;1s 步进,仅 live 期间挂载)。
|
||||
function useElapsed(startedAt: number): string {
|
||||
const [, tick] = useState(0)
|
||||
useEffect(() => {
|
||||
const t = window.setInterval(() => tick(n => n + 1), 1000)
|
||||
return () => window.clearInterval(t)
|
||||
}, [])
|
||||
const s = Math.max(0, Math.floor((Date.now() - startedAt) / 1000))
|
||||
const m = Math.floor(s / 60)
|
||||
return m > 0 ? `${m} 分 ${s % 60} 秒` : `${s} 秒`
|
||||
}
|
||||
|
||||
// 流式状态行(独立组件:计时 hook 只在 live 行挂载期间运转)。
|
||||
function LiveBar({ live }: { live: LiveState }) {
|
||||
const elapsed = useElapsed(live.startedAt)
|
||||
return (
|
||||
<span className="live-bar">
|
||||
<span className="live-dot" />
|
||||
<span className="mk-sweep">{live.phase}</span>
|
||||
<span className="live-elapsed">已工作 {elapsed}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -78,24 +216,37 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null
|
||||
const autoCollapse = useStore(s => s.ui.autoCollapseProcess)
|
||||
const ctxObs = useStore(s => (msg.runId ? s.contextObs[msg.runId] : undefined))
|
||||
const parts = msg.parts ?? []
|
||||
const split = useMemo(() => {
|
||||
const out: { process: Part[]; content: Part[] } = { process: [], content: [] }
|
||||
for (const p of parts) (p.process ? out.process : out.content).push(p)
|
||||
return out
|
||||
}, [parts])
|
||||
// 顺序保真(渲染顺序 = 落库顺序):连续过程部件就地聚块,内容部件原位保留——
|
||||
// 工具调用显示在它真正发生的位置,不被抽到消息头部重排。
|
||||
const blocks = useMemo(() => groupParts(parts), [parts])
|
||||
|
||||
// 流式中:过程部件实时展开;run 终态后:按偏好折叠为摘要行(可回看)。
|
||||
const showProcessInline = live || !autoCollapse
|
||||
// 流式期间:工具/思考阶段过程实时展开;一旦进入 responding(最终回复开始流式)
|
||||
// 立即折叠过程,只显式显示回复(Cursor 式);工具恢复(acting)时重新展开。
|
||||
// run 终态后:按偏好折叠为摘要行(可回看)。
|
||||
const showProcess = showProcessInline(live ?? null, autoCollapse)
|
||||
const lastContentIdx = (() => {
|
||||
for (let i = blocks.length - 1; i >= 0; i--) if (blocks[i].kind === 'content') return i
|
||||
return -1
|
||||
})()
|
||||
return (
|
||||
<div className="msg assistant">
|
||||
<div className="msg assistant" data-msg-id={msg.id}>
|
||||
<div className="msg-role">
|
||||
<span className="role-mark role-assistant"><BrandMark size={16} /></span>
|
||||
<span>助手</span>
|
||||
{live && <span className="live-bar"><span className="live-dot" />{live.phase}</span>}
|
||||
{live && <LiveBar live={live} />}
|
||||
</div>
|
||||
{split.process.length > 0 && (showProcessInline
|
||||
? split.process.map((p, i) => <div className="part" key={i}><PartView part={p} /></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>)}
|
||||
{blocks.map((b, i) => b.kind === 'process'
|
||||
? (showProcess
|
||||
? <div className="part-group" key={i}><ProcessParts parts={b.parts} live={!!live} idKey={`${msg.id}:${i}`} /></div>
|
||||
: <ProcessCollapse key={i} parts={b.parts} idKey={`${msg.id}:${i}`} />)
|
||||
: (() => {
|
||||
// CF0:非 live 的超长文本部件折叠为摘要行;live 与短文本不受影响。
|
||||
if (!live && b.part.type === 'text' && (b.part.text?.length ?? 0) > LONG_REPLY_CHARS)
|
||||
return <LongReplyCollapse key={i} text={b.part.text ?? ''} idKey={`${msg.id}:${i}`} />
|
||||
return <div className="part" key={i}>
|
||||
<PartView part={b.part} live={!!(live && i === lastContentIdx && b.part.type === 'text')} />
|
||||
</div>
|
||||
})())}
|
||||
{live && <LiveAgentNotes live={live} />}
|
||||
{!live && ctxObs && <ContextObsView obs={ctxObs} />}
|
||||
</div>
|
||||
@@ -105,22 +256,25 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null
|
||||
// 上下文组装观测(4.10/4.17):模型实际看到什么、裁了什么——可解释性入口。
|
||||
function ContextObsView({ obs }: { obs: ContextObs }) {
|
||||
return (
|
||||
<details className="process-collapse">
|
||||
<summary>
|
||||
<span>🧩 上下文构成</span>
|
||||
<span>{obs.items.join(' · ')}(约 {obs.estChars} 字符)</span>
|
||||
</summary>
|
||||
<div className="body">
|
||||
<ul className="ui-list">
|
||||
{obs.items.map((it, i) => <li key={i}>{it}</li>)}
|
||||
</ul>
|
||||
{obs.truncations && obs.truncations.length > 0 && (
|
||||
<div style={{ color: 'var(--warn)', fontSize: 12.5, marginTop: 6 }}>
|
||||
{obs.truncations.map((t, i) => <div key={i}>⚠ {t}</div>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
<div className="process-collapse">
|
||||
<Collapsible id={`ctxobs:${obs.estChars}`} triggerClassName="process-collapse-summary" unmountDelay={300}
|
||||
trigger={<>
|
||||
<span className="process-collapse-chev">▸</span>
|
||||
<span>🧩 上下文构成</span>
|
||||
<span className="process-collapse-meta">{obs.items.join(' · ')}(约 {obs.estChars} 字符)</span>
|
||||
</>}>
|
||||
<div className="body">
|
||||
<ul className="ui-list">
|
||||
{obs.items.map((it, i) => <li key={i}>{it}</li>)}
|
||||
</ul>
|
||||
{obs.truncations && obs.truncations.length > 0 && (
|
||||
<div style={{ color: 'var(--warn)', fontSize: 'var(--fs-ui-sm)', marginTop: 6 }}>
|
||||
{obs.truncations.map((t, i) => <div key={i}>⚠ {t}</div>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -144,7 +298,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 => (
|
||||
@@ -190,8 +344,9 @@ 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)
|
||||
@@ -199,6 +354,7 @@ export function Conversation() {
|
||||
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[]>([])
|
||||
@@ -206,12 +362,13 @@ export function Conversation() {
|
||||
const bodyRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
artifactCache = Object.fromEntries(artifacts.map(a => [a.id, a]))
|
||||
cardCache = Object.fromEntries(cards.map(c => [c.id, c]))
|
||||
|
||||
const active = activeRun(runs)
|
||||
|
||||
useEffect(() => {
|
||||
bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight })
|
||||
}, [messages.length, live?.text, live?.thinking, activeHitl.length])
|
||||
}, [messages.length, live?.items, live?.phase, activeHitl.length])
|
||||
|
||||
const send = async () => {
|
||||
if (!text.trim() && attachmentIds.length === 0) return
|
||||
@@ -226,7 +383,7 @@ export function Conversation() {
|
||||
<div className="chat-body"><div className="empty-state">
|
||||
<div style={{ fontSize: 40 }}>⌘</div>
|
||||
<div>选择或创建一个会话开始</div>
|
||||
<div style={{ fontSize: 12 }}>mock 演示模式可在 设置 → runtime 开启,无需模型密钥</div>
|
||||
<div style={{ fontSize: 'var(--fs-ui-sm)' }}>mock 演示模式可在 设置 → runtime 开启,无需模型密钥</div>
|
||||
</div></div>
|
||||
</div>
|
||||
)
|
||||
@@ -235,6 +392,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 => {
|
||||
@@ -244,8 +402,15 @@ 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}
|
||||
@@ -263,8 +428,10 @@ export function Conversation() {
|
||||
// 内联确认卡(T3):锚定本消息的危险命令确认,双现于对话流与收件箱。
|
||||
const anchored = cards.filter(c => c.anchorMessageId === m.id && c.kind === 'confirm')
|
||||
return (
|
||||
<div className="msg user" key={m.id}>
|
||||
<div className="msg-role">你</div>
|
||||
<div className="msg user" key={m.id} data-msg-id={m.id}>
|
||||
<div className="msg-role">
|
||||
<span className="role-mark"><Avatar name={myName} size="sm" /></span>你
|
||||
</div>
|
||||
<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={{
|
||||
@@ -277,25 +444,28 @@ export function Conversation() {
|
||||
}
|
||||
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>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-faint)' }}>mock 模式演示指令:#hitl · #agents · #doc · #error · 命令:/help</div>
|
||||
<span className="es-mark"><Icon name="sparkles" size={22} /></span>
|
||||
<div>发送消息开始对话</div>
|
||||
<div style={{ fontSize: 'var(--fs-ui-sm)', color: 'var(--text-faint)' }}>mock 模式演示指令:#hitl · #agents · #doc · #error · 命令:/help</div>
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
<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]) }
|
||||
@@ -303,7 +473,9 @@ export function Conversation() {
|
||||
e.target.value = ''
|
||||
}} />
|
||||
</label>
|
||||
<textarea rows={1} value={text} placeholder={hasOpenConfirm(cards) ? '有待确认的命令——请先处理上方确认卡' : artifacts.length > 0 ? '输入消息…(/ 命令,@knowledge 检索注入)' : '输入消息…(/ 命令)'}
|
||||
<textarea rows={1} value={text} placeholder={hasOpenConfirm(cards) ? '有待确认的命令——请先处理上方确认卡'
|
||||
: active ? '回合进行中——等当前回合结束(或点「停止」)后再发送'
|
||||
: artifacts.length > 0 ? '输入消息…(/ 命令,@knowledge 检索注入)' : '输入消息…(/ 命令)'}
|
||||
onChange={e => setText(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() } }} />
|
||||
{active ? (
|
||||
|
||||
@@ -1,62 +1,68 @@
|
||||
// 右栏文档查看器(4.4):markdown 渲染 / 文本编辑保存 / 图片预览 / 其他下载。
|
||||
// 文档查看器(4.4 → 预览能力收敛):格式渲染统一交给 @ui FilePreview
|
||||
// (md/图片/pdf/代码/数据/docx/xlsx/pptx/URL 一套分派),这里只保留 OverlayHost
|
||||
// 容器、工具栏与文本族的在线编辑保存。
|
||||
import { useEffect, useState } from 'react'
|
||||
import { marked } from 'marked'
|
||||
import { api } from '../api'
|
||||
import { store } from '../store'
|
||||
import { OverlayHost, Icon, Btn, FilePreview } from '@ui'
|
||||
import type { Artifact } from '../contracts'
|
||||
import { humanSize } from '../messageparts'
|
||||
|
||||
export function DocViewer({ artifact }: { artifact: Artifact }) {
|
||||
const [content, setContent] = useState<string | null>(null)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
|
||||
const textKind = isTextKind(artifact)
|
||||
useEffect(() => {
|
||||
if (artifact.kind === 'image' || isBinaryKind(artifact)) return
|
||||
if (!textKind) return
|
||||
setContent(null)
|
||||
fetch(api.artifactContentUrl(artifact.id)).then(r => r.text()).then(setContent).catch(() => setContent('(读取失败)'))
|
||||
}, [artifact.id])
|
||||
|
||||
const source = {
|
||||
kind: 'artifact' as const,
|
||||
id: artifact.id,
|
||||
name: artifact.name,
|
||||
mime: artifact.mime,
|
||||
contentUrl: api.artifactContentUrl(artifact.id),
|
||||
downloadUrl: api.artifactDownloadUrl(artifact.id),
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overlay" onClick={e => { if (e.target === e.currentTarget) store.viewArtifact(null) }}>
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
📄 {artifact.name}
|
||||
<OverlayHost title={artifact.name} icon="📄" onClose={() => store.viewArtifact(null)}>
|
||||
<div className="docv">
|
||||
<div className="docv-toolbar">
|
||||
<span className="docv-meta">{artifact.kind}{humanSize(artifact.size) ? ` · ${humanSize(artifact.size)}` : ''}</span>
|
||||
<span className="spacer" />
|
||||
{dirty && <span style={{ color: 'var(--warn)', fontSize: 12, marginRight: 8 }}>未保存</span>}
|
||||
{isTextKind(artifact) && !isBinaryKind(artifact) && (
|
||||
{dirty && <span className="docv-dirty">未保存</span>}
|
||||
{textKind && (
|
||||
<>
|
||||
<button className="btn ghost" onClick={() => setEditing(e => !e)}>{editing ? '预览' : '编辑'}</button>
|
||||
{editing && <button className="btn" disabled={!dirty} onClick={async () => {
|
||||
<Btn variant="ghost" icon={editing ? 'eye' : 'list'} onClick={() => setEditing(e => !e)}>{editing ? '预览' : '编辑'}</Btn>
|
||||
{editing && <Btn variant="primary" icon="check" disabled={!dirty} onClick={async () => {
|
||||
await api.saveArtifactContent(artifact.id, content ?? '')
|
||||
setDirty(false)
|
||||
store.toast('已保存')
|
||||
}}>保存</button>
|
||||
}
|
||||
}}>保存</Btn>}
|
||||
</>
|
||||
)}
|
||||
<a className="btn ghost" style={{ textDecoration: 'none' }} href={api.artifactContentUrl(artifact.id)} download={artifact.name}>下载</a>
|
||||
<button className="btn ghost" onClick={() => store.viewArtifact(null)}>关闭</button>
|
||||
<a className="btn v-ghost docv-dl" href={api.artifactDownloadUrl(artifact.id)} download={artifact.name}>
|
||||
<Icon name="download" size={14} /> 下载
|
||||
</a>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{artifact.kind === 'image' ? (
|
||||
<img src={api.artifactContentUrl(artifact.id)} alt={artifact.name} style={{ maxWidth: '100%' }} />
|
||||
) : isBinaryKind(artifact) ? (
|
||||
<div style={{ color: 'var(--text-faint)' }}>二进制工件——请下载查看(docx 深度预览为远期项;md/txt 可在线查看)</div>
|
||||
) : editing ? (
|
||||
<textarea className="search-input" style={{ height: '60vh', fontFamily: 'var(--mono)', fontSize: 12.5 }}
|
||||
value={content ?? ''} onChange={e => { setContent(e.target.value); setDirty(true) }} />
|
||||
<div className="docv-body">
|
||||
{editing && textKind ? (
|
||||
<textarea className="search-input docv-editor" value={content ?? ''}
|
||||
onChange={e => { setContent(e.target.value); setDirty(true) }} />
|
||||
) : (
|
||||
<div className="doc-viewer" dangerouslySetInnerHTML={{ __html: marked.parse(content ?? '(加载中…') as string }} />
|
||||
<FilePreview source={source} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</OverlayHost>
|
||||
)
|
||||
}
|
||||
|
||||
function isTextKind(a: Artifact): boolean {
|
||||
return a.kind === 'document' && /\.(md|txt|csv|log|json)$/i.test(a.name)
|
||||
}
|
||||
|
||||
function isBinaryKind(a: Artifact): boolean {
|
||||
return /\.(docx|pdf|xlsx|pptx|png|jpg|jpeg|gif|webp)$/i.test(a.name)
|
||||
}
|
||||
|
||||
@@ -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 <name></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*/, '')
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,23 @@
|
||||
// 设置中心(4.9):schema 驱动渲染 + 生效时机标注 + 密钥掩码 + 插件管理 + 画像管理。
|
||||
// 设置中心(4.9 → UI 能力平台迁移):@ui SettingsShell + SettingRow 统一行语义,
|
||||
// 保存按钮进固定 actions 底栏(不随内容滚动);schema 驱动 + 生效时机标注 + 密钥掩码。
|
||||
// 重设计方案 阶段②(IA):设置瘦身——只留个人偏好(常规);智能体画像/插件管理/
|
||||
// 插件工作区迁「系统管理」(AdminZone,管理员专属全屏视图)。
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { store, useStore } from '../store'
|
||||
import type { AgentProfile, SettingDesc } from '../contracts'
|
||||
import { store } from '../store'
|
||||
import type { SettingDesc } from '../contracts'
|
||||
import { SettingsShell, SettingRow, SettingsGroup, Btn } from '@ui'
|
||||
|
||||
type Section = 'settings' | 'profiles' | 'plugins'
|
||||
type Section = 'settings'
|
||||
|
||||
// 分组显示名(项目设置面板共用)。
|
||||
export const groupNames: Record<string, string> = {
|
||||
models: '模型与密钥', runtime: '运行时', context: '上下文', retrieval: '检索',
|
||||
ui: '界面', plugins: '插件目录', crossSession: '会话协作', tools: '工具',
|
||||
}
|
||||
|
||||
export function SettingsModal() {
|
||||
const profiles = useStore(s => s.profiles)
|
||||
const [section, setSection] = useState<Section>('settings')
|
||||
const [section] = useState<Section>('settings')
|
||||
const [schema, setSchema] = useState<SettingDesc[]>([])
|
||||
const [, setUser] = useState<Record<string, any>>({})
|
||||
const [draft, setDraft] = useState<Record<string, any>>({})
|
||||
@@ -28,11 +37,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,50 +49,34 @@ 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">
|
||||
{section === 'settings' && (
|
||||
<>
|
||||
{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>
|
||||
))}
|
||||
<button className="btn" onClick={save}>保存设置</button>
|
||||
</>
|
||||
)}
|
||||
{section === 'profiles' && <ProfilesSection profiles={profiles} />}
|
||||
{section === 'plugins' && <PluginsSection />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsShell
|
||||
title="设置" active={section} onSelect={() => undefined}
|
||||
onClose={() => store.openSettings(false)}
|
||||
sections={[
|
||||
{ key: 'settings', label: '常规', icon: 'settings' },
|
||||
]}
|
||||
footer={saved ? <span style={{ color: 'var(--ok)' }}>✓ 已保存</span> : 'Agent-System · 设置中心'}
|
||||
actions={section === 'settings' && schema.length > 0 ? (
|
||||
<>
|
||||
{saved && <span style={{ color: 'var(--ok)', fontSize: 'var(--fs-ui-sm)', marginRight: 8 }}>✓ 已保存</span>}
|
||||
<Btn variant="primary" icon="check" onClick={save}>保存设置</Btn>
|
||||
</>
|
||||
) : undefined}
|
||||
>
|
||||
{section === 'settings' && Object.entries(groups).map(([g, descs]) => (
|
||||
<SettingsGroup key={g} title={groupNames[g] ?? g}>
|
||||
{descs.map(d => (
|
||||
<SettingRow key={d.key} label={d.label} desc={d.description} effect={d.effect}>
|
||||
<SettingInput desc={d} value={draft[d.key] ?? ''} onChange={v => setDraft(x => ({ ...x, [d.key]: v }))} />
|
||||
</SettingRow>
|
||||
))}
|
||||
</SettingsGroup>
|
||||
))}
|
||||
</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 +99,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 // 空串不覆盖(密钥未修改)
|
||||
@@ -128,103 +116,3 @@ function unflatten(flatMap: Record<string, any>): Record<string, any> {
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- 画像管理(数据化:导入导出 + 编辑)----
|
||||
function ProfilesSection({ profiles }: { profiles: AgentProfile[] }) {
|
||||
const [editing, setEditing] = useState<AgentProfile | null>(null)
|
||||
const [text, setText] = useState('')
|
||||
return (
|
||||
<div>
|
||||
<div className="doc-toolbar">
|
||||
<button className="btn" onClick={() => { setEditing(newProfile()); setText('') }}>+ 新建画像</button>
|
||||
<label className="btn ghost" style={{ cursor: 'pointer' }}>
|
||||
导入 JSON<input type="file" accept=".json" style={{ display: 'none' }} onChange={async e => {
|
||||
const f = e.target.files?.[0]
|
||||
if (!f) return
|
||||
try {
|
||||
const p = JSON.parse(await f.text())
|
||||
p.id = p.id || 'prof_imported'
|
||||
p.builtin = false
|
||||
await api.saveProfile(p)
|
||||
store.refreshProfiles()
|
||||
store.toast('画像已导入')
|
||||
} catch (err) { store.toast(`导入失败:${err}`) }
|
||||
}} />
|
||||
</label>
|
||||
</div>
|
||||
{profiles.map(p => (
|
||||
<div className="setting-row" key={p.id}>
|
||||
<label>
|
||||
{p.name} <span style={{ color: 'var(--text-faint)', fontSize: 11 }}>{p.id}{p.builtin ? ' · 内置' : ''}</span>
|
||||
</label>
|
||||
<div style={{ color: 'var(--text-faint)', fontSize: 12 }}>{p.description}</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button className="btn ghost" onClick={() => {
|
||||
const blob = new Blob([JSON.stringify(p, null, 2)], { type: 'application/json' })
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(blob); a.download = `${p.id}.json`; a.click()
|
||||
}}>导出</button>
|
||||
{!p.builtin && <button className="btn ghost" onClick={() => { setEditing(p); setText(p.systemPrompt) }}>编辑</button>}
|
||||
{!p.builtin && <button className="btn ghost" onClick={async () => { await api.deleteProfile(p.id); store.refreshProfiles() }}>删除</button>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{editing && (
|
||||
<div className="settings-group" style={{ borderTop: '1px solid var(--border)', paddingTop: 12 }}>
|
||||
<h4>编辑画像:{editing.name}</h4>
|
||||
<div className="ui-form">
|
||||
<label>名称<input value={editing.name} onChange={e => setEditing({ ...editing, name: e.target.value })} /></label>
|
||||
<label>系统提示<textarea className="search-input" rows={4} style={{ height: 'auto' }} value={text} onChange={e => setText(e.target.value)} /></label>
|
||||
<label>允许工具(逗号分隔,* 全部)<input value={editing.tools.allow.join(',')} onChange={e => setEditing({ ...editing, tools: { ...editing.tools, allow: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })} /></label>
|
||||
<label>执行前确认(前缀. 结尾表示族)<input value={editing.tools.confirm?.join(',') ?? ''} onChange={e => setEditing({ ...editing, tools: { ...editing.tools, confirm: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })} /></label>
|
||||
<label>拒绝工具<input value={editing.tools.deny?.join(',') ?? ''} onChange={e => setEditing({ ...editing, tools: { ...editing.tools, deny: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })} /></label>
|
||||
</div>
|
||||
<button className="btn" onClick={async () => {
|
||||
try {
|
||||
await api.saveProfile({ ...editing, builtin: false, source: 'user', systemPrompt: text })
|
||||
await store.refreshProfiles()
|
||||
setEditing(null)
|
||||
store.toast('画像已保存')
|
||||
} catch (e) { store.toast(`保存失败:${e}`) }
|
||||
}}>保存画像</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function newProfile(): AgentProfile {
|
||||
return {
|
||||
id: '', name: '新画像', systemPrompt: '', model: {}, tools: { allow: ['*'] },
|
||||
context: { maxHistoryMessages: 40 }, hitl: { askOnAmbiguity: true }, budget: {},
|
||||
} as AgentProfile
|
||||
}
|
||||
|
||||
// ---- 插件管理 ----
|
||||
function PluginsSection() {
|
||||
const [plugins, setPlugins] = useState<any[]>([])
|
||||
const refresh = () => api.listPlugins().then(setPlugins).catch(() => undefined)
|
||||
useEffect(() => { refresh() }, [])
|
||||
return (
|
||||
<div>
|
||||
<div className="doc-toolbar">
|
||||
<button className="btn" onClick={async () => {
|
||||
await api.reloadPlugins()
|
||||
await refresh()
|
||||
store.refreshPlugins()
|
||||
store.toast('插件已热重载')
|
||||
}}>⟳ 热重载</button>
|
||||
<span style={{ color: 'var(--text-faint)', fontSize: 12 }}>声明资产(工作流/意图/提示词/面板)热加载;装卸不影响核心</span>
|
||||
</div>
|
||||
{plugins.length === 0 && <div style={{ color: 'var(--text-faint)' }}>暂无已装载插件(目录:设置 → 插件目录;示例插件见 Dev 仓 plugins/example)</div>}
|
||||
{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>
|
||||
<div style={{ color: 'var(--text-faint)', fontSize: 12 }}>
|
||||
{[`工具 ${p.manifest.provides.tools?.length ?? 0}`, `面板 ${p.panels?.length ?? 0}`, `意图 ${p.intents?.length ?? 0}`, `工作流 ${p.workflows?.length ?? 0}`].join(' · ')}
|
||||
</div>
|
||||
<span className="effect-badge">api {p.manifest.apiVersion}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
// 左栏(方案 A「任务中心」,提案 2026-09-30 R1):单一主创建入口(P6)+
|
||||
// 组织视图(P8:按项目/时间线)+ 置顶/归档 + 账户区(P7)。
|
||||
// 重设计方案 阶段②(IA 对话优先):工件/代理/插件快捷条撤出普通用户界面——
|
||||
// 工件走右栏「工件」页签,插件工作区归「系统管理」(管理员专属全屏视图)。
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { store, useStore } from '../store'
|
||||
import { auth } from '../auth'
|
||||
import { api } from '../api'
|
||||
import { Icon, BrandMark } from '@ui'
|
||||
import type { Conversation } from '../contracts'
|
||||
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 +
|
||||
s.projectCards.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>
|
||||
|
||||
{/* 账户区(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>
|
||||
<MenuItem icon={theme === 'dark' ? 'sun' : 'moon'} onClick={() => { store.setUI({ theme: theme === 'dark' ? 'light' : 'dark' }); setMenu(null) }}>
|
||||
{theme === 'dark' ? '切换浅色主题' : '切换深色主题'}
|
||||
</MenuItem>
|
||||
{me?.isAdmin && <MenuItem icon="shield" onClick={() => { store.setUI({ adminZoneOpen: true }); setMenu(null) }}>系统管理…</MenuItem>}
|
||||
{me?.auth && <>
|
||||
<div className="tsb-menu-div" />
|
||||
<MenuItem danger icon="logout" onClick={() => { api.logout().catch(() => undefined); auth.logout() }}>退出登录</MenuItem>
|
||||
</>}
|
||||
</Menu>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+127
-102
@@ -1,139 +1,164 @@
|
||||
// 右栏工作台:收件箱(D25 置首)+ Plan / Todos / Artifacts / Agents + 插件面板;tab 显隐排序见设置。
|
||||
import { store, useStore, isTerminal } from '../store'
|
||||
// 右栏终态(提案 T4/D25 → 预览双落位):收件箱/预览两页签 + 「当前任务」常驻卡。
|
||||
// 预览页签(裁定 D4)承载右栏轻预览:FilePreview compact 模式,边聊边看。
|
||||
import { store, useStore } from '../store'
|
||||
import type { Run } from '../contracts'
|
||||
import { UIRenderer, InboxList, pendingCount, type DocBridge, type ActivityCard } from '@ui'
|
||||
import { InboxList, pendingCount, FilePreview, type ActivityCard, type DocBridge } from '@ui'
|
||||
import { Icon } from '@ui'
|
||||
import { api } from '../api'
|
||||
|
||||
// 工件解析/打开注入:渲染器纯展示,doc-ref 词汇经此桥接本仓状态(D29)。
|
||||
// 打开语义走 requestPreview 唯一入口(阶段③ 收敛:默认右栏轻预览)。
|
||||
export const docBridge: DocBridge = {
|
||||
resolveDoc: id => store.getState().artifacts.find(a => a.id === id),
|
||||
onOpenDoc: id => {
|
||||
const art = store.getState().artifacts.find(a => a.id === id)
|
||||
if (art) store.viewArtifact(art)
|
||||
if (art) store.requestPreview(art)
|
||||
},
|
||||
}
|
||||
|
||||
export function Workbench() {
|
||||
const ui = useStore(s => s.ui)
|
||||
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 })),
|
||||
]
|
||||
const inboxCallbacks = {
|
||||
onAction: (c: ActivityCard, id: string) => store.cardAction(c, id),
|
||||
onPin: (c: ActivityCard, p: boolean) => store.cardPin(c, p),
|
||||
onDismiss: (c: ActivityCard) => store.cardDismiss(c),
|
||||
onLocate: (c: ActivityCard) => store.locateMessage(c.anchorMessageId),
|
||||
onArchive: (c: ActivityCard) => store.cardToProject(c),
|
||||
}
|
||||
|
||||
/** 项目收件箱视图(CF3/D49/D51):项目级卡片 ∪ 其他会话待办(done 不聚合)。 */
|
||||
function ProjectInbox({ cards }: { cards: ActivityCard[] }) {
|
||||
const own = cards.filter(c => !c.conversationId)
|
||||
const cross = cards.filter(c => !!c.conversationId)
|
||||
if (cards.length === 0) {
|
||||
return <div className="inbox-empty">项目收件箱为空——「留档到项目」的产物卡与其他会话的待办会聚合在这里。</div>
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="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>
|
||||
</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} />
|
||||
)}
|
||||
</div>
|
||||
<div className="inbox-zone-label">📁 项目级卡片(跨会话可见)</div>
|
||||
{own.length > 0
|
||||
? <InboxList cards={own} callbacks={inboxCallbacks} docBridge={docBridge} />
|
||||
: <div className="inbox-empty" style={{ padding: '4px 14px' }}>暂无——会话卡点 🗂 即留档至此</div>}
|
||||
{cross.length > 0 && (
|
||||
<>
|
||||
<div className="inbox-zone-label">🕓 其他会话待办</div>
|
||||
<InboxList cards={cross} callbacks={inboxCallbacks} docBridge={docBridge} showPinnedZone={false} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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),
|
||||
}} />
|
||||
}
|
||||
export function Workbench() {
|
||||
const cards = useStore(s => s.cards)
|
||||
const plan = useStore(s => s.plan)
|
||||
const todos = useStore(s => s.todos)
|
||||
const inboxScope = useStore(s => s.inboxScope)
|
||||
const projectCards = useStore(s => s.projectCards)
|
||||
const pending = pendingCount(cards) + pendingCount(projectCards)
|
||||
const rightTab = useStore(s => s.ui.rightTab)
|
||||
const railId = useStore(s => s.ui.railPreviewId)
|
||||
const railArtifact = useStore(s => (railId ? s.artifacts.find(a => a.id === railId) : undefined))
|
||||
|
||||
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)" />
|
||||
const showPreview = rightTab === 'preview'
|
||||
return (
|
||||
<div>
|
||||
<h5 style={{ margin: '4px 0 10px' }}>{plan.title}</h5>
|
||||
{plan.steps.map(s => (
|
||||
<>
|
||||
<div className="tabs">
|
||||
<button className={`tab ${!showPreview ? 'active' : ''}`} onClick={() => store.setUI({ rightTab: 'inbox' })}
|
||||
title="收件箱">收件箱{pending > 0 ? ` ${pending}` : ''}</button>
|
||||
<button className={`tab ${showPreview ? 'active' : ''}`} onClick={() => store.railPreview(railArtifact ?? null)}
|
||||
title="预览(右栏轻预览)"><Icon name="eye" size={13} /> 预览</button>
|
||||
<span className="spacer" style={{ flex: 1 }} />
|
||||
<button className="tab tab-action" title="收纳已完成卡(done→关闭;已确认卡留痕不动)" onClick={() => store.tidyCards()}><Icon name="check" size={13} /> 收纳</button>
|
||||
<button className="tab" title="收起收件箱" onClick={store.toggleRight}><Icon name="chevron-right" size={14} /></button>
|
||||
</div>
|
||||
{showPreview ? (
|
||||
<div className="tab-body" data-rail-preview="">
|
||||
{railArtifact ? (
|
||||
<>
|
||||
<div className="rail-preview-head">
|
||||
<span className="rp-name" title={railArtifact.name}>{railArtifact.name}</span>
|
||||
<span className="spacer" style={{ flex: 1 }} />
|
||||
<button className="icon-btn" title="全屏预览(唯一大窗落位)" onClick={() => store.requestPreview(railArtifact, 'full')}><Icon name="eye" size={14} /></button>
|
||||
<a className="icon-btn" title="下载" href={api.artifactDownloadUrl(railArtifact.id)} download={railArtifact.name}><Icon name="download" size={14} /></a>
|
||||
<button className="icon-btn" title="关闭预览" onClick={() => store.railPreview(null)}><Icon name="x" size={14} /></button>
|
||||
</div>
|
||||
<FilePreview compact source={{
|
||||
kind: 'artifact',
|
||||
id: railArtifact.id,
|
||||
name: railArtifact.name,
|
||||
mime: railArtifact.mime,
|
||||
contentUrl: api.artifactContentUrl(railArtifact.id),
|
||||
downloadUrl: api.artifactDownloadUrl(railArtifact.id),
|
||||
}} />
|
||||
</>
|
||||
) : <div className="empty-note" style={{ padding: 14 }}>点消息里的工件卡片或右侧工件面板——文件会在这里边聊边看(md/图片/pdf/Office/代码均可)</div>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="tab-body">
|
||||
<CurrentTaskCard plan={plan} todos={todos} />
|
||||
<div className="inbox-scope" role="tablist" aria-label="收件箱范围">
|
||||
<button className={inboxScope === 'conv' ? 'active' : ''} onClick={() => store.setInboxScope('conv')}>本会话</button>
|
||||
<button className={inboxScope === 'project' ? 'active' : ''} onClick={() => store.setInboxScope('project')}>
|
||||
项目{pendingCount(projectCards) > 0 ? ` ${pendingCount(projectCards)}` : ''}
|
||||
</button>
|
||||
</div>
|
||||
{inboxScope === 'project'
|
||||
? <ProjectInbox cards={projectCards} />
|
||||
: <InboxList cards={cards} docBridge={docBridge} callbacks={inboxCallbacks} />}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** 当前任务常驻卡(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 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: 'var(--fs-ui-xs)', 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>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
// 工作区弹层(提案 T4/D25):工件/代理 经 OverlayHost 呈现。
|
||||
// 重设计方案 阶段②(IA 对话优先):插件面板/服务端声明工作区属系统能力,
|
||||
// 只进「系统管理」分区(AdminZone),不占普通用户的 ⌘1–9;清单按角色收窄。
|
||||
import { useState } from 'react'
|
||||
import { store, useStore } from '../store'
|
||||
import type { Run } from '../contracts'
|
||||
import { api } from '../api'
|
||||
import { OverlayHost, Icon, type DocBridge } from '@ui'
|
||||
import { docBridge } from './Workbench'
|
||||
import { AgentRow } from './Workbench'
|
||||
import { artifactIcon, humanSize } from '../messageparts'
|
||||
|
||||
/** 工作区清单(顺序即 ⌘ 序号):内置在前,插件面板/声明工作区仅管理员可见。 */
|
||||
export function workspaceEntries(): { key: string; title: string; icon: string; shortcut: string }[] {
|
||||
const s = store.getState()
|
||||
const admin = !!s.me?.isAdmin
|
||||
const out = [
|
||||
{ key: 'artifacts', title: '工件', icon: '📦', shortcut: '⌘1' },
|
||||
{ key: 'agents', title: '代理', icon: '🤖', shortcut: '⌘2' },
|
||||
...(admin ? [
|
||||
...s.pluginPanels.map((p, i) => ({ key: `plugin:${p.id}`, title: p.title, icon: '🧩', shortcut: `⌘${i + 3}` })),
|
||||
...s.serverWorkspaces.map((w, i) => ({ key: `ws:${w.id}`, title: w.title, icon: w.icon || '🗂', shortcut: w.shortcut ? `⌘${w.shortcut}` : `⌘${i + 3 + s.pluginPanels.length}` })),
|
||||
] : []),
|
||||
]
|
||||
return out.slice(0, 9)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/** 工件面板(工作区文件管理):搜索 + kind 过滤 + 上传;行内 深预览/右栏预览/下载。
|
||||
* 删除是保留能力:DELETE /api/artifacts/{id} 与 api.deleteArtifact 均在(插件可用),
|
||||
* 系统默认不提供删除入口(提案 2026-09-30-file-preview-and-reserved-capabilities §2.2)。 */
|
||||
export function ArtifactsPanel() {
|
||||
const artifacts = useStore(s => s.artifacts)
|
||||
const [q, setQ] = useState('')
|
||||
const [kind, setKind] = useState<'all' | 'document' | 'data' | 'image'>('all')
|
||||
|
||||
const kinds = ['all', 'document', 'data', 'image'] as const
|
||||
const visible = artifacts.filter(a =>
|
||||
(kind === 'all' || a.kind === kind) && a.name.toLowerCase().includes(q.toLowerCase()))
|
||||
|
||||
return (
|
||||
<div className="artifacts-panel">
|
||||
<div className="ap-toolbar">
|
||||
<input className="search-input" placeholder="搜索文件名…" value={q} onChange={e => setQ(e.target.value)} />
|
||||
{kinds.map(k => (
|
||||
<button key={k} className={`kind-pill ${kind === k ? 'on' : ''}`} onClick={() => setKind(k)}>
|
||||
{k === 'all' ? '全部' : k === 'document' ? '文档' : k === 'data' ? '数据' : '图片'}
|
||||
</button>
|
||||
))}
|
||||
<label className="btn ghost ap-upload" title="上传文件到当前项目">
|
||||
<Icon name="paperclip" size={14} /> 上传
|
||||
<input type="file" multiple style={{ display: 'none' }} onChange={async e => {
|
||||
for (const f of Array.from(e.target.files ?? [])) await store.uploadAttachment(f)
|
||||
e.target.value = ''
|
||||
}} />
|
||||
</label>
|
||||
</div>
|
||||
{artifacts.length === 0
|
||||
? <div className="empty-note">暂无文件——上传附件或让模型产出文档(mock:#doc);这里可浏览、预览、下载</div>
|
||||
: visible.length === 0
|
||||
? <div className="empty-note">没有匹配「{q}」的文件</div>
|
||||
: visible.map(a => (
|
||||
<div key={a.id} className="artifact-row" data-artifact-row={a.id}
|
||||
onClick={() => store.requestPreview(a)} title={`预览 ${a.name}(右栏,边聊边看)`}>
|
||||
<span className="ar-icon"><Icon name={artifactIcon(a.name, a.kind)} size={15} /></span>
|
||||
<span className="ar-main">
|
||||
<span className="name">{a.name}</span>
|
||||
<span className="meta">{a.kind} · {humanSize(a.size) || '—'} · {shortTime(a.updatedAt)}</span>
|
||||
</span>
|
||||
<span className="ar-acts">
|
||||
<button className="icon-btn" title="全屏预览" onClick={e => { e.stopPropagation(); store.requestPreview(a, 'full') }}><Icon name="eye" size={14} /></button>
|
||||
<a className="icon-btn" title="下载" href={api.artifactDownloadUrl(a.id)} download={a.name}
|
||||
onClick={e => e.stopPropagation()}><Icon name="download" size={14} /></a>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function shortTime(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso)
|
||||
const now = new Date()
|
||||
return d.toDateString() === now.toDateString()
|
||||
? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
: d.toLocaleDateString([], { month: '2-digit', day: '2-digit' })
|
||||
} catch { return '' }
|
||||
}
|
||||
|
||||
/** 代理面板(任务树;点击进 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)
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
// 系统管理分区面板(重设计方案 阶段②):普通用户界面不再暴露的系统能力
|
||||
// 全部收进这里,由 AdminZone 以分区注册表呈现。数据入口与原弹窗完全一致
|
||||
//(api.* 不变),只换呈现位置;插件面板/声明工作区经 UIRenderer 内联渲染。
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { store, useStore } from '../store'
|
||||
import type { AgentProfile } from '../contracts'
|
||||
import { Btn, EmptyState, Icon, SettingRow, SettingsGroup, UIRenderer, type DocBridge } from '@ui'
|
||||
import { docBridge } from './Workbench'
|
||||
import { randomPassword } from '../passwords'
|
||||
|
||||
// ---- 用户管理(原 AdminUsersModal 主体,R2 管理员发放制)----
|
||||
interface AdminUserRow { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string }
|
||||
|
||||
export function UsersPanel() {
|
||||
const me = useStore(s => s.me)
|
||||
const [users, setUsers] = useState<AdminUserRow[] | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [owner, setOwner] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [pw, setPw] = useState('')
|
||||
const [asAdmin, setAsAdmin] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [resetFor, setResetFor] = useState<string | null>(null)
|
||||
const [resetPw, setResetPw] = useState('')
|
||||
|
||||
const refresh = async () => {
|
||||
try { setUsers(await api.listAdminUsers()) } catch (e) { setErr(String(e)) }
|
||||
}
|
||||
useEffect(() => { refresh() }, [])
|
||||
|
||||
const create = async () => {
|
||||
setErr(''); setNotice('')
|
||||
if (pw.length < 8) { setErr('初始密码至少 8 位'); return }
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.createAdminUser(owner.trim(), name.trim(), pw, asAdmin)
|
||||
setNotice(`已创建 ${owner.trim()}:首次登录须改密`)
|
||||
setOwner(''); setName(''); setPw('')
|
||||
await refresh()
|
||||
} catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const reset = async () => {
|
||||
if (!resetFor) return
|
||||
setErr('')
|
||||
if (resetPw.length < 8) { setErr('新密码至少 8 位'); return }
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.resetAdminUserPassword(resetFor, resetPw)
|
||||
setNotice(`已重置 ${resetFor} 的密码:其下次登录须改密(现有会话已吊销)`)
|
||||
setResetFor(null); setResetPw('')
|
||||
await refresh()
|
||||
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{me?.isAdmin && (
|
||||
<div className="ad-create">
|
||||
<div className="ad-row">
|
||||
<input className="lg-input" placeholder="用户名(登录名)" value={owner} onChange={e => setOwner(e.target.value)} />
|
||||
<input className="lg-input" placeholder="显示名(可空)" value={name} onChange={e => setName(e.target.value)} />
|
||||
</div>
|
||||
<div className="ad-row">
|
||||
<input className="lg-input lg-mono" placeholder="初始密码(≥8 位)" value={pw} onChange={e => setPw(e.target.value)} />
|
||||
<button type="button" className="ad-mini" title="生成随机密码" onClick={() => setPw(randomPassword())}>随机</button>
|
||||
<label className="ad-check"><input type="checkbox" checked={asAdmin} onChange={e => setAsAdmin(e.target.checked)} />管理员</label>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !owner.trim() || !pw} onClick={create}>创建账号</button>
|
||||
</div>
|
||||
<p className="ad-hint">创建后该用户首次登录必须修改此初始密码。</p>
|
||||
</div>
|
||||
)}
|
||||
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||
{resetFor ? (
|
||||
<div className="ad-reset">
|
||||
<span>重置 <b>{resetFor}</b> 的密码:</span>
|
||||
<input className="lg-input lg-mono" placeholder="新密码(≥8 位)" value={resetPw} onChange={e => setResetPw(e.target.value)} />
|
||||
<button type="button" className="ad-mini" title="生成随机密码" onClick={() => setResetPw(randomPassword())}>随机</button>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !resetPw} onClick={reset}>确认重置</button>
|
||||
<button type="button" className="ad-mini" onClick={() => { setResetFor(null); setResetPw('') }}>取消</button>
|
||||
</div>
|
||||
) : (
|
||||
<table className="ad-table">
|
||||
<thead><tr><th>用户名</th><th>显示名</th><th>角色</th><th>状态</th><th>密码设置于</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{(users ?? []).map(u => (
|
||||
<tr key={u.owner}>
|
||||
<td className="ad-user-cell"><span className="ad-mono">{u.owner}</span></td>
|
||||
<td>{u.name || '—'}</td>
|
||||
<td>{u.isAdmin ? <span className="ad-chip ad-chip-admin">ADMIN</span> : <span className="ad-chip">USER</span>}</td>
|
||||
<td>{u.mustChangePassword ? <span className="ad-chip ad-chip-warn">待改密</span> : u.hasPassword ? '正常' : '未设密码'}</td>
|
||||
<td className="ad-dim">{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}</td>
|
||||
<td>{me?.isAdmin && (
|
||||
<button className="ad-mini" onClick={() => { setResetFor(u.owner); setResetPw('') }}>重置密码</button>
|
||||
)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{users !== null && users.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无账号</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 智能体画像(原 SettingsModal ProfilesSection:数据化导入导出 + 编辑)----
|
||||
export function ProfilesSection({ profiles }: { profiles: AgentProfile[] }) {
|
||||
const [editing, setEditing] = useState<AgentProfile | null>(null)
|
||||
const [text, setText] = useState('')
|
||||
return (
|
||||
<div>
|
||||
<div className="doc-toolbar">
|
||||
<Btn variant="primary" icon="plus" onClick={() => { setEditing(newProfile()); setText('') }}>新建画像</Btn>
|
||||
<label className="btn ghost" style={{ cursor: 'pointer' }}>
|
||||
导入 JSON<input type="file" accept=".json" style={{ display: 'none' }} onChange={async e => {
|
||||
const f = e.target.files?.[0]
|
||||
if (!f) return
|
||||
try {
|
||||
const p = JSON.parse(await f.text())
|
||||
p.id = p.id || 'prof_imported'
|
||||
p.builtin = false
|
||||
await api.saveProfile(p)
|
||||
store.refreshProfiles()
|
||||
store.toast('画像已导入')
|
||||
} catch (err) { store.toast(`导入失败:${err}`) }
|
||||
}} />
|
||||
</label>
|
||||
</div>
|
||||
{profiles.map(p => (
|
||||
<SettingRow key={p.id}
|
||||
label={<>{p.name} <span style={{ color: 'var(--text-faint)', fontSize: 'var(--fs-ui-xs)' }}>{p.id}{p.builtin ? ' · 内置' : ''}</span></>}
|
||||
desc={p.description}>
|
||||
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
|
||||
<Btn variant="ghost" icon="download" onClick={() => {
|
||||
const blob = new Blob([JSON.stringify(p, null, 2)], { type: 'application/json' })
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(blob); a.download = `${p.id}.json`; a.click()
|
||||
}}>导出</Btn>
|
||||
{!p.builtin && <Btn variant="ghost" onClick={() => { setEditing(p); setText(p.systemPrompt) }}>编辑</Btn>}
|
||||
{!p.builtin && <Btn variant="ghost" icon="trash" onClick={async () => { await api.deleteProfile(p.id); store.refreshProfiles() }}>删除</Btn>}
|
||||
</div>
|
||||
</SettingRow>
|
||||
))}
|
||||
{editing && (
|
||||
<SettingsGroup title={`编辑画像:${editing.name}`}>
|
||||
<div className="ui-form">
|
||||
<label>名称<input value={editing.name} onChange={e => setEditing({ ...editing, name: e.target.value })} /></label>
|
||||
<label>系统提示<textarea className="search-input" rows={4} style={{ height: 'auto' }} value={text} onChange={e => setText(e.target.value)} /></label>
|
||||
<label>允许工具(逗号分隔,* 全部)<input value={editing.tools.allow.join(',')} onChange={e => setEditing({ ...editing, tools: { ...editing.tools, allow: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })} /></label>
|
||||
<label>执行前确认(前缀. 结尾表示族)<input value={editing.tools.confirm?.join(',') ?? ''} onChange={e => setEditing({ ...editing, tools: { ...editing.tools, confirm: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })} /></label>
|
||||
<label>拒绝工具<input value={editing.tools.deny?.join(',') ?? ''} onChange={e => setEditing({ ...editing, tools: { ...editing.tools, deny: e.target.value.split(',').map(s => s.trim()).filter(Boolean) } })} /></label>
|
||||
</div>
|
||||
<Btn variant="primary" icon="check" onClick={async () => {
|
||||
try {
|
||||
await api.saveProfile({ ...editing, builtin: false, source: 'user', systemPrompt: text })
|
||||
await store.refreshProfiles()
|
||||
setEditing(null)
|
||||
store.toast('画像已保存')
|
||||
} catch (e) { store.toast(`保存失败:${e}`) }
|
||||
}}>保存画像</Btn>
|
||||
</SettingsGroup>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function newProfile(): AgentProfile {
|
||||
return {
|
||||
id: '', name: '新画像', systemPrompt: '', model: {}, tools: { allow: ['*'] },
|
||||
context: { maxHistoryMessages: 40 }, hitl: { askOnAmbiguity: true }, budget: {},
|
||||
} as AgentProfile
|
||||
}
|
||||
|
||||
// ---- 插件管理(原 SettingsModal PluginsSection:清单 + 热重载)----
|
||||
export function PluginsSection() {
|
||||
const [plugins, setPlugins] = useState<any[]>([])
|
||||
const refresh = () => api.listPlugins().then(setPlugins).catch(() => undefined)
|
||||
useEffect(() => { refresh() }, [])
|
||||
return (
|
||||
<div>
|
||||
<div className="doc-toolbar">
|
||||
<Btn variant="ghost" icon="undo" onClick={async () => {
|
||||
await api.reloadPlugins()
|
||||
await refresh()
|
||||
store.refreshPlugins()
|
||||
store.toast('插件已热重载')
|
||||
}}>热重载</Btn>
|
||||
<span style={{ color: 'var(--text-faint)', fontSize: 'var(--fs-ui-sm)' }}>声明资产(工作流/意图/提示词/面板)热加载;装卸不影响核心</span>
|
||||
</div>
|
||||
{plugins.length === 0 && <EmptyState icon="puzzle">暂无已装载插件<br />(插件目录在 设置 → 常规 → 插件目录;示例见 Dev 仓 plugins/example)</EmptyState>}
|
||||
{plugins.map(p => (
|
||||
<SettingRow key={p.manifest.id}
|
||||
label={`${p.manifest.name} v${p.manifest.version}`}
|
||||
desc={[`工具 ${p.manifest.provides.tools?.length ?? 0}`, `面板 ${p.panels?.length ?? 0}`, `意图 ${p.intents?.length ?? 0}`, `工作流 ${p.workflows?.length ?? 0}`].join(' · ')}>
|
||||
<span className="effect-badge">api {p.manifest.apiVersion}</span>
|
||||
</SettingRow>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 插件工作区(插件声明面板 + 服务端声明工作区;声明体经 UIRenderer 内联渲染)----
|
||||
export function PluginWorkspacesPanel() {
|
||||
const pluginPanels = useStore(s => s.pluginPanels)
|
||||
const serverWorkspaces = useStore(s => s.serverWorkspaces)
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
|
||||
const entries = [
|
||||
...pluginPanels.map(p => ({ key: `plugin:${p.id}`, title: p.title, icon: '🧩', body: p.body ?? [] })),
|
||||
...serverWorkspaces.map(w => ({ key: `ws:${w.id}`, title: w.title, icon: w.icon || '🗂', body: (w.body ?? []) as any[] })),
|
||||
]
|
||||
const current = entries.find(e => e.key === selected) ?? entries[0]
|
||||
if (entries.length === 0) {
|
||||
return <EmptyState icon="grid">暂无插件声明工作区<br />(插件可在 manifest 里声明 panels / workspaces)</EmptyState>
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<div className="doc-toolbar" style={{ flexWrap: 'wrap' }}>
|
||||
{entries.map(e => (
|
||||
<button key={e.key} className={`kind-pill ${current?.key === e.key ? 'on' : ''}`} onClick={() => setSelected(e.key)}>
|
||||
{e.icon} {e.title}
|
||||
</button>
|
||||
))}
|
||||
<span style={{ flex: 1 }} />
|
||||
<Btn variant="ghost" icon="undo" onClick={() => store.refreshPlugins()}>刷新</Btn>
|
||||
</div>
|
||||
{current && <UIRenderer nodes={current.body} docBridge={docBridge as DocBridge} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+26
-2
@@ -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;
|
||||
}
|
||||
|
||||
@@ -31,7 +33,7 @@ export interface Part {
|
||||
artifact?: { artifactId: ID; name: string; kind?: string };
|
||||
hitl?: { cardId: ID; question: string; resolved: boolean; answer?: string };
|
||||
plan?: PlanSnapshot;
|
||||
card?: { kind: string; data: UINode[] };
|
||||
card?: { kind: string; data: UINode[]; cardId?: ID }; // cardId 非空 = 收件箱卡片引用部件(submit_card)
|
||||
error?: { code: string; message: string };
|
||||
process?: boolean; // run 终态后折叠为摘要行
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
// live 流式投影纯逻辑(中栏过程/回复分离):事件按到达序并入 items——thinking/text
|
||||
// 增量各并入其末段,工具按当前位置落位。SSE 有序且后端每轮按 思考→正文→工具 追加落库
|
||||
// 部件,事件到达序即落库序,故合成消息与落库部件同构,终态对账不重排。
|
||||
// 文本段是否为过程(process)由「本段之后、下一个思考/文本段之前是否出现工具」判定,
|
||||
// 对齐落库的 Process: len(calls) > 0——工具轮解说的 tool.call 一到即并入过程区,
|
||||
// 无需等下一轮文本段开启。
|
||||
// 残余边界:某轮若既无正文也无思考、仅静默发起工具,其调用会被并入上一文本段(需协议
|
||||
// 携带轮次归属才能消除;现行事件流无法区分)。
|
||||
import type { Message, Part } from './contracts'
|
||||
import type { LiveState } from './store'
|
||||
|
||||
// 到达序部件流的元素。
|
||||
export type LiveItem =
|
||||
| { kind: 'thinking'; text: string }
|
||||
| { kind: 'text'; index: number; text: string }
|
||||
| { kind: 'tool'; callId?: string; name: string; status?: string; summary?: string }
|
||||
| { kind: 'artifact'; artifact: { artifactId: string; name: string; kind?: string } }
|
||||
|
||||
// applyThinkingDelta 并入思考增量:末段是思考则追加,否则开启新思考段(新轮开始)。
|
||||
export function applyThinkingDelta(items: LiveItem[], delta: string): LiveItem[] {
|
||||
const last = items[items.length - 1]
|
||||
if (last && last.kind === 'thinking') {
|
||||
const out = items.slice()
|
||||
out[out.length - 1] = { kind: 'thinking', text: last.text + delta }
|
||||
return out
|
||||
}
|
||||
return [...items, { kind: 'thinking', text: delta }]
|
||||
}
|
||||
|
||||
// applyTextDelta 并入正文增量:末段是同 index 的文本段则追加,否则开启新文本段。
|
||||
export function applyTextDelta(items: LiveItem[], index: number, delta: string): LiveItem[] {
|
||||
const last = items[items.length - 1]
|
||||
if (last && last.kind === 'text' && last.index === index) {
|
||||
const out = items.slice()
|
||||
out[out.length - 1] = { kind: 'text', index, text: last.text + delta }
|
||||
return out
|
||||
}
|
||||
return [...items, { kind: 'text', index, text: delta }]
|
||||
}
|
||||
|
||||
export function applyToolCall(items: LiveItem[], name: string, callId?: string): LiveItem[] {
|
||||
return [...items, { kind: 'tool', callId, name }]
|
||||
}
|
||||
|
||||
// applyArtifactCreated 追加工件引用(artifact.created 事件)。投影时作为
|
||||
// 交付物钉在消息尾部(内容部件,不进过程折叠)——与落库序同构。
|
||||
export function applyArtifactCreated(items: LiveItem[], artifact: { artifactId: string; name: string; kind?: string }): LiveItem[] {
|
||||
return [...items, { kind: 'artifact', artifact }]
|
||||
}
|
||||
|
||||
// applyToolResult 回填对应工具(优先按 callId 配对,回落到最近一个未决工具——
|
||||
// 与落库的尾部配对语义一致)。
|
||||
export function applyToolResult(items: LiveItem[], callId: string | undefined, status: string, summary?: string): LiveItem[] {
|
||||
for (let i = items.length - 1; i >= 0; i--) {
|
||||
const it = items[i]
|
||||
if (it.kind !== 'tool' || it.status !== undefined) continue
|
||||
if (callId && it.callId && it.callId !== callId) continue
|
||||
const out = items.slice()
|
||||
out[i] = { kind: 'tool', callId: it.callId, name: it.name, status, summary }
|
||||
return out
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// liveToMessage:run 进行中没有已落库的助手消息,把 LiveState 合成同构临时消息,
|
||||
// 复用 AssistantMessage 渲染(思考/工具/文本实时上屏);终态对账后由真消息替代。
|
||||
export function liveToMessage(live: LiveState): Message {
|
||||
// 文本段是否为过程:本段之后、下一个思考/文本段之前出现工具(= 该轮带工具调用)。
|
||||
const textProcess = new Map<number, boolean>()
|
||||
for (let i = 0; i < live.items.length; i++) {
|
||||
if (live.items[i].kind !== 'text') continue
|
||||
let hasCalls = false
|
||||
for (let j = i + 1; j < live.items.length && live.items[j].kind === 'tool'; j++) hasCalls = true
|
||||
textProcess.set(i, hasCalls)
|
||||
}
|
||||
const parts: Part[] = []
|
||||
const deliverables: Part[] = []
|
||||
for (let i = 0; i < live.items.length; i++) {
|
||||
const it = live.items[i]
|
||||
if (it.kind === 'thinking') {
|
||||
if (it.text) parts.push({ type: 'thinking', text: it.text, process: true })
|
||||
} else if (it.kind === 'tool') {
|
||||
parts.push({ type: 'tool_call', process: true, toolCall: { id: it.callId ?? '', name: it.name } })
|
||||
if (it.status !== undefined) {
|
||||
parts.push({ type: 'tool_result', process: true, toolCall: { id: it.callId ?? '', name: it.name },
|
||||
toolResult: { callId: it.callId ?? '', status: it.status === 'error' ? 'error' : 'ok', summary: it.summary } })
|
||||
}
|
||||
} else if (it.kind === 'artifact') {
|
||||
// 交付物卡片:内容部件,投影钉尾(落库侧 llm.go 同序——最终正文之后),
|
||||
// 流式期间即可在消息底部看到"文件已生成"卡片。
|
||||
deliverables.push({ type: 'artifact_ref', artifact: it.artifact })
|
||||
} else {
|
||||
parts.push({ type: 'text', text: it.text, process: textProcess.get(i) || undefined })
|
||||
}
|
||||
}
|
||||
parts.push(...deliverables)
|
||||
return { id: `live:${live.runId}`, conversationId: '', role: 'assistant', runId: live.runId, parts, createdAt: '' }
|
||||
}
|
||||
|
||||
// showProcessInline:流式期间过程区是否展开——thinking/acting 实时展开;一旦进入
|
||||
// responding(最终回复开始流式)立即折叠,只显示回复(Cursor 式),工具恢复再展开。
|
||||
// 非 live 消息随「自动折叠过程」偏好。
|
||||
export function showProcessInline(live: LiveState | null, autoCollapse: boolean): boolean {
|
||||
return live ? live.phase !== 'responding' : !autoCollapse
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { App } from './App'
|
||||
import { wirePreviewCapabilities } from './previewsetup'
|
||||
import '@ui/tokens.css'
|
||||
import '@ui/motion.css'
|
||||
import '@ui/ui.css'
|
||||
import './styles.css'
|
||||
|
||||
// 预览能力装配(D1–D4):Office 解析器与链接预览 fetcher 注入 @ui(动态加载,不进主包)。
|
||||
wirePreviewCapabilities()
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// 消息部件顺序保真(渲染顺序 = 落库顺序):后端 llm.go 按时间交错落库 parts
|
||||
// (text → tool_call → tool_result → artifact_ref → 下一轮 text…),渲染端不得
|
||||
// 按 process 标志二分重排——只允许把"连续的"过程部件就地聚成一块折叠。
|
||||
import type { Part } from './contracts'
|
||||
|
||||
export type PartBlock =
|
||||
| { kind: 'process'; parts: Part[] } // 连续过程段(thinking/tool_call/tool_result…)
|
||||
| { kind: 'content'; part: Part } // 原位内容部件(text/artifact_ref/card/hitl…)
|
||||
|
||||
/**
|
||||
* 按原始顺序把 parts 切成「过程段 / 内容」交替的块序列。
|
||||
* 例:[text, tool_call, tool_result, text, artifact_ref]
|
||||
* → [content(text), process(tool_call,tool_result), content(text), content(artifact_ref)]
|
||||
* 连续的过程部件合并为一个折叠单元,不改变相对顺序。
|
||||
*/
|
||||
export function groupParts(parts: Part[]): PartBlock[] {
|
||||
const out: PartBlock[] = []
|
||||
for (const p of parts) {
|
||||
if (p.process) {
|
||||
const last = out[out.length - 1]
|
||||
if (last && last.kind === 'process') last.parts.push(p)
|
||||
else out.push({ kind: 'process', parts: [p] })
|
||||
} else {
|
||||
out.push({ kind: 'content', part: p })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 文件大小人类可读(工件面板/DocViewer 共用)。 */
|
||||
export function humanSize(n: number | undefined): string {
|
||||
if (!n || n <= 0) return ''
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let v = n
|
||||
let i = 0
|
||||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++ }
|
||||
const s = v >= 100 || i === 0 ? String(Math.round(v)) : v.toFixed(1).replace(/\.0$/, '')
|
||||
return `${s} ${units[i]}`
|
||||
}
|
||||
|
||||
/** 工件文件图标名(@ui Icon 名):按扩展名优先,回落 kind。 */
|
||||
export function artifactIcon(name: string, kind?: string): 'file' | 'image' | 'package' {
|
||||
if (/\.(png|jpe?g|gif|webp|svg|bmp)$/i.test(name) || kind === 'image') return 'image'
|
||||
if (/\.(zip|tar|gz|7z|rar)$/i.test(name)) return 'package'
|
||||
return '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
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// 预览能力装配(裁定 D1–D4):把重解析器经 configureFilePreviewLoaders 注入 @ui。
|
||||
// 全部 dynamic import——对应格式首次预览时才下载 chunk,主 bundle 不含解析器。
|
||||
// 链接预览 fetcher 走服务端 /api/url-preview(SSRF 防护在服务端执行端)。
|
||||
import { configureFilePreviewLoaders, configureLinkPreviewFetcher } from '@ui'
|
||||
import { auth } from './auth'
|
||||
import type { LinkPreviewData } from '@ui'
|
||||
|
||||
const fetchBuf = async (url: string | undefined): Promise<ArrayBuffer> => {
|
||||
const r = await fetch(url!)
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
||||
return r.arrayBuffer()
|
||||
}
|
||||
|
||||
const docxLoader = async (el: HTMLElement, source: { contentUrl?: string }) => {
|
||||
const mammoth = await import('mammoth')
|
||||
const { value } = await mammoth.convertToHtml({ arrayBuffer: await fetchBuf(source.contentUrl) })
|
||||
el.innerHTML = value
|
||||
}
|
||||
|
||||
const xlsxLoader = async (el: HTMLElement, source: { contentUrl?: string }) => {
|
||||
const XLSX = await import('xlsx')
|
||||
const wb = XLSX.read(await fetchBuf(source.contentUrl), { type: 'array' })
|
||||
el.innerHTML = ''
|
||||
const tabs = document.createElement('div')
|
||||
tabs.className = 'ui-fp-sheet-tabs'
|
||||
const wrap = document.createElement('div')
|
||||
wrap.className = 'ui-fp-sheet-wrap'
|
||||
el.append(tabs, wrap)
|
||||
wb.SheetNames.forEach((name, i) => {
|
||||
const tab = document.createElement('button')
|
||||
tab.className = `ui-fp-sheet-tab ${i === 0 ? 'on' : ''}`
|
||||
tab.textContent = name
|
||||
tab.onclick = () => {
|
||||
tabs.querySelectorAll('.ui-fp-sheet-tab').forEach(t => t.classList.remove('on'))
|
||||
tab.classList.add('on')
|
||||
renderSheet(wrap, XLSX.utils.sheet_to_json<unknown[]>(wb.Sheets[name], { header: 1, defval: '' }))
|
||||
}
|
||||
tabs.append(tab)
|
||||
if (i === 0) renderSheet(wrap, XLSX.utils.sheet_to_json<unknown[]>(wb.Sheets[name], { header: 1, defval: '' }))
|
||||
})
|
||||
}
|
||||
|
||||
function renderSheet(wrap: HTMLElement, rows: unknown[][]) {
|
||||
wrap.innerHTML = ''
|
||||
if (rows.length > 200) {
|
||||
const note = document.createElement('div')
|
||||
note.className = 'ui-fp-note'
|
||||
note.textContent = `仅显示前 200 行(共 ${rows.length} 行)——完整内容请下载`
|
||||
wrap.append(note)
|
||||
}
|
||||
const table = document.createElement('table')
|
||||
table.className = 'ui-fp-sheet'
|
||||
for (const row of rows.slice(0, 200)) {
|
||||
const tr = document.createElement('tr')
|
||||
for (const cell of row) {
|
||||
const td = document.createElement('td')
|
||||
td.textContent = cell == null ? '' : String(cell)
|
||||
td.title = td.textContent
|
||||
tr.append(td)
|
||||
}
|
||||
table.append(tr)
|
||||
}
|
||||
wrap.append(table)
|
||||
}
|
||||
|
||||
const pptxLoader = async (el: HTMLElement, source: { contentUrl?: string }) => {
|
||||
// pptx2html@0.3.4 的 module 入口引用了大小写错配的 ./txml(Linux 打包必炸),
|
||||
// 按 D2 备选条款改用 pptx-preview(init(dom).preview(arrayBuffer) 全保真渲染)。
|
||||
const { init } = await import('pptx-preview')
|
||||
el.innerHTML = ''
|
||||
const stage = document.createElement('div')
|
||||
stage.className = 'ui-fp-slide-stage'
|
||||
el.append(stage)
|
||||
const previewer = init(stage, { width: stage.clientWidth || 900 })
|
||||
await previewer.preview(await fetchBuf(source.contentUrl))
|
||||
}
|
||||
|
||||
export function wirePreviewCapabilities() {
|
||||
configureFilePreviewLoaders({ docx: docxLoader, xlsx: xlsxLoader, pptx: pptxLoader })
|
||||
configureLinkPreviewFetcher(async href => {
|
||||
const r = await fetch(auth.withKey(`/api/url-preview?url=${encodeURIComponent(href)}`))
|
||||
if (!r.ok) return { url: href, error: `HTTP ${r.status}` }
|
||||
return (await r.json()) as LinkPreviewData
|
||||
})
|
||||
}
|
||||
+216
-28
@@ -2,6 +2,7 @@
|
||||
// useSyncExternalStore 驱动;live 流式部件为会话内临时态,run 终态后由消息树对账收敛。
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { api, connectEvents } from './api'
|
||||
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolResult, applyArtifactCreated, type LiveItem } from './live'
|
||||
import type {
|
||||
Project, Conversation, Message, Run, Artifact, HITLCard, PlanSnapshot, TodoList,
|
||||
AgentProfile, StreamEvent, PluginPanel, ActivityCard,
|
||||
@@ -16,9 +17,9 @@ export interface ContextObs {
|
||||
export interface LiveState {
|
||||
runId: string
|
||||
phase: string
|
||||
thinking: string
|
||||
text: string
|
||||
toolCalls: { name: string; status?: string; summary?: string }[]
|
||||
// 到达序部件流(见 live.ts LiveItem):事件到达序即落库序,与落库部件的轮次穿插序
|
||||
// 同构;文本段是否为过程由「本段内是否出现工具调用」判定,tool.call 一到即改标。
|
||||
items: LiveItem[]
|
||||
agentNotes: Record<string, string>
|
||||
startedAt: number
|
||||
}
|
||||
@@ -34,13 +35,26 @@ export interface UIState {
|
||||
settingsOpen: boolean
|
||||
trackerRunId: string | null
|
||||
viewingArtifact: Artifact | null
|
||||
/** 右栏轻预览的工件(rail 预览页签内容;与弹窗深预览 viewingArtifact 并存)。 */
|
||||
railPreviewId: string | null
|
||||
activeWorkspace: string | null
|
||||
taskOrg: 'project' | 'timeline'
|
||||
showArchived: boolean
|
||||
lastProjectId: string | null
|
||||
/** 系统管理全屏视图(阶段② IA:仅管理员;入口账户菜单,纵深防御在 AdminZone 再拦一次)。 */
|
||||
adminZoneOpen: 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[]
|
||||
@@ -51,8 +65,11 @@ interface AppState {
|
||||
todos: TodoList | null
|
||||
artifacts: Artifact[]
|
||||
cards: ActivityCard[]
|
||||
projectCards: ActivityCard[]
|
||||
inboxScope: 'conv' | 'project'
|
||||
profiles: AgentProfile[]
|
||||
pluginPanels: PluginPanel[]
|
||||
serverWorkspaces: { id: string; title: string; icon?: string; shortcut?: string; body?: any[] }[]
|
||||
ui: UIState
|
||||
connected: boolean
|
||||
error: string | null
|
||||
@@ -65,11 +82,19 @@ const DEFAULT_UI: UIState = {
|
||||
rightWidth: 340,
|
||||
rightCollapsed: false,
|
||||
rightTab: 'inbox',
|
||||
activeWorkspace: null,
|
||||
theme: 'dark',
|
||||
autoCollapseProcess: true,
|
||||
settingsOpen: false,
|
||||
trackerRunId: null,
|
||||
viewingArtifact: null,
|
||||
railPreviewId: null,
|
||||
taskOrg: 'project',
|
||||
showArchived: false,
|
||||
lastProjectId: null,
|
||||
adminZoneOpen: false,
|
||||
changePwOpen: false,
|
||||
projectSettingsId: null,
|
||||
}
|
||||
|
||||
function loadUI(): UIState {
|
||||
@@ -82,9 +107,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: [],
|
||||
plan: null, todos: null, artifacts: [], cards: [], projectCards: [], inboxScope: 'conv', profiles: [], pluginPanels: [],
|
||||
serverWorkspaces: [],
|
||||
ui: loadUI(), connected: false, error: null, toast: null,
|
||||
}
|
||||
|
||||
@@ -108,6 +134,8 @@ function applyTheme(theme: string) {
|
||||
}
|
||||
|
||||
let sse: { close: () => void } | null = null
|
||||
let projectSse: { close: () => void } | null = null
|
||||
let projectSeq = 0
|
||||
|
||||
export function useStore<T>(selector: (s: AppState) => T): T {
|
||||
return useSyncExternalStore(
|
||||
@@ -122,12 +150,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,21 +177,63 @@ export const store = {
|
||||
},
|
||||
|
||||
async openProject(projectId: string) {
|
||||
const conversations = await api.listConversations(projectId)
|
||||
set({ currentProjectId: projectId, conversations })
|
||||
set({ currentProjectId: projectId })
|
||||
if (store.getState().inboxScope === 'project') {
|
||||
store.loadProjectCards()
|
||||
store.connectProject()
|
||||
}
|
||||
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) {
|
||||
if (sse) { sse.close(); sse = null }
|
||||
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [] })
|
||||
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], inboxScope: 'conv' })
|
||||
await store.reconcile()
|
||||
await store.connectLive()
|
||||
},
|
||||
@@ -174,6 +255,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) })
|
||||
}
|
||||
@@ -193,26 +275,22 @@ export const store = {
|
||||
switch (e.type) {
|
||||
case 'message.delta': {
|
||||
const p = e.payload as { index: number; text: string }
|
||||
set({ live: touchLive(e.runId ?? '', l => { l.text += p.text }) })
|
||||
set({ live: touchLive(e.runId ?? '', l => { l.items = applyTextDelta(l.items, p.index, p.text) }) })
|
||||
break
|
||||
}
|
||||
case 'thinking.delta': {
|
||||
const p = e.payload as { index: number; text: string }
|
||||
set({ live: touchLive(e.runId ?? '', l => { l.thinking += p.text }) })
|
||||
set({ live: touchLive(e.runId ?? '', l => { l.items = applyThinkingDelta(l.items, p.text) }) })
|
||||
break
|
||||
}
|
||||
case 'tool.call': {
|
||||
const p = e.payload as { callId: string; name: string }
|
||||
set({ live: touchLive(e.runId ?? '', l => { l.toolCalls.push({ name: p.name }) }) })
|
||||
set({ live: touchLive(e.runId ?? '', l => { l.items = applyToolCall(l.items, p.name, p.callId) }) })
|
||||
break
|
||||
}
|
||||
case 'tool.result': {
|
||||
const p = e.payload as { callId: string; status: string; summary?: string }
|
||||
set({ live: touchLive(e.runId ?? '', l => {
|
||||
for (let i = l.toolCalls.length - 1; i >= 0; i--) {
|
||||
if (l.toolCalls[i].status === undefined) { l.toolCalls[i] = { ...l.toolCalls[i], status: p.status, summary: p.summary }; break }
|
||||
}
|
||||
}) })
|
||||
set({ live: touchLive(e.runId ?? '', l => { l.items = applyToolResult(l.items, p.callId, p.status, p.summary) }) })
|
||||
break
|
||||
}
|
||||
case 'run.phase': {
|
||||
@@ -273,6 +351,12 @@ export const store = {
|
||||
break
|
||||
}
|
||||
case 'artifact.created': {
|
||||
const p = e.payload as { artifact?: { id: string; name: string; kind?: string } }
|
||||
if (p?.artifact) {
|
||||
set({ live: touchLive(e.runId ?? '', l => {
|
||||
l.items = applyArtifactCreated(l.items, { artifactId: p.artifact!.id, name: p.artifact!.name, kind: p.artifact!.kind })
|
||||
}) })
|
||||
}
|
||||
if (state.currentProjectId) store.refreshArtifacts(state.currentProjectId)
|
||||
break
|
||||
}
|
||||
@@ -304,10 +388,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', items: [], agentNotes: {}, startedAt: Date.now() } })
|
||||
}
|
||||
set({ messages: [...state.messages, out.message] })
|
||||
store.connectIfNeeded()
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -391,6 +477,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,15 +499,113 @@ 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 盖章留痕不动)。 */
|
||||
// 项目收件箱(CF3/D48-D51):加载聚合视图 + 项目流订阅 + 留档。
|
||||
async loadProjectCards() {
|
||||
const pid = store.getState().currentProjectId
|
||||
if (!pid) return
|
||||
try { set({ projectCards: await api.listProjectCards(pid) }) } catch { /* 静默:切项目竞态 */ }
|
||||
},
|
||||
|
||||
setInboxScope(scope: 'conv' | 'project') {
|
||||
set({ inboxScope: scope })
|
||||
if (scope === 'project') {
|
||||
store.loadProjectCards()
|
||||
store.connectProject()
|
||||
} else if (projectSse) {
|
||||
projectSse.close()
|
||||
projectSse = null
|
||||
}
|
||||
},
|
||||
|
||||
connectProject() {
|
||||
const pid = store.getState().currentProjectId
|
||||
if (!pid) return
|
||||
projectSse?.close()
|
||||
projectSse = connectEvents('', projectSeq, { onEvent: e => store.applyProjectEvent(e) }, pid)
|
||||
},
|
||||
|
||||
applyProjectEvent(e: StreamEvent) {
|
||||
projectSeq = Math.max(projectSeq, e.seq)
|
||||
const upsert = (card: ActivityCard) => {
|
||||
const list = store.getState().projectCards
|
||||
const i = list.findIndex(c => c.id === card.id)
|
||||
set({ projectCards: i >= 0 ? list.map(c => (c.id === card.id ? card : c)) : [card, ...list] })
|
||||
}
|
||||
switch (e.type) {
|
||||
case 'card.created':
|
||||
case 'card.updated':
|
||||
if ((e.payload as { card?: ActivityCard })?.card) upsert((e.payload as { card: ActivityCard }).card)
|
||||
break
|
||||
case 'card.status': {
|
||||
const p = e.payload as { cardId: string; status: ActivityCard['status']; tone?: string }
|
||||
const list = store.getState().projectCards
|
||||
set({ projectCards: list.map(c => (c.id === p.cardId ? { ...c, status: p.status, tone: p.tone ?? c.tone } : c)) })
|
||||
break
|
||||
}
|
||||
case 'card.resolved': {
|
||||
const p = e.payload as { cardId: string }
|
||||
const list = store.getState().projectCards
|
||||
set({ projectCards: list.map(c => (c.id === p.cardId ? { ...c, status: 'resolved' as const } : c)) })
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async cardToProject(card: ActivityCard) {
|
||||
try {
|
||||
const out = await api.cardToProject(card.id)
|
||||
store.upsertCard(out.origin) // 会话视图:原卡 dismissed 留痕
|
||||
const list = store.getState().projectCards
|
||||
const i = list.findIndex(c => c.id === out.card.id)
|
||||
set({ projectCards: i >= 0 ? list.map(c => (c.id === out.card.id ? out.card : c)) : [out.card, ...list] })
|
||||
store.toast('已留档到项目收件箱')
|
||||
store.setInboxScope('project')
|
||||
} catch (e) {
|
||||
store.toast(`留档失败:${e}`)
|
||||
}
|
||||
},
|
||||
|
||||
async tidyCards() {
|
||||
const done = state.cards.filter(c => c.status === 'done')
|
||||
for (const c of done) {
|
||||
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)
|
||||
},
|
||||
// 卡→消息互定位另一半(D32):从收件箱卡滚回产生它的对话消息。
|
||||
locateMessage(messageId?: string) {
|
||||
if (!messageId) return
|
||||
setTimeout(() => {
|
||||
document.querySelector(`[data-msg-id="${messageId}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}, 60)
|
||||
},
|
||||
openTracker(runId: string) { setUI({ trackerRunId: runId }) },
|
||||
viewArtifact(a: Artifact | null) { setUI({ viewingArtifact: a }) },
|
||||
/** 右栏轻预览:打开指定工件并切到 rail 预览页签(双落位中的"轻"侧)。 */
|
||||
railPreview(a: Artifact | null) {
|
||||
if (!a) { setUI({ railPreviewId: null, rightTab: 'inbox' }); return }
|
||||
setUI({ railPreviewId: a.id, rightTab: 'preview', rightCollapsed: false })
|
||||
},
|
||||
/**
|
||||
* 工件预览唯一入口(重设计方案 阶段③ 收敛,Command 语义):一切预览请求只经这里,
|
||||
* 落位由本方法派生——默认右栏轻预览(不打断对话),hint 'full' 落位全屏深读
|
||||
* (DocViewer,全项目唯一"大窗"形态)。下载语义同理由各落位只保留一个按钮。
|
||||
*/
|
||||
requestPreview(a: Artifact, hint?: 'full') {
|
||||
if (hint === 'full') { setUI({ viewingArtifact: a }); return }
|
||||
setUI({ railPreviewId: a.id, rightTab: 'preview', rightCollapsed: false })
|
||||
},
|
||||
openSettings(open: boolean) { setUI({ settingsOpen: open }) },
|
||||
openProjectSettings(id: string | null) { setUI({ projectSettingsId: id }) },
|
||||
toast(msg: string) {
|
||||
set({ toast: msg })
|
||||
setTimeout(() => { if (state.toast === msg) set({ toast: null }) }, 2600)
|
||||
@@ -425,8 +613,8 @@ export const store = {
|
||||
}
|
||||
|
||||
function touchLive(_runId: string, mutate: (l: LiveState) => void): LiveState | null {
|
||||
const base = state.live ?? { runId: _runId, phase: 'thinking', thinking: '', text: '', toolCalls: [], agentNotes: {}, startedAt: Date.now() }
|
||||
const next: LiveState = { ...base, toolCalls: [...base.toolCalls], agentNotes: { ...base.agentNotes } }
|
||||
const base = state.live ?? { runId: _runId, phase: 'thinking', items: [], agentNotes: {}, startedAt: Date.now() }
|
||||
const next: LiveState = { ...base, agentNotes: { ...base.agentNotes } }
|
||||
mutate(next)
|
||||
return next
|
||||
}
|
||||
|
||||
+394
-127
@@ -1,184 +1,451 @@
|
||||
/* 设计令牌与 UI 组件样式已迁入 Agent-System-UI(@ui/tokens.css、@ui/ui.css,main.tsx 引入)。 */
|
||||
/* 宿主样式(R5/Zai 复刻,2026-09-30;权威规则见父仓 DESIGN.md):
|
||||
- 颜色/圆角/字号只消费 @ui/tokens.css 令牌(--fs-ui-* 标尺);
|
||||
- 动效只消费 @ui/motion.css 的 mk-* 类(本文件不得出现 @keyframes,守护测试拦截);
|
||||
- 布局面板化:三栏为浮在 --bg-inset 底座上的圆角面板,间隙 4px; */
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #root { height: 100%; margin: 0; }
|
||||
body { font-family: var(--font); background: var(--bg); color: var(--text); font-size: 14px; }
|
||||
button { font-family: inherit; }
|
||||
body {
|
||||
font-family: var(--font); background: var(--bg-inset); color: var(--text);
|
||||
font-size: var(--fs-ui-base); line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility;
|
||||
}
|
||||
button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
::selection { background: var(--accent-soft-2); }
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-radius: 2px; }
|
||||
|
||||
.app { display: flex; height: 100vh; overflow: hidden; }
|
||||
|
||||
/* ---- 三栏布局 ---- */
|
||||
.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; }
|
||||
/* ---- 面板化三栏:独立圆角面板 + 4px 间隙(DESIGN.md §6) ---- */
|
||||
.app { display: flex; height: 100vh; overflow: hidden; gap: 4px; padding: 4px; background: var(--bg-inset); }
|
||||
.left-nav { background: var(--bg-panel); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); display: flex; flex-direction: column; min-width: 0; flex-shrink: 0; transition: width .15s var(--ease); }
|
||||
.left-nav.collapsed { width: 52px !important; }
|
||||
.divider { width: 4px; cursor: col-resize; background: transparent; flex-shrink: 0; position: relative; }
|
||||
.divider:hover, .divider.dragging { background: var(--accent); }
|
||||
.divider::before { content: ''; position: absolute; left: 1px; top: 12px; bottom: 12px; width: 2px; border-radius: 2px; background: transparent; transition: background .12s; }
|
||||
.divider:hover::before, .divider.dragging::before { background: var(--border-strong); }
|
||||
.divider::after { content: ''; position: absolute; inset: 0 -3px; } /* 拖拽热区加宽 */
|
||||
.main { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--bg); }
|
||||
.right-panel { background: var(--bg-panel); border-left: 1px solid var(--border); display: flex; flex-direction: column; flex-shrink: 0; min-width: 0; }
|
||||
.main { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--bg); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); }
|
||||
.right-panel { background: var(--bg-panel); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); display: flex; flex-direction: column; flex-shrink: 0; min-width: 0; }
|
||||
.right-panel.collapsed { width: 44px !important; }
|
||||
|
||||
/* ---- 左栏 ---- */
|
||||
.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(--primary); color: var(--primary-foreground); border: none;
|
||||
border-radius: var(--radius-sm); padding: 8px 16px; cursor: pointer;
|
||||
font-size: var(--fs-ui-caption); font-weight: 550; white-space: nowrap;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, .25);
|
||||
transition: background .12s var(--ease), transform .12s var(--ease);
|
||||
}
|
||||
.btn:hover:not(:disabled) { background: color-mix(in srgb, var(--primary) 86%, var(--bg)); }
|
||||
.btn:active:not(:disabled) { transform: translateY(1px); }
|
||||
.btn:disabled { opacity: .42; cursor: not-allowed; box-shadow: none; }
|
||||
.btn.ghost { background: var(--bg-raised); border: 1px solid var(--border-strong); color: var(--text-dim); box-shadow: none; }
|
||||
.btn.ghost:hover:not(:disabled) { color: var(--text); border-color: var(--text-faint); background: var(--bg-raised); }
|
||||
.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: var(--fs-ui-caption); width: 100%;
|
||||
transition: border-color .12s, box-shadow .12s;
|
||||
}
|
||||
.search-input:focus { border-color: var(--accent); box-shadow: var(--ring); outline: none; }
|
||||
|
||||
/* ---- 中栏对话 ---- */
|
||||
.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); border-radius: var(--radius-lg) var(--radius-lg) 0 0; }
|
||||
.chat-title { font-weight: 600; font-size: var(--fs-ui-base); letter-spacing: -0.01em; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.chat-body { flex: 1; overflow-y: auto; min-height: 0; padding: 22px max(22px, 13%); }
|
||||
.empty-state { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--text-faint); gap: 10px; font-size: var(--fs-ui-caption); }
|
||||
.empty-state .es-mark { width: 44px; height: 44px; border-radius: var(--radius-lg); background: var(--bg-raised); border: 1px solid var(--border-soft); display: flex; align-items: center; justify-content: center; color: var(--text-faint); margin-bottom: 4px; }
|
||||
.empty-state svg { opacity: .5; }
|
||||
.msg { margin-bottom: 20px; animation: mk-rise-in .18s var(--ease-out); }
|
||||
.msg-role { font-size: var(--fs-ui-sm); color: var(--text-faint); margin-bottom: 6px; display: flex; gap: 8px; align-items: center; }
|
||||
.msg-role .role-mark { width: 18px; height: 18px; border-radius: 5px; display: inline-flex; align-items: center; justify-content: center; color: var(--text-dim); background: var(--bg-raised); border: 1px solid var(--border); }
|
||||
.msg.user .bubble {
|
||||
background: var(--accent-soft); border: 1px solid transparent;
|
||||
border-radius: var(--radius-lg); padding: 11px 16px; white-space: pre-wrap;
|
||||
}
|
||||
.part { margin: 4px 0; }
|
||||
.part-thinking { color: var(--text-faint); font-size: 13px; border-left: 2px solid var(--border); padding-left: 10px; white-space: pre-wrap; }
|
||||
.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); }
|
||||
/* 思考块(Collapsible 契约:触发行 + mk-col 动画体) */
|
||||
.part-thinking { color: var(--text-dim); font-size: var(--fs-ui-sm); border-left: 2px solid var(--border); padding-left: 10px; margin: 4px 0; }
|
||||
.part-thinking .part-thinking-summary { color: var(--text-faint); user-select: none; }
|
||||
.part-thinking .part-thinking-chev { display: inline-flex; color: var(--text-faint); font-size: var(--fs-ui-xs); transition: transform .12s; }
|
||||
.part-thinking .mk-col-trigger[aria-expanded="true"] .part-thinking-chev { transform: rotate(90deg); }
|
||||
.part-thinking .part-thinking-body { white-space: pre-wrap; margin-top: 4px; }
|
||||
.part-tool { font-size: var(--fs-ui-sm); }
|
||||
.tool-chip { display: inline-flex; gap: 6px; align-items: center; background: var(--bg-inset); border: 1px solid var(--border-soft); border-radius: var(--radius-sm); padding: 4px 9px; margin: 2px 4px 2px 0; font-family: var(--mono); }
|
||||
.tool-chip .status-ok { color: var(--ok); }
|
||||
.tool-chip .status-error { color: var(--err); }
|
||||
.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: var(--fs-ui-sm); }
|
||||
.part-text code { font-family: var(--mono); background: var(--bg-raised); border-radius: var(--radius-xs); padding: 1px 5px; font-size: var(--fs-ui-sm); }
|
||||
.part-text pre code { background: none; padding: 0; }
|
||||
.part-text blockquote { margin: 0.5em 0; padding: 0.1em 0 0.1em 10px; border-left: 3px solid var(--border-strong); color: var(--text-dim); }
|
||||
.part-text table { border-collapse: collapse; margin: 0.6em 0; font-size: 0.95em; display: block; max-width: 100%; overflow-x: auto; }
|
||||
.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: stretch; background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; font-size: var(--fs-ui-caption); margin: 2px 0; }
|
||||
.artifact-chip .ac-main { display: inline-flex; align-items: center; gap: 7px; color: var(--accent); background: none; border: none; padding: 5px 10px; cursor: pointer; font: inherit; max-width: 340px; }
|
||||
.artifact-chip .ac-main span, .artifact-chip .ac-main { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.artifact-chip:hover { border-color: var(--accent); }
|
||||
.artifact-chip .ac-act { display: inline-flex; align-items: center; padding: 5px 8px; color: var(--text-dim); border-left: 1px solid var(--border-soft); background: none; cursor: pointer; }
|
||||
.artifact-chip .ac-act:hover { color: var(--accent); background: var(--bg-hover); }
|
||||
|
||||
/* 过程折叠(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 summary::before { content: '▸'; transition: transform .12s; font-size: 10px; }
|
||||
.process-collapse[open] summary::before { transform: rotate(90deg); }
|
||||
/* 交付物卡片(对话内直接给出;整卡点击 = 右栏预览,动作只剩下载) */
|
||||
.deliverable-card { display: flex; align-items: center; gap: 12px; width: fit-content; max-width: 100%; padding: 10px 12px 10px 10px; margin: 6px 0 2px;
|
||||
background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-lg); cursor: pointer; user-select: none;
|
||||
transition: border-color .12s, background .12s; }
|
||||
.deliverable-card:hover { border-color: var(--accent); background: var(--bg-hover); }
|
||||
.deliverable-card .dc-icon { display: inline-flex; align-items: center; justify-content: center; width: 38px; height: 38px; border-radius: var(--radius); flex: none;
|
||||
background: var(--bg-inset); color: var(--accent); border: 1px solid var(--border-soft); }
|
||||
.deliverable-card .dc-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.deliverable-card .dc-name { color: var(--text); font-weight: 600; font-size: var(--fs-ui-base); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 380px; }
|
||||
.deliverable-card .dc-meta { color: var(--text-faint); font-size: var(--fs-ui-sm); white-space: nowrap; }
|
||||
.deliverable-card .dc-act { flex: none; margin-left: auto; padding: 5px 12px; border-radius: var(--radius-sm); color: var(--accent);
|
||||
font-size: var(--fs-ui-caption); border: 1px solid var(--border); background: var(--bg-panel); }
|
||||
.deliverable-card:hover .dc-act { border-color: var(--accent); }
|
||||
.deliverable-card .dc-dl { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: var(--radius-sm);
|
||||
color: var(--text-dim); border: 1px solid var(--border-soft); }
|
||||
.deliverable-card .dc-dl:hover { color: var(--accent); border-color: var(--accent); background: var(--bg-hover); }
|
||||
/* 过程段内联组(流式/关闭自动折叠时,按原位渲染的过程容器) */
|
||||
.part-group { margin: 2px 0; }
|
||||
|
||||
/* 过程折叠(run 终态后;Collapsible 契约:触发行 + mk-col 动画体) */
|
||||
.process-collapse { border: 1px solid var(--border-soft); border-radius: var(--radius); background: var(--bg-panel); margin: 6px 0; }
|
||||
.process-collapse .process-collapse-summary { padding: 7px 12px; color: var(--text-faint); font-size: var(--fs-ui-sm); user-select: none; }
|
||||
.process-collapse .process-collapse-summary:hover { color: var(--text-dim); }
|
||||
.process-collapse .process-collapse-chev { display: inline-flex; color: var(--text-faint); font-size: var(--fs-ui-xs); transition: transform .12s; }
|
||||
.process-collapse .mk-col-trigger[aria-expanded="true"] .process-collapse-chev { transform: rotate(90deg); }
|
||||
.process-collapse .process-collapse-meta { color: var(--text-faint); }
|
||||
.process-collapse .process-collapse-excerpt { color: var(--text-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 40%; }
|
||||
.process-collapse .body { padding: 4px 12px 10px; }
|
||||
|
||||
/* 流式状态行 */
|
||||
.live-bar { display: flex; align-items: center; gap: 8px; color: var(--text-faint); font-size: 12.5px; margin-bottom: 6px; }
|
||||
.live-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); animation: pulse 1.2s infinite; }
|
||||
@keyframes pulse { 0%,100% { opacity: .4 } 50% { opacity: 1 } }
|
||||
.cursor-blink::after { content: '▍'; animation: pulse 1s infinite; color: var(--accent); }
|
||||
/* 流式状态行(呼吸/扫光走 mk-* 类;已工作计时弱色等宽) */
|
||||
.live-bar { display: flex; align-items: center; gap: 8px; color: var(--text-faint); font-size: var(--fs-ui-sm); margin-bottom: 6px; }
|
||||
.live-elapsed { color: var(--text-faint); font-variant-numeric: tabular-nums; }
|
||||
.live-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); animation: mk-breathe 1.2s infinite; }
|
||||
.cursor-blink::after { content: '▍'; animation: mk-breathe 1s infinite; color: var(--accent); }
|
||||
|
||||
/* HITL 卡片 */
|
||||
.hitl-card { border: 1px solid var(--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 .desc { color: var(--text-dim); font-size: var(--fs-ui-sm); margin-top: 3px; }
|
||||
.hitl-choice .impact { color: var(--warn); font-size: var(--fs-ui-sm); margin-top: 3px; }
|
||||
.hitl-choice .rec { font-size: var(--fs-ui-xs); color: var(--accent); border: 1px solid var(--accent); padding: 0 5px; border-radius: var(--radius-full); }
|
||||
.hitl-free { display: flex; gap: 8px; margin-top: 10px; }
|
||||
|
||||
/* 输入区 */
|
||||
.input-bar { border-top: 1px solid var(--border); padding: 12px 18px; background: var(--bg-raised); }
|
||||
/* 输入区:悬浮卡片式输入壳(16px 大圆角 = DESIGN.md 特批例外) */
|
||||
.input-bar { padding: 8px max(16px, 6%) 16px; background: var(--bg); border-radius: 0 0 var(--radius-lg) var(--radius-lg); }
|
||||
.attachments { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
|
||||
.attach-chip { display: inline-flex; gap: 6px; align-items: center; background: var(--bg-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; }
|
||||
.cmd-item { padding: 7px 12px; cursor: pointer; display: flex; gap: 10px; font-size: 13px; }
|
||||
.cmd-item:hover, .cmd-item.active { background: var(--accent-soft); }
|
||||
.attach-chip { display: inline-flex; gap: 6px; align-items: center; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 3px 8px; font-size: var(--fs-ui-sm); }
|
||||
.input-row { display: flex; gap: 8px; align-items: flex-end; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 8px 8px 8px 14px; box-shadow: var(--shadow-md); transition: border-color .12s, box-shadow .12s; }
|
||||
.input-row:focus-within { border-color: var(--accent); box-shadow: var(--shadow-md), var(--ring); }
|
||||
.input-row textarea { flex: 1; resize: none; background: transparent; border: none; color: var(--text); padding: 6px 0; font-family: inherit; font-size: var(--fs-ui-caption); max-height: 160px; outline: none; }
|
||||
.input-row textarea::placeholder { color: var(--text-faint); }
|
||||
.cmd-menu { border: 1px solid var(--border); background: var(--bg-raised); border-radius: var(--radius); margin-bottom: 6px; max-height: 180px; overflow-y: auto; box-shadow: var(--shadow-md); }
|
||||
.cmd-item { padding: 7px 12px; cursor: pointer; display: flex; gap: 10px; font-size: var(--fs-ui-caption); }
|
||||
.cmd-item:hover, .cmd-item.active { background: var(--bg-hover); }
|
||||
.cmd-item .name { color: var(--accent); font-family: var(--mono); }
|
||||
|
||||
/* ---- 右栏 ---- */
|
||||
.tabs { display: flex; border-bottom: 1px solid var(--border); 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: var(--fs-ui-sm); font-weight: 550; border-bottom: 2px solid transparent; margin-bottom: -1px; white-space: nowrap; }
|
||||
.tab:hover { color: var(--text-dim); }
|
||||
.tab.active { color: var(--text); border-bottom-color: var(--accent); }
|
||||
.tab-body { flex: 1; overflow-y: auto; padding: 12px; }
|
||||
.step { display: flex; gap: 8px; align-items: flex-start; padding: 6px 4px; font-size: 13px; }
|
||||
.step { display: flex; gap: 8px; align-items: flex-start; padding: 6px 4px; font-size: var(--fs-ui-caption); }
|
||||
.step-icon { width: 16px; text-align: center; flex-shrink: 0; }
|
||||
.step.pending .step-icon { color: var(--text-faint); }
|
||||
.step.in_progress .step-icon { color: var(--accent); }
|
||||
.step.done .step-icon { color: var(--ok); }
|
||||
.step.skipped { color: var(--text-faint); text-decoration: line-through; }
|
||||
.artifact-row { display: flex; align-items: center; gap: 8px; padding: 8px; border-radius: var(--radius-sm); cursor: pointer; }
|
||||
/* ---- 工件面板(工作区文件管理)---- */
|
||||
.artifacts-panel { display: flex; flex-direction: column; gap: 4px; }
|
||||
.ap-toolbar { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; flex-wrap: wrap; }
|
||||
.ap-toolbar .search-input { flex: 1; min-width: 120px; height: 30px; font-size: var(--fs-ui-sm); }
|
||||
.kind-pill { border: 1px solid var(--border); background: none; color: var(--text-dim); border-radius: var(--radius-full); padding: 3px 10px; font-size: var(--fs-ui-sm); cursor: pointer; }
|
||||
.kind-pill:hover { color: var(--text); background: var(--bg-hover); }
|
||||
.kind-pill.on { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
|
||||
.ap-upload { cursor: pointer; }
|
||||
.artifact-row { display: flex; align-items: center; gap: 8px; padding: 7px 8px; border-radius: var(--radius-sm); }
|
||||
.artifact-row:hover { background: var(--bg-hover); }
|
||||
.artifact-row .name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 13px; }
|
||||
.artifact-row .kind { font-size: 11px; color: var(--text-faint); }
|
||||
.agent-row { display: flex; align-items: center; gap: 8px; padding: 8px; border-radius: var(--radius-sm); cursor: pointer; font-size: 13px; }
|
||||
.artifact-row .ar-icon { display: flex; color: var(--text-dim); cursor: pointer; flex: none; }
|
||||
.artifact-row .ar-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; cursor: pointer; }
|
||||
.artifact-row .name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: var(--fs-ui-caption); }
|
||||
.artifact-row .meta { font-size: var(--fs-ui-xs); color: var(--text-faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.artifact-row .ar-acts { display: none; align-items: center; gap: 2px; flex: none; }
|
||||
.artifact-row:hover .ar-acts { display: inline-flex; }
|
||||
.artifact-row .ar-acts .icon-btn { width: 26px; height: 26px; }
|
||||
.artifact-row .ar-acts .icon-btn.danger { color: var(--err); }
|
||||
.empty-note { color: var(--text-faint); font-size: var(--fs-ui-sm); padding: 14px 6px; }
|
||||
.agent-row { display: flex; align-items: center; gap: 8px; padding: 8px; border-radius: var(--radius-sm); cursor: pointer; font-size: var(--fs-ui-caption); }
|
||||
.agent-row:hover { background: var(--bg-hover); }
|
||||
.status-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.status-running { background: var(--accent); animation: pulse 1.2s infinite; }
|
||||
.status-running { background: var(--accent); animation: mk-breathe 1.2s infinite; }
|
||||
.status-completed { background: var(--ok); }
|
||||
.status-failed, .status-cancelled, .status-interrupted { background: var(--err); }
|
||||
.status-pending, .status-waiting_hitl { background: var(--warn); }
|
||||
|
||||
/* ---- 弹窗 / 模态 ---- */
|
||||
.overlay { position: fixed; inset: 0; background: rgba(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; }
|
||||
/* ---- 弹窗 / 模态(动画走 mk-* 类) ---- */
|
||||
.overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, .55); backdrop-filter: blur(8px); display: flex; align-items: center; justify-content: center; z-index: 50; animation: mk-overlay-in .14s var(--ease); }
|
||||
.modal { background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-xl); width: min(860px, 92vw); max-height: 86vh; display: flex; flex-direction: column; box-shadow: var(--shadow-lg); animation: mk-modal-in .16s var(--ease); }
|
||||
.modal-header { padding: 16px 20px; border-bottom: 1px solid var(--border-soft); display: flex; align-items: center; font-weight: 600; font-size: var(--fs-ui-base); letter-spacing: -0.01em; }
|
||||
.modal-header .spacer { flex: 1; }
|
||||
.modal-body { flex: 1; overflow-y: auto; padding: 16px 18px; }
|
||||
.subagent-tree { font-size: 13px; }
|
||||
.subagent-node { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 10px; margin-bottom: 8px; }
|
||||
.modal-body { flex: 1; overflow-y: auto; padding: 18px 20px; }
|
||||
.subagent-tree { font-size: var(--fs-ui-caption); }
|
||||
.subagent-node { border: 1px solid var(--border); border-radius: var(--radius); padding: 10px; margin-bottom: 8px; background: var(--bg-panel); }
|
||||
.subagent-node .head { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
|
||||
|
||||
/* ---- 设置(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; }
|
||||
.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; }
|
||||
/* ---- 设置(schema 驱动;行组件来自 @ui SettingRow,这里只补控件样式)---- */
|
||||
.ui-setting-control { display: flex; align-items: center; justify-content: flex-end; }
|
||||
.ui-setting-control input, .ui-setting-control select { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text); padding: 7px 10px; font-family: inherit; width: 100%; max-width: 340px; }
|
||||
.ui-setting-control input:focus, .ui-setting-control select:focus { border-color: var(--accent); box-shadow: var(--ring); outline: none; }
|
||||
.ui-setting-control input[type="checkbox"] { width: auto; }
|
||||
.effect-badge { font-size: var(--fs-ui-xs); padding: 2px 7px; border-radius: var(--radius-full); border: 1px solid var(--border); color: var(--text-faint); white-space: nowrap; }
|
||||
.effect-hot { color: var(--ok); border-color: var(--ok); }
|
||||
.effect-new-run { color: var(--warn); border-color: var(--warn); }
|
||||
|
||||
/* UI schema 渲染器样式(.ui-*)与 .doc-viewer 已迁入 @ui/ui.css。 */
|
||||
/* ---- 文档查看器(单层 OverlayHost 内)---- */
|
||||
.docv { display: flex; flex-direction: column; min-height: 0; }
|
||||
.docv-toolbar { display: flex; align-items: center; gap: 8px; padding: 0 0 10px; border-bottom: 1px solid var(--border-soft); margin-bottom: 10px; }
|
||||
.docv-toolbar .spacer { flex: 1; }
|
||||
.docv-meta { font-size: var(--fs-ui-xs); color: var(--text-faint); text-transform: capitalize; }
|
||||
.docv-dirty { color: var(--warn); font-size: var(--fs-ui-sm); }
|
||||
.docv-dl { text-decoration: none; }
|
||||
.docv-body { flex: 1; min-height: 0; overflow: auto; }
|
||||
.docv-pdf { width: 100%; height: 100%; min-height: 60vh; border: 1px solid var(--border-soft); border-radius: var(--radius); background: var(--bg); }
|
||||
.docv-editor { width: 100%; height: 56vh; font-family: var(--mono); font-size: var(--fs-ui-sm); resize: vertical; }
|
||||
.docv-pre { font-family: var(--mono); font-size: var(--fs-ui-sm); white-space: pre-wrap; word-break: break-word; background: var(--bg-inset); border: 1px solid var(--border-soft); border-radius: var(--radius); padding: 12px 14px; }
|
||||
|
||||
/* ---- 杂项 ---- */
|
||||
.toast { position: fixed; bottom: 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: var(--fs-ui-caption); }
|
||||
.conn-dot { width: 8px; height: 8px; border-radius: 50%; }
|
||||
.conn-dot.on { background: var(--ok); }
|
||||
.conn-dot.off { background: var(--err); }
|
||||
.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-2) ---- */
|
||||
.brand-mark { background: linear-gradient(135deg, var(--accent), var(--brand-2)); color: var(--text-on-accent); display: inline-flex; align-items: center; justify-content: center; flex: none; box-shadow: inset 0 1px 0 rgba(255,255,255,.22); }
|
||||
|
||||
/* ---- 左栏:任务中心(方案 A;Zai 复刻重造型) ---- */
|
||||
.tsb-brand { display: flex; align-items: center; gap: 9px; padding: 14px 14px 8px; }
|
||||
.tsb-logo { width: 24px; height: 24px; border-radius: var(--radius); background: linear-gradient(135deg, var(--accent), var(--brand-2)); color: var(--text-on-accent); font-size: var(--fs-ui-sm); font-weight: 700; display: flex; align-items: center; justify-content: center; flex: none; }
|
||||
.tsb-name { font-weight: 650; font-size: var(--fs-ui-caption); letter-spacing: -0.01em; }
|
||||
.tsb-iconbtn { width: 26px; height: 26px; border-radius: var(--radius-sm); background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: var(--fs-ui-caption); display: inline-flex; align-items: center; justify-content: center; position: relative; }
|
||||
.tsb-iconbtn:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.tsb-iconbtn.active { color: var(--accent); background: var(--accent-soft); }
|
||||
|
||||
.tsb-actions { padding: 6px 12px 8px; display: flex; flex-direction: column; gap: 7px; }
|
||||
.tsb-newtask { display: flex; height: 36px; border-radius: var(--radius); background: var(--primary); color: var(--primary-foreground); overflow: hidden; box-shadow: 0 1px 3px rgba(0, 0, 0, .3); transition: background .12s; }
|
||||
.tsb-newtask:hover { background: color-mix(in srgb, var(--primary) 86%, var(--bg)); }
|
||||
.tsb-newtask-main { flex: 1; display: flex; align-items: center; gap: 7px; padding: 0 11px; background: none; border: none; color: inherit; font-weight: 600; font-size: var(--fs-ui-caption); cursor: pointer; min-width: 0; }
|
||||
.tsb-newtask-main svg { flex: none; }
|
||||
.tsb-newtask-proj { font-weight: 450; font-size: var(--fs-ui-xs); opacity: .75; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tsb-newtask-caret { width: 30px; border: none; border-left: 1px solid color-mix(in srgb, var(--primary-foreground) 18%, transparent); background: none; color: inherit; cursor: pointer; display: flex; align-items: center; justify-content: center; }
|
||||
.tsb-newtask-caret:hover { background: color-mix(in srgb, var(--primary-foreground) 8%, transparent); }
|
||||
.tsb-search { display: flex; align-items: center; gap: 7px; height: 32px; border-radius: var(--radius); border: 1px solid var(--border); background: var(--bg-raised); color: var(--text-faint); padding: 0 10px; transition: border-color .12s, box-shadow .12s; }
|
||||
.tsb-search:focus-within { border-color: var(--accent); box-shadow: var(--ring); }
|
||||
.tsb-search svg { flex: none; }
|
||||
.tsb-search input { flex: 1; border: none; background: none; color: var(--text); font-size: var(--fs-ui-sm); outline: none; min-width: 0; }
|
||||
.tsb-search input::placeholder { color: var(--text-faint); }
|
||||
|
||||
.tsb-orgrow { display: flex; align-items: center; gap: 6px; padding: 0 12px 8px; }
|
||||
.tsb-pills { flex: 1; display: flex; background: var(--bg-inset); border: 1px solid var(--border-soft); border-radius: var(--radius-sm); padding: 2px; gap: 2px; }
|
||||
.tsb-pill { flex: 1; height: 22px; border: none; border-radius: var(--radius-xs); background: none; color: var(--text-faint); font-size: var(--fs-ui-sm); cursor: pointer; transition: all .12s; }
|
||||
.tsb-pill:hover { color: var(--text-dim); }
|
||||
.tsb-pill.on { background: var(--bg-raised); color: var(--text); font-weight: 550; box-shadow: 0 1px 2px rgba(0,0,0,.25); }
|
||||
|
||||
.tsb-scroll { flex: 1; overflow-y: auto; padding: 0 10px 8px; min-height: 0; }
|
||||
.tsb-seclabel { font-size: var(--fs-ui-xs); color: var(--text-faint); padding: 12px 8px 4px; letter-spacing: .05em; font-weight: 600; }
|
||||
.tsb-empty { padding: 20px 14px; color: var(--text-faint); font-size: var(--fs-ui-sm); line-height: 1.9; text-align: center; }
|
||||
.tsb-newproject { padding: 0 2px 8px; }
|
||||
|
||||
.tsb-group { margin-bottom: 2px; }
|
||||
.tsb-group-head { display: flex; align-items: center; gap: 6px; height: 30px; padding: 0 6px 0 4px; border-radius: var(--radius-sm); color: var(--text-dim); font-weight: 550; font-size: var(--fs-ui-caption); cursor: pointer; user-select: none; }
|
||||
.tsb-group-head:hover { background: var(--bg-hover); }
|
||||
.tsb-group.closed .tsb-chev { transform: rotate(0deg); }
|
||||
.tsb-chev { display: inline-flex; color: var(--text-faint); transform: rotate(90deg); transition: transform .12s; }
|
||||
.tsb-group-name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tsb-group-cnt { font-size: var(--fs-ui-xs); color: var(--text-faint); font-weight: 400; font-variant-numeric: tabular-nums; }
|
||||
.tsb-group-add { visibility: hidden; width: 22px; height: 22px; border: none; border-radius: var(--radius-xs); background: none; color: var(--text-faint); display: inline-flex; align-items: center; justify-content: center; cursor: pointer; }
|
||||
.tsb-group-head:hover .tsb-group-add { visibility: visible; }
|
||||
.tsb-group-add:hover { background: var(--bg-active); color: var(--text); }
|
||||
|
||||
.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: var(--fs-ui-caption); }
|
||||
.tsb-task-tm { font-size: var(--fs-ui-xs); color: var(--text-faint); font-variant-numeric: tabular-nums; }
|
||||
.tsb-pbadge { font-size: var(--fs-ui-xs); color: var(--text-faint); border: 1px solid var(--border); border-radius: var(--radius-xs); padding: 0 5px; line-height: 1.5; white-space: nowrap; }
|
||||
.tsb-row-acts { display: none; gap: 2px; }
|
||||
.tsb-task:hover .tsb-row-acts { display: inline-flex; }
|
||||
.tsb-task:hover .tsb-task-tm { display: none; }
|
||||
.tsb-row-act { border: none; background: none; color: var(--text-faint); cursor: pointer; padding: 3px; border-radius: var(--radius-xs); display: inline-flex; }
|
||||
.tsb-row-act:hover { background: var(--bg-active); color: var(--text); }
|
||||
|
||||
.tsb-footer { display: flex; align-items: center; gap: 4px; padding: 10px 12px; border-top: 1px solid var(--border-soft); border-radius: 0 0 var(--radius-lg) var(--radius-lg); }
|
||||
.tsb-user { flex: 1; display: flex; align-items: center; gap: 9px; min-width: 0; height: 40px; padding: 0 7px; border: none; border-radius: var(--radius); background: none; cursor: pointer; text-align: left; transition: background .12s; }
|
||||
.tsb-user:hover { background: var(--bg-hover); }
|
||||
.tsb-avatar { width: 27px; height: 27px; border-radius: 50%; background: linear-gradient(135deg, var(--accent), var(--brand-2)); color: var(--text-on-accent); font-size: var(--fs-ui-sm); font-weight: 600; display: flex; align-items: center; justify-content: center; flex: none; box-shadow: inset 0 1px 0 rgba(255,255,255,.2); }
|
||||
.tsb-user-info { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.tsb-user-name { font-size: var(--fs-ui-caption); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tsb-user-sub { font-size: var(--fs-ui-xs); color: var(--text-faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tsb-user-chev { color: var(--text-faint); display: inline-flex; }
|
||||
|
||||
.tsb-menu { position: fixed; z-index: 300; min-width: 216px; background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: var(--radius); box-shadow: var(--shadow-lg); padding: 5px; display: flex; flex-direction: column; animation: mk-modal-in .12s var(--ease); }
|
||||
.tsb-mi { display: flex; align-items: center; gap: 9px; min-height: 31px; padding: 4px 9px; border: none; border-radius: var(--radius-sm); background: none; color: var(--text-dim); font-size: var(--fs-ui-caption); cursor: pointer; text-align: left; }
|
||||
.tsb-mi svg { color: var(--text-faint); flex: none; }
|
||||
.tsb-mi:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.tsb-mi:hover svg { color: var(--text-dim); }
|
||||
.tsb-mi.danger { color: var(--err); }
|
||||
.tsb-mi.danger:hover { background: var(--err); color: var(--text-on-accent); }
|
||||
.tsb-mi.danger:hover svg { color: var(--text-on-accent); }
|
||||
.tsb-mi kbd { margin-left: auto; font-size: var(--fs-ui-xs); font-family: var(--mono); color: var(--text-faint); }
|
||||
.tsb-menu-div { height: 1px; background: var(--border-soft); margin: 5px 4px; }
|
||||
.tsb-acc-head { display: flex; gap: 10px; align-items: center; padding: 8px 9px 6px; }
|
||||
.tsb-acc-name { font-size: var(--fs-ui-caption); font-weight: 600; }
|
||||
.tsb-acc-mail { font-size: var(--fs-ui-xs); color: var(--text-faint); }
|
||||
|
||||
/* ---- 登录页与改密门(Zai 复刻:近单色深底 + 深海军品牌面;品牌面板固定深底浅字) ---- */
|
||||
.lg-wrap { position: fixed; inset: 0; display: flex; align-items: stretch; background: var(--bg-inset); z-index: 500; }
|
||||
.lg-panel { flex: none; width: 46%; max-width: 620px; position: relative; overflow: hidden; display: flex; flex-direction: column; justify-content: center; padding: 64px; color: #dfe4f2; background: linear-gradient(160deg, #0e1116 0%, #101826 55%, #0b1f38 100%); }
|
||||
.lg-panel::before { content: ''; position: absolute; width: 560px; height: 560px; border-radius: 50%; background: radial-gradient(closest-side, rgba(64,153,255,.16), transparent 70%); top: -140px; right: -160px; }
|
||||
.lg-panel::after { content: ''; position: absolute; width: 440px; height: 440px; border-radius: 50%; background: radial-gradient(closest-side, rgba(123,92,229,.12), transparent 70%); bottom: -150px; left: -120px; }
|
||||
.lg-panel h2 { font-size: 26px; font-weight: 700; letter-spacing: -0.02em; line-height: 1.35; margin: 0 0 12px; position: relative; }
|
||||
.lg-panel p { font-size: var(--fs-ui-base); opacity: .72; line-height: 1.75; margin: 0; position: relative; }
|
||||
.lg-panel ul { margin: 28px 0 0; list-style: none; display: flex; flex-direction: column; gap: 13px; position: relative; }
|
||||
.lg-panel li { display: flex; gap: 10px; align-items: flex-start; font-size: var(--fs-ui-caption); opacity: .88; }
|
||||
.lg-panel li svg { margin-top: 2px; color: var(--accent-strong); flex: none; }
|
||||
.lg-panel .lg-foot { position: absolute; bottom: 28px; left: 64px; right: 64px; display: flex; justify-content: space-between; font-size: var(--fs-ui-xs); opacity: .45; }
|
||||
@media (max-width: 960px) { .lg-panel { display: none; } }
|
||||
.lg-formside { flex: 1; display: flex; align-items: center; justify-content: center; padding: 24px; min-width: 0; }
|
||||
.lg-card { width: 392px; max-width: 100%; }
|
||||
.lg-brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: var(--fs-ui-base); letter-spacing: -0.01em; margin-bottom: 26px; }
|
||||
.lg-title { margin: 0 0 4px; font-size: var(--fs-ui-xl); font-weight: 700; letter-spacing: -0.02em; }
|
||||
.lg-sub { margin: 0 0 18px; font-size: var(--fs-ui-sm); color: var(--text-faint); line-height: 1.6; }
|
||||
.lg-tabs { display: flex; background: var(--bg-inset); border: 1px solid var(--border-soft); border-radius: var(--radius); padding: 3px; gap: 3px; margin-bottom: 16px; }
|
||||
.lg-tab { flex: 1; height: 30px; border: none; border-radius: var(--radius-xs); background: none; color: var(--text-faint); font-size: var(--fs-ui-sm); cursor: pointer; transition: all .12s; }
|
||||
.lg-tab:hover { color: var(--text-dim); }
|
||||
.lg-tab.on { background: var(--bg-raised); color: var(--text); font-weight: 550; box-shadow: 0 1px 2px rgba(0,0,0,.25); }
|
||||
.lg-label { display: block; font-size: var(--fs-ui-sm); font-weight: 550; color: var(--text-dim); margin: 12px 0 6px; }
|
||||
.lg-input { width: 100%; height: 38px; border-radius: var(--radius); border: 1px solid var(--border); background: var(--bg-raised); color: var(--text); padding: 0 12px; font-size: var(--fs-ui-caption); transition: border-color .12s, box-shadow .12s; }
|
||||
.lg-input:focus { border-color: var(--accent); box-shadow: var(--ring); outline: none; }
|
||||
.lg-mono { font-family: var(--mono); font-size: var(--fs-ui-sm); }
|
||||
.lg-err { margin-top: 12px; font-size: var(--fs-ui-sm); color: var(--err); background: var(--err-soft); border: 1px solid color-mix(in srgb, var(--err) 35%, transparent); border-radius: var(--radius); padding: 8px 11px; display: flex; gap: 7px; align-items: flex-start; }
|
||||
.lg-err svg { flex: none; margin-top: 1px; }
|
||||
.lg-submit {
|
||||
width: 100%; height: 38px; margin-top: 16px; border: none; border-radius: var(--radius);
|
||||
background: var(--primary); color: var(--primary-foreground); font-weight: 600; font-size: var(--fs-ui-caption); cursor: pointer;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, .3);
|
||||
transition: background .12s, transform .12s;
|
||||
}
|
||||
.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);
|
||||
.lg-submit:disabled { opacity: .45; cursor: default; box-shadow: none; }
|
||||
.lg-submit:not(:disabled):hover { background: color-mix(in srgb, var(--primary) 86%, var(--bg)); }
|
||||
.lg-submit:not(:disabled):active { transform: translateY(1px); }
|
||||
.lg-cancel { width: 100%; margin-top: 8px; border: none; background: none; color: var(--text-faint); font-size: var(--fs-ui-sm); cursor: pointer; }
|
||||
.lg-cancel:hover { color: var(--text-dim); text-decoration: underline; }
|
||||
.lg-hint { margin: 14px 0 0; font-size: var(--fs-ui-xs); color: var(--text-faint); line-height: 1.7; }
|
||||
.lg-hint code { font-family: var(--mono); font-size: var(--fs-ui-xs); background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-xs); padding: 1px 5px; }
|
||||
|
||||
/* ---- 系统管理(AdminZone,重设计方案 阶段②):管理员专属全屏视图 ---- */
|
||||
.az { position: fixed; inset: 4px; z-index: 70; display: flex; flex-direction: column; background: var(--bg); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); overflow: hidden; animation: mk-modal-in .16s var(--ease); }
|
||||
.az-head { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border-bottom: 1px solid var(--border-soft); background: var(--bg-panel); }
|
||||
.az-title { font-weight: 650; font-size: var(--fs-ui-base); letter-spacing: -0.01em; }
|
||||
.az-sub { font-size: var(--fs-ui-xs); color: var(--text-faint); }
|
||||
.az-tenant { font-size: var(--fs-ui-xs); color: var(--text-faint); font-family: var(--mono); border: 1px solid var(--border); border-radius: var(--radius-full); padding: 2px 10px; }
|
||||
.az-body { flex: 1; min-height: 0; display: flex; }
|
||||
.az-side { width: 220px; flex: none; border-right: 1px solid var(--border-soft); background: var(--bg-panel); padding: 10px 8px; display: flex; flex-direction: column; gap: 2px; }
|
||||
.az-nav-item { display: flex; align-items: center; gap: 9px; padding: 9px 11px; border: none; background: none; color: var(--text-dim); font-size: var(--fs-ui-caption); border-radius: var(--radius-sm); cursor: pointer; text-align: left; }
|
||||
.az-nav-item:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.az-nav-item.on { background: var(--bg-active); color: var(--text); font-weight: 550; }
|
||||
.az-nav-item.on svg { color: var(--accent); }
|
||||
.az-main { flex: 1; min-width: 0; overflow-y: auto; padding: 20px 24px; }
|
||||
.az-section-head { margin-bottom: 14px; }
|
||||
.az-section-head h3 { margin: 0 0 4px; font-size: var(--fs-ui-lg); font-weight: 600; letter-spacing: -0.01em; }
|
||||
.az-section-head p { margin: 0; font-size: var(--fs-ui-sm); color: var(--text-faint); }
|
||||
|
||||
/* ---- 用户管理 / 改密弹层 ---- */
|
||||
.ad-body { padding: 16px 20px 20px; display: flex; flex-direction: column; gap: 12px; max-height: 70vh; overflow-y: auto; }
|
||||
.ad-create { border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 14px; display: flex; flex-direction: column; gap: 8px; background: var(--bg-panel); }
|
||||
.ad-row { display: flex; gap: 8px; align-items: center; }
|
||||
.ad-row .lg-input { flex: 1; min-width: 0; height: 34px; }
|
||||
.ad-check { display: flex; align-items: center; gap: 5px; font-size: var(--fs-ui-sm); color: var(--text-dim); white-space: nowrap; }
|
||||
.ad-create-btn { width: auto; padding: 0 14px; margin-top: 0; height: 34px; flex: none; }
|
||||
.ad-mini { border: 1px solid var(--border); background: var(--bg-raised); color: var(--text-dim); border-radius: var(--radius-sm); padding: 4px 9px; font-size: var(--fs-ui-sm); cursor: pointer; white-space: nowrap; display: inline-flex; align-items: center; gap: 4px; }
|
||||
.ad-mini:hover { color: var(--text); border-color: var(--accent); }
|
||||
.ad-hint { margin: 0; font-size: var(--fs-ui-xs); color: var(--text-faint); }
|
||||
.ad-notice { font-size: var(--fs-ui-sm); color: var(--ok); background: var(--ok-soft); border: 1px solid var(--ok-border); border-radius: var(--radius); padding: 8px 11px; display: flex; gap: 7px; align-items: flex-start; }
|
||||
.ad-notice svg { flex: none; margin-top: 1px; }
|
||||
.ad-table { width: 100%; border-collapse: collapse; font-size: var(--fs-ui-sm); }
|
||||
.ad-table th { text-align: left; font-weight: 600; color: var(--text-faint); font-size: var(--fs-ui-xs); text-transform: uppercase; letter-spacing: .06em; padding: 8px; border-bottom: 1px solid var(--border); }
|
||||
.ad-table td { padding: 9px 8px; border-bottom: 1px solid var(--border-soft); }
|
||||
.ad-table tr:last-child td { border-bottom: none; }
|
||||
.ad-mono { font-family: var(--mono); font-size: var(--fs-ui-sm); }
|
||||
.ad-dim { color: var(--text-faint); }
|
||||
.ad-chip { font-size: var(--fs-ui-xs); border-radius: var(--radius-xs); padding: 2px 7px; font-weight: 700; letter-spacing: .5px; background: var(--bg-hover); color: var(--text-faint); }
|
||||
.ad-chip-admin { background: var(--accent-soft); color: var(--accent); }
|
||||
.ad-chip-warn { background: var(--warn-soft); color: var(--warn); }
|
||||
.ad-reset { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; border: 1px solid color-mix(in srgb, var(--warn) 40%, transparent); background: var(--warn-soft); border-radius: var(--radius-lg); padding: 12px; font-size: var(--fs-ui-sm); }
|
||||
.ad-reset .lg-input { flex: 1; min-width: 160px; height: 32px; }
|
||||
.ad-changepw { display: flex; flex-direction: column; gap: 2px; max-width: 340px; }
|
||||
|
||||
/* ---- 收起轨道(左栏收起态) ---- */
|
||||
.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: var(--text-on-accent); font-size: var(--fs-ui-xs); font-weight: 700; display: flex; align-items: center; justify-content: center; padding: 0 4px; }
|
||||
|
||||
/* ---- UI 能力平台消费(消息头像列 / 设置外壳 / 管理弹层头像) ---- */
|
||||
.role-mark { width: 20px; height: 20px; border-radius: 6px; display: inline-flex; align-items: center; justify-content: center; color: var(--text-dim); background: var(--bg-raised); border: 1px solid var(--border); }
|
||||
.role-mark.role-assistant { background: none; border: none; padding: 0; }
|
||||
.msg.user .msg-role { align-items: center; }
|
||||
.ad-user-cell { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
/* ---- 右栏轻预览(裁定 D4:右栏 + 弹窗双落位)---- */
|
||||
.rail-preview-head { display: flex; align-items: center; gap: 6px; padding: 0 2px 8px; border-bottom: 1px solid var(--border-soft); margin-bottom: 8px; }
|
||||
.rail-preview-head .rp-name { font-size: var(--fs-ui-caption); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 55%; }
|
||||
.tab-body[data-rail-preview] { display: flex; flex-direction: column; }
|
||||
.tab-body[data-rail-preview] .ui-fp { flex: 1; }
|
||||
.artifact-chip .ac-act { border: none; background: none; }
|
||||
|
||||
/* 项目收件箱(CF3/D51):会话/项目范围分段控件(令牌对齐 DESIGN.md,阶段④合入主线时归一) */
|
||||
.inbox-scope {
|
||||
display: flex; gap: 4px; padding: 6px 14px 2px;
|
||||
}
|
||||
.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;
|
||||
.inbox-scope button {
|
||||
border: 1px solid var(--border); background: transparent; color: inherit;
|
||||
border-radius: var(--radius-full); padding: 2px 12px; font-size: var(--fs-ui-sm); cursor: pointer; opacity: .72;
|
||||
}
|
||||
.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; }
|
||||
.inbox-scope button.active { opacity: 1; font-weight: 600; background: var(--bg-hover); }
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// 设计系统守护测试(父仓 DESIGN.md §11 禁止清单 · 宿主侧,2026-09-30)。
|
||||
// 棘轮基线:现存豁免登记在下方;新增同类违规直接失败。收紧 = 修掉违规后删基线条目。
|
||||
// 范围:src/styles.css 与 src/**/*.tsx。@ui 组件库侧由 Agent-System-UI 的守护测试覆盖。
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'
|
||||
import { join, relative, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url)) // test/
|
||||
const FE_ROOT = join(HERE, '..')
|
||||
const SRC = join(FE_ROOT, 'src')
|
||||
const DEV_ROOT = join(FE_ROOT, '..', '..') // Dev 父仓根(DESIGN.md;单独检出本仓时不存在)
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const p = join(dir, name)
|
||||
if (statSync(p).isDirectory()) walk(p, out)
|
||||
else out.push(p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
const read = (p: string) => readFileSync(p, 'utf8')
|
||||
const stripCssComments = (t: string) => t.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
const collectHex = (t: string) => [...t.matchAll(/#[0-9a-fA-F]{3,8}\b/g)].map(m => m[0].toLowerCase())
|
||||
|
||||
// ---- 1. 宪法在位(组合检出时) ----
|
||||
describe('DESIGN.md 宪法在位', () => {
|
||||
it('DESIGN.md 存在且 AGENTS.md 带强制阅读门禁', () => {
|
||||
const designPath = join(DEV_ROOT, 'DESIGN.md')
|
||||
if (!existsSync(designPath)) { console.warn('(单独检出 Frontend 仓:跳过宪法检查)'); return }
|
||||
expect(read(designPath)).toContain('Agent-System-Dev 设计系统')
|
||||
const agents = read(join(DEV_ROOT, 'AGENTS.md'))
|
||||
expect(agents).toContain('DESIGN.md')
|
||||
})
|
||||
})
|
||||
|
||||
// ---- 2. 动效唯一所有者 ----
|
||||
describe('动效唯一所有者', () => {
|
||||
it('styles.css 无 @keyframes(全部走 @ui/motion.css 的 mk-* 类)', () => {
|
||||
expect(stripCssComments(read(join(SRC, 'styles.css')))).not.toContain('@keyframes')
|
||||
})
|
||||
})
|
||||
|
||||
// ---- 3. 裸色值棘轮 ----
|
||||
const HEX_BASELINE_STYLES = [
|
||||
'#0e1116', '#101826', '#0b1f38', // 登录品牌面板固定深底渐变(面板自带深底,主题无关)
|
||||
'#dfe4f2', // 同上面板的固定浅字
|
||||
] as const
|
||||
describe('裸色值棘轮', () => {
|
||||
it('styles.css 不引入基线外裸色值', () => {
|
||||
const found = collectHex(read(join(SRC, 'styles.css')))
|
||||
const allow = new Set<string>(HEX_BASELINE_STYLES)
|
||||
const extra = found.filter(h => !allow.has(h))
|
||||
expect(extra, `新增裸色值 ${[...new Set(extra)].join(', ')}——改用 tokens 令牌;确需豁免更新 HEX_BASELINE_STYLES 并写明理由`).toEqual([])
|
||||
})
|
||||
it('组件 TSX 无裸色值', () => {
|
||||
const extra: string[] = []
|
||||
for (const p of walk(SRC).filter(p => /\.(tsx|ts)$/.test(p) && !p.endsWith('.d.ts'))) {
|
||||
for (const h of collectHex(read(p))) extra.push(`${relative(SRC, p)}: ${h}`)
|
||||
}
|
||||
expect(extra, '组件里出现裸色值——走 tokens 令牌').toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// ---- 4. 行内字号棘轮(--fs-ui-* 标尺) ----
|
||||
const FONTSIZE_LITERAL_BASELINE = ['40'] as const // Conversation 空态装饰字形(⌘ 展示字号,非 UI 层级)
|
||||
describe('行内字号棘轮', () => {
|
||||
it('组件 TSX 无基线外 fontSize 数值字面量', () => {
|
||||
const allow = new Set<string>(FONTSIZE_LITERAL_BASELINE)
|
||||
const extra: string[] = []
|
||||
for (const p of walk(SRC).filter(p => p.endsWith('.tsx'))) {
|
||||
const lines = read(p).split('\n')
|
||||
lines.forEach((l: string, i: number) => {
|
||||
const m = l.match(/fontSize:\s*['"`]?([\d.]+)/)
|
||||
if (m && !allow.has(m[1])) extra.push(`${relative(SRC, p)}:${i + 1} (${m[1]})`)
|
||||
})
|
||||
}
|
||||
expect(extra, '行内 fontSize 数值——改用 var(--fs-ui-*);确需豁免更新基线并写明理由').toEqual([])
|
||||
})
|
||||
it('组件 TSX 无 <details> 折叠(走 @ui Collapsible,DESIGN.md §11.7)', () => {
|
||||
const extra: string[] = []
|
||||
for (const p of walk(SRC).filter(p => p.endsWith('.tsx'))) {
|
||||
const code = read(p).replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
|
||||
if (/<details[\s>]/.test(code)) extra.push(relative(SRC, p))
|
||||
}
|
||||
expect(extra, '<details> 折叠——改用 @ui Collapsible').toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,150 @@
|
||||
// live 流式投影纯逻辑测试(中栏过程/回复分离):到达序并段、process 标记时机、
|
||||
// 轮次穿插同构、过程区折叠谓词。
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
applyTextDelta, applyThinkingDelta, applyToolCall, applyToolResult, applyArtifactCreated,
|
||||
liveToMessage, showProcessInline, type LiveItem,
|
||||
} from '../src/live'
|
||||
import type { LiveState } from '../src/store'
|
||||
|
||||
function live(over: Partial<LiveState> = {}): LiveState {
|
||||
return { runId: 'r1', phase: 'thinking', items: [], agentNotes: {}, startedAt: 0, ...over }
|
||||
}
|
||||
|
||||
// 两轮流式事件回放:思考→解说文本→工具→思考→最终回复(与后端事件序一致)。
|
||||
function twoTurnItems(): LiveItem[] {
|
||||
let items: LiveItem[] = []
|
||||
items = applyThinkingDelta(items, '先想想')
|
||||
items = applyThinkingDelta(items, '一下')
|
||||
items = applyTextDelta(items, 0, '我先更新待办。')
|
||||
items = applyToolCall(items, 'update_todos', 'c1')
|
||||
items = applyToolResult(items, 'c1', 'ok', '已更新')
|
||||
items = applyThinkingDelta(items, '再想想')
|
||||
items = applyTextDelta(items, 1, '最终')
|
||||
items = applyTextDelta(items, 1, '回复')
|
||||
return items
|
||||
}
|
||||
|
||||
describe('applyThinkingDelta / applyTextDelta', () => {
|
||||
it('merges thinking deltas into the last thinking segment', () => {
|
||||
let items: LiveItem[] = []
|
||||
items = applyThinkingDelta(items, '先')
|
||||
items = applyThinkingDelta(items, '想想')
|
||||
expect(items).toEqual([{ kind: 'thinking', text: '先想想' }])
|
||||
})
|
||||
it('opens a new thinking segment after text/tool (turn boundary)', () => {
|
||||
let items: LiveItem[] = applyThinkingDelta([], '第一轮思考')
|
||||
items = applyTextDelta(items, 0, '解说')
|
||||
items = applyThinkingDelta(items, '第二轮思考')
|
||||
expect(items).toEqual([
|
||||
{ kind: 'thinking', text: '第一轮思考' },
|
||||
{ kind: 'text', index: 0, text: '解说' },
|
||||
{ kind: 'thinking', text: '第二轮思考' },
|
||||
])
|
||||
})
|
||||
it('appends text to the last segment when index matches', () => {
|
||||
let items: LiveItem[] = []
|
||||
items = applyTextDelta(items, 0, '先查')
|
||||
items = applyTextDelta(items, 0, '一下')
|
||||
expect(items).toEqual([{ kind: 'text', index: 0, text: '先查一下' }])
|
||||
})
|
||||
it('opens a new text segment when index advances (new turn)', () => {
|
||||
let items: LiveItem[] = applyTextDelta([], 0, '解说')
|
||||
items = applyTextDelta(items, 1, '最终')
|
||||
items = applyTextDelta(items, 1, '回复')
|
||||
expect(items).toEqual([
|
||||
{ kind: 'text', index: 0, text: '解说' },
|
||||
{ kind: 'text', index: 1, text: '最终回复' },
|
||||
])
|
||||
})
|
||||
it('fills the most recent pending tool call (callId match, tail fallback)', () => {
|
||||
let items: LiveItem[] = []
|
||||
items = applyToolCall(items, 'a', 'ca')
|
||||
items = applyToolCall(items, 'b', 'cb')
|
||||
items = applyToolResult(items, 'cb', 'ok', '结果')
|
||||
expect(items).toEqual([
|
||||
{ kind: 'tool', callId: 'ca', name: 'a' },
|
||||
{ kind: 'tool', callId: 'cb', name: 'b', status: 'ok', summary: '结果' },
|
||||
])
|
||||
// 无 callId 的事件回落到尾部配对。
|
||||
items = applyToolResult(items, undefined, 'ok')
|
||||
expect(items[0]).toEqual({ kind: 'tool', callId: 'ca', name: 'a', status: 'ok' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('liveToMessage', () => {
|
||||
it('interleaves parts per turn in arrival order, matching persisted parts', () => {
|
||||
const msg = liveToMessage(live({ items: twoTurnItems() }))
|
||||
expect(msg.parts).toEqual([
|
||||
{ type: 'thinking', text: '先想想一下', process: true },
|
||||
{ type: 'text', text: '我先更新待办。', process: true },
|
||||
{ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'update_todos' } },
|
||||
{ type: 'tool_result', process: true, toolCall: { id: 'c1', name: 'update_todos' },
|
||||
toolResult: { callId: 'c1', status: 'ok', summary: '已更新' } },
|
||||
{ type: 'thinking', text: '再想想', process: true },
|
||||
{ type: 'text', text: '最终回复' },
|
||||
])
|
||||
})
|
||||
it('marks the in-flight segment as process the moment its tool call arrives', () => {
|
||||
let items: LiveItem[] = []
|
||||
items = applyTextDelta(items, 0, '我先查一下')
|
||||
const before = liveToMessage(live({ items }))
|
||||
expect(before.parts.map(p => p.process)).toEqual([undefined]) // 尚无工具:仍在回复位
|
||||
items = applyToolCall(items, 'web_search')
|
||||
const after = liveToMessage(live({ items }))
|
||||
expect(after.parts.map(p => p.process)).toEqual([true, true]) // tool.call 一到即改标
|
||||
})
|
||||
it('single final segment without tools carries no process flag', () => {
|
||||
let items: LiveItem[] = []
|
||||
items = applyTextDelta(items, 0, '直接回答')
|
||||
const msg = liveToMessage(live({ items }))
|
||||
expect(msg.parts.map(p => p.process)).toEqual([undefined])
|
||||
})
|
||||
it('tools between segments keep every non-final segment in the process area', () => {
|
||||
let items: LiveItem[] = []
|
||||
items = applyTextDelta(items, 0, '第一轮')
|
||||
items = applyToolCall(items, 'a')
|
||||
items = applyToolResult(items, undefined, 'ok')
|
||||
items = applyTextDelta(items, 1, '第二轮')
|
||||
items = applyToolCall(items, 'b')
|
||||
items = applyTextDelta(items, 2, '最终回复')
|
||||
const msg = liveToMessage(live({ items }))
|
||||
expect(msg.parts.filter(p => p.type === 'text').map(p => p.process)).toEqual([true, true, undefined])
|
||||
// 顺序必须与落库穿插序一致:text → tool → text → tool → text。
|
||||
expect(msg.parts.map(p => p.type)).toEqual(['text', 'tool_call', 'tool_result', 'text', 'tool_call', 'text'])
|
||||
})
|
||||
it('thinking and tool parts are always process', () => {
|
||||
let items: LiveItem[] = []
|
||||
items = applyThinkingDelta(items, '想')
|
||||
items = applyTextDelta(items, 0, '答')
|
||||
items = applyToolCall(items, 'web_search')
|
||||
items = applyToolResult(items, undefined, 'ok', '命中')
|
||||
const msg = liveToMessage(live({ items }))
|
||||
for (const p of msg.parts.filter(p => p.type !== 'text')) expect(p.process).toBe(true)
|
||||
})
|
||||
it('deliverable cards are content parts pinned after the reply text', () => {
|
||||
// 与落库序同构(llm.go 交付物钉尾):artifact_ref 在终轮正文之后、不折叠。
|
||||
let items: LiveItem[] = []
|
||||
items = applyTextDelta(items, 0, '生成报表。')
|
||||
items = applyToolCall(items, 'make_report', 'c1')
|
||||
items = applyToolResult(items, 'c1', 'ok')
|
||||
items = applyArtifactCreated(items, { artifactId: 'a1', name: 'report.csv', kind: 'csv' })
|
||||
items = applyTextDelta(items, 1, '完成。')
|
||||
const msg = liveToMessage(live({ items }))
|
||||
expect(msg.parts.map(p => p.type)).toEqual(['text', 'tool_call', 'tool_result', 'text', 'artifact_ref'])
|
||||
expect(msg.parts.find(p => p.type === 'artifact_ref')?.process).toBeFalsy() // 内容部件,不折叠
|
||||
expect(msg.parts.filter(p => p.type === 'text').map(p => p.process)).toEqual([true, undefined])
|
||||
})
|
||||
})
|
||||
|
||||
describe('showProcessInline', () => {
|
||||
it('expands during thinking/acting and collapses the moment responding starts', () => {
|
||||
expect(showProcessInline(live({ phase: 'thinking' }), true)).toBe(true)
|
||||
expect(showProcessInline(live({ phase: 'acting' }), true)).toBe(true)
|
||||
expect(showProcessInline(live({ phase: 'responding' }), true)).toBe(false)
|
||||
})
|
||||
it('falls back to the auto-collapse preference when no live run', () => {
|
||||
expect(showProcessInline(null, true)).toBe(false)
|
||||
expect(showProcessInline(null, false)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
// 消息部件顺序保真纯函数测试(渲染顺序 = 落库顺序)。
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { groupParts, humanSize, artifactIcon } from '../src/messageparts'
|
||||
import type { Part } from '../src/contracts'
|
||||
|
||||
function p(partial: Partial<Part> & { type: Part['type'] }): Part {
|
||||
return partial as Part
|
||||
}
|
||||
|
||||
describe('groupParts(顺序保真)', () => {
|
||||
it('interleaves tool blocks at their original positions', () => {
|
||||
// 后端 llm.go 的真实落库序(交付物钉尾):text(过程解说) → tool → tool_result → text(终轮回复) → artifact_ref
|
||||
const parts: Part[] = [
|
||||
p({ type: 'text', text: '先找资料', process: true }),
|
||||
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'search' } }),
|
||||
p({ type: 'tool_result', process: true, toolResult: { callId: 'c1', status: 'ok' } }),
|
||||
p({ type: 'text', text: '文档已生成,请查收' }),
|
||||
p({ type: 'artifact_ref', artifact: { artifactId: 'a1', name: '会议纪要.docx' } }),
|
||||
]
|
||||
const blocks = groupParts(parts)
|
||||
expect(blocks.map(b => b.kind)).toEqual(['process', 'content', 'content'])
|
||||
expect((blocks[0] as { parts: Part[] }).parts.map(x => x.type)).toEqual(['text', 'tool_call', 'tool_result'])
|
||||
expect((blocks[1] as { part: Part }).part.text).toBe('文档已生成,请查收')
|
||||
expect((blocks[2] as { part: Part }).part.type).toBe('artifact_ref')
|
||||
})
|
||||
|
||||
it('renders deliverable cards as content in legacy inline order too', () => {
|
||||
// 旧落库序(artifact_ref 夹在过程与正文之间):仍是内容部件,原位渲染不折叠。
|
||||
const parts: Part[] = [
|
||||
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'search' } }),
|
||||
p({ type: 'artifact_ref', artifact: { artifactId: 'a1', name: '会议纪要.docx' } }),
|
||||
p({ type: 'text', text: '文档已生成' }),
|
||||
]
|
||||
const blocks = groupParts(parts)
|
||||
expect(blocks.map(b => b.kind)).toEqual(['process', 'content', 'content'])
|
||||
expect((blocks[1] as { part: Part }).part.type).toBe('artifact_ref')
|
||||
})
|
||||
|
||||
it('merges only consecutive process parts into one block', () => {
|
||||
const parts: Part[] = [
|
||||
p({ type: 'thinking', process: true, text: '思考' }),
|
||||
p({ type: 'text', text: '第一段' }),
|
||||
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'a' } }),
|
||||
p({ type: 'tool_result', process: true, toolResult: { callId: 'c1', status: 'ok' } }),
|
||||
p({ type: 'text', text: '第二段' }),
|
||||
p({ type: 'tool_call', process: true, toolCall: { id: 'c2', name: 'b' } }),
|
||||
]
|
||||
const blocks = groupParts(parts)
|
||||
expect(blocks.map(b => b.kind)).toEqual(['process', 'content', 'process', 'content', 'process'])
|
||||
expect((blocks[2] as { parts: Part[] }).parts).toHaveLength(2)
|
||||
expect((blocks[4] as { parts: Part[] }).parts).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('handles all-process and all-content messages', () => {
|
||||
expect(groupParts([p({ type: 'thinking', process: true, text: 'x' }), p({ type: 'tool_call', process: true, toolCall: { id: '', name: '' } })]))
|
||||
.toEqual([{ kind: 'process', parts: [expect.anything(), expect.anything()] }])
|
||||
expect(groupParts([p({ type: 'text', text: 'hi' })]).map(b => b.kind)).toEqual(['content'])
|
||||
expect(groupParts([])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('humanSize / artifactIcon', () => {
|
||||
it('formats sizes compactly', () => {
|
||||
expect(humanSize(undefined)).toBe('')
|
||||
expect(humanSize(0)).toBe('')
|
||||
expect(humanSize(512)).toBe('512 B')
|
||||
expect(humanSize(2048)).toBe('2 KB')
|
||||
expect(humanSize(1536 * 1024)).toBe('1.5 MB')
|
||||
})
|
||||
it('picks icons by extension then kind', () => {
|
||||
expect(artifactIcon('会议纪要.docx')).toBe('file')
|
||||
expect(artifactIcon('截图.png')).toBe('image')
|
||||
expect(artifactIcon('blob', 'image')).toBe('image')
|
||||
expect(artifactIcon('打包.zip')).toBe('package')
|
||||
})
|
||||
})
|
||||
@@ -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]])
|
||||
})
|
||||
})
|
||||
+5
-2
@@ -12,11 +12,14 @@
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"],
|
||||
"types": ["vite/client", "node"],
|
||||
"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 @@
|
||||
{"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/live.ts","./src/main.tsx","./src/messageparts.ts","./src/passwords.ts","./src/previewsetup.ts","./src/store.ts","./src/tasklist.ts","./src/components/AdminUsersModal.tsx","./src/components/AdminZone.tsx","./src/components/AgentTracker.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/LoginPage.tsx","./src/components/ProjectSettingsModal.tsx","./src/components/SettingsModal.tsx","./src/components/TaskSidebar.tsx","./src/components/Workbench.tsx","./src/components/Workspaces.tsx","./src/components/adminSections.tsx","./test/auth.test.ts","./test/contracts.test.ts","./test/design-guard.test.ts","./test/live.test.ts","./test/messageparts.test.ts","./test/tasklist.test.ts"],"version":"5.9.3"}
|
||||
Reference in New Issue
Block a user