Why: 方案 r1 U6/R4/KTD5——RDM #266 已交付与实际判定同源的矩阵解释读面,项目设置需消费入口(UI 不重算,服务端解释)。 Evidence: 红:3 failed(导出缺失,/tmp/fe-matrix-red.txt);绿:vitest 25 文件/127 测试全过(含 design-guard)+ npm run build 成功(tsc -b 干净);收据 /tmp/fe-matrix-vitest.txt、/tmp/fe-matrix-build.txt。 Impact: rdmManagement 增 RDM_PERM_MATRIX_COMMANDS/rdmPermMatrixReady/permCheckPayload/describeSource;RdmPermMatrixSection(成员选择默认自省+12 动作矩阵表+来源标签+拒绝原因);项目设置「项目权限」页签(rdmPermMatrixAvailable 门控);store 同源计算标志。样式仅复用既有类。 Refs: RDM #266;方案 r1 U6/R4
1049 lines
42 KiB
TypeScript
1049 lines
42 KiB
TypeScript
// 应用状态:服务端权威(事件 + Query 对账),前端 store 是投影。
|
||
// useSyncExternalStore 驱动;live 流式部件为会话内临时态,run 终态后由消息树对账收敛。
|
||
import { useSyncExternalStore } from 'react'
|
||
import { type AdminZoneSection, api, connectEvents } from './api'
|
||
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated, applyArtifactUpdated, applyCheck, applyFinalizing, type LiveItem } from './live'
|
||
import { closePreviewTab, mergeOrphanPreviewTabs, openPreviewTab, previewTabsKey, type PreviewTab } from './previewtabs'
|
||
import { deliveryRequestId, enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage, removeQueuedMessage, replaceQueuedText, type QueuedMessage } from './outbox'
|
||
import { canFlushOutbox, deriveShellPhase } from './runstate'
|
||
import { flashElement } from './scrollsync'
|
||
import { isThemeChoice, resolveTheme, type ThemeChoice } from './theme'
|
||
import { rdmAuditReady } from './workspaces'
|
||
import { rdmRolesReady, rdmMembersReady, rdmDocReviewReady, rdmPermMatrixReady } from './rdmManagement'
|
||
|
||
type FontSizeChoice = 13 | 14 | 16 | 18
|
||
const FONT_SIZE_CHOICES: readonly FontSizeChoice[] = [13, 14, 16, 18]
|
||
|
||
export function isFontSizeChoice(value: number): value is FontSizeChoice {
|
||
return (FONT_SIZE_CHOICES as readonly number[]).includes(value)
|
||
}
|
||
import type {
|
||
ActivityCard,
|
||
AgentProfile,
|
||
Artifact,
|
||
Conversation,
|
||
HITLCard,
|
||
Message,
|
||
PlanSnapshot,
|
||
PluginPanel,
|
||
Project,
|
||
Run,
|
||
StreamEvent,
|
||
SurfaceMeta,
|
||
SurfaceSnapshot,
|
||
TodoList
|
||
} from './contracts'
|
||
|
||
export interface ContextObs {
|
||
items: string[]
|
||
truncations?: string[]
|
||
estChars: number
|
||
sections?: { name: string; estTokens: number }[]
|
||
prefixHash?: string
|
||
}
|
||
|
||
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: 'work' | 'files'
|
||
/** 文件入口子视图:列表 / 预览(预览标签存在时才有意义)。 */
|
||
filesView: 'list' | 'preview'
|
||
/** U6:工作区任务页固定(surfaceId;null=跟随最新;固定后新页面不抢占显示)。 */
|
||
workspaceSurfacePin: string | null
|
||
/** 收件箱筛选:全部 / 待处理。铃铛打开时切到待处理。 */
|
||
inboxFilter: 'all' | 'todo'
|
||
theme: ThemeChoice
|
||
/** 界面字号,只写本机。对应 --ui-font-size。 */
|
||
fontSize: FontSizeChoice
|
||
autoCollapseProcess: boolean
|
||
settingsOpen: boolean
|
||
trackerRunId: string | null
|
||
/** 预览区宽度。标签列表按会话放在 AppState,不进本地偏好。 */
|
||
previewWidth: number
|
||
/** 预览栏铺满主区。标签不持久化,这个标志在 loadUI 时强制复位。 */
|
||
previewExpanded: boolean
|
||
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[]
|
||
/** generative-ui:会话内已保存页面(surface.updated 投影;按 surfaceId 存最新修订)。 */
|
||
surfaces: SurfaceSnapshot[]
|
||
/** generative-ui U6:项目级跨会话页面聚合(打开项目时权威装载)。 */
|
||
projectSurfaces: SurfaceMeta[]
|
||
cards: ActivityCard[]
|
||
projectCards: ActivityCard[]
|
||
inboxScope: 'conv' | 'project'
|
||
/** runtime.mock:只控制演示指令提示,不改变对话能力。 */
|
||
runtimeMock: boolean
|
||
profiles: AgentProfile[]
|
||
pluginPanels: PluginPanel[]
|
||
serverWorkspaces: { id: string; title: string; icon?: string; shortcut?: string; body?: any[] }[]
|
||
/** 审计入口能力(增量 2):GET /api/commands 含全部 rdm.inspect.* 且启用;探测失败/未登录为 false,入口隐藏。 */
|
||
rdmAuditAvailable: boolean
|
||
/** U6 RDM 管理页能力:命令面齐备才显示对应分区/页签(普通基座零暴露,fail-closed)。 */
|
||
rdmRolesAvailable: boolean
|
||
rdmMembersAvailable: boolean
|
||
rdmDocReviewAvailable: boolean
|
||
rdmPermMatrixAvailable: boolean
|
||
/** 管理区声明分区(WP3-I3):编译插件声明,入口全员可见、区内置权限不合并。 */
|
||
adminSections: AdminZoneSection[]
|
||
ui: UIState
|
||
connected: boolean
|
||
/** 发送请求已经出去,run 记录还没回来。 */
|
||
posting: boolean
|
||
error: string | null
|
||
toast: string | null
|
||
outbox: QueuedMessage[]
|
||
outboxDraining: boolean
|
||
previewTabs: Record<string, PreviewTab[]>
|
||
previewActive: Record<string, string | null>
|
||
}
|
||
|
||
const DEFAULT_UI: UIState = {
|
||
leftWidth: 260,
|
||
leftCollapsed: false,
|
||
rightWidth: 360,
|
||
rightCollapsed: false,
|
||
rightTab: 'work',
|
||
filesView: 'list', workspaceSurfacePin: null,
|
||
inboxFilter: 'all',
|
||
activeWorkspace: null,
|
||
theme: 'dark',
|
||
fontSize: 14,
|
||
autoCollapseProcess: true,
|
||
settingsOpen: false,
|
||
trackerRunId: null,
|
||
previewWidth: 640,
|
||
previewExpanded: false,
|
||
taskOrg: 'project',
|
||
showArchived: false,
|
||
lastProjectId: null,
|
||
adminZoneOpen: false,
|
||
changePwOpen: false,
|
||
projectSettingsId: null,
|
||
}
|
||
|
||
function loadUI(): UIState {
|
||
try {
|
||
const raw = localStorage.getItem('as.ui')
|
||
if (raw) {
|
||
const ui = { ...DEFAULT_UI, ...JSON.parse(raw) } as UIState
|
||
if (!isThemeChoice(ui.theme)) ui.theme = 'dark'
|
||
if (!isFontSizeChoice(ui.fontSize)) ui.fontSize = 14
|
||
if (ui.inboxFilter !== 'all' && ui.inboxFilter !== 'todo') ui.inboxFilter = 'all'
|
||
// 旧本地偏好里存的是 'inbox'——第三栏双入口上线时归一到 'work'。
|
||
if (ui.rightTab !== 'work' && ui.rightTab !== 'files') ui.rightTab = 'work'
|
||
if (ui.filesView !== 'list' && ui.filesView !== 'preview') ui.filesView = 'list'
|
||
// 预览标签不进本地偏好。展开态若随刷新残留,会在没有标签时藏起对话。
|
||
ui.previewExpanded = false
|
||
return ui
|
||
}
|
||
} catch { /* ignore */ }
|
||
return { ...DEFAULT_UI }
|
||
}
|
||
|
||
const surfaceActionsInflight: Record<string, boolean> = {}
|
||
|
||
let state: AppState = {
|
||
contextObs: {},
|
||
projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null,
|
||
messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
|
||
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [], cards: [], projectCards: [], inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
|
||
serverWorkspaces: [],
|
||
rdmAuditAvailable: false,
|
||
rdmRolesAvailable: false, rdmMembersAvailable: false, rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false,
|
||
adminSections: [],
|
||
ui: loadUI(), connected: false, posting: false, error: null, toast: null,
|
||
outbox: [], outboxDraining: false,
|
||
previewTabs: {}, previewActive: {},
|
||
}
|
||
|
||
const listeners = new Set<() => void>()
|
||
|
||
function set(patch: Partial<AppState>) {
|
||
state = { ...state, ...patch }
|
||
listeners.forEach(l => l())
|
||
}
|
||
|
||
// 第三栏「文件」入口联动(方案:预览并入第三栏):开第一张标签自动切到文件入口的
|
||
// 预览子视图并展开右栏;关掉最后一张回到文件列表。不再收起右栏——预览与工作同居第三栏。
|
||
function syncFilesEntry(hasTabs: boolean) {
|
||
if (hasTabs) {
|
||
setUI({ rightTab: 'files', rightCollapsed: false, filesView: 'preview' })
|
||
return
|
||
}
|
||
if (state.ui.filesView === 'preview') setUI({ filesView: 'list' })
|
||
}
|
||
|
||
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)
|
||
applyFontSize(ui.fontSize)
|
||
listeners.forEach(l => l())
|
||
}
|
||
|
||
// 会话恢复(空态点工件无反应的高频前置):currentConvId 不持久化时刷新必回空态,
|
||
// 空态下 requestPreview 的标签挂在 '_' 键、渲染判空键——点击只剩弹层关闭,没有任何反馈。
|
||
const CONV_KEY = 'as.conv'
|
||
let convRestoreDone = false
|
||
function persistConvId(id: string | null) {
|
||
try { id ? localStorage.setItem(CONV_KEY, id) : localStorage.removeItem(CONV_KEY) } catch { /* ignore */ }
|
||
}
|
||
function savedConvId(): string | null {
|
||
try { return localStorage.getItem(CONV_KEY) } catch { return null }
|
||
}
|
||
|
||
let systemQuery: MediaQueryList | null = null
|
||
let systemHandler: (() => void) | null = null
|
||
|
||
function applyTheme(theme: string) {
|
||
if (typeof document === 'undefined') return
|
||
if (systemQuery && systemHandler) {
|
||
systemQuery.removeEventListener('change', systemHandler)
|
||
systemQuery = null
|
||
systemHandler = null
|
||
}
|
||
const paint = (prefersDark: boolean) => {
|
||
document.documentElement.dataset.theme = resolveTheme(theme, prefersDark)
|
||
}
|
||
if (theme === 'system' && typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
|
||
systemQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||
systemHandler = () => paint(systemQuery?.matches ?? false)
|
||
systemQuery.addEventListener('change', systemHandler)
|
||
paint(systemQuery.matches)
|
||
return
|
||
}
|
||
paint(false)
|
||
}
|
||
|
||
applyTheme(state.ui.theme)
|
||
applyFontSize(state.ui.fontSize)
|
||
|
||
function applyFontSize(size: FontSizeChoice) {
|
||
if (typeof document === 'undefined') return
|
||
document.documentElement.style.setProperty('--ui-font-size', `${size}px`)
|
||
}
|
||
|
||
function flashLocate(root: string, attr: string, id: string) {
|
||
if (typeof document === 'undefined') return
|
||
const esc = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(id) : id.replace(/["\\]/g, '')
|
||
const el = document.querySelector(`${root} [${attr}="${esc}"]`)
|
||
el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||
flashElement(el)
|
||
}
|
||
|
||
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 {
|
||
const tasks = await api.listTasks(includeArchived)
|
||
set({ tasks })
|
||
// 首次加载恢复上次会话(仅一次;此后用户主动切换/新建不再打扰)。
|
||
if (!convRestoreDone) {
|
||
convRestoreDone = true
|
||
const saved = savedConvId()
|
||
if (saved && !state.currentConvId && tasks.some(t => t.id === saved && !t.archived)) {
|
||
store.openConversation(saved)
|
||
}
|
||
}
|
||
} 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) {
|
||
let pid = projectId ?? state.currentProjectId
|
||
if (!pid && state.projects.length > 0) pid = state.projects[0].id
|
||
if (!pid) {
|
||
const p = await store.createProject('默认项目')
|
||
pid = p.id
|
||
}
|
||
if (pid !== state.currentProjectId) await store.openProject(pid)
|
||
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 setPermissionMode(convId: string, permissionMode: 'default' | 'plan' | 'auto_edit') {
|
||
const tasks = state.tasks.map(t => t.id === convId ? { ...t, permissionMode } : t)
|
||
set({ tasks })
|
||
try { await api.setPermissionMode(convId, permissionMode) } catch (e) { store.toast(`权限模式切换失败:${e}`); store.refreshTasks() }
|
||
},
|
||
|
||
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: [], surfaces: [], projectSurfaces: [], inboxScope: 'conv', outbox: [], outboxDraining: false, posting: false })
|
||
persistConvId(convId)
|
||
// 空态时打开的预览标签('_' 键)随会话进入一起迁移,不留在不可见的孤儿键下。
|
||
if ((state.previewTabs['_'] ?? []).length > 0) {
|
||
const active = state.previewActive[convId] ?? state.previewActive['_'] ?? null
|
||
set({
|
||
previewTabs: mergeOrphanPreviewTabs(state.previewTabs, convId),
|
||
previewActive: { ...state.previewActive, [convId]: active },
|
||
})
|
||
}
|
||
// U4/R5:已保存页面从权威快照重载(刷新/断线恢复;事件流只做增量投影)。
|
||
api.projectSurfaces(state.currentProjectId ?? '').then(list => { if (state.currentConvId === convId) set({ projectSurfaces: list ?? [] }) }).catch(() => {})
|
||
api.surfaceList(convId).then(list => {
|
||
if (state.currentConvId !== convId) return
|
||
set({ surfaces: list ?? [] })
|
||
// 刷新/重进恢复:有页面且尚无页面标签时补开最新一张的标签(不强切
|
||
// 右栏——只在实时事件到达时切换,避免每次进会话劫持视图)。
|
||
const conv = previewTabsKey(state.currentConvId)
|
||
const tabs = state.previewTabs[conv] ?? []
|
||
const hasSurfaceTab = tabs.some(t => t.id.startsWith('surface:'))
|
||
if (!hasSurfaceTab && (list?.length ?? 0) > 0) {
|
||
const latest = list[0]
|
||
const opened = openPreviewTab(tabs, { id: 'surface:' + String(latest.surfaceId), name: latest.title || '页面' })
|
||
set({
|
||
previewTabs: { ...state.previewTabs, [conv]: opened.tabs },
|
||
previewActive: { ...state.previewActive, [conv]: opened.activeId },
|
||
})
|
||
}
|
||
}).catch(() => {})
|
||
const resumeSeq = await store.reconcile()
|
||
store.connectLive(resumeSeq ?? state.lastSeq)
|
||
},
|
||
|
||
// 终态对账:以 Query 的权威消息树/运行树/游标为准收敛。
|
||
// 返回值是实时流应从这个 seq 之后重放;有进行中的 run 时它早于 lastSeq。
|
||
async reconcile(): Promise<number | undefined> {
|
||
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)
|
||
const resumeSeq = d.resumeSeq ?? d.lastSeq
|
||
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()
|
||
return resumeSeq
|
||
} catch (e) {
|
||
set({ error: String(e) })
|
||
}
|
||
},
|
||
|
||
connectLive(since?: number) {
|
||
if (!state.currentConvId) return
|
||
const convId = state.currentConvId
|
||
sse = connectEvents(convId, since ?? state.lastSeq, {
|
||
onOpen: () => set({ connected: true }),
|
||
onError: () => set({ connected: false }),
|
||
onEvent: e => store.applyEvent(e),
|
||
})
|
||
},
|
||
|
||
applyEvent(e: StreamEvent) {
|
||
const now = eventTime(e)
|
||
const project = (mutate: (l: LiveState) => void) => touchLive(e.runId ?? '', mutate, now)
|
||
switch (e.type) {
|
||
case 'message.delta': {
|
||
const p = e.payload as { index: number; text: string }
|
||
set({ live: project(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: project(l => { l.items = applyThinkingDelta(l.items, p.text, now) }) })
|
||
break
|
||
}
|
||
case 'tool.call.delta': {
|
||
const p = e.payload as { index: number; callId?: string; name?: string; delta: string }
|
||
set({ live: project(l => { l.items = applyToolCallDelta(l.items, p.index, p.delta, p.callId, p.name, now) }) })
|
||
break
|
||
}
|
||
case 'tool.call': {
|
||
const p = e.payload as { callId: string; name: string; arguments?: string }
|
||
set({ live: project(l => { l.items = applyToolCall(l.items, p.name, p.callId, p.arguments, now) }) })
|
||
break
|
||
}
|
||
case 'tool.output': {
|
||
const p = e.payload as { callId?: string; text: string }
|
||
set({ live: project(l => { l.items = applyToolOutput(l.items, p.callId, p.text) }) })
|
||
break
|
||
}
|
||
case 'tool.result': {
|
||
const p = e.payload as { callId: string; status: string; summary?: string }
|
||
set({ live: project(l => { l.items = applyToolResult(l.items, p.callId, p.status, p.summary, now) }) })
|
||
break
|
||
}
|
||
case 'run.phase': {
|
||
const p = e.payload as { phase: string }
|
||
set({ live: project(l => {
|
||
l.phase = p.phase
|
||
if (p.phase === 'finalizing') l.items = applyFinalizing(l.items)
|
||
}) })
|
||
break
|
||
}
|
||
case 'run.check': {
|
||
const p = e.payload as { passed?: boolean; reason?: string }
|
||
set({ live: project(l => { l.items = applyCheck(l.items, !!p.passed, p.reason ?? '') }) })
|
||
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: project(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: project(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: project(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 'surface.updated': {
|
||
// KTD8:单一状态所有者——服务端快照为权威,本地按 revision 守卫:
|
||
// 旧修订/重复事件不覆盖新修订;切会话由 selectConversation 清空。
|
||
const p = e.payload as SurfaceSnapshot
|
||
if (!p?.identity?.surfaceId) break
|
||
const id = String(p.identity.surfaceId)
|
||
const surfaces = state.surfaces.filter(x => String(x.identity.surfaceId) !== id)
|
||
const existing = state.surfaces.find(x => String(x.identity.surfaceId) === id)
|
||
if (!existing || p.revision > existing.revision) {
|
||
surfaces.push(p)
|
||
set({ surfaces })
|
||
// 新页面到达:没有页面标签或当前正在看页面 → 开标签并把右栏切到
|
||
// 文件预览(与 requestPreview 同语义,用户能立刻看到);否则不抢占
|
||
// 已打开的对象,但留 toast 痕迹(KTD「新结果不抢占」)。
|
||
const conv = previewTabsKey(state.currentConvId)
|
||
const tabs = state.previewTabs[conv] ?? []
|
||
const activeId = state.previewActive[conv] ?? null
|
||
const hasSurfaceTab = tabs.some(t => t.id.startsWith('surface:'))
|
||
if (!hasSurfaceTab || (activeId ?? '').startsWith('surface:')) {
|
||
const opened = openPreviewTab(tabs, { id: 'surface:' + id, name: p.spec?.title || '页面' })
|
||
set({
|
||
previewTabs: { ...state.previewTabs, [conv]: opened.tabs },
|
||
previewActive: { ...state.previewActive, [conv]: opened.activeId },
|
||
})
|
||
syncFilesEntry(true)
|
||
} else {
|
||
store.toast(`页面已更新:${p.spec?.title || id}`)
|
||
}
|
||
}
|
||
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':
|
||
case 'artifact.updated': {
|
||
const p = e.payload as { artifact?: { id: string; name: string; kind?: string } }
|
||
if (p?.artifact) {
|
||
const ref = { artifactId: p.artifact.id, name: p.artifact.name, kind: p.artifact.kind }
|
||
set({ live: project(l => {
|
||
l.items = e.type === 'artifact.updated' ? applyArtifactUpdated(l.items, ref) : applyArtifactCreated(l.items, ref)
|
||
}) })
|
||
}
|
||
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(() => { void store.reconcile().then(() => store.flushOutbox()) }, 250)
|
||
break
|
||
}
|
||
case 'run.failed':
|
||
case 'error': {
|
||
// 终态:短暂延迟后对账(等待消息树落库),折叠过程输出,再发送排队消息。
|
||
setTimeout(() => { void store.reconcile().then(() => store.flushOutbox()) }, 250)
|
||
break
|
||
}
|
||
default: {
|
||
const unexpected: never = e.type
|
||
void unexpected
|
||
}
|
||
}
|
||
},
|
||
|
||
async sendMessage(text: string, attachmentIds: string[], requestId?: string): Promise<'ok' | 'busy'> {
|
||
if (!state.currentConvId) return 'ok'
|
||
const id = requestId ?? newQueuedMessage(text, attachmentIds, []).id
|
||
set({ posting: true })
|
||
try {
|
||
const out = await api.sendMessage(state.currentConvId, text, attachmentIds, id)
|
||
if (out.command) {
|
||
store.toast(out.reply ?? `命令 ${out.command} 已执行`)
|
||
if (out.conversation) await store.openConversation(out.conversation.id)
|
||
else await store.reconcile()
|
||
await store.flushOutbox()
|
||
return 'ok'
|
||
}
|
||
if (out.run) {
|
||
const started = out.run
|
||
// 立刻把 run 合进列表,停止/排队不必等终态对账。已有同 id 记录保持不动。
|
||
const runs = mergeStartedRun(state.runs, started)
|
||
const messages = state.messages.some(m => m.id === out.message.id)
|
||
? state.messages
|
||
: [...state.messages, out.message]
|
||
if (isTerminal(runs.find(r => r.id === started.id)?.status)) {
|
||
set({ runs, messages })
|
||
await store.reconcile()
|
||
return 'ok'
|
||
}
|
||
// 竞态保护:POST 返回前 SSE 事件可能已先行到达并累积(同 run),
|
||
// 此时保留已累积的 live,只补挂用户消息;否则初始化空 live。
|
||
const live = !state.live || state.live.runId !== started.id
|
||
? { runId: started.id, phase: 'thinking', items: [], agentNotes: {}, startedAt: Date.now() }
|
||
: state.live
|
||
set({ live, runs, messages })
|
||
store.connectIfNeeded()
|
||
}
|
||
return 'ok'
|
||
} catch (e) {
|
||
if (isConversationBusy(e)) {
|
||
set({ outbox: enqueueMessageFront(state.outbox, { id, text, attachmentIds, attachmentNames: [] }) })
|
||
return 'busy'
|
||
}
|
||
store.toast(`发送失败:${e}`)
|
||
await store.reconcile()
|
||
return 'ok'
|
||
} finally {
|
||
set({ posting: false })
|
||
}
|
||
},
|
||
|
||
enqueueOutbox(text: string, attachmentIds: string[], attachmentNames: string[]) {
|
||
set({ outbox: enqueueMessage(state.outbox, newQueuedMessage(text, attachmentIds, attachmentNames)) })
|
||
},
|
||
|
||
updateOutbox(id: string, text: string) {
|
||
set({ outbox: replaceQueuedText(state.outbox, id, text) })
|
||
},
|
||
|
||
removeOutbox(id: string) {
|
||
set({ outbox: removeQueuedMessage(state.outbox, id) })
|
||
},
|
||
|
||
async sendOutboxNow(id: string) {
|
||
const item = state.outbox.find(q => q.id === id)
|
||
if (!item) return
|
||
const rest = removeQueuedMessage(state.outbox, id)
|
||
if (!canFlushOutbox(shellPhase())) {
|
||
set({ outbox: promoteQueuedMessage(state.outbox, id) })
|
||
store.toast('回合进行中,已放到队首,结束后自动发送')
|
||
return
|
||
}
|
||
set({ outbox: rest })
|
||
await store.sendMessage(item.text, item.attachmentIds, deliveryRequestId(item))
|
||
},
|
||
|
||
async flushOutbox() {
|
||
if (state.outboxDraining) return
|
||
if (!canFlushOutbox(shellPhase())) return
|
||
const next = state.outbox[0]
|
||
if (!next) return
|
||
set({ outbox: state.outbox.slice(1), outboxDraining: true })
|
||
let result: 'ok' | 'busy' = 'ok'
|
||
try {
|
||
result = await store.sendMessage(next.text, next.attachmentIds, deliveryRequestId(next))
|
||
} finally {
|
||
set({ outboxDraining: false })
|
||
}
|
||
if (result === 'busy') return
|
||
if (canFlushOutbox(shellPhase()) && state.outbox.length > 0) {
|
||
await store.flushOutbox()
|
||
}
|
||
},
|
||
|
||
connectIfNeeded() {
|
||
if (!sse && state.currentConvId) store.connectLive()
|
||
},
|
||
|
||
async cancelActive() {
|
||
const active = activeRun(state.runs)
|
||
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] })
|
||
// 命令确认卡的动作响应是决议前快照。再拉一次,内联卡才能从待确认过渡到折叠。
|
||
if (card.kind === 'confirm' && actionId !== 'recheck' && out.card.status === 'action_required' && card.conversationId) {
|
||
await store.refreshCards(card.conversationId)
|
||
}
|
||
} 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 */ }
|
||
// 审计入口能力门控(增量 2):与工作区清单同源刷新——能力撤销(插件卸载/命令停用)后入口随之隐藏。
|
||
try {
|
||
const cmds = await api.listCommands()
|
||
set({
|
||
rdmAuditAvailable: rdmAuditReady(cmds),
|
||
rdmRolesAvailable: rdmRolesReady(cmds),
|
||
rdmMembersAvailable: rdmMembersReady(cmds),
|
||
rdmDocReviewAvailable: rdmDocReviewReady(cmds),
|
||
rdmPermMatrixAvailable: rdmPermMatrixReady(cmds),
|
||
})
|
||
} catch { set({ rdmAuditAvailable: false }) }
|
||
try {
|
||
const sections = await api.adminZoneSections()
|
||
set({ adminSections: sections ?? [] })
|
||
} catch { /* ignore */ }
|
||
try {
|
||
const settings = await api.getSettings()
|
||
const runtime = settings.user.runtime
|
||
const mock = !!runtime && typeof runtime === 'object' && (runtime as { mock?: unknown }).mock === true
|
||
set({ runtimeMock: mock })
|
||
} 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 }) },
|
||
/** 铃铛:打开右栏并切到待处理。哪一侧有待办就看哪一侧。 */
|
||
focusPending() {
|
||
const conv = state.cards.filter(c => c.status === 'action_required').length
|
||
const proj = state.projectCards.filter(c => c.status === 'action_required').length
|
||
if (conv === 0 && proj > 0) store.setInboxScope('project')
|
||
setUI({ rightCollapsed: false, rightTab: 'work', inboxFilter: 'todo' })
|
||
},
|
||
openInbox(cardId?: string) {
|
||
setUI({ rightCollapsed: false, rightTab: 'work' })
|
||
if (cardId) setTimeout(() => flashLocate('.right-panel', 'data-card-id', cardId), 60)
|
||
},
|
||
/** 左栏/收起轨的「文件」入口:打开第三栏文件入口(列表或既有预览)。 */
|
||
openFiles() { setUI({ rightTab: 'files', rightCollapsed: false }) },
|
||
// 卡→消息互定位另一半(D32):从收件箱卡滚回产生它的对话消息。
|
||
locateMessage(messageId?: string) {
|
||
if (!messageId) return
|
||
setTimeout(() => flashLocate('.chat', 'data-msg-id', messageId), 60)
|
||
},
|
||
openTracker(runId: string) { setUI({ trackerRunId: runId }) },
|
||
/**
|
||
* 工件预览唯一入口:在第三栏「文件」入口打开或激活标签(多文件多标签)。
|
||
* 打开第一张自动切到预览子视图;关掉最后一张回到文件列表,并收回展开态。
|
||
*/
|
||
requestPreview(a: Artifact) {
|
||
const conv = previewTabsKey(state.currentConvId)
|
||
const opened = openPreviewTab(state.previewTabs[conv] ?? [], { id: a.id, name: a.name })
|
||
set({
|
||
previewTabs: { ...state.previewTabs, [conv]: opened.tabs },
|
||
previewActive: { ...state.previewActive, [conv]: opened.activeId },
|
||
})
|
||
syncFilesEntry(true)
|
||
},
|
||
openSurface(surfaceId: string) {
|
||
// generative-ui:页面进预览第三栏(标签 id 带 surface: 前缀区分工件)。
|
||
const snap = state.surfaces.find(x => String(x.identity.surfaceId) === surfaceId)
|
||
const conv = previewTabsKey(state.currentConvId)
|
||
const opened = openPreviewTab(state.previewTabs[conv] ?? [], { id: 'surface:' + surfaceId, name: snap?.spec?.title || '页面' })
|
||
set({
|
||
previewTabs: { ...state.previewTabs, [conv]: opened.tabs },
|
||
previewActive: { ...state.previewActive, [conv]: opened.activeId },
|
||
})
|
||
syncFilesEntry(true)
|
||
},
|
||
// U5:页面动作提交——requestId 由客户端生成(服务端幂等键);草稿只在此刻
|
||
// 随动作外发(未提交输入不上传、不进模型上下文)。在途防抖挡双击。
|
||
async handleSurfaceAction(surfaceId: string, actionId: string, data: Record<string, unknown>) {
|
||
const inflight = surfaceActionsInflight[surfaceId + ':' + actionId]
|
||
if (inflight) return
|
||
const snap = state.surfaces.find(x => String(x.identity.surfaceId) === surfaceId)
|
||
if (!snap) { store.toast('页面快照不可用,无法提交'); return }
|
||
const requestId = (crypto.randomUUID ? crypto.randomUUID() : 'req-' + Date.now() + '-' + Math.random().toString(36).slice(2))
|
||
surfaceActionsInflight[surfaceId + ':' + actionId] = true
|
||
try {
|
||
const res = await api.submitSurfaceAction(surfaceId, {
|
||
actionId, revision: snap.revision, requestId, data,
|
||
})
|
||
if (res?.state === 'awaiting_confirm') store.toast('高危动作已发确认卡,请确认后执行')
|
||
else if (res?.state === 'committed') store.toast('已提交')
|
||
else store.toast('动作状态:' + (res?.state ?? 'unknown'))
|
||
} catch (e: any) {
|
||
const msg = String(e?.message ?? e)
|
||
if (msg.includes('revision conflict')) store.toast('页面已更新,请刷新后重试')
|
||
else if (msg.includes('not enabled') || msg.includes('403')) store.toast('动作被拒绝:插件未启用')
|
||
else if (msg.includes('different params')) store.toast('重复请求参数不一致,已拒绝')
|
||
else store.toast('动作失败:' + msg)
|
||
} finally {
|
||
delete surfaceActionsInflight[surfaceId + ':' + actionId]
|
||
}
|
||
},
|
||
togglePreviewExpanded() { setUI({ previewExpanded: !state.ui.previewExpanded }) },
|
||
activatePreview(id: string) {
|
||
const conv = previewTabsKey(state.currentConvId)
|
||
set({ previewActive: { ...state.previewActive, [conv]: id } })
|
||
},
|
||
closePreview(id: string) {
|
||
const conv = previewTabsKey(state.currentConvId)
|
||
const next = closePreviewTab(state.previewTabs[conv] ?? [], state.previewActive[conv] ?? null, id)
|
||
set({
|
||
previewTabs: { ...state.previewTabs, [conv]: next.tabs },
|
||
previewActive: { ...state.previewActive, [conv]: next.activeId },
|
||
})
|
||
if (next.tabs.length === 0) setUI({ previewExpanded: false })
|
||
syncFilesEntry(next.tabs.length > 0)
|
||
},
|
||
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, now = Date.now()): LiveState | null {
|
||
const base = state.live ?? { runId: _runId, phase: 'thinking', items: [], agentNotes: {}, startedAt: 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'
|
||
}
|
||
|
||
function isConversationBusy(err: unknown): boolean {
|
||
return String(err).includes('409:')
|
||
}
|
||
|
||
function shellPhase() {
|
||
const liveRunId = state.live?.runId ?? null
|
||
return deriveShellPhase({
|
||
posting: state.posting,
|
||
openRun: !!activeRun(state.runs),
|
||
liveRunId,
|
||
assistantRunIds: state.messages.filter(m => m.role === 'assistant' && m.runId).map(m => m.runId as string),
|
||
})
|
||
}
|
||
|
||
export function activeRun(runs: Run[]): Run | undefined {
|
||
return runs.find(r => r.kind !== 'subagent' && !isTerminal(r.status))
|
||
}
|
||
|
||
// mergeStartedRun 把刚启动的 run 补进列表。同 id 已在(通常是对账拿到的更新记录)时不覆盖。
|
||
export function mergeStartedRun(runs: Run[], run: Run): Run[] {
|
||
if (runs.some(r => r.id === run.id)) return runs
|
||
return [...runs, run]
|
||
}
|
||
|
||
// eventTime 取事件上的服务端时间。重放时墙上时钟会把「已工作」和工具耗时算成 0。
|
||
export function eventTime(e: { ts?: string }, now = Date.now()): number {
|
||
if (!e.ts) return now
|
||
const t = Date.parse(e.ts)
|
||
return Number.isNaN(t) ? now : t
|
||
}
|