Why: src/contracts.ts is regenerated from Agent-System-Core by the Dev
composite; the scratch field added there is dropped on regeneration and
tsc then fails in make build.
Evidence: contracts.ts equals the Core-generated output; npx tsc -b
--noEmit, npx vitest run (685 tests) and check-knip pass locally (SELF).
Impact: ConversationView (Conversation & { scratch?: boolean }) in
api.ts carries the server-local flag, mirroring the Backend
conversationView; no behavior change.
Refs: Agent-System-Frontend#123, Agent-System-Dev#691;
agent-exec receipt u3f-run (cursor, success)
Co-authored-by: Cursor <cursoragent@cursor.com>
1825 lines
84 KiB
TypeScript
1825 lines
84 KiB
TypeScript
// 应用状态:服务端权威(事件 + Query 对账),前端 store 是投影。
|
||
// useSyncExternalStore 驱动;live 流式部件为会话内临时态,run 终态后由消息树对账收敛。
|
||
import { useSyncExternalStore } from 'react'
|
||
import { type AdminZoneSection, type ConversationView, type LinkedCapabilities, api, connectEvents } from './api'
|
||
import { auth } from './auth'
|
||
import { noProjectError, projectActionError, projectWorkError } from './projectmeta'
|
||
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, type WorkReference } from './outbox'
|
||
import { canFlushOutbox, deriveShellPhase } from './runstate'
|
||
import { flashElement } from './scrollsync'
|
||
import { isThemeChoice, resolveTheme, type ThemeChoice } from './theme'
|
||
import { mergeSurfacesByRevision, surfaceKey } from './surfaceKey'
|
||
import { cockpitReady, rdmAuditReady, rdmExportReady } from './workspaces'
|
||
import { rdmRolesReady, rdmMembersReady, rdmDutyReady, rdmPermMatrixReady } from './rdmManagement'
|
||
import { NO_RENDER_CAPABILITIES, renderCapabilities, type RenderCapabilities } from './documentRenderPresentation'
|
||
|
||
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,
|
||
TaskSourceRef,
|
||
AgentProfile,
|
||
Artifact,
|
||
HITLCard,
|
||
Message,
|
||
PlanSnapshot,
|
||
PluginPanel,
|
||
Project,
|
||
Run,
|
||
StreamEvent,
|
||
SurfaceMeta,
|
||
SurfaceSnapshot,
|
||
TodoList,
|
||
WorkCardAction
|
||
} 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
|
||
/** 用户上次停留在无项目对话视图;刷新后回到同一视图。 */
|
||
lastScratch: boolean
|
||
/** 系统管理全屏视图(阶段② IA:仅管理员;入口账户菜单,纵深防御在 AdminZone 再拦一次)。 */
|
||
adminZoneOpen: boolean
|
||
changePwOpen: boolean
|
||
projectSettingsId: string | null
|
||
}
|
||
|
||
export interface Me { owner: string; name: string; auth: boolean; isAdmin?: boolean; isSuperAdmin?: boolean; mustChangePassword?: boolean; systemActions?: string[]; linkedCapabilities?: LinkedCapabilities }
|
||
|
||
/** 能否新建真实项目(无项目对话不需要此能力);服务端仍按当前身份最终判定。 */
|
||
export function canCreateProject(me: Pick<Me, 'auth' | 'systemActions'> | null | undefined): boolean {
|
||
return !!me && (!me.auth || !!me.systemActions?.includes('project.create'))
|
||
}
|
||
|
||
/** KTD9 项目自动发现:可见页每 2 秒串行重查;单次请求预算 2 秒、渲染预留 1 秒,正常链路 5 秒内可见。 */
|
||
export const PROJECT_SYNC_INTERVAL_MS = 2000
|
||
export const PROJECT_SYNC_REQUEST_BUDGET_MS = 2000
|
||
export const PROJECT_SYNC_RENDER_BUDGET_MS = 1000
|
||
const PROJECT_SYNC_MAX_BACKOFF_MS = 30000
|
||
export type ProjectSyncStatus = 'idle' | 'ok' | 'degraded' | 'paused' | 'offline'
|
||
|
||
interface AppState {
|
||
contextObs: Record<string, ContextObs>
|
||
projects: Project[]
|
||
/** 本身份的项目列表是否已到达过;未到达不等于空列表。 */
|
||
projectsLoaded: boolean
|
||
projectSync: { status: ProjectSyncStatus; failures: number }
|
||
currentProjectId: string | null
|
||
/** 无项目对话视图:currentProjectId 此时是无项目对话的内部项目,不在 projects 里。 */
|
||
scratchMode: boolean
|
||
tasks: ConversationView[]
|
||
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[]
|
||
/** 同一操作的双入口反馈;未知结果必须先对账,不重新发送副作用。 */
|
||
cardActionStates: Record<string, 'submitting' | 'unknown'>
|
||
workRecord: { cardId: string; runId: string; conversationId: string; title: string } | null
|
||
workReference: WorkReference | null
|
||
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
|
||
rdmCockpitAvailable: boolean
|
||
/** 概览导出区:三条 rdm.inspect.export.* 都启用才为 true。不参与七命令就绪门。 */
|
||
rdmExportAvailable: boolean
|
||
/** U6 RDM 管理页能力:命令面齐备才显示对应分区/页签(普通基座零暴露,fail-closed)。 */
|
||
rdmRolesAvailable: boolean
|
||
rdmMembersAvailable: boolean
|
||
rdmDocReviewAvailable: boolean
|
||
rdmPermMatrixAvailable: boolean
|
||
/** 统一渲染(Task 11):rdm.render.* 核心命令齐备才 ready;审阅/批注/导出各自门控。 */
|
||
rdmRender: RenderCapabilities
|
||
/** 管理区声明分区(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>
|
||
/** Ephemeral explicit opening intent; restoring saved metadata must not advance it. */
|
||
previewOpenIntent: number
|
||
}
|
||
|
||
const DEFAULT_UI: UIState = {
|
||
leftWidth: 260,
|
||
leftCollapsed: false,
|
||
rightWidth: 360,
|
||
rightCollapsed: true,
|
||
rightTab: 'work',
|
||
filesView: 'list', workspaceSurfacePin: null,
|
||
inboxFilter: 'all',
|
||
activeWorkspace: null,
|
||
theme: 'light',
|
||
fontSize: 14,
|
||
autoCollapseProcess: true,
|
||
settingsOpen: false,
|
||
trackerRunId: null,
|
||
previewWidth: 640,
|
||
previewExpanded: false,
|
||
taskOrg: 'project',
|
||
showArchived: false,
|
||
lastProjectId: null,
|
||
lastScratch: false,
|
||
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: [], projectsLoaded: false, projectSync: { status: 'idle', failures: 0 },
|
||
currentProjectId: null, scratchMode: false, tasks: [], me: null, currentConvId: null,
|
||
messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
|
||
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [], cards: [], projectCards: [], cardActionStates: {}, workRecord: null, workReference: null, inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
|
||
serverWorkspaces: [],
|
||
rdmAuditAvailable: false,
|
||
rdmCockpitAvailable: false,
|
||
rdmExportAvailable: false,
|
||
rdmRolesAvailable: false, rdmMembersAvailable: false, rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false,
|
||
rdmRender: NO_RENDER_CAPABILITIES,
|
||
adminSections: [],
|
||
ui: loadUI(), connected: false, posting: false, error: null, toast: null,
|
||
outbox: [], outboxDraining: false,
|
||
previewTabs: {}, previewActive: {}, previewOpenIntent: 0,
|
||
}
|
||
|
||
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 }
|
||
const navigation=(['rightTab','filesView','rightCollapsed','activeWorkspace'] as const).some(key=>key in patch&&patch[key]!==state.ui[key])
|
||
state = { ...state, ui,previewOpenIntent:state.previewOpenIntent+(navigation?1:0) }
|
||
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
|
||
let conversationEpoch = 0
|
||
let identityEpoch = 0
|
||
let projectsRequest = 0
|
||
let tasksRequest = 0
|
||
let meRequest = 0
|
||
let streamSeq = 0
|
||
let streamHasWatermark = false
|
||
let gapReconnectPending = false
|
||
let streamPending = new Map<number, StreamEvent>()
|
||
let detailRequest = 0
|
||
let detailSeq = 0
|
||
let detailVersion = 0
|
||
type DetailObservation = { version: number; seq: number; terminalSeq?: number; terminalVersion?: number }
|
||
let runObservations: Record<string, DetailObservation> = {}
|
||
let messageObservations: Record<string, DetailObservation> = {}
|
||
let hitlObservations: Record<string, DetailObservation> = {}
|
||
let cardsRequest = 0
|
||
let projectCardsRequest = 0
|
||
let cardVersion = 0
|
||
let projectCardVersion = 0
|
||
let cardVersions: Record<string, number> = {}
|
||
let projectCardVersions: Record<string, number> = {}
|
||
let cardPatches: Record<string, Pick<ActivityCard, 'status' | 'tone'>> = {}
|
||
let projectCardPatches: Record<string, Pick<ActivityCard, 'status' | 'tone'>> = {}
|
||
const actionInflight = new Set<string>()
|
||
const actionFeedbackOwners: Record<string, symbol> = {}
|
||
const workActionExpectations = new Map<string, { actionId: string; payload: WorkCardAction }>()
|
||
|
||
/** 离开当前项目:关闭项目/会话流并清空项目内投影(不触碰服务端历史)。 */
|
||
function resetProjectScope() {
|
||
projectSse?.close(); projectSse = null
|
||
sse?.close(); sse = null
|
||
projectSeq = 0
|
||
projectCardsRequest++
|
||
projectCardVersion = 0; projectCardVersions = {}; projectCardPatches = {}
|
||
conversationEpoch++; streamSeq = 0; streamHasWatermark=false;streamPending.clear();gapReconnectPending=false; cardVersion = 0; cardVersions = {}; cardPatches = {}
|
||
detailSeq = 0; detailVersion = 0; runObservations = {}; messageObservations = {}; hitlObservations = {}
|
||
set({ currentConvId: null, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
|
||
cards: [], projectCards: [], workRecord: null, workReference: null, plan: null, todos: null, surfaces: [], projectSurfaces: [],
|
||
artifacts: [], outbox: [], outboxDraining: false, posting: false })
|
||
setUI({ previewExpanded: false, filesView: 'list' })
|
||
}
|
||
|
||
/** remember=false 用于没有真实项目时的自动进入:不改写用户上次选择的视图。 */
|
||
function setScratchMode(on: boolean, remember = true) {
|
||
if (state.scratchMode !== on) set({ scratchMode: on })
|
||
if (remember && state.ui.lastScratch !== on) setUI({ lastScratch: on })
|
||
}
|
||
|
||
/** 绑定会话所属项目(真实项目或无项目对话的内部项目),供上传与工件归属。 */
|
||
async function bindProject(projectId: string, remember: boolean) {
|
||
if (state.currentProjectId !== projectId) resetProjectScope()
|
||
set({ currentProjectId: projectId })
|
||
if (state.inboxScope === 'project') {
|
||
store.loadProjectCards()
|
||
store.connectProject()
|
||
}
|
||
if (remember) setUI({ lastProjectId: projectId })
|
||
await store.refreshArtifacts(projectId)
|
||
}
|
||
|
||
function latestScratchTask(): ConversationView | undefined {
|
||
return state.tasks.filter(t => t.scratch && !t.archived)
|
||
.reduce<ConversationView | undefined>((latest, t) => !latest || t.updatedAt > latest.updatedAt ? t : latest, undefined)
|
||
}
|
||
|
||
function observeDetail(observations: Record<string, DetailObservation>, id: string, seq = state.lastSeq, terminal = false) {
|
||
const version = ++detailVersion
|
||
observations[id] = { ...observations[id], version, seq,
|
||
...(terminal ? { terminalSeq: seq, terminalVersion: version } : {}) }
|
||
}
|
||
|
||
function detailRowIsNewer(observations: Record<string, DetailObservation>, id: string, started: number, seq: number) {
|
||
const observation = observations[id]
|
||
return observation && (observation.version > started || observation.seq > seq)
|
||
}
|
||
|
||
function terminalObservationIsNewer(id: string, started: number, seq: number) {
|
||
const observation = runObservations[id]
|
||
return (observation?.terminalVersion ?? 0) > started || (observation?.terminalSeq ?? 0) > seq
|
||
}
|
||
|
||
function mergeDetailRows<T extends { id: string }>(snapshot: T[], current: T[], observations: Record<string, DetailObservation>, started: number, seq: number) {
|
||
const fresh = current.filter(row => detailRowIsNewer(observations, row.id, started, seq))
|
||
const byId = new Map(fresh.map(row => [row.id, row]))
|
||
const snapshotIds = new Set(snapshot.map(row => row.id))
|
||
return [...snapshot.map(row => byId.get(row.id) ?? row), ...fresh.filter(row => !snapshotIds.has(row.id))]
|
||
}
|
||
|
||
function scheduleTerminalReconcile() {
|
||
const convId = state.currentConvId
|
||
const epoch = conversationEpoch
|
||
setTimeout(() => {
|
||
if (state.currentConvId !== convId || conversationEpoch !== epoch) return
|
||
void store.reconcile().then(() => {
|
||
if (state.currentConvId === convId && conversationEpoch === epoch) return store.flushOutbox()
|
||
})
|
||
}, 250)
|
||
}
|
||
|
||
// Snapshot queries may overlap events. Only rows changed after the request began
|
||
// override that snapshot; a newer request/context invalidates the old query entirely.
|
||
export function mergeTaskSnapshot<T extends {taskId:string;attemptId:string;revision:number}>(current:T,incoming:T):T {
|
||
if(current.taskId!==incoming.taskId||current.attemptId!==incoming.attemptId||!Number.isSafeInteger(incoming.revision)||incoming.revision<=current.revision)return current
|
||
return incoming
|
||
}
|
||
function mergeTaskCard(current:ActivityCard|undefined,incoming:ActivityCard):ActivityCard {
|
||
if(!current?.task||!incoming.task||current.task.schemaVersion!==1||incoming.task.schemaVersion!==1)return incoming
|
||
const a=current.task,b=incoming.task
|
||
if(a.taskId!==b.taskId||a.attemptId!==b.attemptId||a.projectId!==b.projectId||a.conversationId!==b.conversationId||a.ownerInstanceId!==b.ownerInstanceId||(a.executionEpoch??0)>(b.executionEpoch??0)||a.revision>b.revision)return current
|
||
if(['completed','failed','interrupted'].includes(a.executionState)&&!['completed','failed','interrupted'].includes(b.executionState))return current
|
||
if(a.revision!==b.revision&&mergeTaskSnapshot(a,b)===a)return current
|
||
return incoming
|
||
}
|
||
function mergeCardSnapshot(snapshot: ActivityCard[], current: ActivityCard[], versions: Record<string, number>, started: number,
|
||
patches: Record<string, Pick<ActivityCard, 'status' | 'tone'>>) {
|
||
const fresh = current.filter(c => (versions[c.id] ?? 0) > started)
|
||
const freshIds = new Set(fresh.map(c => c.id))
|
||
const combined=fresh.map(c=>{const row=snapshot.find(r=>r.id===c.id);return row?.task&&c.task&&row.task.revision>c.task.revision?mergeTaskCard(c,row):c})
|
||
return [...combined, ...snapshot.filter(c => !freshIds.has(c.id)).map(row => {const c=mergeTaskCard(current.find(old=>old.id===row.id),row);return (versions[c.id] ?? 0) > started && patches[c.id] ? { ...c, ...patches[c.id], tone: patches[c.id].tone ?? c.tone } : c})]
|
||
}
|
||
|
||
function hitlSource(card: ActivityCard): string | undefined {
|
||
for (const node of card.body ?? []) {
|
||
const value = node.type === 'kv' ? node.kv?.find(([key]) => key === '来源')?.[1] : undefined
|
||
if (value?.startsWith('hitl:')) return value.slice(5)
|
||
}
|
||
}
|
||
|
||
function actionFeedback(key: string, value?: 'submitting' | 'unknown', ids: string[] = [], owner?: symbol) {
|
||
let states: AppState['cardActionStates'] | undefined
|
||
for (const id of [key, ...ids]) {
|
||
if (owner && actionFeedbackOwners[id] !== owner) continue
|
||
const current = states ?? state.cardActionStates
|
||
if (value ? current[id] === value : !(id in current)) continue
|
||
states ??= { ...state.cardActionStates }
|
||
if (value) states[id] = value
|
||
else { delete states[id]; delete actionFeedbackOwners[id]; workActionExpectations.delete(id) }
|
||
}
|
||
if (states) set({ cardActionStates: states })
|
||
}
|
||
|
||
function startActionFeedback(key: string, ids: string[] = []) {
|
||
const owner = Symbol(key)
|
||
for (const id of [key, ...ids]) actionFeedbackOwners[id] = owner
|
||
actionFeedback(key, 'submitting', ids, owner)
|
||
return owner
|
||
}
|
||
|
||
function settleCardFeedback(cards: ActivityCard[]) {
|
||
let states: AppState['cardActionStates'] | undefined
|
||
for (const card of cards) {
|
||
const expected = workActionExpectations.get(card.id)
|
||
if (expected) {
|
||
const decision = card.work?.decision?.state
|
||
const settled = expected.actionId === 'work.read' ? (card.work?.readVersion ?? 0) >= expected.payload.resultVersion
|
||
: expected.actionId === 'work.defer' ? decision === 'deferred'
|
||
: expected.actionId === 'work.reopen' ? decision === 'open' : decision === 'submitted'
|
||
if (!settled) continue
|
||
}
|
||
if (!expected && card.status === 'action_required') continue
|
||
const source = hitlSource(card)
|
||
for (const id of [card.id, ...(source ? [`hitl:${source}`] : [])]) {
|
||
if (!(id in (states ?? state.cardActionStates))) continue
|
||
states ??= { ...state.cardActionStates }
|
||
delete states[id]
|
||
delete actionFeedbackOwners[id]
|
||
workActionExpectations.delete(id)
|
||
}
|
||
}
|
||
if (states) set({ cardActionStates: states })
|
||
}
|
||
|
||
const liveEventTypes = new Set<StreamEvent['type']>([
|
||
'message.delta', 'thinking.delta', 'tool.call.delta', 'tool.call', 'tool.output',
|
||
'tool.result', 'run.phase', 'run.check',
|
||
])
|
||
|
||
export function useStore<T>(selector: (s: AppState) => T): T {
|
||
return useSyncExternalStore(
|
||
(cb) => { listeners.add(cb); return () => listeners.delete(cb) },
|
||
() => selector(state),
|
||
() => selector(state),
|
||
)
|
||
}
|
||
|
||
export interface ProjectSyncEnv {
|
||
visible: () => boolean
|
||
online: () => boolean
|
||
/** wake:重新聚焦/页面恢复可见/网络恢复;sleep:页面隐藏或断网。 */
|
||
subscribe: (handlers: { wake: () => void; sleep: (status: 'paused' | 'offline') => void }) => () => void
|
||
}
|
||
|
||
export function projectSyncDelay(failures: number): number {
|
||
return failures <= 0 ? PROJECT_SYNC_INTERVAL_MS : Math.min(PROJECT_SYNC_MAX_BACKOFF_MS, PROJECT_SYNC_INTERVAL_MS * 2 ** failures)
|
||
}
|
||
|
||
/**
|
||
* 单一所有者的串行重查:同一时刻至多一个请求;进行中被唤醒只记一次补偿,完成后立即再查。
|
||
* 下一轮在本轮结束后才排期(不重叠);失败/超时按指数退避,成功或唤醒补偿后恢复 2 秒节奏。
|
||
*/
|
||
export function createProjectAutoDiscover(
|
||
fetchOnce: (signal: AbortSignal) => Promise<void>,
|
||
env: ProjectSyncEnv,
|
||
report: (status: ProjectSyncStatus, failures: number) => void,
|
||
) {
|
||
let active = false
|
||
let inflight = false
|
||
let pending = false
|
||
let failures = 0
|
||
let timer: ReturnType<typeof setTimeout> | null = null
|
||
let controller: AbortController | null = null
|
||
let unsubscribe: (() => void) | null = null
|
||
const clear = () => { if (timer !== null) { clearTimeout(timer); timer = null } }
|
||
const blocked = (): 'paused' | 'offline' | null => !env.online() ? 'offline' : !env.visible() ? 'paused' : null
|
||
const schedule = () => {
|
||
clear()
|
||
const reason = blocked()
|
||
if (reason) { report(reason, failures); return }
|
||
timer = setTimeout(() => { timer = null; void run() }, projectSyncDelay(failures))
|
||
}
|
||
const run = async (): Promise<void> => {
|
||
if (!active) return
|
||
const reason = blocked()
|
||
if (reason) { clear(); report(reason, failures); return }
|
||
if (inflight) { pending = true; return }
|
||
clear()
|
||
inflight = true
|
||
const own = new AbortController()
|
||
controller = own
|
||
const budget = setTimeout(() => own.abort(), PROJECT_SYNC_REQUEST_BUDGET_MS)
|
||
const aborted = new Promise<never>((_, reject) =>
|
||
own.signal.addEventListener('abort', () => reject(new Error('project sync timeout')), { once: true }))
|
||
try {
|
||
await Promise.race([fetchOnce(own.signal), aborted])
|
||
if (active && controller === own) { failures = 0; report('ok', 0) }
|
||
} catch {
|
||
if (active && controller === own) { failures++; report('degraded', failures) }
|
||
} finally {
|
||
clearTimeout(budget)
|
||
inflight = false
|
||
if (controller === own) controller = null
|
||
}
|
||
if (!active) return
|
||
if (pending) { pending = false; return run() }
|
||
schedule()
|
||
}
|
||
return {
|
||
start() {
|
||
if (active) return
|
||
active = true
|
||
unsubscribe = env.subscribe({
|
||
wake: () => { void run() },
|
||
sleep: status => { clear(); report(status, failures) },
|
||
})
|
||
schedule()
|
||
},
|
||
stop() {
|
||
active = false; pending = false; failures = 0
|
||
clear()
|
||
controller?.abort(); controller = null
|
||
unsubscribe?.(); unsubscribe = null
|
||
report('idle', 0)
|
||
},
|
||
trigger: () => run(),
|
||
}
|
||
}
|
||
|
||
function browserProjectSyncEnv(): ProjectSyncEnv {
|
||
return {
|
||
visible: () => typeof document === 'undefined' || document.visibilityState !== 'hidden',
|
||
online: () => typeof navigator === 'undefined' || navigator.onLine !== false,
|
||
subscribe: ({ wake, sleep }) => {
|
||
if (typeof window === 'undefined' || typeof document === 'undefined') return () => {}
|
||
const onVisibility = () => { if (document.visibilityState === 'hidden') sleep('paused'); else wake() }
|
||
const onOffline = () => sleep('offline')
|
||
document.addEventListener('visibilitychange', onVisibility)
|
||
window.addEventListener('focus', wake)
|
||
window.addEventListener('online', wake)
|
||
window.addEventListener('offline', onOffline)
|
||
return () => {
|
||
document.removeEventListener('visibilitychange', onVisibility)
|
||
window.removeEventListener('focus', wake)
|
||
window.removeEventListener('online', wake)
|
||
window.removeEventListener('offline', onOffline)
|
||
}
|
||
},
|
||
}
|
||
}
|
||
|
||
let projectSync: ReturnType<typeof createProjectAutoDiscover> | null = null
|
||
|
||
const projectIdsKey = (projects: Project[]) => projects.map(p => p.id).sort().join('\n')
|
||
|
||
/** 列表合并只更新投影:不重复 openProject、不切对话、不重建流;仅当前项目失效时清除其状态。 */
|
||
async function mergeProjectList(projects: Project[]) {
|
||
if (state.projectsLoaded && JSON.stringify(state.projects) === JSON.stringify(projects)) return
|
||
const membershipChanged = !state.projectsLoaded || projectIdsKey(state.projects) !== projectIdsKey(projects)
|
||
set({ projects, projectsLoaded: true, error: null })
|
||
const current = state.currentProjectId
|
||
// 无项目对话视图绑定的是不在列表里的内部项目,列表变化不切换视图。
|
||
if (state.scratchMode) { /* keep the scratch view */ }
|
||
else if (current && !projects.some(p => p.id === current)) {
|
||
const fallback = projects[0]
|
||
if (fallback) await store.openProject(fallback.id)
|
||
else {
|
||
resetProjectScope()
|
||
set({ currentProjectId: null })
|
||
setUI({ lastProjectId: null })
|
||
setScratchMode(true, false)
|
||
}
|
||
store.toast('你已不在原项目中,已清除该项目的入口')
|
||
} else if (!current) {
|
||
const next = projects.find(p => p.id === state.ui.lastProjectId) ?? projects[0]
|
||
if (next) await store.openProject(next.id)
|
||
else setScratchMode(true, false)
|
||
}
|
||
if (membershipChanged) await store.refreshTasks()
|
||
}
|
||
|
||
export const store = {
|
||
getState: () => state,
|
||
getIdentityEpoch: () => identityEpoch,
|
||
|
||
resetIdentity() {
|
||
identityEpoch++; projectsRequest++; tasksRequest++; meRequest++
|
||
sse?.close(); sse = null
|
||
projectSse?.close(); projectSse = null
|
||
conversationEpoch++; detailRequest++; cardsRequest++; projectCardsRequest++
|
||
projectSeq = 0; streamSeq = 0; detailSeq = 0; detailVersion = 0
|
||
cardVersion = 0; projectCardVersion = 0
|
||
cardVersions = {}; projectCardVersions = {}; cardPatches = {}; projectCardPatches = {}
|
||
runObservations = {}; messageObservations = {}; hitlObservations = {}
|
||
actionInflight.clear()
|
||
workActionExpectations.clear()
|
||
for (const key of Object.keys(actionFeedbackOwners)) delete actionFeedbackOwners[key]
|
||
for (const key of Object.keys(surfaceActionsInflight)) delete surfaceActionsInflight[key]
|
||
convRestoreDone = true
|
||
persistConvId(null)
|
||
set({ projects: [], projectsLoaded: false, currentProjectId: null, scratchMode: false, tasks: [], me: null, currentConvId: null,
|
||
contextObs: {}, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
|
||
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [],
|
||
cards: [], projectCards: [], cardActionStates: {}, workRecord: null, workReference: null, inboxScope: 'conv',
|
||
runtimeMock: false, profiles: [], pluginPanels: [], serverWorkspaces: [], adminSections: [],
|
||
rdmAuditAvailable: false, rdmCockpitAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false,
|
||
rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false, rdmRender: NO_RENDER_CAPABILITIES,
|
||
connected: false, posting: false, error: null, toast: null, outbox: [], outboxDraining: false,
|
||
previewTabs: {}, previewActive: {}, previewOpenIntent: 0 })
|
||
setUI({ lastProjectId: null, lastScratch: false, projectSettingsId: null, adminZoneOpen: false, activeWorkspace: null,
|
||
settingsOpen: false, changePwOpen: false, trackerRunId: null, workspaceSurfacePin: null,
|
||
previewExpanded: false, filesView: 'list', showArchived: false })
|
||
},
|
||
|
||
async refreshProjects() {
|
||
const epoch = identityEpoch
|
||
const request = ++projectsRequest
|
||
let scratch = false
|
||
try {
|
||
const projects = await api.listProjects()
|
||
if (epoch !== identityEpoch || request !== projectsRequest) return
|
||
// 没有真实项目(或上次停留在无项目对话)时进入无项目对话视图,而不是停在空项目上。
|
||
scratch = projects.length === 0 || state.ui.lastScratch
|
||
if (scratch) set({ projects, projectsLoaded: true, error: null })
|
||
else {
|
||
const current = projects.find(p => p.id === state.currentProjectId) ?? projects.find(p => p.id === state.ui.lastProjectId) ?? projects[0]
|
||
set({ projects, projectsLoaded: true, currentProjectId: current?.id ?? null, error: null })
|
||
if (current) await store.openProject(current.id)
|
||
}
|
||
} catch (e) {
|
||
if (epoch === identityEpoch && request === projectsRequest) set({ error: projectActionError(e) })
|
||
}
|
||
if (epoch === identityEpoch && request === projectsRequest) await store.refreshTasks()
|
||
if (scratch && epoch === identityEpoch && request === projectsRequest) await store.enterScratchMode(state.ui.lastScratch)
|
||
},
|
||
|
||
/** 自动发现的一次重查:与 refreshProjects 共用请求代次,迟到/旧身份响应丢弃;失败抛出供退避。 */
|
||
async syncProjects(signal?: AbortSignal) {
|
||
const epoch = identityEpoch
|
||
const request = ++projectsRequest
|
||
const projects = await api.listProjects(signal)
|
||
if (epoch !== identityEpoch || request !== projectsRequest) return
|
||
await mergeProjectList(projects)
|
||
},
|
||
|
||
startProjectSync(env: ProjectSyncEnv = browserProjectSyncEnv()) {
|
||
projectSync?.stop()
|
||
projectSync = createProjectAutoDiscover(signal => store.syncProjects(signal), env, (status, failures) => {
|
||
if (state.projectSync.status !== status || state.projectSync.failures !== failures) set({ projectSync: { status, failures } })
|
||
})
|
||
projectSync.start()
|
||
},
|
||
|
||
stopProjectSync() {
|
||
projectSync?.stop()
|
||
projectSync = null
|
||
},
|
||
|
||
// 跨项目任务列表(方案 A 侧栏数据源):归档视图切换时带 includeArchived 重取。
|
||
async refreshTasks(includeArchived = state.ui.showArchived) {
|
||
const epoch = identityEpoch
|
||
const request = ++tasksRequest
|
||
try {
|
||
const tasks = await api.listTasks(includeArchived)
|
||
if (epoch !== identityEpoch || request !== tasksRequest || !Array.isArray(tasks)) return
|
||
set({ tasks })
|
||
// 首次加载恢复上次会话(仅一次;此后用户主动切换/新建不再打扰)。
|
||
if (!convRestoreDone) {
|
||
convRestoreDone = true
|
||
const saved = savedConvId()
|
||
const restored = saved ? tasks.find(t => t.id === saved && !t.archived) : undefined
|
||
if (restored && !state.currentConvId) {
|
||
if (restored.scratch) await store.openTask(restored.id)
|
||
else store.openConversation(restored.id)
|
||
}
|
||
}
|
||
} catch { /* 列表竞态,静默 */ }
|
||
},
|
||
|
||
async refreshMe() {
|
||
const epoch = identityEpoch
|
||
const request = ++meRequest
|
||
try {
|
||
const me = await api.me()
|
||
if (epoch !== identityEpoch || request !== meRequest) return
|
||
const changed = state.me !== null && state.me.owner !== me.owner
|
||
if (changed) store.resetIdentity()
|
||
set({ me })
|
||
if (changed) {
|
||
await store.refreshProjects()
|
||
void store.refreshProfiles()
|
||
void store.refreshPlugins()
|
||
}
|
||
} catch { /* 身份不可用时侧栏只显示通用入口 */ }
|
||
},
|
||
|
||
async createProject(name: string) {
|
||
const epoch = identityEpoch
|
||
try {
|
||
const p = await api.createProject(name)
|
||
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
|
||
await store.refreshProjects()
|
||
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
|
||
store.toast(`项目「${p.name}」已创建`)
|
||
return p
|
||
} catch (e) {
|
||
if (epoch === identityEpoch) store.toast(`项目创建失败:${projectActionError(e)}`)
|
||
throw e
|
||
}
|
||
},
|
||
|
||
/** 打开真实项目:同时离开无项目对话视图。 */
|
||
async openProject(projectId: string) {
|
||
setScratchMode(false)
|
||
await bindProject(projectId, true)
|
||
},
|
||
|
||
/** 进入无项目对话视图:当前会话不是无项目对话时清空会话视图,并打开最近一条未归档的无项目对话。 */
|
||
async enterScratchMode(remember = true) {
|
||
const epoch = identityEpoch
|
||
const current = state.tasks.find(t => t.id === state.currentConvId)
|
||
setScratchMode(true, remember)
|
||
if (current?.scratch) return
|
||
if (state.currentProjectId || state.currentConvId) resetProjectScope()
|
||
set({ currentProjectId: null })
|
||
const latest = latestScratchTask()
|
||
if (latest && epoch === identityEpoch) await store.openTask(latest.id)
|
||
},
|
||
|
||
/** 新建任务(=会话):默认当前项目上下文;显式 projectId 用于"新建于指定项目"。
|
||
* 无项目对话视图、或项目列表已到达且没有真实项目时,新建无项目对话;从不代建真实项目。 */
|
||
async createTask(projectId?: string) {
|
||
const epoch = identityEpoch
|
||
const changed = () => { if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试') }
|
||
try {
|
||
if (state.scratchMode || (!projectId && !state.currentProjectId && state.projectsLoaded && state.projects.length === 0)) {
|
||
const { conversation } = await api.createScratchSession()
|
||
changed()
|
||
setScratchMode(true)
|
||
await store.refreshTasks()
|
||
changed()
|
||
if (!state.tasks.some(t => t.id === conversation.id)) set({ tasks: [{ ...conversation, scratch: true }, ...state.tasks] })
|
||
await store.openTask(conversation.id)
|
||
changed()
|
||
return
|
||
}
|
||
let pid = projectId ?? state.currentProjectId
|
||
if (!pid && state.projects.length > 0) pid = state.projects[0].id
|
||
if (!pid) throw new Error(noProjectError(state.projectsLoaded, canCreateProject(state.me)))
|
||
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
|
||
const error = projectWorkError(state.projects.find(p => p.id === pid))
|
||
if (error) throw new Error(error)
|
||
if (pid !== state.currentProjectId) await store.openProject(pid)
|
||
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
|
||
const c = await api.createConversation(pid)
|
||
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
|
||
await store.refreshTasks()
|
||
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
|
||
await store.openTask(c.id)
|
||
if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试')
|
||
} catch (e) {
|
||
if (epoch === identityEpoch) store.toast(`对话创建失败:${projectActionError(e)}`)
|
||
throw e
|
||
}
|
||
},
|
||
|
||
/** 兼容旧入口(⌘/命令总线) */
|
||
async createConversation(title?: string) {
|
||
await store.createTask()
|
||
if (title) store.toast('已在当前项目创建任务')
|
||
},
|
||
|
||
/** 打开任务:跨项目点击时切换项目上下文(工件/上传归属正确);视图随任务进入或离开无项目对话。 */
|
||
async openTask(convId: string) {
|
||
const epoch = identityEpoch
|
||
const t = state.tasks.find(x => x.id === convId)
|
||
if (t?.scratch) {
|
||
if (t.projectId !== state.currentProjectId) await bindProject(t.projectId, false)
|
||
} else if (t && t.projectId !== state.currentProjectId) await store.openProject(t.projectId)
|
||
if (epoch !== identityEpoch) return
|
||
if (t) setScratchMode(!!t.scratch)
|
||
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 }
|
||
const epoch = ++conversationEpoch
|
||
streamSeq = 0; streamHasWatermark=false;streamPending.clear();gapReconnectPending=false; cardVersion = 0; cardVersions = {}; cardPatches = {}
|
||
detailSeq = 0; detailVersion = 0; runObservations = {}; messageObservations = {}; hitlObservations = {}
|
||
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], workRecord: null, workReference: null, surfaces: [], projectSurfaces: [], inboxScope: 'conv', outbox: [], outboxDraining: false, posting: false })
|
||
setUI({ previewExpanded: 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:已保存页面从权威快照重载(刷新/断线恢复;事件流只做增量投影)。
|
||
// surfaceList 返回扁平 SurfaceMeta,不可写入 surfaces;必须经 surfaceGet 水合为 Snapshot。
|
||
if (state.currentProjectId) api.projectSurfaces(state.currentProjectId).then(list => { if (state.currentConvId === convId && conversationEpoch === epoch) set({ projectSurfaces: list ?? [] }) }).catch(() => {})
|
||
api.surfaceList(convId).then(async list => {
|
||
if (state.currentConvId !== convId || conversationEpoch !== epoch) return
|
||
const metas = list ?? []
|
||
// 刷新/重进恢复:有页面且尚无页面标签时补开最新一张的标签(不强切
|
||
// 右栏——只在实时事件到达时切换,避免每次进会话劫持视图)。标签只用 Meta。
|
||
const conv = previewTabsKey(state.currentConvId)
|
||
const tabs = state.previewTabs[conv] ?? []
|
||
const hasSurfaceTab = tabs.some(t => t.id.startsWith('surface:'))
|
||
if (!hasSurfaceTab && metas.length > 0) {
|
||
const latest = metas[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 },
|
||
})
|
||
}
|
||
const fetched = await Promise.all(metas.map(m =>
|
||
api.surfaceGet(String(m.surfaceId)).catch(() => null),
|
||
))
|
||
if (state.currentConvId !== convId || conversationEpoch !== epoch) return
|
||
const hydrated = fetched.filter((s): s is SurfaceSnapshot => surfaceKey(s) != null)
|
||
// 慢水合不得覆盖并发 surface.updated 已写入的较新 revision。
|
||
set({ surfaces: mergeSurfacesByRevision(state.surfaces, hydrated) })
|
||
}).catch(() => {})
|
||
const resumeSeq = await store.reconcile()
|
||
if (state.currentConvId !== convId || conversationEpoch !== epoch) return
|
||
store.connectLive(resumeSeq ?? state.lastSeq)
|
||
},
|
||
|
||
// 终态对账:以 Query 的权威消息树/运行树/游标为准收敛。
|
||
// 返回值是实时流应从这个 seq 之后重放;有进行中的 run 时它早于 lastSeq。
|
||
async reconcile(): Promise<number | undefined> {
|
||
const convId = state.currentConvId
|
||
const epoch = conversationEpoch
|
||
const request = ++detailRequest
|
||
const started = detailVersion
|
||
if (!convId) return
|
||
try {
|
||
const d = await api.conversationDetail(convId)
|
||
if (state.currentConvId !== convId || conversationEpoch !== epoch || request !== detailRequest || d.lastSeq < detailSeq) return
|
||
// A background frame may overtake this snapshot without changing its
|
||
// messages/runs. Preserve only newer observations of the same object.
|
||
const messages = mergeDetailRows(d.messages, state.messages, messageObservations, started, d.lastSeq)
|
||
const snapshotRuns = new Map(d.runs.map(run => [run.id, run]))
|
||
const runs = mergeDetailRows(d.runs, state.runs, runObservations, started, d.lastSeq).map(run => {
|
||
const snapshot = snapshotRuns.get(run.id)
|
||
const newerTerminal = terminalObservationIsNewer(run.id, started, d.lastSeq)
|
||
return snapshot && isTerminal(snapshot.status) && !isTerminal(run.status) && !newerTerminal ? snapshot : run
|
||
})
|
||
const activeHitl = mergeDetailRows(d.activeHitl, state.activeHitl, hitlObservations, started, d.lastSeq)
|
||
.filter(card => !detailRowIsNewer(hitlObservations, card.id, started, d.lastSeq) || state.activeHitl.some(c => c.id === card.id))
|
||
const live = state.live
|
||
const liveRunActive = live && (!isTerminal(runs.find(r => r.id === live.runId)?.status) ||
|
||
(terminalObservationIsNewer(live.runId, started, d.lastSeq) && !messages.some(m => m.role === 'assistant' && m.runId === live.runId)))
|
||
const resumeSeq = d.resumeSeq ?? d.lastSeq
|
||
detailSeq = d.lastSeq
|
||
set({
|
||
messages,
|
||
runs,
|
||
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)])
|
||
if (state.currentConvId !== convId || conversationEpoch !== epoch || request !== detailRequest) return
|
||
if (state.lastSeq === d.lastSeq) set({ plan, todos })
|
||
store.refreshCards(d.conversation.id)
|
||
store.refreshTasks()
|
||
return resumeSeq
|
||
} catch (e) {
|
||
if (state.currentConvId === convId && conversationEpoch === epoch) set({ error: String(e) })
|
||
}
|
||
},
|
||
|
||
connectLive(since?: number) {
|
||
if (!state.currentConvId) return
|
||
const convId = state.currentConvId
|
||
const epoch = conversationEpoch
|
||
streamSeq = Math.max(streamSeq, since ?? state.lastSeq)
|
||
sse?.close()
|
||
sse = connectEvents(convId, since ?? state.lastSeq, {
|
||
onOpen: () => { if (state.currentConvId === convId && conversationEpoch === epoch) {set({ connected: true });void store.refreshCards(convId)} },
|
||
onError: () => { if (state.currentConvId === convId && conversationEpoch === epoch) set({ connected: false }) },
|
||
onEvent: e => { if (state.currentConvId === convId && conversationEpoch === epoch) store.applyEvent(e) },
|
||
})
|
||
},
|
||
|
||
applyEvent(e: StreamEvent) {
|
||
if (!state.currentConvId || e.conversationId !== state.currentConvId || !Number.isSafeInteger(e.seq) || e.seq <= streamSeq) return
|
||
if(streamHasWatermark&&e.seq>streamSeq+1){
|
||
if(streamPending.size<1000)streamPending.set(e.seq,e)
|
||
if(!gapReconnectPending){gapReconnectPending=true;const conv=state.currentConvId,epoch=conversationEpoch;queueMicrotask(()=>{gapReconnectPending=false;if(conv===state.currentConvId&&epoch===conversationEpoch)store.connectLive(streamSeq)})}
|
||
return
|
||
}
|
||
streamSeq = e.seq
|
||
if(streamPending.has(e.seq+1)){const pending=streamPending.get(e.seq+1)!;streamPending.delete(e.seq+1);queueMicrotask(()=>store.applyEvent(pending))}
|
||
const previousSeq = state.lastSeq
|
||
// Running-turn replay reconstructs transient output, but cannot regress the
|
||
// persistent card/plan/status snapshot already returned by the detail query.
|
||
if (e.seq <= previousSeq && !liveEventTypes.has(e.type)) return
|
||
set({ lastSeq: Math.max(previousSeq, e.seq) })
|
||
if (e.runId && liveEventTypes.has(e.type)) observeDetail(runObservations, e.runId, e.seq)
|
||
const now = eventTime(e)
|
||
const project = (mutate: (l: LiveState) => void) => touchLive(e.runId ?? '', mutate, now)
|
||
const belongsToLive = (agentRun: string) => !!state.live && (e.runId === state.live.runId
|
||
|| Object.prototype.hasOwnProperty.call(state.live.agentNotes, agentRun)
|
||
|| state.runs.some(run => run.id === agentRun && run.parentRunId === state.live?.runId))
|
||
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': {
|
||
const card = e.payload as HITLCard
|
||
if (card?.id) {
|
||
observeDetail(hitlObservations, card.id, e.seq)
|
||
set({ activeHitl: [...state.activeHitl.filter(c => c.id !== card.id), card] })
|
||
}
|
||
store.reconcile()
|
||
break
|
||
}
|
||
case 'hitl.resolved': {
|
||
const cardId = (e.payload as { cardId: string }).cardId
|
||
observeDetail(hitlObservations, cardId, e.seq)
|
||
set({ activeHitl: state.activeHitl.filter(c => c.id !== cardId) })
|
||
break
|
||
}
|
||
case 'agent.spawn': {
|
||
const p = e.payload as { agentRun: string; name: string; task: string }
|
||
if (state.live && belongsToLive(p.agentRun)) set({ live: { ...state.live, agentNotes: { ...state.live.agentNotes, [p.agentRun]: `已派生 ${p.name}` } } })
|
||
break
|
||
}
|
||
case 'agent.progress': {
|
||
const p = e.payload as { agentRun: string; note?: string }
|
||
if (state.live && p.note && belongsToLive(p.agentRun)) set({ live: { ...state.live, agentNotes: { ...state.live.agentNotes, [p.agentRun]: p.note } } })
|
||
break
|
||
}
|
||
case 'agent.result': {
|
||
const p = e.payload as { agentRun: string; summary?: string }
|
||
if (state.live && belongsToLive(p.agentRun)) set({ live: { ...state.live, agentNotes: { ...state.live.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
|
||
const id = surfaceKey(p)
|
||
if (!id) break
|
||
const surfaces = state.surfaces.filter(x => surfaceKey(x) !== id)
|
||
const existing = state.surfaces.find(x => surfaceKey(x) === id)
|
||
if (!existing || p.revision > existing.revision) {
|
||
surfaces.push(p)
|
||
set({ surfaces })
|
||
// Only update the title of the page already being read. New background
|
||
// pages stay in the work list and never open a pane or change selection.
|
||
const conv = previewTabsKey(state.currentConvId)
|
||
const tabs = state.previewTabs[conv] ?? []
|
||
const activeId = state.previewActive[conv] ?? null
|
||
if (activeId === 'surface:' + id) {
|
||
const opened = openPreviewTab(tabs, { id: 'surface:' + id, name: p.spec?.title || '页面' })
|
||
set({
|
||
previewTabs: { ...state.previewTabs, [conv]: opened.tabs },
|
||
previewActive: { ...state.previewActive, [conv]: opened.activeId },
|
||
})
|
||
} else {
|
||
store.toast(`新成果可查看:${p.spec?.title || '任务页面'}`)
|
||
}
|
||
}
|
||
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 }
|
||
cardVersions[p.cardId] = ++cardVersion
|
||
cardPatches[p.cardId] = { status: p.status, tone: p.tone }
|
||
const cards = state.cards.map(c => c.id === p.cardId ? { ...c, status: p.status, tone: p.tone ?? c.tone } : c)
|
||
set({ cards })
|
||
settleCardFeedback(cards)
|
||
break
|
||
}
|
||
case 'card.resolved': {
|
||
const p = e.payload as { cardId: string }
|
||
cardVersions[p.cardId] = ++cardVersion
|
||
cardPatches[p.cardId] = { status: 'resolved' }
|
||
const cards = state.cards.map(c => c.id === p.cardId ? { ...c, status: 'resolved' as const } : c)
|
||
set({ cards })
|
||
settleCardFeedback(cards)
|
||
break
|
||
}
|
||
case 'artifact.created':
|
||
case 'artifact.updated': {
|
||
if(e.type==='artifact.updated'&&state.currentConvId)void store.refreshCards(state.currentConvId)
|
||
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': {
|
||
if (e.runId) observeDetail(runObservations, e.runId, e.seq, true)
|
||
const p = e.payload as { usage?: unknown; context?: ContextObs }
|
||
if (p?.context && e.runId) {
|
||
set({ contextObs: { ...state.contextObs, [e.runId]: p.context } })
|
||
}
|
||
scheduleTerminalReconcile()
|
||
break
|
||
}
|
||
case 'run.failed':
|
||
case 'error': {
|
||
if (e.runId && e.type === 'run.failed') observeDetail(runObservations, e.runId, e.seq, true)
|
||
// 终态:短暂延迟后对账(等待消息树落库),折叠过程输出,再发送排队消息。
|
||
scheduleTerminalReconcile()
|
||
break
|
||
}
|
||
default: {
|
||
const unexpected: never = e.type
|
||
void unexpected
|
||
}
|
||
}
|
||
},
|
||
|
||
async sendMessage(text: string, attachmentIds: string[], requestId?: string, taskRef?: WorkReference): Promise<'ok' | 'busy' | 'rejected'> {
|
||
const convId = state.currentConvId
|
||
const epoch = conversationEpoch
|
||
const sameContext = () => state.currentConvId === convId && conversationEpoch === epoch
|
||
if (!convId) return 'ok'
|
||
const id = requestId ?? newQueuedMessage(text, attachmentIds, []).id
|
||
set({ posting: true })
|
||
try {
|
||
const out = await (taskRef ? api.sendMessage(convId, text, attachmentIds, id, taskRef) : api.sendMessage(convId, text, attachmentIds, id))
|
||
if (!sameContext()) return 'ok'
|
||
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
|
||
observeDetail(runObservations, started.id)
|
||
observeDetail(messageObservations, out.message.id)
|
||
// 立刻把 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: eventTime({ ts: started.createdAt }) }
|
||
: { ...state.live, startedAt: eventTime({ ts: started.createdAt }, state.live.startedAt) }
|
||
set({ live, runs, messages })
|
||
store.connectIfNeeded()
|
||
}
|
||
return 'ok'
|
||
} catch (e) {
|
||
if (!sameContext()) return 'ok'
|
||
if (isConversationBusy(e)) {
|
||
set({ outbox: enqueueMessageFront(state.outbox, { id, text, attachmentIds, attachmentNames: [], ...(taskRef ? { taskRef } : {}) }) })
|
||
return 'busy'
|
||
}
|
||
const removed = isNotProjectMember(e)
|
||
store.toast(removed ? '无法继续发送:你已不在该项目中或项目已关闭;历史对话仍可查看' : `发送失败:${e}`)
|
||
set({ outbox: enqueueMessageFront(state.outbox, { id, text, attachmentIds,
|
||
attachmentNames: attachmentIds.map(attachmentId => state.artifacts.find(artifact => artifact.id === attachmentId)?.name ?? '已添加材料'),
|
||
...(taskRef ? { taskRef } : {}),
|
||
deliveryError: removed ? '无法发送:你已不在该项目中或项目已关闭,内容已保留' : '发送未成功,内容已保留;可修改后重试或移除' }) })
|
||
await store.reconcile()
|
||
return 'rejected'
|
||
} finally {
|
||
if (sameContext()) set({ posting: false })
|
||
}
|
||
},
|
||
|
||
enqueueOutbox(text: string, attachmentIds: string[], attachmentNames: string[], taskRef?: WorkReference) {
|
||
set({ outbox: enqueueMessage(state.outbox, newQueuedMessage(text, attachmentIds, attachmentNames, taskRef)) })
|
||
},
|
||
|
||
updateOutbox(id: string, text: string) {
|
||
set({ outbox: replaceQueuedText(state.outbox, id, text) })
|
||
},
|
||
|
||
removeOutbox(id: string) {
|
||
set({ outbox: removeQueuedMessage(state.outbox, id) })
|
||
},
|
||
|
||
clearOutboxReference(id: string) {
|
||
set({ outbox: state.outbox.map(item => item.id === id ? { ...item, taskRef: undefined } : item) })
|
||
},
|
||
|
||
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), item.taskRef)
|
||
},
|
||
|
||
async flushOutbox() {
|
||
if (state.outboxDraining) return
|
||
if (!canFlushOutbox(shellPhase())) return
|
||
const next = state.outbox.find(item => !item.deliveryError)
|
||
if (!next) return
|
||
set({ outbox: removeQueuedMessage(state.outbox, next.id), outboxDraining: true })
|
||
let result: 'ok' | 'busy' | 'rejected' = 'ok'
|
||
try {
|
||
result = await store.sendMessage(next.text, next.attachmentIds, deliveryRequestId(next), next.taskRef)
|
||
} finally {
|
||
set({ outboxDraining: false })
|
||
}
|
||
if (result === 'busy') return
|
||
if (canFlushOutbox(shellPhase()) && state.outbox.some(item => !item.deliveryError)) {
|
||
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 }) {
|
||
const key = `hitl:${cardId}`
|
||
const related = [...state.cards, ...state.projectCards].filter(c => hitlSource(c) === cardId).map(c => c.id)
|
||
if (actionInflight.has(key) || state.cardActionStates[key]) return
|
||
const convId = state.currentConvId
|
||
const epoch = conversationEpoch
|
||
actionInflight.add(key)
|
||
const feedbackOwner = startActionFeedback(key, related)
|
||
try {
|
||
const out = await api.resolveHITL(cardId, resp)
|
||
if (state.currentConvId !== convId || conversationEpoch !== epoch) return
|
||
if (!out.resolved) {
|
||
actionFeedback(key, 'unknown', related, feedbackOwner)
|
||
await store.reconcile()
|
||
return
|
||
}
|
||
set({ activeHitl: state.activeHitl.filter(c => c.id !== cardId) })
|
||
// Resolution is not necessarily execution: fetch the actual confirmation
|
||
// outcome (including cancellation) instead of stamping every choice resolved.
|
||
if (convId) await store.refreshCards(convId)
|
||
if (state.currentConvId !== convId || conversationEpoch !== epoch) return
|
||
actionFeedback(key, undefined, [], feedbackOwner)
|
||
for (const id of related) {
|
||
const current = [...state.cards, ...state.projectCards].find(c => c.id === id)
|
||
actionFeedback(id, current?.status === 'action_required' ? 'unknown' : undefined, [], feedbackOwner)
|
||
}
|
||
if (out.pivoted && out.pivotText) {
|
||
await store.sendMessage(out.pivotText, [])
|
||
}
|
||
} catch (e) {
|
||
const rejected = /^4\d\d:/.test(String(e instanceof Error ? e.message : e))
|
||
actionFeedback(key, rejected ? undefined : 'unknown', related, feedbackOwner)
|
||
if (state.currentConvId !== convId || conversationEpoch !== epoch) return
|
||
if (rejected) {
|
||
store.toast('本次确认未被接受,请查看当前事项后再处理')
|
||
} else {
|
||
store.toast('确认结果暂时未知,正在核实处理状态')
|
||
await store.reconcile()
|
||
}
|
||
} finally {
|
||
actionInflight.delete(key)
|
||
}
|
||
},
|
||
|
||
upsertCard(card: ActivityCard) {
|
||
if (card.conversationId && card.conversationId !== state.currentConvId) return
|
||
cardVersions[card.id] = ++cardVersion
|
||
delete cardPatches[card.id]
|
||
card=mergeTaskCard(state.cards.find(c=>c.id===card.id),card)
|
||
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 })
|
||
settleCardFeedback([card])
|
||
},
|
||
|
||
async refreshCards(convId: string) {
|
||
const epoch = conversationEpoch
|
||
const request = ++cardsRequest
|
||
const started = cardVersion
|
||
try {
|
||
const out = await api.listCardsSnapshot(convId)
|
||
const snapshot=out.cards??[]
|
||
if (state.currentConvId !== convId || conversationEpoch !== epoch || request !== cardsRequest) return
|
||
if(Number.isSafeInteger(out.replaySeq)&&out.replaySeq!>=0)streamHasWatermark=true
|
||
const cards = mergeCardSnapshot(snapshot, state.cards, cardVersions, started, cardPatches)
|
||
set({ cards })
|
||
settleCardFeedback(cards)
|
||
} catch { /* Keep the last observed snapshot while disconnected. */ }
|
||
},
|
||
|
||
// 卡片动作:确认投影卡(来源 hitl:)路由回 HITL 解析(恢复挂起 run,服务端同步销项);
|
||
// 其余卡走卡片动作 API,并把服务端持久化的回执消息追加进本地时间线(id 一致,对账不重复)。
|
||
async cardAction(card: ActivityCard, actionId: string, payload?: string | WorkCardAction) {
|
||
const freeText = typeof payload === 'object' ? payload.freeText : payload
|
||
const source = hitlSource(card)
|
||
if (source) {
|
||
await store.resolveHITL(source, actionId ? { choiceId: actionId } : { freeText })
|
||
return
|
||
}
|
||
if (actionInflight.has(card.id) || state.cardActionStates[card.id]) return
|
||
const convId = state.currentConvId
|
||
const pid = state.currentProjectId
|
||
const epoch = conversationEpoch
|
||
const identity = identityEpoch
|
||
const startedVersion = card.conversationId === state.currentConvId ? cardVersion : projectCardVersion
|
||
const sameContext = () => identity === identityEpoch && state.currentProjectId === pid && (!card.conversationId || (state.currentConvId === convId && conversationEpoch === epoch))
|
||
actionInflight.add(card.id)
|
||
const feedbackOwner = startActionFeedback(card.id)
|
||
if (actionId.startsWith('work.') && typeof payload === 'object') workActionExpectations.set(card.id, { actionId, payload })
|
||
try {
|
||
const out = await api.cardAction(card.id, actionId, payload)
|
||
if (!sameContext()) return
|
||
const newer = (card.conversationId === state.currentConvId ? cardVersions[card.id] : projectCardVersions[card.id]) > startedVersion
|
||
if (!newer && card.conversationId === state.currentConvId) store.upsertCard(out.card)
|
||
else if (!newer) {
|
||
projectCardVersions[out.card.id] = ++projectCardVersion
|
||
const cards = state.projectCards
|
||
set({ projectCards: cards.some(c => c.id === out.card.id) ? cards.map(c => c.id === out.card.id ? out.card : c) : [out.card, ...cards] })
|
||
}
|
||
if (!actionId.startsWith('work.') && out.receipt && out.receipt.conversationId === state.currentConvId && !state.messages.some(m => m.id === out.receipt?.id)) {
|
||
observeDetail(messageObservations, out.receipt.id)
|
||
set({ messages: [...state.messages, out.receipt as Message] })
|
||
}
|
||
// 命令确认卡的动作响应是决议前快照。再拉一次,内联卡才能从待确认过渡到折叠。
|
||
if (!newer && card.kind === 'confirm' && actionId !== 'recheck' && out.card.status === 'action_required' && card.conversationId) {
|
||
if (card.conversationId === state.currentConvId) await store.refreshCards(card.conversationId)
|
||
else await store.loadProjectCards()
|
||
}
|
||
if (sameContext()) {
|
||
const current = [...state.cards, ...state.projectCards].find(c => c.id === card.id)
|
||
actionFeedback(card.id, !actionId.startsWith('work.') && actionId !== 'recheck' && current?.status === 'action_required' ? 'unknown' : undefined, [], feedbackOwner)
|
||
}
|
||
} catch (e) {
|
||
const rejected = /^4\d\d:/.test(String(e instanceof Error ? e.message : e))
|
||
actionFeedback(card.id, rejected ? undefined : 'unknown', [], feedbackOwner)
|
||
if (!sameContext()) return
|
||
if (rejected) {
|
||
store.toast('本次操作未被接受,请查看当前事项后再处理')
|
||
} else {
|
||
store.toast('操作结果暂时未知,正在核实处理状态')
|
||
if (card.conversationId === state.currentConvId) await store.refreshCards(card.conversationId)
|
||
else await store.loadProjectCards()
|
||
}
|
||
} finally {
|
||
if (identity === identityEpoch) actionInflight.delete(card.id)
|
||
}
|
||
},
|
||
|
||
async cardPin(card: ActivityCard, pinned: boolean) {
|
||
const epoch = identityEpoch
|
||
try {
|
||
const out = await api.cardPin(card.id, pinned)
|
||
if (epoch === identityEpoch) store.upsertCard(out.card)
|
||
} catch (e) { if (epoch === identityEpoch) store.toast(`置顶失败:${e}`) }
|
||
},
|
||
|
||
async cardDismiss(card: ActivityCard) {
|
||
const epoch = identityEpoch
|
||
try {
|
||
const out = await api.cardDismiss(card.id)
|
||
if (epoch === identityEpoch) store.upsertCard(out.card)
|
||
} catch (e) { if (epoch === identityEpoch) store.toast(`关闭失败:${e}`) }
|
||
},
|
||
|
||
async refreshArtifacts(projectId: string) {
|
||
const epoch = identityEpoch
|
||
try {
|
||
const artifacts = await api.listArtifacts(projectId)
|
||
if (epoch !== identityEpoch || state.currentProjectId !== projectId) return
|
||
set({ artifacts })
|
||
} catch { /* 项目删除竞态等,静默 */ }
|
||
},
|
||
|
||
async refreshProfiles() {
|
||
const epoch = identityEpoch
|
||
try {
|
||
const profiles = await api.listProfiles()
|
||
if (epoch === identityEpoch) set({ profiles })
|
||
} catch { /* ignore */ }
|
||
},
|
||
|
||
async refreshPlugins() {
|
||
const epoch = identityEpoch
|
||
try {
|
||
const list = await api.listPlugins() as { panels?: PluginPanel[] }[]
|
||
if (epoch !== identityEpoch) return
|
||
const panels = list.flatMap(p => p.panels ?? [])
|
||
set({ pluginPanels: panels })
|
||
} catch { /* ignore */ }
|
||
try {
|
||
const ws = await api.listWorkspaces()
|
||
if (epoch !== identityEpoch) return
|
||
set({ serverWorkspaces: ws ?? [] })
|
||
} catch { /* ignore */ }
|
||
// 审计入口能力门控(增量 2):与工作区清单同源刷新——能力撤销(插件卸载/命令停用)后入口随之隐藏。
|
||
try {
|
||
const cmds = await api.listCommands()
|
||
if (epoch !== identityEpoch) return
|
||
set({
|
||
rdmAuditAvailable: rdmAuditReady(cmds),
|
||
rdmCockpitAvailable: cockpitReady(cmds),
|
||
rdmExportAvailable: rdmExportReady(cmds),
|
||
rdmRolesAvailable: rdmRolesReady(cmds),
|
||
rdmMembersAvailable: rdmMembersReady(cmds),
|
||
rdmDocReviewAvailable: rdmDutyReady(cmds),
|
||
rdmPermMatrixAvailable: rdmPermMatrixReady(cmds),
|
||
rdmRender: renderCapabilities(cmds),
|
||
})
|
||
} catch {
|
||
if (epoch !== identityEpoch) return
|
||
set({ rdmAuditAvailable: false, rdmCockpitAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false,
|
||
rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false, rdmRender: NO_RENDER_CAPABILITIES })
|
||
}
|
||
try {
|
||
const sections = await api.adminZoneSections()
|
||
if (epoch !== identityEpoch) return
|
||
set({ adminSections: sections ?? [] })
|
||
} catch { /* ignore */ }
|
||
try {
|
||
const settings = await api.getSettings()
|
||
if (epoch !== identityEpoch) return
|
||
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> {
|
||
const pid = state.currentProjectId
|
||
const epoch = identityEpoch
|
||
if (!pid) return null
|
||
try {
|
||
const up = await api.upload(pid, file)
|
||
if (epoch !== identityEpoch || state.currentProjectId !== pid) return null
|
||
store.toast(`已收到材料:${up.filename}${up.parsed ? ' · 已读取内容' : ''}`)
|
||
await store.refreshArtifacts(pid)
|
||
return epoch === identityEpoch && state.currentProjectId === pid ? up.artifactId : null
|
||
} catch (e) {
|
||
if (epoch === identityEpoch && state.currentProjectId === pid) store.toast('材料未能上传,请检查文件后重新选择')
|
||
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
|
||
const request = ++projectCardsRequest
|
||
const started = projectCardVersion
|
||
try {
|
||
const snapshot = await api.listProjectCards(pid)
|
||
if (state.currentProjectId !== pid || request !== projectCardsRequest) return
|
||
const projectCards = mergeCardSnapshot(snapshot, state.projectCards, projectCardVersions, started, projectCardPatches)
|
||
set({ projectCards })
|
||
settleCardFeedback(projectCards)
|
||
} catch { /* Keep existing readable content while reconnecting. */ }
|
||
},
|
||
|
||
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
|
||
const epoch = identityEpoch
|
||
if (!pid) return
|
||
projectSse?.close()
|
||
projectSse = connectEvents('', projectSeq, { onEvent: e => { if (epoch === identityEpoch && state.currentProjectId === pid) store.applyProjectEvent(e) } }, pid)
|
||
},
|
||
|
||
applyProjectEvent(e: StreamEvent) {
|
||
if (!Number.isFinite(e.seq) || e.seq <= projectSeq) return
|
||
projectSeq = e.seq
|
||
const upsert = (incoming: ActivityCard) => {
|
||
const card=mergeTaskCard(state.projectCards.find(c=>c.id===incoming.id),incoming)
|
||
if (card.projectId !== state.currentProjectId) return
|
||
projectCardVersions[card.id] = ++projectCardVersion
|
||
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] })
|
||
settleCardFeedback([card])
|
||
}
|
||
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 }
|
||
projectCardVersions[p.cardId] = ++projectCardVersion
|
||
projectCardPatches[p.cardId] = { status: p.status, tone: p.tone }
|
||
const list = store.getState().projectCards
|
||
set({ projectCards: list.map(c => (c.id === p.cardId ? { ...c, status: p.status, tone: p.tone ?? c.tone } : c)) })
|
||
settleCardFeedback(state.projectCards)
|
||
break
|
||
}
|
||
case 'card.resolved': {
|
||
const p = e.payload as { cardId: string }
|
||
projectCardVersions[p.cardId] = ++projectCardVersion
|
||
projectCardPatches[p.cardId] = { status: 'resolved' }
|
||
const list = store.getState().projectCards
|
||
set({ projectCards: list.map(c => (c.id === p.cardId ? { ...c, status: 'resolved' as const } : c)) })
|
||
settleCardFeedback(state.projectCards)
|
||
break
|
||
}
|
||
}
|
||
},
|
||
|
||
async cardToProject(card: ActivityCard) {
|
||
const epoch = identityEpoch
|
||
try {
|
||
const out = await api.cardToProject(card.id)
|
||
if (epoch !== identityEpoch) return
|
||
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) {
|
||
if (epoch === identityEpoch) store.toast(`留档失败:${e}`)
|
||
}
|
||
},
|
||
|
||
async tidyCards() {
|
||
const epoch = identityEpoch
|
||
// U1:不收纳失败语义卡(failed,或旧 done+danger),避免把失败当完成清掉。
|
||
const done = state.cards.filter(c => c.status === 'done' && c.tone !== 'danger')
|
||
for (const c of done) {
|
||
if (epoch !== identityEpoch) return
|
||
try {
|
||
const out = await api.cardDismiss(c.id)
|
||
if (epoch !== identityEpoch) return
|
||
store.upsertCard(out.card)
|
||
} catch { /* 单卡失败不阻断 */ }
|
||
}
|
||
if (epoch === identityEpoch && 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) {
|
||
if (state.workRecord) set({ workRecord: null })
|
||
if (cardId) {
|
||
const scope = state.cards.some(card => card.id === cardId) ? 'conv'
|
||
: state.projectCards.some(card => card.id === cardId) ? 'project' : 'conv'
|
||
set({ inboxScope: scope })
|
||
}
|
||
setUI({ rightCollapsed: false, rightTab: 'work', ...(cardId ? { inboxFilter: 'all' as const } : {}) })
|
||
if (cardId) setTimeout(() => flashLocate('.right-panel', 'data-card-id', cardId), 60)
|
||
},
|
||
openWorkRecord(card: ActivityCard) {
|
||
if (!card.sourceRunId || !card.conversationId || card.conversationId !== state.currentConvId || card.projectId !== state.currentProjectId) {
|
||
store.toast('请先打开这项工作所属的对话,再查看执行记录')
|
||
return
|
||
}
|
||
set({ workRecord: { cardId: card.id, runId: card.sourceRunId, conversationId: card.conversationId, title: card.title } })
|
||
},
|
||
closeWorkRecord() { set({ workRecord: null }) },
|
||
referenceWork(card: ActivityCard) {
|
||
if (card.projectId !== state.currentProjectId || card.conversationId !== state.currentConvId || !card.work?.result?.trim()) {
|
||
store.toast('请在这项工作所属的对话中引用结果')
|
||
return
|
||
}
|
||
set({ workReference: { cardId: card.id, resultVersion: card.work.resultVersion, title: card.title } })
|
||
if (card.work.resultVersion > card.work.readVersion) void store.cardAction(card, 'work.read', { resultVersion: card.work.resultVersion })
|
||
if (typeof document !== 'undefined') document.querySelector<HTMLTextAreaElement>('.input-row textarea')?.focus()
|
||
},
|
||
clearWorkReference() { set({ workReference: null }) },
|
||
/** 左栏/收起轨的「文件」入口:打开第三栏文件入口(列表或既有预览)。 */
|
||
openFiles() { setUI({ rightTab: 'files', rightCollapsed: false }) },
|
||
// 卡→消息互定位另一半(D32):从收件箱卡滚回产生它的对话消息。
|
||
locateMessage(messageId?: string) {
|
||
if (!messageId) return
|
||
setUI({ rightCollapsed: true, previewExpanded: false })
|
||
setTimeout(() => flashLocate('.chat', 'data-msg-id', messageId), 60)
|
||
},
|
||
openTracker(runId: string) { setUI({ trackerRunId: runId }) },
|
||
/**
|
||
* 工件预览唯一入口:在第三栏「文件」入口打开或激活标签(多文件多标签)。
|
||
* 打开第一张自动切到预览子视图;关掉最后一张回到文件列表,并收回展开态。
|
||
*/
|
||
async openTaskSource(cardId: string, source: TaskSourceRef) {
|
||
const conversationId = state.currentConvId
|
||
const projectId = state.currentProjectId
|
||
const epoch = conversationEpoch
|
||
const intent = state.previewOpenIntent + 1
|
||
set({ previewOpenIntent: intent })
|
||
const isCurrent = () => conversationId === state.currentConvId && projectId === state.currentProjectId
|
||
&& epoch === conversationEpoch && intent === state.previewOpenIntent
|
||
try {
|
||
const out = await api.taskSource(cardId, source.id)
|
||
if (!isCurrent()) return
|
||
if (!out.artifact) {
|
||
store.toast('来源当前不可用,正在核实工作记录')
|
||
if (conversationId) void store.refreshCards(conversationId)
|
||
return
|
||
}
|
||
store.requestTaskSourcePreview(out.artifact, {
|
||
notice: out.versionState === 'changed'
|
||
? '当前文件已更新;下方引用来自任务处理时的版本,不能作为当前文件的结论'
|
||
: '任务处理时引用的来源版本;下方是本项发现引用的片段',
|
||
quote: out.source.quote ?? '',
|
||
expectedHash: out.versionState === 'current' ? out.source.contentHash : undefined,
|
||
})
|
||
if (out.versionState === 'changed' && conversationId) void store.refreshCards(conversationId)
|
||
} catch {
|
||
if (!isCurrent()) return
|
||
store.toast('来源当前无法读取,正在核实工作记录')
|
||
if (conversationId) void store.refreshCards(conversationId)
|
||
}
|
||
},
|
||
requestTaskSourcePreview(a:Artifact,sourceContext:{notice:string;quote:string;expectedHash?:string}){
|
||
store.requestPreview(a)
|
||
const conv=previewTabsKey(state.currentConvId)
|
||
set({previewTabs:{...state.previewTabs,[conv]:(state.previewTabs[conv]??[]).map(tab=>tab.id===a.id?{...tab,sourceContext}:tab)}})
|
||
},
|
||
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 },
|
||
previewOpenIntent: state.previewOpenIntent + 1,
|
||
})
|
||
syncFilesEntry(true)
|
||
},
|
||
openSurface(surfaceId: string) {
|
||
// generative-ui:页面进预览第三栏(标签 id 带 surface: 前缀区分工件)。
|
||
const snap = state.surfaces.find(x => surfaceKey(x) === 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 },
|
||
previewOpenIntent: state.previewOpenIntent + 1,
|
||
})
|
||
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 => surfaceKey(x) === 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 },previewOpenIntent:state.previewOpenIntent+1 })
|
||
},
|
||
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 },
|
||
previewOpenIntent:state.previewOpenIntent+1,
|
||
})
|
||
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)
|
||
},
|
||
}
|
||
|
||
auth.onIdentityChange(() => store.resetIdentity())
|
||
|
||
function touchLive(_runId: string, mutate: (l: LiveState) => void, now = Date.now()): LiveState | null {
|
||
if (state.runs.find(r => r.id === _runId)?.kind === 'subagent') return state.live
|
||
if (state.live && state.live.runId !== _runId && !isTerminal(state.runs.find(r => r.id === state.live?.runId)?.status)) return state.live
|
||
const base = state.live ?? { runId: _runId, phase: 'thinking', items: [], agentNotes: {},
|
||
startedAt: eventTime({ ts: state.runs.find(run => run.id === _runId)?.createdAt }, 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:')
|
||
}
|
||
|
||
/** 已被移出项目或项目已关闭:后端以 403 `conversation: not-project-member` 拒绝继续发送。 */
|
||
function isNotProjectMember(err: unknown): boolean {
|
||
return String(err).includes('not-project-member')
|
||
}
|
||
|
||
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)
|
||
// 后端 0 值占位(0001-01-01T00:00:00Z 等)会让状态栏读到巨大耗时,回落 now 让「已工作」保持真实时钟。
|
||
if (Number.isNaN(t) || t <= 0) return now
|
||
return t
|
||
}
|