629 lines
23 KiB
TypeScript
629 lines
23 KiB
TypeScript
// 应用状态:服务端权威(事件 + Query 对账),前端 store 是投影。
|
||
// useSyncExternalStore 驱动;live 流式部件为会话内临时态,run 终态后由消息树对账收敛。
|
||
import { useSyncExternalStore } from 'react'
|
||
import { api, connectEvents } from './api'
|
||
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolResult, applyArtifactCreated, type LiveItem } from './live'
|
||
import type {
|
||
Project, Conversation, Message, Run, Artifact, HITLCard, PlanSnapshot, TodoList,
|
||
AgentProfile, StreamEvent, PluginPanel, ActivityCard,
|
||
} from './contracts'
|
||
|
||
export interface ContextObs {
|
||
items: string[]
|
||
truncations?: string[]
|
||
estChars: number
|
||
}
|
||
|
||
export interface LiveState {
|
||
runId: string
|
||
phase: string
|
||
// 到达序部件流(见 live.ts LiveItem):事件到达序即落库序,与落库部件的轮次穿插序
|
||
// 同构;文本段是否为过程由「本段内是否出现工具调用」判定,tool.call 一到即改标。
|
||
items: LiveItem[]
|
||
agentNotes: Record<string, string>
|
||
startedAt: number
|
||
}
|
||
|
||
export interface UIState {
|
||
leftWidth: number
|
||
leftCollapsed: boolean
|
||
rightWidth: number
|
||
rightCollapsed: boolean
|
||
rightTab: string
|
||
theme: 'dark' | 'light'
|
||
autoCollapseProcess: boolean
|
||
settingsOpen: boolean
|
||
trackerRunId: string | null
|
||
viewingArtifact: Artifact | null
|
||
/** 右栏轻预览的工件(rail 预览页签内容;与弹窗深预览 viewingArtifact 并存)。 */
|
||
railPreviewId: string | null
|
||
activeWorkspace: string | null
|
||
taskOrg: 'project' | 'timeline'
|
||
showArchived: boolean
|
||
lastProjectId: string | null
|
||
/** 系统管理全屏视图(阶段② IA:仅管理员;入口账户菜单,纵深防御在 AdminZone 再拦一次)。 */
|
||
adminZoneOpen: boolean
|
||
changePwOpen: boolean
|
||
projectSettingsId: string | null
|
||
}
|
||
|
||
export interface Me { owner: string; name: string; auth: boolean; isAdmin?: boolean; mustChangePassword?: boolean }
|
||
|
||
interface AppState {
|
||
contextObs: Record<string, ContextObs>
|
||
projects: Project[]
|
||
currentProjectId: string | null
|
||
tasks: Conversation[]
|
||
me: Me | null
|
||
currentConvId: string | null
|
||
messages: Message[]
|
||
runs: Run[]
|
||
activeHitl: HITLCard[]
|
||
lastSeq: number
|
||
live: LiveState | null
|
||
plan: PlanSnapshot | null
|
||
todos: TodoList | null
|
||
artifacts: Artifact[]
|
||
cards: ActivityCard[]
|
||
projectCards: ActivityCard[]
|
||
inboxScope: 'conv' | 'project'
|
||
profiles: AgentProfile[]
|
||
pluginPanels: PluginPanel[]
|
||
serverWorkspaces: { id: string; title: string; icon?: string; shortcut?: string; body?: any[] }[]
|
||
ui: UIState
|
||
connected: boolean
|
||
error: string | null
|
||
toast: string | null
|
||
}
|
||
|
||
const DEFAULT_UI: UIState = {
|
||
leftWidth: 260,
|
||
leftCollapsed: false,
|
||
rightWidth: 340,
|
||
rightCollapsed: false,
|
||
rightTab: 'inbox',
|
||
activeWorkspace: null,
|
||
theme: 'dark',
|
||
autoCollapseProcess: true,
|
||
settingsOpen: false,
|
||
trackerRunId: null,
|
||
viewingArtifact: null,
|
||
railPreviewId: null,
|
||
taskOrg: 'project',
|
||
showArchived: false,
|
||
lastProjectId: null,
|
||
adminZoneOpen: false,
|
||
changePwOpen: false,
|
||
projectSettingsId: null,
|
||
}
|
||
|
||
function loadUI(): UIState {
|
||
try {
|
||
const raw = localStorage.getItem('as.ui')
|
||
if (raw) return { ...DEFAULT_UI, ...JSON.parse(raw) }
|
||
} catch { /* ignore */ }
|
||
return { ...DEFAULT_UI }
|
||
}
|
||
|
||
let state: AppState = {
|
||
contextObs: {},
|
||
projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null,
|
||
messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
|
||
plan: null, todos: null, artifacts: [], cards: [], projectCards: [], inboxScope: 'conv', profiles: [], pluginPanels: [],
|
||
serverWorkspaces: [],
|
||
ui: loadUI(), connected: false, error: null, toast: null,
|
||
}
|
||
|
||
const listeners = new Set<() => void>()
|
||
|
||
function set(patch: Partial<AppState>) {
|
||
state = { ...state, ...patch }
|
||
listeners.forEach(l => l())
|
||
}
|
||
|
||
function setUI(patch: Partial<UIState>) {
|
||
const ui = { ...state.ui, ...patch }
|
||
state = { ...state, ui }
|
||
try { localStorage.setItem('as.ui', JSON.stringify(ui)) } catch { /* ignore */ }
|
||
applyTheme(ui.theme)
|
||
listeners.forEach(l => l())
|
||
}
|
||
|
||
function applyTheme(theme: string) {
|
||
document.documentElement.dataset.theme = theme
|
||
}
|
||
|
||
let sse: { close: () => void } | null = null
|
||
let projectSse: { close: () => void } | null = null
|
||
let projectSeq = 0
|
||
|
||
export function useStore<T>(selector: (s: AppState) => T): T {
|
||
return useSyncExternalStore(
|
||
(cb) => { listeners.add(cb); return () => listeners.delete(cb) },
|
||
() => selector(state),
|
||
)
|
||
}
|
||
|
||
export const store = {
|
||
getState: () => state,
|
||
|
||
async refreshProjects() {
|
||
try {
|
||
const projects = await api.listProjects()
|
||
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) {
|
||
const p = await api.createProject(name)
|
||
await store.refreshProjects()
|
||
store.toast(`项目「${p.name}」已创建`)
|
||
return p
|
||
},
|
||
|
||
async openProject(projectId: string) {
|
||
set({ currentProjectId: projectId })
|
||
if (store.getState().inboxScope === 'project') {
|
||
store.loadProjectCards()
|
||
store.connectProject()
|
||
}
|
||
store.setUI({ lastProjectId: projectId })
|
||
await store.refreshArtifacts(projectId)
|
||
},
|
||
|
||
/** 新建任务(=会话):默认当前项目上下文;显式 projectId 用于"新建于指定项目"。 */
|
||
async createTask(projectId?: string) {
|
||
const pid = projectId ?? state.currentProjectId
|
||
if (!pid) { store.toast('请先创建项目'); return }
|
||
const c = await api.createConversation(pid)
|
||
await store.refreshTasks()
|
||
await store.openTask(c.id)
|
||
},
|
||
|
||
/** 兼容旧入口(⌘/命令总线) */
|
||
async createConversation(title?: string) {
|
||
if (title) store.toast('已在当前项目创建任务')
|
||
await store.createTask()
|
||
},
|
||
|
||
/** 打开任务:跨项目点击时切换项目上下文(工件/上传归属正确)。 */
|
||
async openTask(convId: string) {
|
||
const t = state.tasks.find(x => x.id === convId)
|
||
if (t && t.projectId !== state.currentProjectId) await store.openProject(t.projectId)
|
||
await store.openConversation(convId)
|
||
},
|
||
|
||
async pinTask(convId: string, pinned: boolean) {
|
||
const tasks = state.tasks.map(t => t.id === convId ? { ...t, pinned } : t)
|
||
set({ tasks })
|
||
try { await api.setConversationFlags(convId, { pinned }) } catch (e) { store.toast(`置顶失败:${e}`); store.refreshTasks() }
|
||
},
|
||
|
||
async archiveTask(convId: string, archived: boolean) {
|
||
const tasks = state.tasks.map(t => t.id === convId ? { ...t, archived } : t)
|
||
set({ tasks })
|
||
try {
|
||
await api.setConversationFlags(convId, { archived })
|
||
if (state.currentConvId === convId && archived) store.toast('任务已归档')
|
||
} catch (e) { store.toast(`归档失败:${e}`) }
|
||
store.refreshTasks()
|
||
},
|
||
|
||
// 跨会话标记(提案 2026-09-30-cross-session-reading):出站 allowCrossRead / 入站 crossPrivate。
|
||
async setCrossFlags(convId: string, flags: { allowCrossRead?: boolean; crossPrivate?: boolean }) {
|
||
const tasks = state.tasks.map(t => t.id === convId ? { ...t, ...flags } : t)
|
||
set({ tasks })
|
||
try { await api.setConversationFlags(convId, flags) } catch (e) { store.toast(`跨会话设置失败:${e}`); store.refreshTasks() }
|
||
},
|
||
|
||
async openConversation(convId: string) {
|
||
if (sse) { sse.close(); sse = null }
|
||
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], inboxScope: 'conv' })
|
||
await store.reconcile()
|
||
await store.connectLive()
|
||
},
|
||
|
||
// 终态对账:以 Query 的权威消息树/运行树/游标为准收敛。
|
||
async reconcile() {
|
||
if (!state.currentConvId) return
|
||
try {
|
||
const d = await api.conversationDetail(state.currentConvId)
|
||
const liveRunActive = state.live && !isTerminal(d.runs.find(r => r.id === state.live!.runId)?.status)
|
||
set({
|
||
messages: d.messages,
|
||
runs: d.runs,
|
||
activeHitl: d.activeHitl,
|
||
lastSeq: Math.max(state.lastSeq, d.lastSeq),
|
||
live: liveRunActive ? state.live : null,
|
||
connected: true,
|
||
})
|
||
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) })
|
||
}
|
||
},
|
||
|
||
connectLive() {
|
||
if (!state.currentConvId) return
|
||
const convId = state.currentConvId
|
||
sse = connectEvents(convId, state.lastSeq, {
|
||
onOpen: () => set({ connected: true }),
|
||
onError: () => set({ connected: false }),
|
||
onEvent: e => store.applyEvent(e),
|
||
})
|
||
},
|
||
|
||
applyEvent(e: StreamEvent) {
|
||
switch (e.type) {
|
||
case 'message.delta': {
|
||
const p = e.payload as { index: number; text: string }
|
||
set({ live: touchLive(e.runId ?? '', l => { l.items = applyTextDelta(l.items, p.index, p.text) }) })
|
||
break
|
||
}
|
||
case 'thinking.delta': {
|
||
const p = e.payload as { index: number; text: string }
|
||
set({ live: touchLive(e.runId ?? '', l => { l.items = applyThinkingDelta(l.items, p.text) }) })
|
||
break
|
||
}
|
||
case 'tool.call': {
|
||
const p = e.payload as { callId: string; name: string }
|
||
set({ live: touchLive(e.runId ?? '', l => { l.items = applyToolCall(l.items, p.name, p.callId) }) })
|
||
break
|
||
}
|
||
case 'tool.result': {
|
||
const p = e.payload as { callId: string; status: string; summary?: string }
|
||
set({ live: touchLive(e.runId ?? '', l => { l.items = applyToolResult(l.items, p.callId, p.status, p.summary) }) })
|
||
break
|
||
}
|
||
case 'run.phase': {
|
||
const p = e.payload as { phase: string }
|
||
set({ live: touchLive(e.runId ?? '', l => { l.phase = p.phase }) })
|
||
break
|
||
}
|
||
case 'hitl.request': {
|
||
store.reconcile()
|
||
break
|
||
}
|
||
case 'hitl.resolved': {
|
||
set({ activeHitl: state.activeHitl.filter(c => c.id !== (e.payload as { cardId: string }).cardId) })
|
||
break
|
||
}
|
||
case 'agent.spawn': {
|
||
const p = e.payload as { agentRun: string; name: string; task: string }
|
||
set({ live: touchLive(e.runId ?? '', l => { l.agentNotes = { ...l.agentNotes, [p.agentRun]: `已派生 ${p.name}:${p.task}` } }) })
|
||
break
|
||
}
|
||
case 'agent.progress': {
|
||
const p = e.payload as { agentRun: string; note?: string }
|
||
set({ live: touchLive(e.runId ?? '', l => { if (p.note) l.agentNotes = { ...l.agentNotes, [p.agentRun]: p.note! } }) })
|
||
break
|
||
}
|
||
case 'agent.result': {
|
||
const p = e.payload as { agentRun: string; summary?: string }
|
||
set({ live: touchLive(e.runId ?? '', l => { l.agentNotes = { ...l.agentNotes, [p.agentRun]: p.summary ?? '完成' } }) })
|
||
store.reconcile()
|
||
break
|
||
}
|
||
case 'plan.update': {
|
||
const p = e.payload as { plan: PlanSnapshot }
|
||
set({ plan: p.plan })
|
||
break
|
||
}
|
||
case 'todo.update': {
|
||
const p = e.payload as { list: TodoList }
|
||
set({ todos: p.list })
|
||
break
|
||
}
|
||
case 'card.created':
|
||
case 'card.updated': {
|
||
const p = e.payload as { card: ActivityCard }
|
||
if (p?.card) store.upsertCard(p.card)
|
||
break
|
||
}
|
||
case 'card.status': {
|
||
const p = e.payload as { cardId: string; status: ActivityCard['status']; tone?: string }
|
||
const cards = state.cards.map(c => c.id === p.cardId ? { ...c, status: p.status, tone: p.tone ?? c.tone } : c)
|
||
set({ cards })
|
||
break
|
||
}
|
||
case 'card.resolved': {
|
||
const p = e.payload as { cardId: string }
|
||
const cards = state.cards.map(c => c.id === p.cardId ? { ...c, status: 'resolved' as const } : c)
|
||
set({ cards })
|
||
break
|
||
}
|
||
case 'artifact.created': {
|
||
const p = e.payload as { artifact?: { id: string; name: string; kind?: string } }
|
||
if (p?.artifact) {
|
||
set({ live: touchLive(e.runId ?? '', l => {
|
||
l.items = applyArtifactCreated(l.items, { artifactId: p.artifact!.id, name: p.artifact!.name, kind: p.artifact!.kind })
|
||
}) })
|
||
}
|
||
if (state.currentProjectId) store.refreshArtifacts(state.currentProjectId)
|
||
break
|
||
}
|
||
case 'run.completed': {
|
||
const p = e.payload as { usage?: unknown; context?: ContextObs }
|
||
if (p?.context && e.runId) {
|
||
set({ contextObs: { ...state.contextObs, [e.runId]: p.context } })
|
||
}
|
||
setTimeout(() => store.reconcile(), 250)
|
||
break
|
||
}
|
||
case 'run.failed':
|
||
case 'error': {
|
||
// 终态:短暂延迟后对账(等待消息树落库),折叠过程输出。
|
||
setTimeout(() => store.reconcile(), 250)
|
||
break
|
||
}
|
||
}
|
||
},
|
||
|
||
async sendMessage(text: string, attachmentIds: string[]) {
|
||
if (!state.currentConvId) return
|
||
try {
|
||
const out = await api.sendMessage(state.currentConvId, text, attachmentIds)
|
||
if (out.command) {
|
||
store.toast(out.reply ?? `命令 ${out.command} 已执行`)
|
||
if (out.conversation) await store.openConversation(out.conversation.id)
|
||
else await store.reconcile()
|
||
return
|
||
}
|
||
if (out.run) {
|
||
// 竞态保护:POST 返回前 SSE 事件可能已先行到达并累积(同 run),
|
||
// 此时保留已累积的 live,只补挂用户消息;否则初始化空 live。
|
||
if (!state.live || state.live.runId !== out.run.id) {
|
||
set({ live: { runId: out.run.id, phase: 'thinking', items: [], agentNotes: {}, startedAt: Date.now() } })
|
||
}
|
||
set({ messages: [...state.messages, out.message] })
|
||
store.connectIfNeeded()
|
||
}
|
||
} catch (e) {
|
||
store.toast(`发送失败:${e}`)
|
||
await store.reconcile()
|
||
}
|
||
},
|
||
|
||
connectIfNeeded() {
|
||
if (!sse && state.currentConvId) store.connectLive()
|
||
},
|
||
|
||
async cancelActive() {
|
||
const active = state.runs.find(r => !isTerminal(r.status))
|
||
if (!active) return
|
||
await api.cancelRun(active.id).catch(() => undefined)
|
||
setTimeout(() => store.reconcile(), 200)
|
||
},
|
||
|
||
async resolveHITL(cardId: string, resp: { choiceId?: string; freeText?: string }) {
|
||
try {
|
||
const out = await api.resolveHITL(cardId, resp)
|
||
set({ activeHitl: state.activeHitl.filter(c => c.id !== cardId) })
|
||
if (out.pivoted && out.pivotText) {
|
||
await store.sendMessage(out.pivotText, [])
|
||
}
|
||
} catch (e) {
|
||
store.toast(`HITL 解析失败:${e}`)
|
||
}
|
||
},
|
||
|
||
upsertCard(card: ActivityCard) {
|
||
const i = state.cards.findIndex(c => c.id === card.id)
|
||
const cards = i >= 0 ? state.cards.map(c => c.id === card.id ? card : c) : [card, ...state.cards]
|
||
set({ cards })
|
||
},
|
||
|
||
async refreshCards(convId: string) {
|
||
try { set({ cards: await api.listCards(convId) }) } catch { /* 会话竞态,静默 */ }
|
||
},
|
||
|
||
// 卡片动作:确认投影卡(来源 hitl:)路由回 HITL 解析(恢复挂起 run,服务端同步销项);
|
||
// 其余卡走卡片动作 API,并把服务端持久化的回执消息追加进本地时间线(id 一致,对账不重复)。
|
||
async cardAction(card: ActivityCard, actionId: string, freeText?: string) {
|
||
const hitlRef = card.body?.find(n => n.type === 'kv')?.kv?.find(([k]) => k === '来源')?.[1]
|
||
if (hitlRef?.startsWith('hitl:')) {
|
||
await store.resolveHITL(hitlRef.slice(5), actionId ? { choiceId: actionId } : { freeText })
|
||
return
|
||
}
|
||
try {
|
||
const out = await api.cardAction(card.id, actionId, freeText)
|
||
store.upsertCard(out.card)
|
||
if (out.receipt) set({ messages: [...state.messages, out.receipt as Message] })
|
||
} catch (e) {
|
||
store.toast(`操作失败:${e}`)
|
||
}
|
||
},
|
||
|
||
async cardPin(card: ActivityCard, pinned: boolean) {
|
||
try { store.upsertCard((await api.cardPin(card.id, pinned)).card) } catch (e) { store.toast(`置顶失败:${e}`) }
|
||
},
|
||
|
||
async cardDismiss(card: ActivityCard) {
|
||
try { store.upsertCard((await api.cardDismiss(card.id)).card) } catch (e) { store.toast(`关闭失败:${e}`) }
|
||
},
|
||
|
||
async refreshArtifacts(projectId: string) {
|
||
try {
|
||
const artifacts = await api.listArtifacts(projectId)
|
||
set({ artifacts })
|
||
} catch { /* 项目删除竞态等,静默 */ }
|
||
},
|
||
|
||
async refreshProfiles() {
|
||
try { set({ profiles: await api.listProfiles() }) } catch { /* ignore */ }
|
||
},
|
||
|
||
async refreshPlugins() {
|
||
try {
|
||
const list = await api.listPlugins() as { panels?: PluginPanel[] }[]
|
||
const panels = list.flatMap(p => p.panels ?? [])
|
||
set({ pluginPanels: panels })
|
||
} catch { /* ignore */ }
|
||
try {
|
||
const ws = await api.listWorkspaces()
|
||
set({ serverWorkspaces: ws ?? [] })
|
||
} catch { /* ignore */ }
|
||
},
|
||
|
||
async uploadAttachment(file: File): Promise<string | null> {
|
||
if (!state.currentProjectId) return null
|
||
try {
|
||
const up = await api.upload(state.currentProjectId, file)
|
||
store.toast(`已上传 ${up.filename}${up.parsed ? `(解析:${up.parsed.meta.parser})` : ''}`)
|
||
await store.refreshArtifacts(state.currentProjectId)
|
||
return up.artifactId
|
||
} catch (e) {
|
||
store.toast(`上传失败:${e}`)
|
||
return null
|
||
}
|
||
},
|
||
|
||
setUI,
|
||
toggleLeft() { setUI({ leftCollapsed: !state.ui.leftCollapsed }) },
|
||
toggleRight() { setUI({ rightCollapsed: !state.ui.rightCollapsed }) },
|
||
openWorkspace(id: string) { setUI({ activeWorkspace: id }) },
|
||
/** 收纳策略(T5):一键收纳已完成卡(done→dismissed;resolved 盖章留痕不动)。 */
|
||
// 项目收件箱(CF3/D48-D51):加载聚合视图 + 项目流订阅 + 留档。
|
||
async loadProjectCards() {
|
||
const pid = store.getState().currentProjectId
|
||
if (!pid) return
|
||
try { set({ projectCards: await api.listProjectCards(pid) }) } catch { /* 静默:切项目竞态 */ }
|
||
},
|
||
|
||
setInboxScope(scope: 'conv' | 'project') {
|
||
set({ inboxScope: scope })
|
||
if (scope === 'project') {
|
||
store.loadProjectCards()
|
||
store.connectProject()
|
||
} else if (projectSse) {
|
||
projectSse.close()
|
||
projectSse = null
|
||
}
|
||
},
|
||
|
||
connectProject() {
|
||
const pid = store.getState().currentProjectId
|
||
if (!pid) return
|
||
projectSse?.close()
|
||
projectSse = connectEvents('', projectSeq, { onEvent: e => store.applyProjectEvent(e) }, pid)
|
||
},
|
||
|
||
applyProjectEvent(e: StreamEvent) {
|
||
projectSeq = Math.max(projectSeq, e.seq)
|
||
const upsert = (card: ActivityCard) => {
|
||
const list = store.getState().projectCards
|
||
const i = list.findIndex(c => c.id === card.id)
|
||
set({ projectCards: i >= 0 ? list.map(c => (c.id === card.id ? card : c)) : [card, ...list] })
|
||
}
|
||
switch (e.type) {
|
||
case 'card.created':
|
||
case 'card.updated':
|
||
if ((e.payload as { card?: ActivityCard })?.card) upsert((e.payload as { card: ActivityCard }).card)
|
||
break
|
||
case 'card.status': {
|
||
const p = e.payload as { cardId: string; status: ActivityCard['status']; tone?: string }
|
||
const list = store.getState().projectCards
|
||
set({ projectCards: list.map(c => (c.id === p.cardId ? { ...c, status: p.status, tone: p.tone ?? c.tone } : c)) })
|
||
break
|
||
}
|
||
case 'card.resolved': {
|
||
const p = e.payload as { cardId: string }
|
||
const list = store.getState().projectCards
|
||
set({ projectCards: list.map(c => (c.id === p.cardId ? { ...c, status: 'resolved' as const } : c)) })
|
||
break
|
||
}
|
||
}
|
||
},
|
||
|
||
async cardToProject(card: ActivityCard) {
|
||
try {
|
||
const out = await api.cardToProject(card.id)
|
||
store.upsertCard(out.origin) // 会话视图:原卡 dismissed 留痕
|
||
const list = store.getState().projectCards
|
||
const i = list.findIndex(c => c.id === out.card.id)
|
||
set({ projectCards: i >= 0 ? list.map(c => (c.id === out.card.id ? out.card : c)) : [out.card, ...list] })
|
||
store.toast('已留档到项目收件箱')
|
||
store.setInboxScope('project')
|
||
} catch (e) {
|
||
store.toast(`留档失败:${e}`)
|
||
}
|
||
},
|
||
|
||
async tidyCards() {
|
||
const done = state.cards.filter(c => c.status === 'done')
|
||
for (const c of done) {
|
||
try { const out = await api.cardDismiss(c.id); store.upsertCard(out.card) } catch { /* 单卡失败不阻断 */ }
|
||
}
|
||
if (done.length > 0) store.toast(`已收纳 ${done.length} 张完成卡`)
|
||
},
|
||
closeWorkspace() { setUI({ activeWorkspace: null }) },
|
||
openInbox(cardId?: string) {
|
||
setUI({ rightCollapsed: false, rightTab: 'inbox' })
|
||
if (cardId) setTimeout(() => {
|
||
document.querySelector(`[data-card-id="${cardId}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||
}, 60)
|
||
},
|
||
// 卡→消息互定位另一半(D32):从收件箱卡滚回产生它的对话消息。
|
||
locateMessage(messageId?: string) {
|
||
if (!messageId) return
|
||
setTimeout(() => {
|
||
document.querySelector(`[data-msg-id="${messageId}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||
}, 60)
|
||
},
|
||
openTracker(runId: string) { setUI({ trackerRunId: runId }) },
|
||
viewArtifact(a: Artifact | null) { setUI({ viewingArtifact: a }) },
|
||
/** 右栏轻预览:打开指定工件并切到 rail 预览页签(双落位中的"轻"侧)。 */
|
||
railPreview(a: Artifact | null) {
|
||
if (!a) { setUI({ railPreviewId: null, rightTab: 'inbox' }); return }
|
||
setUI({ railPreviewId: a.id, rightTab: 'preview', rightCollapsed: false })
|
||
},
|
||
/**
|
||
* 工件预览唯一入口(重设计方案 阶段③ 收敛,Command 语义):一切预览请求只经这里,
|
||
* 落位由本方法派生——默认右栏轻预览(不打断对话),hint 'full' 落位全屏深读
|
||
* (DocViewer,全项目唯一"大窗"形态)。下载语义同理由各落位只保留一个按钮。
|
||
*/
|
||
requestPreview(a: Artifact, hint?: 'full') {
|
||
if (hint === 'full') { setUI({ viewingArtifact: a }); return }
|
||
setUI({ railPreviewId: a.id, rightTab: 'preview', rightCollapsed: false })
|
||
},
|
||
openSettings(open: boolean) { setUI({ settingsOpen: open }) },
|
||
openProjectSettings(id: string | null) { setUI({ projectSettingsId: id }) },
|
||
toast(msg: string) {
|
||
set({ toast: msg })
|
||
setTimeout(() => { if (state.toast === msg) set({ toast: null }) }, 2600)
|
||
},
|
||
}
|
||
|
||
function touchLive(_runId: string, mutate: (l: LiveState) => void): LiveState | null {
|
||
const base = state.live ?? { runId: _runId, phase: 'thinking', items: [], agentNotes: {}, startedAt: Date.now() }
|
||
const next: LiveState = { ...base, agentNotes: { ...base.agentNotes } }
|
||
mutate(next)
|
||
return next
|
||
}
|
||
|
||
export function isTerminal(status?: string): boolean {
|
||
return status === 'completed' || status === 'failed' || status === 'cancelled' || status === 'interrupted'
|
||
}
|
||
|
||
export function activeRun(runs: Run[]): Run | undefined {
|
||
return runs.find(r => !isTerminal(r.status))
|
||
}
|