Why: creation entries were split (project + at sidebar top-left, new session at bottom-left) and the project tree forced per-project navigation; the approved option A design makes one primary New Task entry, organization a view mode, and identity always present. Evidence: - New TaskSidebar: single New Task split button (current-project context, Cmd-N, secondary menu for new project / new task in project), project <-> timeline organization with persisted collapse, pinned section, archive view toggle, hover row actions, account footer fed by /api/me (embedded mode shows local tenant; logout clears the key), workspace icon strip retained (D25 shared list with Cmd-1..9). - store: tasks = cross-project list via GET /api/conversations; openTask switches project context; pin/archive optimistic + refresh; last project context persisted; stale conversations field removed. - tasklist.ts pure helpers + 6 vitest cases; npm run build passes; headless walk on the real binary: cross-project open, pinned-first, archive hide/show/restore, new-task flow, zero console errors. Impact: ProjectNav removed; contracts.ts regenerated from core (pinned/archived); no composer/right-pane changes (D25/D26/D31 intact). Refs: Agent-System-Documents docs/proposals/2026-09-30-ui-redesign-and-auth.md
45 lines
1.7 KiB
TypeScript
45 lines
1.7 KiB
TypeScript
// API key 凭证模块:登录门存取(localStorage 优先,无 DOM 环境回退内存)。
|
||
// 服务端开启认证时(PG 档默认),所有请求带 Bearer 头;SSE/静态资源链接走 ?key=。
|
||
const KEY = 'agent-system.apiKey'
|
||
|
||
interface KV { getItem(k: string): string | null; setItem(k: string, v: string): void; removeItem(k: string): void }
|
||
|
||
function pickStorage(): KV {
|
||
try {
|
||
if (typeof localStorage !== 'undefined') return localStorage
|
||
} catch { /* 隐私模式等 */ }
|
||
const mem = new Map<string, string>()
|
||
return {
|
||
getItem: k => mem.get(k) ?? null,
|
||
setItem: (k, v) => void mem.set(k, v),
|
||
removeItem: k => void mem.delete(k),
|
||
}
|
||
}
|
||
|
||
const storage = pickStorage()
|
||
|
||
export const auth = {
|
||
apiKey(): string { return storage.getItem(KEY) ?? '' },
|
||
setKey(key: string) { storage.setItem(KEY, key.trim()) },
|
||
clear() { storage.removeItem(KEY) },
|
||
// 带凭证的 fetch:未设置密钥时与原生 fetch 等价(嵌入式档无认证)。
|
||
fetch(input: string, init?: RequestInit): Promise<Response> {
|
||
const key = this.apiKey()
|
||
if (!key) return fetch(input, init)
|
||
const headers = new Headers(init?.headers ?? {})
|
||
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()
|
||
if (!key) return url
|
||
return url + (url.includes('?') ? '&' : '?') + 'key=' + encodeURIComponent(key)
|
||
},
|
||
}
|