Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fc1387235 | ||
|
|
2516df5b3b | ||
|
|
1d6b8a64a6 | ||
|
|
6cfc1059c5 | ||
|
|
dc70399d45 | ||
|
|
3aeafc4440 | ||
|
|
4abf13d33d | ||
|
|
0eb9066510 | ||
|
|
616795be41 | ||
|
|
a54022c438 | ||
|
|
a617504268 | ||
|
|
4f4711a22a | ||
|
|
e5b65ca79a |
+38
-38
@@ -1,7 +1,11 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { auth } from './auth'
|
||||
import { api } from './api'
|
||||
import { LoginPage, ChangePasswordGate } from './components/LoginPage'
|
||||
import { AdminUsersModal, ChangePasswordModal } from './components/AdminUsersModal'
|
||||
import { Icon } from './icons'
|
||||
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'
|
||||
@@ -13,33 +17,6 @@ import { DocViewer } from './components/DocViewer'
|
||||
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)
|
||||
@@ -51,14 +28,20 @@ export function App() {
|
||||
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') // 网络异常不是认证问题;放行交给业务层报错
|
||||
}
|
||||
@@ -102,9 +85,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 (
|
||||
@@ -112,16 +105,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>
|
||||
)}
|
||||
@@ -140,7 +136,9 @@ 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>
|
||||
)}
|
||||
@@ -148,6 +146,8 @@ export function App() {
|
||||
<Workspaces />
|
||||
{trackerRunId && <AgentTracker />}
|
||||
{ui.settingsOpen && <SettingsModal />}
|
||||
{ui.adminUsersOpen && <AdminUsersModal onClose={() => store.setUI({ adminUsersOpen: false })} />}
|
||||
{ui.changePwOpen && <ChangePasswordModal onClose={() => store.setUI({ changePwOpen: false })} />}
|
||||
{viewingArtifact && <DocViewer artifact={viewingArtifact} />}
|
||||
{toast && <div className="toast">{toast}</div>}
|
||||
</div>
|
||||
|
||||
+19
-1
@@ -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 }) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(flags) }).then(r => { if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) }),
|
||||
me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean; isAdmin?: boolean; mustChangePassword?: boolean }>(r)),
|
||||
// ---- 认证与会话(管理员发放制,R2)----
|
||||
login: (owner: string, password: string) => auth.fetch(`${BASE}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, password }) }).then(async r => {
|
||||
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
|
||||
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean }>
|
||||
}),
|
||||
logout: () => auth.fetch(`${BASE}/api/auth/logout`, { method: 'POST' }),
|
||||
changePassword: (current: string, next: string) => auth.fetch(`${BASE}/api/auth/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ current, next }) }).then(async r => {
|
||||
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`)
|
||||
return r.json() as Promise<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean }>
|
||||
}),
|
||||
listAdminUsers: () => auth.fetch(`${BASE}/api/admin/users`).then(r => json<{ owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string }[]>(r)),
|
||||
createAdminUser: (owner: string, name: string, password: string, isAdmin: boolean) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, name, password, isAdmin }) }).then(r => json<{ owner: string; mustChangePassword: boolean }>(r)),
|
||||
resetAdminUserPassword: (owner: string, password: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) }).then(r => json<{ owner: string; mustChangePassword: boolean }>(r)),
|
||||
createConversation: (projectId: string, title?: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: title ?? '' }) }).then(r => json<Conversation>(r)),
|
||||
deleteConversation: (id: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'DELETE' }),
|
||||
setConversationProfile: (id: string, profileId: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profileId }) }).then(r => json<Conversation>(r)),
|
||||
@@ -67,6 +84,7 @@ export const api = {
|
||||
getTodos: (convId: string) => auth.fetch(`${BASE}/api/conversations/${convId}/todos`).then(r => r.ok ? r.json() : null) as Promise<TodoList | null>,
|
||||
|
||||
listProfiles: () => auth.fetch(`${BASE}/api/profiles`).then(r => json<AgentProfile[]>(r)),
|
||||
listPromptModules: () => auth.fetch(`${BASE}/api/prompts`).then(r => json<PromptModule[]>(r)),
|
||||
saveProfile: (p: AgentProfile) => auth.fetch(`${BASE}/api/profiles`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(p) }).then(r => { if (!r.ok) throw new Error(r.statusText); return r.json() }),
|
||||
deleteProfile: (id: string) => auth.fetch(`${BASE}/api/profiles/${id}`, { method: 'DELETE' }),
|
||||
|
||||
|
||||
@@ -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,162 @@
|
||||
// 管理员用户管理弹层 + 主动改密弹窗(R2 管理员发放制)。
|
||||
// AdminUsersModal:建号(用户名/显示名/初始密码/管理员位)、重置密码、列表状态。
|
||||
// ChangePasswordModal:登录者主动改密(与强制改密共用 API)。
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { store, useStore } from '../store'
|
||||
import { Icon } from '../icons'
|
||||
import { randomPassword } from '../passwords'
|
||||
|
||||
interface AdminUserRow { owner: string; name: string; isAdmin: boolean; mustChangePassword: boolean; hasPassword: boolean; passwordSetAt?: string; createdAt: string }
|
||||
|
||||
function Shell({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="modal ad-card" role="dialog" aria-label={title} style={{ maxWidth: 640 }}>
|
||||
<div className="modal-header">
|
||||
<b>{title}</b>
|
||||
<span className="spacer" />
|
||||
<button className="ad-mini" onClick={onClose} aria-label="关闭"><Icon name="x" size={13} /></button>
|
||||
</div>
|
||||
<div className="ad-body">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AdminUsersModal({ onClose }: { onClose: () => void }) {
|
||||
const me = useStore(s => s.me)
|
||||
const [users, setUsers] = useState<AdminUserRow[] | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [owner, setOwner] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [pw, setPw] = useState('')
|
||||
const [asAdmin, setAsAdmin] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [resetFor, setResetFor] = useState<string | null>(null)
|
||||
const [resetPw, setResetPw] = useState('')
|
||||
|
||||
const refresh = async () => {
|
||||
try { setUsers(await api.listAdminUsers()) } catch (e) { setErr(String(e)) }
|
||||
}
|
||||
useEffect(() => { refresh() }, [])
|
||||
|
||||
const create = async () => {
|
||||
setErr(''); setNotice('')
|
||||
if (pw.length < 8) { setErr('初始密码至少 8 位'); return }
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.createAdminUser(owner.trim(), name.trim(), pw, asAdmin)
|
||||
setNotice(`已创建 ${owner.trim()}:首次登录须改密`)
|
||||
setOwner(''); setName(''); setPw('')
|
||||
await refresh()
|
||||
} catch (e) { setErr(String(e).includes('409') ? '用户名已存在' : String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const reset = async () => {
|
||||
if (!resetFor) return
|
||||
setErr('')
|
||||
if (resetPw.length < 8) { setErr('新密码至少 8 位'); return }
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.resetAdminUserPassword(resetFor, resetPw)
|
||||
setNotice(`已重置 ${resetFor} 的密码:其下次登录须改密(现有会话已吊销)`)
|
||||
setResetFor(null); setResetPw('')
|
||||
await refresh()
|
||||
} catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<Shell title={`用户管理${me?.isAdmin ? '' : '(只读)'}`} onClose={onClose}>
|
||||
{me?.isAdmin && (
|
||||
<div className="ad-create">
|
||||
<div className="ad-row">
|
||||
<input className="lg-input" placeholder="用户名(登录名)" value={owner} onChange={e => setOwner(e.target.value)} />
|
||||
<input className="lg-input" placeholder="显示名(可空)" value={name} onChange={e => setName(e.target.value)} />
|
||||
</div>
|
||||
<div className="ad-row">
|
||||
<input className="lg-input lg-mono" placeholder="初始密码(≥8 位)" value={pw} onChange={e => setPw(e.target.value)} />
|
||||
<button type="button" className="ad-mini" title="生成随机密码" onClick={() => setPw(randomPassword())}>随机</button>
|
||||
<label className="ad-check"><input type="checkbox" checked={asAdmin} onChange={e => setAsAdmin(e.target.checked)} />管理员</label>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !owner.trim() || !pw} onClick={create}>创建账号</button>
|
||||
</div>
|
||||
<p className="ad-hint">创建后该用户首次登录必须修改此初始密码。</p>
|
||||
</div>
|
||||
)}
|
||||
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||
{resetFor ? (
|
||||
<div className="ad-reset">
|
||||
<span>重置 <b>{resetFor}</b> 的密码:</span>
|
||||
<input className="lg-input lg-mono" placeholder="新密码(≥8 位)" value={resetPw} onChange={e => setResetPw(e.target.value)} />
|
||||
<button type="button" className="ad-mini" title="生成随机密码" onClick={() => setResetPw(randomPassword())}>随机</button>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !resetPw} onClick={reset}>确认重置</button>
|
||||
<button type="button" className="ad-mini" onClick={() => { setResetFor(null); setResetPw('') }}>取消</button>
|
||||
</div>
|
||||
) : (
|
||||
<table className="ad-table">
|
||||
<thead><tr><th>用户名</th><th>显示名</th><th>角色</th><th>状态</th><th>密码设置于</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{(users ?? []).map(u => (
|
||||
<tr key={u.owner}>
|
||||
<td className="ad-mono">{u.owner}</td>
|
||||
<td>{u.name || '—'}</td>
|
||||
<td>{u.isAdmin ? <span className="ad-chip ad-chip-admin">ADMIN</span> : <span className="ad-chip">USER</span>}</td>
|
||||
<td>{u.mustChangePassword ? <span className="ad-chip ad-chip-warn">待改密</span> : u.hasPassword ? '正常' : '未设密码'}</td>
|
||||
<td className="ad-dim">{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}</td>
|
||||
<td>{me?.isAdmin && (
|
||||
<button className="ad-mini" onClick={() => { setResetFor(u.owner); setResetPw('') }}>重置密码</button>
|
||||
)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{users !== null && users.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无账号</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</Shell>
|
||||
)
|
||||
}
|
||||
|
||||
/** 主动改密(账户菜单入口):当前密码 + 新密码 + 确认。 */
|
||||
export function ChangePasswordModal({ onClose }: { onClose: () => void }) {
|
||||
const me = useStore(s => s.me)
|
||||
const [current, setCurrent] = useState('')
|
||||
const [next, setNext] = useState('')
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
const [ok, setOk] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const submit = async () => {
|
||||
setErr('')
|
||||
if (next !== confirm) { setErr('两次输入的新密码不一致'); return }
|
||||
if (next.length < 8) { setErr('新密码至少 8 位'); return }
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.changePassword(current, next)
|
||||
setOk(true)
|
||||
store.toast('密码已更新')
|
||||
setTimeout(onClose, 900)
|
||||
} catch (e) {
|
||||
setErr(String(e).includes('401') ? '当前密码不正确' : String(e))
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<Shell title="修改密码" onClose={onClose}>
|
||||
{ok ? <div className="ad-notice">密码已更新,其他设备的会话已注销。</div> : (
|
||||
<div className="ad-changepw">
|
||||
<label className="lg-label">当前密码({me?.owner ?? ''})</label>
|
||||
<input className="lg-input" type="password" value={current} onChange={e => setCurrent(e.target.value)} autoFocus />
|
||||
<label className="lg-label">新密码(≥8 位)</label>
|
||||
<input className="lg-input" type="password" value={next} onChange={e => setNext(e.target.value)} />
|
||||
<label className="lg-label">确认新密码</label>
|
||||
<input className="lg-input" type="password" value={confirm} onChange={e => setConfirm(e.target.value)} />
|
||||
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||
<button className="lg-submit" disabled={busy || !current || !next || !confirm} onClick={submit}>{busy ? '提交中…' : '更新密码'}</button>
|
||||
</div>
|
||||
)}
|
||||
</Shell>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
// 中栏:消息流(Part 渲染器注册表)+ 流式渲染 + 过程折叠 + HITL 卡片 + 输入区(4.4)。
|
||||
import { Icon } from '../icons'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { marked } from 'marked'
|
||||
import { api } from '../api'
|
||||
@@ -7,7 +8,8 @@ import type { Message, Part, HITLCard } from '../contracts'
|
||||
import { UIRenderer, InlineConfirmCard, hasOpenConfirm } from '@ui'
|
||||
import { docBridge } from './Workbench'
|
||||
|
||||
marked.setOptions({ breaks: true })
|
||||
// marked 走标准 Markdown 语义(不开 breaks):模型输出按块级排版渲染,
|
||||
// 行内换行交给段落折叠,避免与 CSS 块间距叠加出松散排版。
|
||||
|
||||
// Part 渲染器注册表:按类型查表;未知类型静默忽略(前向兼容)。
|
||||
function PartView({ part, live }: { part: Part; live?: boolean | null }) {
|
||||
@@ -16,7 +18,13 @@ function PartView({ part, live }: { part: Part; live?: boolean | null }) {
|
||||
return <div className={live ? 'part-text cursor-blink' : 'part-text'}
|
||||
dangerouslySetInnerHTML={{ __html: marked.parse(part.text ?? '') }} />
|
||||
case 'thinking':
|
||||
return <div className="part-thinking">{part.text}</div>
|
||||
// 流式期间展开可实时看到思考;run 终态后收进过程折叠区,默认收起。
|
||||
return (
|
||||
<details className="part-thinking" open={live ? true : undefined}>
|
||||
<summary>思考过程</summary>
|
||||
<div className="part-thinking-body">{part.text}</div>
|
||||
</details>
|
||||
)
|
||||
case 'tool_call':
|
||||
return <span className="tool-chip">🛠 {part.toolCall?.name}</span>
|
||||
case 'tool_result': {
|
||||
@@ -31,7 +39,7 @@ function PartView({ part, live }: { part: Part; live?: boolean | null }) {
|
||||
if (art) store.viewArtifact(art)
|
||||
}}>📄 {part.artifact?.name}</span>
|
||||
case 'hitl':
|
||||
return <div className="tool-chip">❓ {part.hitl?.question} → {part.hitl?.answer || '…'}</div>
|
||||
return <div className="tool-chip"><Icon name="message" size={12} /> {part.hitl?.question} → {part.hitl?.answer || '…'}</div>
|
||||
case 'plan':
|
||||
return null // 计划在右栏 Plan 标签呈现
|
||||
case 'card': {
|
||||
@@ -64,7 +72,7 @@ function ProcessCollapse({ parts }: { parts: Part[] }) {
|
||||
return (
|
||||
<details className="process-collapse">
|
||||
<summary>
|
||||
<span>⚙ 处程输出</span>
|
||||
<span>过程输出</span>
|
||||
<span>{hasThinking ? '含思考' : ''}{toolCount > 0 ? ` · ${toolCount} 次工具调用` : ''}(点击展开)</span>
|
||||
</summary>
|
||||
<div className="body">
|
||||
@@ -93,7 +101,7 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null
|
||||
{live && <span className="live-bar"><span className="live-dot" />{live.phase}</span>}
|
||||
</div>
|
||||
{split.process.length > 0 && (showProcessInline
|
||||
? split.process.map((p, i) => <div className="part" key={i}><PartView part={p} /></div>)
|
||||
? split.process.map((p, i) => <div className="part" key={i}><PartView part={p} live={!!live} /></div>)
|
||||
: <ProcessCollapse parts={split.process} />)}
|
||||
{split.content.map((p, i) => <div className="part" key={i}><PartView part={p} live={!!(live && i === split.content.length - 1 && p.type === 'text')} /></div>)}
|
||||
{live && <LiveAgentNotes live={live} />}
|
||||
@@ -102,6 +110,22 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null
|
||||
)
|
||||
}
|
||||
|
||||
// live 投影:run 进行中没有已落库的助手消息,把 LiveState 合成为同构的临时消息
|
||||
// 复用 AssistantMessage 渲染(思考/工具/文本实时上屏);run 终态对账后由真消息替代。
|
||||
function liveToMessage(live: LiveState): Message {
|
||||
const parts: Part[] = []
|
||||
if (live.thinking) parts.push({ type: 'thinking', text: live.thinking, process: true })
|
||||
for (const tc of live.toolCalls) {
|
||||
parts.push({ type: 'tool_call', process: true, toolCall: { id: '', name: tc.name } })
|
||||
if (tc.status !== undefined) {
|
||||
parts.push({ type: 'tool_result', process: true, toolCall: { id: '', name: tc.name },
|
||||
toolResult: { callId: '', status: tc.status === 'error' ? 'error' : 'ok', summary: tc.summary } })
|
||||
}
|
||||
}
|
||||
if (live.text) parts.push({ type: 'text', text: live.text })
|
||||
return { id: `live:${live.runId}`, conversationId: '', role: 'assistant', runId: live.runId, parts, createdAt: '' }
|
||||
}
|
||||
|
||||
// 上下文组装观测(4.10/4.17):模型实际看到什么、裁了什么——可解释性入口。
|
||||
function ContextObsView({ obs }: { obs: ContextObs }) {
|
||||
return (
|
||||
@@ -144,7 +168,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,7 +214,7 @@ 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 activeHitl = useStore(s => s.activeHitl)
|
||||
const live = useStore(s => s.live)
|
||||
@@ -212,7 +236,7 @@ export function Conversation() {
|
||||
|
||||
useEffect(() => {
|
||||
bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight })
|
||||
}, [messages.length, live?.text, live?.thinking, activeHitl.length])
|
||||
}, [messages.length, live?.text, live?.thinking, live?.toolCalls.length, live?.phase, activeHitl.length])
|
||||
|
||||
const send = async () => {
|
||||
if (!text.trim() && attachmentIds.length === 0) return
|
||||
@@ -247,7 +271,7 @@ export function Conversation() {
|
||||
{profiles.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
<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}
|
||||
@@ -279,9 +303,12 @@ 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>
|
||||
<span className="es-mark"><Icon name="sparkles" size={22} /></span>
|
||||
<div>发送消息开始对话</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-faint)' }}>mock 模式演示指令:#hitl · #agents · #doc · #error · 命令:/help</div>
|
||||
</div>}
|
||||
</div>
|
||||
@@ -289,15 +316,15 @@ export function Conversation() {
|
||||
<div className="input-bar">
|
||||
{attachmentNames.length > 0 && (
|
||||
<div className="attachments">
|
||||
{attachmentNames.map((n, i) => <span className="attach-chip" key={i}>📎 {n}
|
||||
<span style={{ cursor: 'pointer' }} onClick={() => { setAttachmentIds(a => a.filter((_, j) => j !== i)); setAttachmentNames(a => a.filter((_, j) => j !== i)) }}>✕</span>
|
||||
{attachmentNames.map((n, i) => <span className="attach-chip" key={i}><Icon name='paperclip' size={12} /> {n}
|
||||
<span style={{ cursor: 'pointer', display: 'inline-flex' }} onClick={() => { setAttachmentIds(a => a.filter((_, j) => j !== i)); setAttachmentNames(a => a.filter((_, j) => j !== i)) }}><Icon name="x" size={11} /></span>
|
||||
</span>)}
|
||||
</div>
|
||||
)}
|
||||
{text.startsWith('/') && <CommandMenu text={text} onPick={c => setText(c)} />}
|
||||
<div className="input-row">
|
||||
<label className="icon-btn" title="上传附件(文档解析后注入上下文)">
|
||||
📎<input type="file" multiple style={{ display: 'none' }} onChange={async e => {
|
||||
<Icon name="paperclip" size={16} /><input type="file" multiple style={{ display: 'none' }} onChange={async e => {
|
||||
for (const f of Array.from(e.target.files ?? [])) {
|
||||
const id = await store.uploadAttachment(f)
|
||||
if (id) { setAttachmentIds(a => [...a, id]); setAttachmentNames(a => [...a, f.name]) }
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
// 登录与改密(管理员发放制,R2):
|
||||
// LoginPage —— 用户名+密码(主)/ API 密钥(兼容程序化客户端)双入口;
|
||||
// ChangePasswordGate —— 初始/重置密码后的首登强制改密(不完成不放行)。
|
||||
import { useState } from 'react'
|
||||
import { auth } from '../auth'
|
||||
import { api } from '../api'
|
||||
import { Icon, BrandMark } from '../icons'
|
||||
|
||||
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,74 +0,0 @@
|
||||
// 左栏:项目折叠组 → 会话列表;底部账户/设置入口(4.4)。
|
||||
import { useEffect, useState } from 'react'
|
||||
import { store, useStore } from '../store'
|
||||
import { workspaceEntries } from './Workspaces'
|
||||
|
||||
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>
|
||||
<div className="ws-quick">
|
||||
<div className="nav-label">工作区</div>
|
||||
<div className="ws-quick-list">
|
||||
{workspaceEntries().map(e => (
|
||||
<button key={e.key} className="ws-quick-item" title={`${e.title}(${e.shortcut})`}
|
||||
onClick={() => store.openWorkspace(e.key)}>
|
||||
<span>{e.icon}</span><span className="name">{e.title}</span><span className="sc">{e.shortcut}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</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,251 @@
|
||||
// 左栏(方案 A「任务中心」,提案 2026-09-30 R1):单一主创建入口(P6)+
|
||||
// 组织视图(P8:按项目/时间线)+ 置顶/归档 + 账户区(P7)。
|
||||
// 工作区快捷图标条保留(D25:⌘ 与左栏共享同一清单)。
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { store, useStore } from '../store'
|
||||
import { auth } from '../auth'
|
||||
import { api } from '../api'
|
||||
import { Icon, BrandMark, workspaceIcon } from '../icons'
|
||||
import type { Conversation } from '../contracts'
|
||||
import { workspaceEntries } from './Workspaces'
|
||||
import { groupByProject, groupByTimeline, relativeTime, filterTasks, type TaskOrg } from '../tasklist'
|
||||
|
||||
const GROUPS_KEY = 'as.taskGroups'
|
||||
|
||||
function loadCollapsed(): Record<string, boolean> {
|
||||
try { return JSON.parse(localStorage.getItem(GROUPS_KEY) ?? '{}') } catch { return {} }
|
||||
}
|
||||
|
||||
/** 小型弹出菜单:锚点下方渲染,点击外部关闭。 */
|
||||
function Menu({ anchor, onClose, children }: { anchor: HTMLElement | null; onClose: () => void; children: React.ReactNode }) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
if (!anchor || !ref.current) return
|
||||
const r = anchor.getBoundingClientRect()
|
||||
const el = ref.current
|
||||
let x = r.left, y = r.bottom + 6
|
||||
if (x + el.offsetWidth > window.innerWidth - 8) x = window.innerWidth - el.offsetWidth - 8
|
||||
if (y + el.offsetHeight > window.innerHeight - 8) y = r.top - el.offsetHeight - 6
|
||||
el.style.left = `${Math.max(8, x)}px`
|
||||
el.style.top = `${y}px`
|
||||
const h = (e: MouseEvent) => { if (!el.contains(e.target as Node)) onClose() }
|
||||
window.addEventListener('mousedown', h)
|
||||
return () => window.removeEventListener('mousedown', h)
|
||||
}, [anchor])
|
||||
if (!anchor) return null
|
||||
return <div ref={ref} className="tsb-menu" onClick={e => e.stopPropagation()}>{children}</div>
|
||||
}
|
||||
|
||||
function MenuItem({ onClick, danger, icon, children }: { onClick: () => void; danger?: boolean; icon?: Parameters<typeof Icon>[0]['name']; children: React.ReactNode }) {
|
||||
return <button className={`tsb-mi ${danger ? 'danger' : ''}`} onClick={onClick}>
|
||||
{icon && <Icon name={icon} size={15} />}{children}
|
||||
</button>
|
||||
}
|
||||
|
||||
export function TaskSidebar() {
|
||||
const projects = useStore(s => s.projects)
|
||||
const tasks = useStore(s => s.tasks)
|
||||
const me = useStore(s => s.me)
|
||||
const currentConvId = useStore(s => s.currentConvId)
|
||||
const currentProjectId = useStore(s => s.currentProjectId)
|
||||
const org = useStore(s => s.ui.taskOrg)
|
||||
const showArchived = useStore(s => s.ui.showArchived)
|
||||
const theme = useStore(s => s.ui.theme)
|
||||
const pendingBadge = useStore(s => s.cards.filter(c => c.status === 'action_required').length)
|
||||
|
||||
const [q, setQ] = useState('')
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(loadCollapsed)
|
||||
const [menu, setMenu] = useState<{ kind: 'new' | 'filter' | 'account' | `row:${string}`; anchor: HTMLElement } | null>(null)
|
||||
const [creatingProject, setCreatingProject] = useState(false)
|
||||
const [newProjectName, setNewProjectName] = useState('')
|
||||
|
||||
// ⌘N/Ctrl+N 新建任务(P6:创建入口唯一 + 快捷键)。
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => {
|
||||
const k = e.key.toLowerCase()
|
||||
if ((e.metaKey || e.ctrlKey) && k === 'n') { e.preventDefault(); store.createTask() }
|
||||
if ((e.metaKey || e.ctrlKey) && k === 'b') { e.preventDefault(); store.toggleLeft() }
|
||||
}
|
||||
window.addEventListener('keydown', h, true)
|
||||
return () => window.removeEventListener('keydown', h, true)
|
||||
}, [])
|
||||
|
||||
const tglGroup = (key: string) => {
|
||||
setCollapsed(c => {
|
||||
const next = { ...c, [key]: !c[key] }
|
||||
try { localStorage.setItem(GROUPS_KEY, JSON.stringify(next)) } catch { /* ignore */ }
|
||||
return next
|
||||
})
|
||||
}
|
||||
const setOrg = (o: TaskOrg) => store.setUI({ taskOrg: o })
|
||||
|
||||
const visible = filterTasks(tasks, q)
|
||||
const pinned = visible.filter(t => t.pinned)
|
||||
const rest = visible.filter(t => !t.pinned)
|
||||
const groups = org === 'project' ? groupByProject(rest, projects) : groupByTimeline(rest)
|
||||
const currentProject = projects.find(p => p.id === currentProjectId)
|
||||
const rowTask = menu?.kind.startsWith('row:') ? tasks.find(t => t.id === menu.kind.slice(4)) : null
|
||||
const rowMenuAnchor = menu && menu.kind.startsWith('row:') ? menu.anchor : null
|
||||
|
||||
const rowActions = (t: Conversation) => (
|
||||
<span className="tsb-row-acts">
|
||||
<button className="tsb-row-act" title="置顶 / 取消置顶" onClick={e => { e.stopPropagation(); store.pinTask(t.id, !t.pinned) }}><Icon name="pin" size={13} /></button>
|
||||
<button className="tsb-row-act" title={t.archived ? '恢复' : '归档'} onClick={e => { e.stopPropagation(); store.archiveTask(t.id, !t.archived) }}>
|
||||
<Icon name={t.archived ? 'undo' : 'archive'} size={13} />
|
||||
</button>
|
||||
</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>
|
||||
{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(); store.createTask(g.projectId) }}><Icon name="plus" size={13} /></button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="tsb-seclabel">{g.label}</div>
|
||||
)}
|
||||
{!collapsed[g.key] && g.tasks.map(t => taskRow(t, org === 'timeline' ? projects.find(p => p.id === t.projectId)?.name : undefined))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 工作区快捷图标条(D25:与 ⌘1–9 同一清单) */}
|
||||
<div className="tsb-wsstrip">
|
||||
{workspaceEntries().map(e => (
|
||||
<button key={e.key} title={`${e.title}(${e.shortcut})`} onClick={() => store.openWorkspace(e.key)}><Icon name={workspaceIcon(e.icon)} size={15} /></button>
|
||||
))}
|
||||
<span style={{ flex: 1 }} />
|
||||
<button title={theme === 'dark' ? '切换浅色' : '切换深色'} onClick={() => store.setUI({ theme: theme === 'dark' ? 'light' : 'dark' })}><Icon name={theme === 'dark' ? 'sun' : 'moon'} size={15} /></button>
|
||||
</div>
|
||||
|
||||
{/* 账户区(P7):身份常驻;阶段 1 = API key 即身份(user 即租户 owner) */}
|
||||
<div className="tsb-footer">
|
||||
<button className="tsb-user" onClick={e => { const el = e.currentTarget; setMenu(m => m?.kind === 'account' ? null : { kind: 'account', anchor: el }) }}>
|
||||
<span className="tsb-avatar">{(me?.name || me?.owner || '?').slice(0, 1).toUpperCase()}</span>
|
||||
<span className="tsb-user-info">
|
||||
<span className="tsb-user-name">{me?.name || me?.owner || '未连接'}</span>
|
||||
<span className="tsb-user-sub">{me ? (me.auth ? (me.isAdmin ? '管理员 · 已认证' : '已认证') : '本地模式 · 默认租户') : '…'}</span>
|
||||
</span>
|
||||
<span className="tsb-user-chev"><Icon name="chevron-down" size={13} /></span>
|
||||
</button>
|
||||
<button className="tsb-iconbtn" title="设置" onClick={() => store.openSettings(true)}><Icon name="settings" size={15} /></button>
|
||||
{pendingBadge > 0 && <button className="tsb-iconbtn active" title={`待办 ${pendingBadge}`} onClick={() => store.openInbox()}><Icon name="inbox" size={15} />{pendingBadge}</button>}
|
||||
</div>
|
||||
|
||||
{/* 弹出菜单们 */}
|
||||
<Menu anchor={menu?.kind === 'new' ? menu.anchor : null} onClose={() => setMenu(null)}>
|
||||
<MenuItem icon="plus" onClick={() => { store.createTask(); setMenu(null) }}>新建任务 · 当前项目<kbd>⌘N</kbd></MenuItem>
|
||||
<div className="tsb-menu-div" />
|
||||
<MenuItem icon="folder" onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
|
||||
{projects.filter(p => p.id !== currentProjectId).slice(0, 6).map(p => (
|
||||
<MenuItem key={p.id} onClick={() => { store.createTask(p.id); setMenu(null) }}>在「{p.name}」新建任务</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
|
||||
<Menu anchor={rowMenuAnchor} onClose={() => setMenu(null)}>
|
||||
{rowTask && <>
|
||||
<MenuItem icon="pin" onClick={() => { store.pinTask(rowTask.id, !rowTask.pinned); setMenu(null) }}>{rowTask.pinned ? '取消置顶' : '置顶'}</MenuItem>
|
||||
<MenuItem icon={rowTask.archived ? 'undo' : 'archive'} onClick={() => { store.archiveTask(rowTask.id, !rowTask.archived); setMenu(null) }}>{rowTask.archived ? '恢复(移出归档)' : '归档'}</MenuItem>
|
||||
<div className="tsb-menu-div" />
|
||||
<MenuItem icon="clock" onClick={() => { store.setUI({ taskOrg: 'timeline' }); setMenu(null) }}>按时间线查看</MenuItem>
|
||||
</>}
|
||||
</Menu>
|
||||
|
||||
<Menu anchor={menu?.kind === 'account' ? menu.anchor : null} onClose={() => setMenu(null)}>
|
||||
<div className="tsb-acc-head">
|
||||
<span className="tsb-avatar">{(me?.name || me?.owner || '?').slice(0, 1).toUpperCase()}</span>
|
||||
<span>
|
||||
<div className="tsb-acc-name">{me?.name || '未连接'}</div>
|
||||
<div className="tsb-acc-mail">{me?.owner ? `租户:${me.owner}` : '—'}</div>
|
||||
</span>
|
||||
</div>
|
||||
<div className="tsb-menu-div" />
|
||||
<MenuItem icon="settings" onClick={() => { store.openSettings(true); setMenu(null) }}>偏好设置</MenuItem>
|
||||
<MenuItem icon="inbox" onClick={() => { store.openInbox(); setMenu(null) }}>收件箱{pendingBadge > 0 ? `(待办 ${pendingBadge})` : ''}</MenuItem>
|
||||
<MenuItem icon="key" onClick={() => { store.setUI({ changePwOpen: true }); setMenu(null) }}>修改密码…</MenuItem>
|
||||
{me?.isAdmin && <MenuItem icon="users" onClick={() => { store.setUI({ adminUsersOpen: true }); setMenu(null) }}>用户管理…</MenuItem>}
|
||||
{me?.auth && <>
|
||||
<div className="tsb-menu-div" />
|
||||
<MenuItem danger icon="logout" onClick={() => { api.logout().catch(() => undefined); auth.logout() }}>退出登录</MenuItem>
|
||||
</>}
|
||||
</Menu>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { store, useStore } from '../store'
|
||||
import type { Run } from '../contracts'
|
||||
import { InboxList, pendingCount, type ActivityCard, type DocBridge } from '@ui'
|
||||
import { Icon } from '../icons'
|
||||
|
||||
// 工件解析/打开注入:渲染器纯展示,doc-ref 词汇经此桥接本仓状态(D29)。
|
||||
export const docBridge: DocBridge = {
|
||||
@@ -23,7 +24,8 @@ export function Workbench() {
|
||||
<>
|
||||
<div className="tabs">
|
||||
<span className="tab active">收件箱{pending > 0 ? ` ${pending}` : ''}</span>
|
||||
<button className="tab" title="收起收件箱" onClick={store.toggleRight}>»</button>
|
||||
<button className="tab tab-action" title="收纳已完成卡(done→关闭;已确认卡留痕不动)" onClick={() => store.tidyCards()}><Icon name="check" size={13} /> 收纳</button>
|
||||
<button className="tab" title="收起收件箱" onClick={store.toggleRight}><Icon name="chevron-right" size={14} /></button>
|
||||
</div>
|
||||
<div className="tab-body">
|
||||
<CurrentTaskCard plan={plan} todos={todos} />
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface Project {
|
||||
|
||||
export interface Conversation {
|
||||
id: ID; projectId: ID; owner?: ID; title: string; profileId?: ID;
|
||||
pinned?: boolean; archived?: boolean;
|
||||
createdAt: string; updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -192,11 +193,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: {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// 线性图标集(R4 视觉收敛;Lucide 风格:16px 画布、1.6 描边、圆角端点)。
|
||||
// 统一替换此前散落各处的 emoji/字符图标(emoji 跨平台渲染不一致且观感廉价)。
|
||||
import type { ButtonHTMLAttributes, ReactNode, SVGProps } from 'react'
|
||||
|
||||
export type IconName =
|
||||
| 'plus' | 'search' | 'panel-left' | 'inbox' | 'settings' | 'sun' | 'moon'
|
||||
| 'pin' | 'archive' | 'undo' | 'chevron-right' | 'chevron-down'
|
||||
| 'folder' | 'message' | 'package' | 'bot' | 'puzzle' | 'grid'
|
||||
| 'arrow-up' | 'paperclip' | 'more' | 'logout' | 'key' | 'user' | 'users'
|
||||
| 'shield' | 'x' | 'check' | 'alert' | 'send' | 'sparkles' | 'clock' | 'list'
|
||||
|
||||
const PATHS: Record<IconName, ReactNode> = {
|
||||
plus: <path d="M8 3.5v9M3.5 8h9" />,
|
||||
search: <><circle cx="7" cy="7" r="4.2" /><path d="m13.2 13.2-2.9-2.9" /></>,
|
||||
'panel-left': <><rect x="1.8" y="2.8" width="12.4" height="10.4" rx="2" /><path d="M6 2.8v10.4" /></>,
|
||||
inbox: <><path d="M1.8 9.4 3.6 3.6a1.6 1.6 0 0 1 1.5-1h5.8a1.6 1.6 0 0 1 1.5 1l1.8 5.8" /><path d="M1.8 9.4h3.3l.9 1.8h4l.9-1.8h3.3v2.9a1.5 1.5 0 0 1-1.5 1.5H3.3a1.5 1.5 0 0 1-1.5-1.5Z" /></>,
|
||||
settings: <><circle cx="8" cy="8" r="2.1" /><path d="M12.9 6.9a1 1 0 0 0 .2 1.1l.1.1a1.2 1.2 0 1 1-1.7 1.7l-.1-.1a1 1 0 0 0-1.7.7v.1a1.2 1.2 0 1 1-2.4 0v-.1a1 1 0 0 0-1.7-.7l-.1.1a1.2 1.2 0 1 1-1.7-1.7l.1-.1a1 1 0 0 0 .2-1.1 1 1 0 0 0-.6-.6H3.5a1.2 1.2 0 1 1 0-2.4h.1a1 1 0 0 0 .7-1.7l-.1-.1a1.2 1.2 0 1 1 1.7-1.7l.1.1a1 1 0 0 0 1.1.2h.1a1 1 0 0 0 .6-.6V1.9a1.2 1.2 0 1 1 2.4 0v.1a1 1 0 0 0 1.7.7l.1-.1a1.2 1.2 0 1 1 1.7 1.7l-.1.1a1 1 0 0 0-.2 1.1v.1a1 1 0 0 0 .6.6h.1a1.2 1.2 0 1 1 0 2.4h-.1a1 1 0 0 0-.6.6Z" /></>,
|
||||
sun: <><circle cx="8" cy="8" r="3" /><path d="M8 1.3v1.4M8 13.3v1.4M1.3 8h1.4M13.3 8h1.4M3.3 3.3l1 1M11.7 11.7l1 1M12.7 3.3l-1 1M4.3 11.7l-1 1" /></>,
|
||||
moon: <path d="M13.4 9.6A6 6 0 0 1 6.4 2.6a6 6 0 1 0 7 7Z" />,
|
||||
pin: <path d="M9.3 1.9 14.1 6.7l-1.4 1.4-.7-.2-2.9 2.9.3 1.6-1.2 1.2L6 10.9l-3.3 3.4-1-1L5 9.9 2.4 7.4l1.2-1.2 1.6.3L8.1 3.6l-.2-.7Z" />,
|
||||
archive: <><rect x="1.8" y="2.5" width="12.4" height="3" rx="1" /><path d="M3 5.5v7a1.5 1.5 0 0 0 1.5 1.5h7a1.5 1.5 0 0 0 1.5-1.5v-7M6.5 8.8h3" /></>,
|
||||
undo: <><path d="M2.7 6.8h6.5a3.6 3.6 0 1 1 0 7.2H6" /><path d="m5.4 4-2.7 2.8 2.7 2.8" /></>,
|
||||
'chevron-right': <path d="m6 3.5 4.5 4.5L6 12.5" />,
|
||||
'chevron-down': <path d="m3.5 6 4.5 4.5L12.5 6" />,
|
||||
folder: <path d="M1.8 4.8a1.5 1.5 0 0 1 1.5-1.5h3l1.5 1.7h4.9a1.5 1.5 0 0 1 1.5 1.5v5.7a1.5 1.5 0 0 1-1.5 1.5H3.3a1.5 1.5 0 0 1-1.5-1.5Z" />,
|
||||
message: <path d="M14 8.7c0 2.6-2.7 4.7-6 4.7a7 7 0 0 1-2-.3L2.3 14.3l.9-2.8A4.4 4.4 0 0 1 2 8.7c0-2.6 2.7-4.7 6-4.7s6 2.1 6 4.7Z" />,
|
||||
package: <><path d="m8 1.7 5.8 3.1v6.4L8 14.3l-5.8-3.1V4.8Z" /><path d="m2.2 4.8 5.8 3.1 5.8-3.1M8 7.9v6.4" /></>,
|
||||
bot: <><rect x="3.2" y="5.4" width="9.6" height="7" rx="2" /><path d="M8 5.4V3.2M1.7 8.8v1.6M14.3 8.8v1.6" /><path d="M6 8.5v.1M10 8.5v.1" /></>,
|
||||
puzzle: <path d="M9.8 2a1.5 1.5 0 0 1 1.5 1.5v.6h.7A1.5 1.5 0 0 1 13.5 5.6v1.9h-.6a1.5 1.5 0 0 0 0 3h.6v1.9a1.5 1.5 0 0 1-1.5 1.5h-1.9v-.6a1.5 1.5 0 0 0-3 0v.6H5.2a1.5 1.5 0 0 1-1.5-1.5v-1.9H3a1.5 1.5 0 0 1 0-3h.7V5.6A1.5 1.5 0 0 1 5.2 4.1h3.1v-.6A1.5 1.5 0 0 1 9.8 2Z" />,
|
||||
grid: <><rect x="2" y="2" width="5" height="5" rx="1" /><rect x="9" y="2" width="5" height="5" rx="1" /><rect x="2" y="9" width="5" height="5" rx="1" /><rect x="9" y="9" width="5" height="5" rx="1" /></>,
|
||||
'arrow-up': <path d="M8 13V3M3.5 7.5 8 3l4.5 4.5" />,
|
||||
paperclip: <path d="m13.1 7.7-5 5a3.7 3.7 0 0 1-5.2-5.2l5.4-5.4a2.4 2.4 0 0 1 3.4 3.4L6.3 10.9a1.2 1.2 0 0 1-1.7-1.7l4.9-4.9" />,
|
||||
more: <><circle cx="3.2" cy="8" r="1.1" fill="currentColor" stroke="none" /><circle cx="8" cy="8" r="1.1" fill="currentColor" stroke="none" /><circle cx="12.8" cy="8" r="1.1" fill="currentColor" stroke="none" /></>,
|
||||
logout: <><path d="M6.2 13.5H3.5a1.5 1.5 0 0 1-1.5-1.5V4a1.5 1.5 0 0 1 1.5-1.5h2.7" /><path d="m9.8 10.7 2.7-2.7-2.7-2.7M12.5 8H6" /></>,
|
||||
key: <><circle cx="5.6" cy="10.4" r="3" /><path d="m7.8 8.2 5-5M11 5l1.6 1.6M9.4 6.6 11 8.2" /></>,
|
||||
user: <><circle cx="8" cy="5.4" r="2.9" /><path d="M2.7 13.8a5.5 5.5 0 0 1 10.6 0" /></>,
|
||||
users: <><circle cx="6.5" cy="5.4" r="2.7" /><path d="M1.8 13.6a4.8 4.8 0 0 1 9.4 0M11 3a2.7 2.7 0 0 1 0 5.2M12.4 10.1a4.8 4.8 0 0 1 1.9 3.5" /></>,
|
||||
shield: <path d="M8 1.8 13 3.6v4.2c0 3.2-2.2 5.4-5 6.4-2.8-1-5-3.2-5-6.4V3.6Z" />,
|
||||
x: <path d="m4 4 8 8M12 4l-8 8" />,
|
||||
check: <path d="m3.2 8.4 3.2 3.2 6.4-6.4" />,
|
||||
alert: <><path d="M8 2 14.3 13.3H1.7Z" /><path d="M8 6.4v3M8 11.4v.1" /></>,
|
||||
send: <path d="M14.3 1.8 1.8 7.1l4.9 1.8 1.8 4.9Z M14.3 1.8 6.7 8.9" />,
|
||||
sparkles: <><path d="m11.3 1.8.9 2.3 2.3.9-2.3.9-.9 2.3-.9-2.3-2.3-.9 2.3-.9Z" /><path d="M5 6.5 5.7 8.3 7.5 9 5.7 9.7 5 11.5 4.3 9.7 2.5 9 4.3 8.3ZM8.4 10.8l.5 1.3 1.3.5-1.3.5-.5 1.3-.5-1.3L6.6 12.6l1.3-.5Z" /></>,
|
||||
clock: <><circle cx="8" cy="8" r="6" /><path d="M8 4.7V8l2.3 1.4" /></>,
|
||||
list: <path d="M5.5 4h8M5.5 8h8M5.5 12h8M2.6 4v.1M2.6 8v.1M2.6 12v.1" />,
|
||||
}
|
||||
|
||||
export function Icon({ name, size = 16, ...rest }: { name: IconName; size?: number } & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width={size} height={size} viewBox="0 0 16 16" fill="none"
|
||||
stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"
|
||||
aria-hidden="true" {...rest}
|
||||
>
|
||||
{PATHS[name]}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
type IconButtonProps = {
|
||||
name: IconName; size?: number; label: string
|
||||
} & Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'title'>
|
||||
|
||||
/** 通用图标按钮:尺寸/圆角/悬停统一走 .icon-btn 样式。 */
|
||||
export function IconButton({ name, size = 16, label, className = '', ...rest }: IconButtonProps) {
|
||||
return (
|
||||
<button className={`icon-btn ${className}`} type="button" aria-label={label} title={label} {...rest}>
|
||||
<Icon name={name} size={size} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** 品牌标(侧栏/登录页共用):星芒 + 渐变底。 */
|
||||
export function BrandMark({ size = 22 }: { size?: number }) {
|
||||
return (
|
||||
<span className="brand-mark" style={{ width: size, height: size, borderRadius: Math.round(size * 0.3) }}>
|
||||
<Icon name="sparkles" size={Math.round(size * 0.62)} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** 工作区条目历史 emoji 字符串 → 线性图标。 */
|
||||
export function workspaceIcon(char: string | undefined): IconName {
|
||||
switch (char) {
|
||||
case '📦': return 'package'
|
||||
case '🤖': return 'bot'
|
||||
case '🧩': return 'puzzle'
|
||||
case '🗂': return 'grid'
|
||||
default: return 'grid'
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+79
-13
@@ -35,13 +35,21 @@ export interface UIState {
|
||||
trackerRunId: string | null
|
||||
viewingArtifact: Artifact | null
|
||||
activeWorkspace: string | null
|
||||
taskOrg: 'project' | 'timeline'
|
||||
showArchived: boolean
|
||||
lastProjectId: string | null
|
||||
adminUsersOpen: boolean
|
||||
changePwOpen: boolean
|
||||
}
|
||||
|
||||
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[]
|
||||
@@ -73,6 +81,11 @@ const DEFAULT_UI: UIState = {
|
||||
settingsOpen: false,
|
||||
trackerRunId: null,
|
||||
viewingArtifact: null,
|
||||
taskOrg: 'project',
|
||||
showArchived: false,
|
||||
lastProjectId: null,
|
||||
adminUsersOpen: false,
|
||||
changePwOpen: false,
|
||||
}
|
||||
|
||||
function loadUI(): UIState {
|
||||
@@ -85,7 +98,7 @@ function loadUI(): UIState {
|
||||
|
||||
let state: AppState = {
|
||||
contextObs: {},
|
||||
projects: [], currentProjectId: null, conversations: [], currentConvId: null,
|
||||
projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null,
|
||||
messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
|
||||
plan: null, todos: null, artifacts: [], cards: [], profiles: [], pluginPanels: [],
|
||||
serverWorkspaces: [],
|
||||
@@ -126,12 +139,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) {
|
||||
@@ -142,16 +166,47 @@ export const store = {
|
||||
},
|
||||
|
||||
async openProject(projectId: string) {
|
||||
const conversations = await api.listConversations(projectId)
|
||||
set({ currentProjectId: projectId, conversations })
|
||||
set({ currentProjectId: projectId })
|
||||
store.setUI({ lastProjectId: projectId })
|
||||
await store.refreshArtifacts(projectId)
|
||||
},
|
||||
|
||||
/** 新建任务(=会话):默认当前项目上下文;显式 projectId 用于"新建于指定项目"。 */
|
||||
async createTask(projectId?: string) {
|
||||
const pid = projectId ?? state.currentProjectId
|
||||
if (!pid) { store.toast('请先创建项目'); return }
|
||||
const c = await api.createConversation(pid)
|
||||
await store.refreshTasks()
|
||||
await store.openTask(c.id)
|
||||
},
|
||||
|
||||
/** 兼容旧入口(⌘/命令总线) */
|
||||
async createConversation(title?: string) {
|
||||
if (!state.currentProjectId) return
|
||||
const c = await api.createConversation(state.currentProjectId, title)
|
||||
await store.openProject(state.currentProjectId)
|
||||
await store.openConversation(c.id)
|
||||
if (title) store.toast('已在当前项目创建任务')
|
||||
await store.createTask()
|
||||
},
|
||||
|
||||
/** 打开任务:跨项目点击时切换项目上下文(工件/上传归属正确)。 */
|
||||
async openTask(convId: string) {
|
||||
const t = state.tasks.find(x => x.id === convId)
|
||||
if (t && t.projectId !== state.currentProjectId) await store.openProject(t.projectId)
|
||||
await store.openConversation(convId)
|
||||
},
|
||||
|
||||
async pinTask(convId: string, pinned: boolean) {
|
||||
const tasks = state.tasks.map(t => t.id === convId ? { ...t, pinned } : t)
|
||||
set({ tasks })
|
||||
try { await api.setConversationFlags(convId, { pinned }) } catch (e) { store.toast(`置顶失败:${e}`); store.refreshTasks() }
|
||||
},
|
||||
|
||||
async archiveTask(convId: string, archived: boolean) {
|
||||
const tasks = state.tasks.map(t => t.id === convId ? { ...t, archived } : t)
|
||||
set({ tasks })
|
||||
try {
|
||||
await api.setConversationFlags(convId, { archived })
|
||||
if (state.currentConvId === convId && archived) store.toast('任务已归档')
|
||||
} catch (e) { store.toast(`归档失败:${e}`) }
|
||||
store.refreshTasks()
|
||||
},
|
||||
|
||||
async openConversation(convId: string) {
|
||||
@@ -178,6 +233,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) })
|
||||
}
|
||||
@@ -308,10 +364,12 @@ export const store = {
|
||||
return
|
||||
}
|
||||
if (out.run) {
|
||||
set({
|
||||
live: { runId: out.run.id, phase: 'thinking', thinking: '', text: '', toolCalls: [], agentNotes: {}, startedAt: Date.now() },
|
||||
messages: [...state.messages, out.message],
|
||||
})
|
||||
// 竞态保护:POST 返回前 SSE 事件可能已先行到达并累积(同 run),
|
||||
// 此时保留已累积的 live,只补挂用户消息;否则初始化空 live。
|
||||
if (!state.live || state.live.runId !== out.run.id) {
|
||||
set({ live: { runId: out.run.id, phase: 'thinking', thinking: '', text: '', toolCalls: [], agentNotes: {}, startedAt: Date.now() } })
|
||||
}
|
||||
set({ messages: [...state.messages, out.message] })
|
||||
store.connectIfNeeded()
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -418,6 +476,14 @@ export const store = {
|
||||
toggleLeft() { setUI({ leftCollapsed: !state.ui.leftCollapsed }) },
|
||||
toggleRight() { setUI({ rightCollapsed: !state.ui.rightCollapsed }) },
|
||||
openWorkspace(id: string) { setUI({ activeWorkspace: id }) },
|
||||
/** 收纳策略(T5):一键收纳已完成卡(done→dismissed;resolved 盖章留痕不动)。 */
|
||||
async tidyCards() {
|
||||
const done = state.cards.filter(c => c.status === 'done')
|
||||
for (const c of done) {
|
||||
try { const out = await api.cardDismiss(c.id); store.upsertCard(out.card) } catch { /* 单卡失败不阻断 */ }
|
||||
}
|
||||
if (done.length > 0) store.toast(`已收纳 ${done.length} 张完成卡`)
|
||||
},
|
||||
closeWorkspace() { setUI({ activeWorkspace: null }) },
|
||||
openInbox(cardId?: string) {
|
||||
setUI({ rightCollapsed: false, rightTab: 'inbox' })
|
||||
|
||||
+280
-116
@@ -1,70 +1,112 @@
|
||||
/* 设计令牌与 UI 组件样式已迁入 Agent-System-UI(@ui/tokens.css、@ui/ui.css,main.tsx 引入)。 */
|
||||
/* 设计令牌与 UI 组件样式已迁入 Agent-System-UI(@ui/tokens.css、@ui/ui.css,main.tsx 引入)。
|
||||
R4 视觉收敛(2026-09-30):三级表面阶梯 + 发丝边框 + 圆角标尺 + 克制阴影 + 统一线性图标。 */
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #root { height: 100%; margin: 0; }
|
||||
body { font-family: var(--font); background: var(--bg); color: var(--text); font-size: 14px; }
|
||||
button { font-family: inherit; }
|
||||
body {
|
||||
font-family: var(--font); background: var(--bg); color: var(--text);
|
||||
font-size: 13.5px; line-height: 1.55;
|
||||
-webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility;
|
||||
}
|
||||
button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
::selection { background: var(--accent-soft-2); }
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-radius: 2px; }
|
||||
|
||||
.app { display: flex; height: 100vh; overflow: hidden; }
|
||||
|
||||
/* ---- 三栏布局 ---- */
|
||||
.left-nav { background: var(--bg-panel); border-right: 1px solid var(--border); display: flex; flex-direction: column; min-width: 0; flex-shrink: 0; transition: width .08s ease; }
|
||||
/* ---- 三栏布局:层次靠背景阶梯(bg-panel 侧栏 / bg 主区),边框只发丝勾边 ---- */
|
||||
.left-nav { background: var(--bg-panel); border-right: 1px solid var(--border); display: flex; flex-direction: column; min-width: 0; flex-shrink: 0; transition: width .15s var(--ease); }
|
||||
.left-nav.collapsed { width: 52px !important; }
|
||||
.divider { width: 4px; cursor: col-resize; background: transparent; flex-shrink: 0; position: relative; }
|
||||
.divider:hover, .divider.dragging { background: var(--accent); }
|
||||
.divider::before { content: ''; position: absolute; left: 1.5px; top: 0; bottom: 0; width: 1px; background: transparent; transition: background .12s; }
|
||||
.divider:hover::before, .divider.dragging::before { background: var(--accent); }
|
||||
.divider::after { content: ''; position: absolute; inset: 0 -3px; } /* 拖拽热区加宽 */
|
||||
.main { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--bg); }
|
||||
.right-panel { background: var(--bg-panel); border-left: 1px solid var(--border); display: flex; flex-direction: column; flex-shrink: 0; min-width: 0; }
|
||||
.right-panel.collapsed { width: 44px !important; }
|
||||
|
||||
/* ---- 左栏 ---- */
|
||||
.nav-header { padding: 14px 14px 8px; font-weight: 600; display: flex; align-items: center; justify-content: space-between; }
|
||||
.nav-header button { background: none; border: none; color: var(--text-dim); cursor: pointer; font-size: 16px; padding: 4px; border-radius: var(--radius-sm); }
|
||||
.nav-header button:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.nav-scroll { flex: 1; overflow-y: auto; padding: 4px 8px 8px; }
|
||||
.nav-project { margin-bottom: 4px; }
|
||||
.nav-project-name { display: flex; align-items: center; gap: 6px; padding: 6px 8px; border-radius: var(--radius-sm); color: var(--text-dim); font-weight: 500; cursor: pointer; user-select: none; }
|
||||
.nav-project-name:hover { background: var(--bg-hover); }
|
||||
.nav-project-name .chev { font-size: 10px; transition: transform .12s; }
|
||||
.nav-project-name.open .chev { transform: rotate(90deg); }
|
||||
.nav-conv { padding: 5px 8px 5px 26px; border-radius: var(--radius-sm); color: var(--text-dim); cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.nav-conv:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.nav-conv.active { background: var(--accent-soft); color: var(--accent); }
|
||||
.nav-footer { border-top: 1px solid var(--border); padding: 8px; display: flex; gap: 6px; }
|
||||
.nav-footer button { flex: 1; background: none; border: none; color: var(--text-dim); padding: 7px; border-radius: var(--radius-sm); cursor: pointer; font-size: 12px; }
|
||||
.nav-footer button:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.left-nav.collapsed .nav-label { display: none; }
|
||||
.icon-only { display: flex; flex-direction: column; align-items: center; gap: 10px; padding-top: 10px; }
|
||||
.icon-only button { background: none; border: none; color: var(--text-dim); font-size: 16px; cursor: pointer; padding: 8px; border-radius: var(--radius-sm); }
|
||||
.icon-only button:hover { background: var(--bg-hover); color: var(--text); }
|
||||
/* ---- 通用控件 ---- */
|
||||
.btn {
|
||||
background: var(--accent); color: var(--text-on-accent); border: none;
|
||||
border-radius: var(--radius); padding: 8px 16px; cursor: pointer;
|
||||
font-size: 13px; font-weight: 550; white-space: nowrap;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.14), 0 1px 2px rgba(8,9,12,.3);
|
||||
transition: filter .12s var(--ease), transform .12s var(--ease);
|
||||
}
|
||||
.btn:hover:not(:disabled) { filter: brightness(1.09); }
|
||||
.btn:active:not(:disabled) { transform: translateY(1px); }
|
||||
.btn:disabled { opacity: .42; cursor: not-allowed; box-shadow: none; }
|
||||
.btn.ghost { background: var(--bg-raised); border: 1px solid var(--border-strong); color: var(--text-dim); box-shadow: none; }
|
||||
.btn.ghost:hover:not(:disabled) { color: var(--text); border-color: var(--text-faint); filter: none; }
|
||||
.btn.danger { background: var(--err); }
|
||||
.icon-btn {
|
||||
background: none; border: none; color: var(--text-faint);
|
||||
border-radius: var(--radius-sm); padding: 0; width: 28px; height: 28px;
|
||||
display: inline-flex; align-items: center; justify-content: center; cursor: pointer;
|
||||
transition: background .12s, color .12s;
|
||||
}
|
||||
.icon-btn:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.search-input, .cmd-menu input {
|
||||
background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
color: var(--text); padding: 7px 11px; font-family: inherit; font-size: 13px; width: 100%;
|
||||
transition: border-color .12s, box-shadow .12s;
|
||||
}
|
||||
.search-input:focus { border-color: var(--accent); box-shadow: var(--ring); outline: none; }
|
||||
|
||||
/* ---- 中栏对话 ---- */
|
||||
.chat { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||
.chat-header { padding: 10px 18px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 10px; background: var(--bg-raised); }
|
||||
.chat-title { font-weight: 600; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.chat-body { flex: 1; overflow-y: auto; padding: 18px max(18px, 12%); }
|
||||
.empty-state { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--text-faint); gap: 8px; }
|
||||
.msg { margin-bottom: 18px; }
|
||||
.msg-role { font-size: 12px; color: var(--text-faint); margin-bottom: 4px; display: flex; gap: 8px; align-items: center; }
|
||||
.msg.user .bubble { background: var(--accent-soft); border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 14px; white-space: pre-wrap; }
|
||||
/* min-height:0 须随列向 flex 链下传(.main→.chat→.chat-body):flex 项的自动最小
|
||||
高度是内容高度,缺失时消息会撑破 .chat——chat-body 永不滚动、输入栏被顶出视口。 */
|
||||
.chat { flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0; }
|
||||
.chat-header { padding: 12px 22px; border-bottom: 1px solid var(--border-soft); display: flex; align-items: center; gap: 10px; background: var(--bg); }
|
||||
.chat-title { font-weight: 600; font-size: 14.5px; letter-spacing: -0.01em; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.chat-body { flex: 1; overflow-y: auto; min-height: 0; padding: 22px max(22px, 13%); }
|
||||
.empty-state { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--text-faint); gap: 10px; font-size: 13.5px; }
|
||||
.empty-state .es-mark { width: 44px; height: 44px; border-radius: 12px; background: var(--bg-raised); border: 1px solid var(--border-soft); display: flex; align-items: center; justify-content: center; color: var(--text-faint); margin-bottom: 4px; }
|
||||
.empty-state svg { opacity: .5; }
|
||||
.msg { margin-bottom: 20px; }
|
||||
.msg-role { font-size: 12px; color: var(--text-faint); margin-bottom: 6px; display: flex; gap: 8px; align-items: center; }
|
||||
.msg-role .role-mark { width: 18px; height: 18px; border-radius: 5px; display: inline-flex; align-items: center; justify-content: center; color: var(--text-dim); background: var(--bg-raised); border: 1px solid var(--border); }
|
||||
.msg.user .bubble {
|
||||
background: var(--accent-soft); border: 1px solid transparent;
|
||||
border-radius: var(--radius-lg); padding: 11px 16px; white-space: pre-wrap;
|
||||
}
|
||||
.part { margin: 4px 0; }
|
||||
.part-thinking { color: var(--text-faint); font-size: 13px; border-left: 2px solid var(--border); padding-left: 10px; white-space: pre-wrap; }
|
||||
.part-thinking { color: var(--text-dim); font-size: 12.5px; border-left: 2px solid var(--border); padding-left: 10px; margin: 4px 0; }
|
||||
.part-thinking summary { cursor: pointer; color: var(--text-faint); user-select: none; }
|
||||
.part-thinking .part-thinking-body { white-space: pre-wrap; margin-top: 4px; }
|
||||
.part-tool { font-size: 12.5px; }
|
||||
.tool-chip { display: inline-flex; gap: 6px; align-items: center; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 4px 8px; margin: 2px 4px 2px 0; font-family: var(--mono); }
|
||||
.tool-chip { display: inline-flex; gap: 6px; align-items: center; background: var(--bg-inset); border: 1px solid var(--border-soft); border-radius: var(--radius-sm); padding: 4px 9px; margin: 2px 4px 2px 0; font-family: var(--mono); }
|
||||
.tool-chip .status-ok { color: var(--ok); }
|
||||
.tool-chip .status-error { color: var(--err); }
|
||||
.part-text { white-space: pre-wrap; line-height: 1.7; }
|
||||
.part-text :is(h1,h2,h3) { margin: 0.6em 0 0.3em; }
|
||||
.part-text pre { background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 10px; overflow-x: auto; font-family: var(--mono); font-size: 12.5px; }
|
||||
.part-text code { font-family: var(--mono); background: var(--bg-raised); border-radius: 4px; padding: 1px 5px; font-size: 12.5px; }
|
||||
.part-text :is(p,ul,ol) { margin: 0.4em 0; }
|
||||
.part-error { color: var(--err); background: color-mix(in srgb, var(--err) 8%, transparent); border: 1px solid var(--err); border-radius: var(--radius-sm); padding: 8px 12px; }
|
||||
.artifact-chip { display: inline-flex; align-items: center; gap: 6px; color: var(--accent); background: var(--accent-soft); border-radius: var(--radius-sm); padding: 4px 10px; cursor: pointer; font-size: 13px; }
|
||||
.artifact-chip:hover { filter: brightness(1.15); }
|
||||
.part-text { line-height: 1.7; overflow-wrap: break-word; }
|
||||
/* 块级间距只由标签自身控制:禁用 pre-wrap(marked 输出的块间换行会被渲染成空行,
|
||||
叠加出"列表逐项空一行"的松散排版);行内换行语义交给 Markdown 段落。 */
|
||||
.part-text > *:first-child { margin-top: 0; }
|
||||
.part-text > *:last-child { margin-bottom: 0; }
|
||||
.part-text p { margin: 0.5em 0; }
|
||||
.part-text :is(h1,h2,h3,h4) { margin: 0.8em 0 0.35em; line-height: 1.35; font-weight: 600; letter-spacing: -0.01em; }
|
||||
.part-text h1 { font-size: 1.3em; }
|
||||
.part-text h2 { font-size: 1.18em; }
|
||||
.part-text h3 { font-size: 1.06em; }
|
||||
.part-text :is(ul,ol) { margin: 0.4em 0; padding-left: 1.6em; }
|
||||
.part-text li { margin: 0.15em 0; }
|
||||
.part-text li > p { margin: 0.2em 0; }
|
||||
.part-text pre { background: var(--bg-inset); border: 1px solid var(--border-soft); border-radius: var(--radius); padding: 11px 13px; overflow-x: auto; font-family: var(--mono); font-size: 12.5px; }
|
||||
.part-text code { font-family: var(--mono); background: var(--bg-raised); border-radius: var(--radius-xs); padding: 1px 5px; font-size: 12.5px; }
|
||||
.part-text pre code { background: none; padding: 0; }
|
||||
.part-text blockquote { margin: 0.5em 0; padding: 0.1em 0 0.1em 10px; border-left: 3px solid var(--border-strong); color: var(--text-dim); }
|
||||
.part-text table { border-collapse: collapse; margin: 0.6em 0; font-size: 0.95em; display: block; max-width: 100%; overflow-x: auto; }
|
||||
.part-text th, .part-text td { border: 1px solid var(--border); padding: 4px 10px; text-align: left; }
|
||||
.part-text th { background: var(--bg-raised); font-weight: 600; }
|
||||
.part-text hr { border: none; border-top: 1px solid var(--border); margin: 0.8em 0; }
|
||||
.part-text a { color: var(--accent); }
|
||||
.part-error { color: var(--err); background: var(--err-soft); border: 1px solid var(--err); border-radius: var(--radius-sm); padding: 8px 12px; }
|
||||
.artifact-chip { display: inline-flex; align-items: center; gap: 6px; color: var(--accent); background: var(--accent-soft); border: 1px solid transparent; border-radius: var(--radius-sm); padding: 5px 10px; cursor: pointer; font-size: 13px; transition: background .12s; }
|
||||
.artifact-chip:hover { background: var(--accent-soft-2); }
|
||||
|
||||
/* 过程折叠(run 终态后) */
|
||||
.process-collapse { border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-raised); margin: 6px 0; }
|
||||
.process-collapse summary { list-style: none; cursor: pointer; padding: 6px 12px; color: var(--text-faint); font-size: 12.5px; display: flex; align-items: center; gap: 8px; user-select: none; }
|
||||
.process-collapse { border: 1px solid var(--border-soft); border-radius: var(--radius); background: var(--bg-panel); margin: 6px 0; }
|
||||
.process-collapse summary { list-style: none; cursor: pointer; padding: 7px 12px; color: var(--text-faint); font-size: 12.5px; display: flex; align-items: center; gap: 8px; user-select: none; }
|
||||
.process-collapse summary::before { content: '▸'; transition: transform .12s; font-size: 10px; }
|
||||
.process-collapse[open] summary::before { transform: rotate(90deg); }
|
||||
.process-collapse .body { padding: 4px 12px 10px; }
|
||||
@@ -75,41 +117,37 @@ button { font-family: inherit; }
|
||||
@keyframes pulse { 0%,100% { opacity: .4 } 50% { opacity: 1 } }
|
||||
.cursor-blink::after { content: '▍'; animation: pulse 1s infinite; color: var(--accent); }
|
||||
|
||||
/* HITL 卡片 */
|
||||
.hitl-card { border: 1px solid var(--accent); background: var(--bg-raised); border-radius: var(--radius); padding: 14px; margin: 10px 0; }
|
||||
.hitl-q { font-weight: 600; margin-bottom: 10px; }
|
||||
/* HITL 卡片(确认类交互统一绿色语言) */
|
||||
.hitl-card { border: 1px solid var(--ok-border); background: var(--ok-soft); border-radius: var(--radius-lg); padding: 14px; margin: 10px 0; }
|
||||
.hitl-q { font-weight: 600; margin-bottom: 10px; display: flex; gap: 8px; align-items: flex-start; }
|
||||
.hitl-q svg { color: var(--ok); flex: none; margin-top: 1px; }
|
||||
.hitl-choices { display: flex; flex-direction: column; gap: 8px; }
|
||||
.hitl-choice { text-align: left; border: 1px solid var(--border); background: var(--bg); color: var(--text); border-radius: var(--radius-sm); padding: 10px 12px; cursor: pointer; }
|
||||
.hitl-choice { text-align: left; border: 1px solid var(--border); background: var(--bg-raised); color: var(--text); border-radius: var(--radius); padding: 10px 12px; cursor: pointer; transition: border-color .12s, background .12s; }
|
||||
.hitl-choice:hover { border-color: var(--accent); background: var(--accent-soft); }
|
||||
.hitl-choice .label { font-weight: 600; display: flex; gap: 8px; align-items: center; }
|
||||
.hitl-choice .desc { color: var(--text-dim); font-size: 12.5px; margin-top: 3px; }
|
||||
.hitl-choice .impact { color: var(--warn); font-size: 12px; margin-top: 3px; }
|
||||
.hitl-choice .rec { font-size: 10.5px; color: var(--accent); border: 1px solid var(--accent); padding: 0 5px; border-radius: 8px; }
|
||||
.hitl-choice .rec { font-size: 10.5px; color: var(--accent); border: 1px solid var(--accent); padding: 0 5px; border-radius: var(--radius-full); }
|
||||
.hitl-free { display: flex; gap: 8px; margin-top: 10px; }
|
||||
|
||||
/* 输入区 */
|
||||
.input-bar { border-top: 1px solid var(--border); padding: 12px 18px; background: var(--bg-raised); }
|
||||
/* 输入区:悬浮卡片式输入壳(外圆角大、聚焦发光) */
|
||||
.input-bar { padding: 8px max(16px, 6%) 16px; background: var(--bg); }
|
||||
.attachments { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
|
||||
.attach-chip { display: inline-flex; gap: 6px; align-items: center; background: var(--bg-hover); border-radius: var(--radius-sm); padding: 3px 8px; font-size: 12px; }
|
||||
.input-row { display: flex; gap: 8px; align-items: flex-end; }
|
||||
.input-row textarea { flex: 1; resize: none; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); padding: 10px 12px; font-family: inherit; font-size: 14px; max-height: 160px; outline: none; }
|
||||
.input-row textarea:focus { border-color: var(--accent); }
|
||||
.btn { background: var(--accent); color: #fff; border: none; border-radius: var(--radius-sm); padding: 9px 16px; cursor: pointer; font-size: 13px; white-space: nowrap; }
|
||||
.btn:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.btn.ghost { background: none; border: 1px solid var(--border); color: var(--text-dim); }
|
||||
.btn.ghost:hover { color: var(--text); border-color: var(--text-faint); }
|
||||
.btn.danger { background: var(--err); }
|
||||
.icon-btn { background: none; border: 1px solid var(--border); color: var(--text-dim); border-radius: var(--radius-sm); padding: 8px 10px; cursor: pointer; font-size: 14px; }
|
||||
.icon-btn:hover { color: var(--text); border-color: var(--text-faint); }
|
||||
.cmd-menu { border: 1px solid var(--border); background: var(--bg-raised); border-radius: var(--radius-sm); margin-bottom: 6px; max-height: 180px; overflow-y: auto; }
|
||||
.attach-chip { display: inline-flex; gap: 6px; align-items: center; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 3px 8px; font-size: 12px; }
|
||||
.input-row { display: flex; gap: 8px; align-items: flex-end; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 8px 8px 8px 14px; box-shadow: var(--shadow-md); transition: border-color .12s, box-shadow .12s; }
|
||||
.input-row:focus-within { border-color: var(--accent); box-shadow: var(--shadow-md), var(--ring); }
|
||||
.input-row textarea { flex: 1; resize: none; background: transparent; border: none; color: var(--text); padding: 6px 0; font-family: inherit; font-size: 13.5px; max-height: 160px; outline: none; }
|
||||
.input-row textarea::placeholder { color: var(--text-faint); }
|
||||
.cmd-menu { border: 1px solid var(--border); background: var(--bg-raised); border-radius: var(--radius); margin-bottom: 6px; max-height: 180px; overflow-y: auto; box-shadow: var(--shadow-md); }
|
||||
.cmd-item { padding: 7px 12px; cursor: pointer; display: flex; gap: 10px; font-size: 13px; }
|
||||
.cmd-item:hover, .cmd-item.active { background: var(--accent-soft); }
|
||||
.cmd-item:hover, .cmd-item.active { background: var(--bg-hover); }
|
||||
.cmd-item .name { color: var(--accent); font-family: var(--mono); }
|
||||
|
||||
/* ---- 右栏 ---- */
|
||||
.tabs { display: flex; border-bottom: 1px solid var(--border); overflow-x: auto; }
|
||||
.tab { background: none; border: none; color: var(--text-dim); padding: 9px 12px; cursor: pointer; font-size: 12.5px; border-bottom: 2px solid transparent; white-space: nowrap; }
|
||||
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
.tabs { display: flex; border-bottom: 1px solid var(--border-soft); overflow-x: auto; padding: 0 8px; }
|
||||
.tab { background: none; border: none; color: var(--text-faint); padding: 10px 10px; cursor: pointer; font-size: 12.5px; font-weight: 550; border-bottom: 2px solid transparent; margin-bottom: -1px; white-space: nowrap; }
|
||||
.tab:hover { color: var(--text-dim); }
|
||||
.tab.active { color: var(--text); border-bottom-color: var(--accent); }
|
||||
.tab-body { flex: 1; overflow-y: auto; padding: 12px; }
|
||||
.step { display: flex; gap: 8px; align-items: flex-start; padding: 6px 4px; font-size: 13px; }
|
||||
.step-icon { width: 16px; text-align: center; flex-shrink: 0; }
|
||||
@@ -130,69 +168,195 @@ button { font-family: inherit; }
|
||||
.status-pending, .status-waiting_hitl { background: var(--warn); }
|
||||
|
||||
/* ---- 弹窗 / 模态 ---- */
|
||||
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,.55); display: flex; align-items: center; justify-content: center; z-index: 50; }
|
||||
.modal { background: var(--bg-raised); border: 1px solid var(--border); border-radius: 12px; width: min(860px, 92vw); max-height: 86vh; display: flex; flex-direction: column; box-shadow: 0 18px 60px rgba(0,0,0,.4); }
|
||||
.modal-header { padding: 14px 18px; border-bottom: 1px solid var(--border); display: flex; align-items: center; font-weight: 600; }
|
||||
.overlay { position: fixed; inset: 0; background: rgba(8, 9, 12, .6); backdrop-filter: blur(8px); display: flex; align-items: center; justify-content: center; z-index: 50; animation: overlay-in .14s var(--ease); }
|
||||
@keyframes overlay-in { from { opacity: 0 } }
|
||||
.modal { background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-xl); width: min(860px, 92vw); max-height: 86vh; display: flex; flex-direction: column; box-shadow: var(--shadow-lg); animation: modal-in .16s var(--ease); }
|
||||
@keyframes modal-in { from { opacity: 0; transform: translateY(6px) scale(.99) } }
|
||||
.modal-header { padding: 16px 20px; border-bottom: 1px solid var(--border-soft); display: flex; align-items: center; font-weight: 600; font-size: 14.5px; letter-spacing: -0.01em; }
|
||||
.modal-header .spacer { flex: 1; }
|
||||
.modal-body { flex: 1; overflow-y: auto; padding: 16px 18px; }
|
||||
.modal-body { flex: 1; overflow-y: auto; padding: 18px 20px; }
|
||||
.subagent-tree { font-size: 13px; }
|
||||
.subagent-node { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 10px; margin-bottom: 8px; }
|
||||
.subagent-node { border: 1px solid var(--border); border-radius: var(--radius); padding: 10px; margin-bottom: 8px; background: var(--bg-panel); }
|
||||
.subagent-node .head { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
|
||||
|
||||
/* ---- 设置(schema 驱动)---- */
|
||||
.settings-group { margin-bottom: 18px; }
|
||||
.settings-group h4 { margin: 0 0 8px; font-size: 13px; color: var(--text-dim); text-transform: uppercase; letter-spacing: .04em; }
|
||||
.setting-row { display: grid; grid-template-columns: 200px 1fr auto; gap: 10px; align-items: center; padding: 7px 0; border-bottom: 1px dashed var(--border); font-size: 13px; }
|
||||
.settings-group h4 { margin: 0 0 8px; font-size: 11.5px; color: var(--text-faint); text-transform: uppercase; letter-spacing: .07em; font-weight: 600; }
|
||||
.setting-row { display: grid; grid-template-columns: 200px 1fr auto; gap: 10px; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--border-soft); font-size: 13px; }
|
||||
.setting-row label { color: var(--text-dim); }
|
||||
.setting-row input, .setting-row select { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text); padding: 7px 10px; font-family: inherit; }
|
||||
.effect-badge { font-size: 10.5px; padding: 2px 7px; border-radius: 8px; border: 1px solid var(--border); color: var(--text-faint); white-space: nowrap; }
|
||||
.setting-row input:focus, .setting-row select:focus { border-color: var(--accent); box-shadow: var(--ring); outline: none; }
|
||||
.effect-badge { font-size: 10.5px; padding: 2px 7px; border-radius: var(--radius-full); border: 1px solid var(--border); color: var(--text-faint); white-space: nowrap; }
|
||||
.effect-hot { color: var(--ok); border-color: var(--ok); }
|
||||
.effect-new-run { color: var(--warn); border-color: var(--warn); }
|
||||
|
||||
/* UI schema 渲染器样式(.ui-*)与 .doc-viewer 已迁入 @ui/ui.css。 */
|
||||
|
||||
/* ---- 杂项 ---- */
|
||||
.toast { position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%); background: var(--bg-raised); border: 1px solid var(--border); color: var(--text); padding: 9px 16px; border-radius: 20px; box-shadow: 0 8px 30px rgba(0,0,0,.35); z-index: 90; font-size: 13px; }
|
||||
.toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); background: var(--bg-raised); border: 1px solid var(--border); color: var(--text); padding: 9px 16px; border-radius: var(--radius-full); box-shadow: var(--shadow-lg); z-index: 90; font-size: 13px; }
|
||||
.conn-dot { width: 8px; height: 8px; border-radius: 50%; }
|
||||
.conn-dot.on { background: var(--ok); }
|
||||
.conn-dot.off { background: var(--err); }
|
||||
.doc-toolbar { display: flex; gap: 8px; margin-bottom: 10px; align-items: center; }
|
||||
.search-input { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text); padding: 6px 10px; font-family: inherit; width: 100%; }
|
||||
::-webkit-scrollbar { width: 9px; height: 9px; }
|
||||
::-webkit-scrollbar-thumb { background: var(--bg-active); border-radius: 5px; }
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-thumb { background: var(--bg-active); border-radius: 6px; border: 3px solid transparent; background-clip: content-box; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--border-strong); background-clip: content-box; border: 3px solid transparent; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
|
||||
/* ---- 登录门(API key 认证,PG 档默认开启) ---- */
|
||||
.login-gate {
|
||||
position: fixed; inset: 0; display: flex; align-items: center; justify-content: center;
|
||||
background: var(--bg, #14161a); z-index: 1000;
|
||||
}
|
||||
.login-card {
|
||||
width: 360px; padding: 28px; border-radius: 12px; text-align: center;
|
||||
background: #1d2026; border: 1px solid #2c313a; box-shadow: 0 8px 32px rgba(0,0,0,.45);
|
||||
}
|
||||
.login-card h2 { margin: 0 0 8px; }
|
||||
.login-hint { font-size: 12px; color: #9aa3b2; margin-bottom: 16px; line-height: 1.6; }
|
||||
.login-card input {
|
||||
width: 100%; box-sizing: border-box; padding: 9px 12px; border-radius: 8px;
|
||||
border: 1px solid #2c313a; background: #14161a; color: inherit; font-size: 14px;
|
||||
}
|
||||
.login-card button {
|
||||
margin-top: 12px; width: 100%; padding: 9px 0; border: none; border-radius: 8px;
|
||||
background: #3b82f6; color: #fff; font-size: 14px; cursor: pointer;
|
||||
}
|
||||
.login-err { margin-top: 10px; font-size: 12px; color: #f87171; }
|
||||
/* ---- 品牌标(侧栏 / 登录页 / 头像共用的星芒渐变底) ---- */
|
||||
.brand-mark { background: linear-gradient(135deg, #6d8dff, #9d7bff); color: #fff; display: inline-flex; align-items: center; justify-content: center; flex: none; box-shadow: inset 0 1px 0 rgba(255,255,255,.22); }
|
||||
|
||||
/* 工作区快捷区(T4/D26 左栏)与当前任务常驻卡(D25 R4) */
|
||||
.ws-quick{padding:6px 8px 8px;border-bottom:1px solid var(--border)}
|
||||
.ws-quick-list{display:flex;flex-direction:column;gap:2px;margin-top:4px}
|
||||
.ws-quick-item{display:flex;align-items:center;gap:8px;padding:4px 8px;border:none;background:transparent;color:inherit;cursor:pointer;border-radius:6px;text-align:left;font-size:12px}
|
||||
.ws-quick-item:hover{background:rgba(120,160,255,.12)}
|
||||
.ws-quick-item .sc{margin-left:auto;font-size:10px;color:var(--text-faint);border:1px solid var(--border);border-radius:3px;padding:0 4px}
|
||||
.current-task{border:1px solid var(--border);border-radius:8px;padding:8px 10px;margin-bottom:8px;background:rgba(120,160,255,.05)}
|
||||
.ct-head{display:flex;align-items:center;gap:8px;font-weight:600;margin-bottom:6px}
|
||||
.ct-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.ct-progress{font-size:11px;color:var(--text-faint)}
|
||||
.project-badge{font-size:11px;padding:1px 8px;border-radius:8px;background:rgba(120,160,255,.15);color:var(--accent);white-space:nowrap}
|
||||
.tab-hint{font-size:10px;color:var(--text-faint);align-self:center}
|
||||
.empty-note{color:var(--text-faint);font-size:12px;padding:12px 4px}
|
||||
/* ---- 左栏:任务中心(方案 A;R4 重造型) ---- */
|
||||
.tsb-brand { display: flex; align-items: center; gap: 9px; padding: 14px 14px 8px; }
|
||||
.tsb-logo { width: 24px; height: 24px; border-radius: 8px; background: linear-gradient(135deg, #6d8dff, #9d7bff); color: #fff; font-size: 12px; font-weight: 700; display: flex; align-items: center; justify-content: center; flex: none; }
|
||||
.tsb-name { font-weight: 650; font-size: 13.5px; letter-spacing: -0.01em; }
|
||||
.tsb-iconbtn { width: 26px; height: 26px; border-radius: var(--radius-sm); background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: 13px; display: inline-flex; align-items: center; justify-content: center; position: relative; }
|
||||
.tsb-iconbtn:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.tsb-iconbtn.active { color: var(--accent); background: var(--accent-soft); }
|
||||
|
||||
.tsb-actions { padding: 6px 12px 8px; display: flex; flex-direction: column; gap: 7px; }
|
||||
.tsb-newtask { display: flex; height: 36px; border-radius: var(--radius); background: var(--accent); color: var(--text-on-accent); overflow: hidden; box-shadow: inset 0 1px 0 rgba(255,255,255,.16), 0 2px 6px rgba(8,9,12,.28); transition: filter .12s; }
|
||||
.tsb-newtask:hover { filter: brightness(1.08); }
|
||||
.tsb-newtask-main { flex: 1; display: flex; align-items: center; gap: 7px; padding: 0 11px; background: none; border: none; color: inherit; font-weight: 600; font-size: 13px; cursor: pointer; min-width: 0; }
|
||||
.tsb-newtask-main svg { flex: none; }
|
||||
.tsb-newtask-proj { font-weight: 450; font-size: 11.5px; opacity: .75; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tsb-newtask-caret { width: 30px; border: none; border-left: 1px solid rgba(255,255,255,.18); background: none; color: inherit; cursor: pointer; display: flex; align-items: center; justify-content: center; }
|
||||
.tsb-newtask-caret:hover { background: rgba(255,255,255,.08); }
|
||||
.tsb-search { display: flex; align-items: center; gap: 7px; height: 32px; border-radius: var(--radius); border: 1px solid var(--border); background: var(--bg-raised); color: var(--text-faint); padding: 0 10px; transition: border-color .12s, box-shadow .12s; }
|
||||
.tsb-search:focus-within { border-color: var(--accent); box-shadow: var(--ring); }
|
||||
.tsb-search svg { flex: none; }
|
||||
.tsb-search input { flex: 1; border: none; background: none; color: var(--text); font-size: 12.5px; outline: none; min-width: 0; }
|
||||
.tsb-search input::placeholder { color: var(--text-faint); }
|
||||
|
||||
.tsb-orgrow { display: flex; align-items: center; gap: 6px; padding: 0 12px 8px; }
|
||||
.tsb-pills { flex: 1; display: flex; background: var(--bg-inset); border: 1px solid var(--border-soft); border-radius: var(--radius-sm); padding: 2px; gap: 2px; }
|
||||
.tsb-pill { flex: 1; height: 22px; border: none; border-radius: var(--radius-xs); background: none; color: var(--text-faint); font-size: 12px; cursor: pointer; transition: all .12s; }
|
||||
.tsb-pill:hover { color: var(--text-dim); }
|
||||
.tsb-pill.on { background: var(--bg-raised); color: var(--text); font-weight: 550; box-shadow: 0 1px 2px rgba(8,9,12,.25); }
|
||||
|
||||
.tsb-scroll { flex: 1; overflow-y: auto; padding: 0 10px 8px; min-height: 0; }
|
||||
.tsb-seclabel { font-size: 11px; color: var(--text-faint); padding: 12px 8px 4px; letter-spacing: .05em; font-weight: 600; }
|
||||
.tsb-empty { padding: 20px 14px; color: var(--text-faint); font-size: 12.5px; line-height: 1.9; text-align: center; }
|
||||
.tsb-newproject { padding: 0 2px 8px; }
|
||||
|
||||
.tsb-group { margin-bottom: 2px; }
|
||||
.tsb-group-head { display: flex; align-items: center; gap: 6px; height: 30px; padding: 0 6px 0 4px; border-radius: var(--radius-sm); color: var(--text-dim); font-weight: 550; font-size: 13px; cursor: pointer; user-select: none; }
|
||||
.tsb-group-head:hover { background: var(--bg-hover); }
|
||||
.tsb-group.closed .tsb-chev { transform: rotate(0deg); }
|
||||
.tsb-chev { display: inline-flex; color: var(--text-faint); transform: rotate(90deg); transition: transform .12s; }
|
||||
.tsb-group-name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tsb-group-cnt { font-size: 11px; color: var(--text-faint); font-weight: 400; font-variant-numeric: tabular-nums; }
|
||||
.tsb-group-add { visibility: hidden; width: 22px; height: 22px; border: none; border-radius: var(--radius-xs); background: none; color: var(--text-faint); display: inline-flex; align-items: center; justify-content: center; cursor: pointer; }
|
||||
.tsb-group-head:hover .tsb-group-add { visibility: visible; }
|
||||
.tsb-group-add:hover { background: var(--bg-active); color: var(--text); }
|
||||
|
||||
.tsb-task { display: flex; align-items: center; gap: 8px; height: 31px; padding: 0 8px; margin: 1px 0; border-radius: var(--radius-sm); color: var(--text-dim); cursor: pointer; position: relative; transition: background .1s, color .1s; }
|
||||
.tsb-task:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.tsb-task.active { background: var(--bg-active); color: var(--text); }
|
||||
.tsb-task.active::before { content: ''; position: absolute; left: -2px; top: 6px; bottom: 6px; width: 2.5px; border-radius: 2px; background: var(--accent); }
|
||||
.tsb-task.archived .tsb-task-t { text-decoration: line-through; opacity: .6; }
|
||||
.tsb-task-t { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 13px; }
|
||||
.tsb-task-tm { font-size: 11px; color: var(--text-faint); font-variant-numeric: tabular-nums; }
|
||||
.tsb-pbadge { font-size: 10.5px; color: var(--text-faint); border: 1px solid var(--border); border-radius: var(--radius-xs); padding: 0 5px; line-height: 1.5; white-space: nowrap; }
|
||||
.tsb-row-acts { display: none; gap: 2px; }
|
||||
.tsb-task:hover .tsb-row-acts { display: inline-flex; }
|
||||
.tsb-task:hover .tsb-task-tm { display: none; }
|
||||
.tsb-row-act { border: none; background: none; color: var(--text-faint); cursor: pointer; padding: 3px; border-radius: var(--radius-xs); display: inline-flex; }
|
||||
.tsb-row-act:hover { background: var(--bg-active); color: var(--text); }
|
||||
|
||||
.tsb-wsstrip { display: flex; align-items: center; gap: 2px; padding: 6px 12px; border-top: 1px solid var(--border-soft); }
|
||||
.tsb-wsstrip button { border: none; background: none; color: var(--text-faint); cursor: pointer; padding: 5px; border-radius: var(--radius-sm); display: inline-flex; }
|
||||
.tsb-wsstrip button:hover { background: var(--bg-hover); color: var(--text); }
|
||||
|
||||
.tsb-footer { display: flex; align-items: center; gap: 4px; padding: 10px 12px; border-top: 1px solid var(--border-soft); }
|
||||
.tsb-user { flex: 1; display: flex; align-items: center; gap: 9px; min-width: 0; height: 40px; padding: 0 7px; border: none; border-radius: var(--radius); background: none; cursor: pointer; text-align: left; transition: background .12s; }
|
||||
.tsb-user:hover { background: var(--bg-hover); }
|
||||
.tsb-avatar { width: 27px; height: 27px; border-radius: 50%; background: linear-gradient(135deg, #6d8dff, #9d7bff); color: #fff; font-size: 12px; font-weight: 600; display: flex; align-items: center; justify-content: center; flex: none; box-shadow: inset 0 1px 0 rgba(255,255,255,.2); }
|
||||
.tsb-user-info { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.tsb-user-name { font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tsb-user-sub { font-size: 11px; color: var(--text-faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tsb-user-chev { color: var(--text-faint); display: inline-flex; }
|
||||
|
||||
.tsb-menu { position: fixed; z-index: 300; min-width: 216px; background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); box-shadow: var(--shadow-lg); padding: 5px; display: flex; flex-direction: column; animation: modal-in .12s var(--ease); }
|
||||
.tsb-mi { display: flex; align-items: center; gap: 9px; min-height: 31px; padding: 4px 9px; border: none; border-radius: var(--radius-sm); background: none; color: var(--text-dim); font-size: 13px; cursor: pointer; text-align: left; }
|
||||
.tsb-mi svg { color: var(--text-faint); flex: none; }
|
||||
.tsb-mi:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.tsb-mi:hover svg { color: var(--text-dim); }
|
||||
.tsb-mi.danger { color: var(--err); }
|
||||
.tsb-mi.danger:hover { background: var(--err); color: #fff; }
|
||||
.tsb-mi.danger:hover svg { color: #fff; }
|
||||
.tsb-mi kbd { margin-left: auto; font-size: 10.5px; font-family: var(--mono); color: var(--text-faint); }
|
||||
.tsb-menu-div { height: 1px; background: var(--border-soft); margin: 5px 4px; }
|
||||
.tsb-acc-head { display: flex; gap: 10px; align-items: center; padding: 8px 9px 6px; }
|
||||
.tsb-acc-name { font-size: 13.5px; font-weight: 600; }
|
||||
.tsb-acc-mail { font-size: 11.5px; color: var(--text-faint); }
|
||||
|
||||
/* ---- 登录页与改密门(R4 重造型:宽屏右侧品牌面板) ---- */
|
||||
.lg-wrap { position: fixed; inset: 0; display: flex; align-items: stretch; background: var(--bg); z-index: 500; }
|
||||
.lg-panel { flex: none; width: 46%; max-width: 620px; position: relative; overflow: hidden; display: flex; flex-direction: column; justify-content: center; padding: 64px; color: #dfe4f2; background: linear-gradient(160deg, #171e38 0%, #1d2145 55%, #251d4d 100%); }
|
||||
.lg-panel::before { content: ''; position: absolute; width: 560px; height: 560px; border-radius: 50%; background: radial-gradient(closest-side, rgba(109,141,255,.22), transparent 70%); top: -140px; right: -160px; }
|
||||
.lg-panel::after { content: ''; position: absolute; width: 440px; height: 440px; border-radius: 50%; background: radial-gradient(closest-side, rgba(157,123,255,.18), transparent 70%); bottom: -150px; left: -120px; }
|
||||
.lg-panel h2 { font-size: 26px; font-weight: 700; letter-spacing: -0.02em; line-height: 1.35; margin: 0 0 12px; position: relative; }
|
||||
.lg-panel p { font-size: 14px; opacity: .72; line-height: 1.75; margin: 0; position: relative; }
|
||||
.lg-panel ul { margin: 28px 0 0; list-style: none; display: flex; flex-direction: column; gap: 13px; position: relative; }
|
||||
.lg-panel li { display: flex; gap: 10px; align-items: flex-start; font-size: 13.5px; opacity: .88; }
|
||||
.lg-panel li svg { margin-top: 2px; color: #8fa8ff; flex: none; }
|
||||
.lg-panel .lg-foot { position: absolute; bottom: 28px; left: 64px; right: 64px; display: flex; justify-content: space-between; font-size: 11.5px; opacity: .45; }
|
||||
@media (max-width: 960px) { .lg-panel { display: none; } }
|
||||
.lg-formside { flex: 1; display: flex; align-items: center; justify-content: center; padding: 24px; min-width: 0; }
|
||||
.lg-card { width: 392px; max-width: 100%; }
|
||||
.lg-brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 15.5px; letter-spacing: -0.01em; margin-bottom: 26px; }
|
||||
.lg-title { margin: 0 0 4px; font-size: 22px; font-weight: 700; letter-spacing: -0.02em; }
|
||||
.lg-sub { margin: 0 0 18px; font-size: 12.5px; color: var(--text-faint); line-height: 1.6; }
|
||||
.lg-tabs { display: flex; background: var(--bg-inset); border: 1px solid var(--border-soft); border-radius: var(--radius); padding: 3px; gap: 3px; margin-bottom: 16px; }
|
||||
.lg-tab { flex: 1; height: 30px; border: none; border-radius: var(--radius-xs); background: none; color: var(--text-faint); font-size: 12.5px; cursor: pointer; transition: all .12s; }
|
||||
.lg-tab:hover { color: var(--text-dim); }
|
||||
.lg-tab.on { background: var(--bg-raised); color: var(--text); font-weight: 550; box-shadow: 0 1px 2px rgba(8,9,12,.25); }
|
||||
.lg-label { display: block; font-size: 12px; font-weight: 550; color: var(--text-dim); margin: 12px 0 6px; }
|
||||
.lg-input { width: 100%; height: 38px; border-radius: var(--radius); border: 1px solid var(--border); background: var(--bg-raised); color: var(--text); padding: 0 12px; font-size: 13.5px; transition: border-color .12s, box-shadow .12s; }
|
||||
.lg-input:focus { border-color: var(--accent); box-shadow: var(--ring); outline: none; }
|
||||
.lg-mono { font-family: var(--mono); font-size: 12.5px; }
|
||||
.lg-err { margin-top: 12px; font-size: 12.5px; color: var(--err); background: var(--err-soft); border: 1px solid color-mix(in srgb, var(--err) 35%, transparent); border-radius: var(--radius); padding: 8px 11px; display: flex; gap: 7px; align-items: flex-start; }
|
||||
.lg-err svg { flex: none; margin-top: 1px; }
|
||||
.lg-submit {
|
||||
width: 100%; height: 38px; margin-top: 16px; border: none; border-radius: var(--radius);
|
||||
background: var(--accent); color: var(--text-on-accent); font-weight: 600; font-size: 13.5px; cursor: pointer;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.16), 0 2px 8px var(--accent-glow);
|
||||
transition: filter .12s, transform .12s;
|
||||
}
|
||||
.lg-submit:disabled { opacity: .45; cursor: default; box-shadow: none; }
|
||||
.lg-submit:not(:disabled):hover { filter: brightness(1.09); }
|
||||
.lg-submit:not(:disabled):active { transform: translateY(1px); }
|
||||
.lg-cancel { width: 100%; margin-top: 8px; border: none; background: none; color: var(--text-faint); font-size: 12.5px; cursor: pointer; }
|
||||
.lg-cancel:hover { color: var(--text-dim); text-decoration: underline; }
|
||||
.lg-hint { margin: 14px 0 0; font-size: 11.5px; color: var(--text-faint); line-height: 1.7; }
|
||||
.lg-hint code { font-family: var(--mono); font-size: 10.5px; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-xs); padding: 1px 5px; }
|
||||
|
||||
/* ---- 用户管理 / 改密弹层 ---- */
|
||||
.ad-body { padding: 16px 20px 20px; display: flex; flex-direction: column; gap: 12px; max-height: 70vh; overflow-y: auto; }
|
||||
.ad-create { border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 14px; display: flex; flex-direction: column; gap: 8px; background: var(--bg-panel); }
|
||||
.ad-row { display: flex; gap: 8px; align-items: center; }
|
||||
.ad-row .lg-input { flex: 1; min-width: 0; height: 34px; }
|
||||
.ad-check { display: flex; align-items: center; gap: 5px; font-size: 12.5px; color: var(--text-dim); white-space: nowrap; }
|
||||
.ad-create-btn { width: auto; padding: 0 14px; margin-top: 0; height: 34px; flex: none; box-shadow: inset 0 1px 0 rgba(255,255,255,.14); }
|
||||
.ad-mini { border: 1px solid var(--border); background: var(--bg-raised); color: var(--text-dim); border-radius: var(--radius-sm); padding: 4px 9px; font-size: 12px; cursor: pointer; white-space: nowrap; display: inline-flex; align-items: center; gap: 4px; }
|
||||
.ad-mini:hover { color: var(--text); border-color: var(--accent); }
|
||||
.ad-hint { margin: 0; font-size: 11.5px; color: var(--text-faint); }
|
||||
.ad-notice { font-size: 12.5px; color: var(--ok); background: var(--ok-soft); border: 1px solid var(--ok-border); border-radius: var(--radius); padding: 8px 11px; display: flex; gap: 7px; align-items: flex-start; }
|
||||
.ad-notice svg { flex: none; margin-top: 1px; }
|
||||
.ad-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
|
||||
.ad-table th { text-align: left; font-weight: 600; color: var(--text-faint); font-size: 11px; text-transform: uppercase; letter-spacing: .06em; padding: 8px; border-bottom: 1px solid var(--border); }
|
||||
.ad-table td { padding: 9px 8px; border-bottom: 1px solid var(--border-soft); }
|
||||
.ad-table tr:last-child td { border-bottom: none; }
|
||||
.ad-mono { font-family: var(--mono); font-size: 12px; }
|
||||
.ad-dim { color: var(--text-faint); }
|
||||
.ad-chip { font-size: 10px; border-radius: var(--radius-xs); padding: 2px 7px; font-weight: 700; letter-spacing: .5px; background: var(--bg-hover); color: var(--text-faint); }
|
||||
.ad-chip-admin { background: var(--accent-soft); color: var(--accent); }
|
||||
.ad-chip-warn { background: var(--warn-soft); color: var(--warn); }
|
||||
.ad-reset { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; border: 1px solid color-mix(in srgb, var(--warn) 40%, transparent); background: var(--warn-soft); border-radius: var(--radius-lg); padding: 12px; font-size: 12.5px; }
|
||||
.ad-reset .lg-input { flex: 1; min-width: 160px; height: 32px; }
|
||||
.ad-changepw { display: flex; flex-direction: column; gap: 2px; max-width: 340px; }
|
||||
|
||||
/* ---- 收起轨道(左栏收起态) ---- */
|
||||
.icon-only { display: flex; flex-direction: column; align-items: center; gap: 6px; padding-top: 12px; }
|
||||
.icon-only button { background: none; border: none; color: var(--text-faint); cursor: pointer; padding: 0; width: 34px; height: 34px; border-radius: var(--radius); display: inline-flex; align-items: center; justify-content: center; position: relative; }
|
||||
.icon-only button:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.rail-badge { position: absolute; top: 3px; right: 3px; min-width: 14px; height: 14px; border-radius: 7px; background: var(--warn); color: #fff; font-size: 9.5px; font-weight: 700; display: flex; align-items: center; justify-content: center; padding: 0 4px; }
|
||||
|
||||
@@ -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,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]])
|
||||
})
|
||||
})
|
||||
@@ -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","./src/components/Workspaces.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/icons.tsx","./src/main.tsx","./src/passwords.ts","./src/store.ts","./src/tasklist.ts","./src/components/AdminUsersModal.tsx","./src/components/AgentTracker.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/LoginPage.tsx","./src/components/SettingsModal.tsx","./src/components/TaskSidebar.tsx","./src/components/Workbench.tsx","./src/components/Workspaces.tsx","./test/auth.test.ts","./test/contracts.test.ts","./test/tasklist.test.ts"],"version":"5.9.3"}
|
||||
Reference in New Issue
Block a user