fix: restore complete source code and fix launchers
- Copy complete source code packages from original CodeNomad project - Add root package.json with npm workspace configuration - Include electron-app, server, ui, tauri-app, and opencode-config packages - Fix Launch-Windows.bat and Launch-Dev-Windows.bat to work with correct npm scripts - Fix Launch-Unix.sh to work with correct npm scripts - Launchers now correctly call npm run dev:electron which launches Electron app
This commit is contained in:
46
packages/ui/src/stores/alerts.ts
Normal file
46
packages/ui/src/stores/alerts.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { createSignal } from "solid-js"
|
||||
|
||||
export type AlertVariant = "info" | "warning" | "error"
|
||||
|
||||
export type AlertDialogState = {
|
||||
type?: "alert" | "confirm"
|
||||
title?: string
|
||||
message: string
|
||||
detail?: string
|
||||
variant?: AlertVariant
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
onConfirm?: () => void
|
||||
onCancel?: () => void
|
||||
resolve?: (value: boolean) => void
|
||||
}
|
||||
|
||||
const [alertDialogState, setAlertDialogState] = createSignal<AlertDialogState | null>(null)
|
||||
|
||||
export function showAlertDialog(message: string, options?: Omit<AlertDialogState, "message">) {
|
||||
setAlertDialogState({
|
||||
type: "alert",
|
||||
message,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export function showConfirmDialog(message: string, options?: Omit<AlertDialogState, "message">): Promise<boolean> {
|
||||
const activeElement = typeof document !== "undefined" ? (document.activeElement as HTMLElement | null) : null
|
||||
activeElement?.blur()
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
setAlertDialogState({
|
||||
type: "confirm",
|
||||
message,
|
||||
...options,
|
||||
resolve,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function dismissAlertDialog() {
|
||||
setAlertDialogState(null)
|
||||
}
|
||||
|
||||
export { alertDialogState }
|
||||
47
packages/ui/src/stores/attachments.ts
Normal file
47
packages/ui/src/stores/attachments.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import type { Attachment } from "../types/attachment"
|
||||
|
||||
const [attachments, setAttachments] = createSignal<Map<string, Attachment[]>>(new Map())
|
||||
|
||||
function getSessionKey(instanceId: string, sessionId: string): string {
|
||||
return `${instanceId}:${sessionId}`
|
||||
}
|
||||
|
||||
function getAttachments(instanceId: string, sessionId: string): Attachment[] {
|
||||
const key = getSessionKey(instanceId, sessionId)
|
||||
return attachments().get(key) || []
|
||||
}
|
||||
|
||||
function addAttachment(instanceId: string, sessionId: string, attachment: Attachment) {
|
||||
const key = getSessionKey(instanceId, sessionId)
|
||||
setAttachments((prev) => {
|
||||
const next = new Map(prev)
|
||||
const existing = next.get(key) || []
|
||||
next.set(key, [...existing, attachment])
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function removeAttachment(instanceId: string, sessionId: string, attachmentId: string) {
|
||||
const key = getSessionKey(instanceId, sessionId)
|
||||
setAttachments((prev) => {
|
||||
const next = new Map(prev)
|
||||
const existing = next.get(key) || []
|
||||
next.set(
|
||||
key,
|
||||
existing.filter((a) => a.id !== attachmentId),
|
||||
)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function clearAttachments(instanceId: string, sessionId: string) {
|
||||
const key = getSessionKey(instanceId, sessionId)
|
||||
setAttachments((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(key)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
export { getAttachments, addAttachment, removeAttachment, clearAttachments }
|
||||
36
packages/ui/src/stores/command-palette.ts
Normal file
36
packages/ui/src/stores/command-palette.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { createSignal } from "solid-js"
|
||||
|
||||
const [openStates, setOpenStates] = createSignal<Map<string, boolean>>(new Map())
|
||||
|
||||
function updateState(instanceId: string, open: boolean) {
|
||||
setOpenStates((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, open)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
export function showCommandPalette(instanceId: string) {
|
||||
if (!instanceId) return
|
||||
updateState(instanceId, true)
|
||||
}
|
||||
|
||||
export function hideCommandPalette(instanceId?: string) {
|
||||
if (!instanceId) {
|
||||
setOpenStates(new Map())
|
||||
return
|
||||
}
|
||||
updateState(instanceId, false)
|
||||
}
|
||||
|
||||
export function toggleCommandPalette(instanceId: string) {
|
||||
if (!instanceId) return
|
||||
const current = openStates().get(instanceId) ?? false
|
||||
updateState(instanceId, !current)
|
||||
}
|
||||
|
||||
export function isOpen(instanceId: string): boolean {
|
||||
return openStates().get(instanceId) ?? false
|
||||
}
|
||||
|
||||
export { openStates }
|
||||
30
packages/ui/src/stores/commands.ts
Normal file
30
packages/ui/src/stores/commands.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import type { Command as SDKCommand } from "@opencode-ai/sdk"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/client"
|
||||
|
||||
const [commandMap, setCommandMap] = createSignal<Map<string, SDKCommand[]>>(new Map())
|
||||
|
||||
export async function fetchCommands(instanceId: string, client: OpencodeClient): Promise<void> {
|
||||
const response = await client.command.list()
|
||||
const commands = response.data ?? []
|
||||
setCommandMap((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, commands)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
export function getCommands(instanceId: string): SDKCommand[] {
|
||||
return commandMap().get(instanceId) ?? []
|
||||
}
|
||||
|
||||
export function clearCommands(instanceId: string): void {
|
||||
setCommandMap((prev) => {
|
||||
if (!prev.has(instanceId)) return prev
|
||||
const next = new Map(prev)
|
||||
next.delete(instanceId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
export { commandMap as commands }
|
||||
141
packages/ui/src/stores/instance-config.tsx
Normal file
141
packages/ui/src/stores/instance-config.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { createContext, createMemo, createSignal, onCleanup, type Accessor, type ParentComponent, useContext } from "solid-js"
|
||||
import type { InstanceData } from "../../../server/src/api-types"
|
||||
import { storage } from "../lib/storage"
|
||||
import { getLogger } from "../lib/logger"
|
||||
|
||||
const log = getLogger("api")
|
||||
|
||||
const DEFAULT_INSTANCE_DATA: InstanceData = { messageHistory: [], agentModelSelections: {} }
|
||||
|
||||
const [instanceDataMap, setInstanceDataMap] = createSignal<Map<string, InstanceData>>(new Map())
|
||||
const loadPromises = new Map<string, Promise<void>>()
|
||||
const instanceSubscriptions = new Map<string, () => void>()
|
||||
|
||||
function cloneInstanceData(data?: InstanceData | null): InstanceData {
|
||||
const source = data ?? DEFAULT_INSTANCE_DATA
|
||||
return {
|
||||
...source,
|
||||
messageHistory: Array.isArray(source.messageHistory) ? [...source.messageHistory] : [],
|
||||
agentModelSelections: { ...(source.agentModelSelections ?? {}) },
|
||||
}
|
||||
}
|
||||
|
||||
function attachSubscription(instanceId: string) {
|
||||
if (instanceSubscriptions.has(instanceId)) return
|
||||
const unsubscribe = storage.onInstanceDataChanged(instanceId, (data) => {
|
||||
setInstanceData(instanceId, data)
|
||||
})
|
||||
instanceSubscriptions.set(instanceId, unsubscribe)
|
||||
}
|
||||
|
||||
function detachSubscription(instanceId: string) {
|
||||
const unsubscribe = instanceSubscriptions.get(instanceId)
|
||||
if (!unsubscribe) return
|
||||
unsubscribe()
|
||||
instanceSubscriptions.delete(instanceId)
|
||||
}
|
||||
|
||||
function setInstanceData(instanceId: string, data: InstanceData) {
|
||||
setInstanceDataMap((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, cloneInstanceData(data))
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
async function ensureInstanceConfig(instanceId: string): Promise<void> {
|
||||
if (!instanceId) return
|
||||
if (instanceDataMap().has(instanceId)) return
|
||||
if (loadPromises.has(instanceId)) {
|
||||
await loadPromises.get(instanceId)
|
||||
return
|
||||
}
|
||||
const promise = storage
|
||||
.loadInstanceData(instanceId)
|
||||
.then((data) => {
|
||||
setInstanceData(instanceId, data)
|
||||
attachSubscription(instanceId)
|
||||
})
|
||||
.catch((error) => {
|
||||
log.warn("Failed to load instance data", error)
|
||||
setInstanceData(instanceId, DEFAULT_INSTANCE_DATA)
|
||||
attachSubscription(instanceId)
|
||||
})
|
||||
.finally(() => {
|
||||
loadPromises.delete(instanceId)
|
||||
})
|
||||
loadPromises.set(instanceId, promise)
|
||||
await promise
|
||||
}
|
||||
|
||||
async function updateInstanceConfig(instanceId: string, mutator: (draft: InstanceData) => void): Promise<void> {
|
||||
if (!instanceId) return
|
||||
await ensureInstanceConfig(instanceId)
|
||||
const current = instanceDataMap().get(instanceId) ?? DEFAULT_INSTANCE_DATA
|
||||
const draft = cloneInstanceData(current)
|
||||
mutator(draft)
|
||||
try {
|
||||
await storage.saveInstanceData(instanceId, draft)
|
||||
} catch (error) {
|
||||
log.warn("Failed to persist instance data", error)
|
||||
}
|
||||
setInstanceData(instanceId, draft)
|
||||
}
|
||||
|
||||
function getInstanceConfig(instanceId: string): InstanceData {
|
||||
return instanceDataMap().get(instanceId) ?? DEFAULT_INSTANCE_DATA
|
||||
}
|
||||
|
||||
function useInstanceConfig(instanceId: string): Accessor<InstanceData> {
|
||||
const context = useContext(InstanceConfigContext)
|
||||
if (!context) {
|
||||
throw new Error("useInstanceConfig must be used within InstanceConfigProvider")
|
||||
}
|
||||
return createMemo(() => instanceDataMap().get(instanceId) ?? DEFAULT_INSTANCE_DATA)
|
||||
}
|
||||
|
||||
function clearInstanceConfig(instanceId: string): void {
|
||||
setInstanceDataMap((prev) => {
|
||||
if (!prev.has(instanceId)) return prev
|
||||
const next = new Map(prev)
|
||||
next.delete(instanceId)
|
||||
return next
|
||||
})
|
||||
detachSubscription(instanceId)
|
||||
}
|
||||
|
||||
interface InstanceConfigContextValue {
|
||||
getInstanceConfig: typeof getInstanceConfig
|
||||
ensureInstanceConfig: typeof ensureInstanceConfig
|
||||
updateInstanceConfig: typeof updateInstanceConfig
|
||||
clearInstanceConfig: typeof clearInstanceConfig
|
||||
}
|
||||
|
||||
const InstanceConfigContext = createContext<InstanceConfigContextValue>()
|
||||
|
||||
const contextValue: InstanceConfigContextValue = {
|
||||
getInstanceConfig,
|
||||
ensureInstanceConfig,
|
||||
updateInstanceConfig,
|
||||
clearInstanceConfig,
|
||||
}
|
||||
|
||||
const InstanceConfigProvider: ParentComponent = (props) => {
|
||||
onCleanup(() => {
|
||||
for (const unsubscribe of instanceSubscriptions.values()) {
|
||||
unsubscribe()
|
||||
}
|
||||
instanceSubscriptions.clear()
|
||||
})
|
||||
|
||||
return <InstanceConfigContext.Provider value={contextValue}>{props.children}</InstanceConfigContext.Provider>
|
||||
}
|
||||
|
||||
export {
|
||||
InstanceConfigProvider,
|
||||
useInstanceConfig,
|
||||
ensureInstanceConfig as ensureInstanceConfigLoaded,
|
||||
getInstanceConfig,
|
||||
updateInstanceConfig,
|
||||
clearInstanceConfig,
|
||||
}
|
||||
35
packages/ui/src/stores/instance-metadata.ts
Normal file
35
packages/ui/src/stores/instance-metadata.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import type { InstanceMetadata } from "../types/instance"
|
||||
|
||||
const [metadataMap, setMetadataMap] = createSignal<Map<string, InstanceMetadata | undefined>>(new Map())
|
||||
|
||||
function getInstanceMetadata(instanceId: string): InstanceMetadata | undefined {
|
||||
return metadataMap().get(instanceId)
|
||||
}
|
||||
|
||||
function setInstanceMetadata(instanceId: string, metadata: InstanceMetadata | undefined): void {
|
||||
setMetadataMap((prev) => {
|
||||
const next = new Map(prev)
|
||||
if (metadata === undefined) {
|
||||
next.delete(instanceId)
|
||||
} else {
|
||||
next.set(instanceId, metadata)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function mergeInstanceMetadata(instanceId: string, updates: InstanceMetadata): void {
|
||||
setMetadataMap((prev) => {
|
||||
const next = new Map(prev)
|
||||
const existing = next.get(instanceId) ?? {}
|
||||
next.set(instanceId, { ...existing, ...updates })
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function clearInstanceMetadata(instanceId: string): void {
|
||||
setInstanceMetadata(instanceId, undefined)
|
||||
}
|
||||
|
||||
export { metadataMap, getInstanceMetadata, setInstanceMetadata, mergeInstanceMetadata, clearInstanceMetadata }
|
||||
622
packages/ui/src/stores/instances.ts
Normal file
622
packages/ui/src/stores/instances.ts
Normal file
@@ -0,0 +1,622 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import type { Instance, LogEntry } from "../types/instance"
|
||||
import type { LspStatus, Permission } from "@opencode-ai/sdk"
|
||||
import { sdkManager } from "../lib/sdk-manager"
|
||||
import { sseManager } from "../lib/sse-manager"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { serverEvents } from "../lib/server-events"
|
||||
import type { WorkspaceDescriptor, WorkspaceEventPayload, WorkspaceLogEntry } from "../../../server/src/api-types"
|
||||
import { ensureInstanceConfigLoaded } from "./instance-config"
|
||||
import {
|
||||
fetchSessions,
|
||||
fetchAgents,
|
||||
fetchProviders,
|
||||
clearInstanceDraftPrompts,
|
||||
} from "./sessions"
|
||||
import { fetchCommands, clearCommands } from "./commands"
|
||||
import { preferences } from "./preferences"
|
||||
import { setSessionPendingPermission } from "./session-state"
|
||||
import { setHasInstances } from "./ui"
|
||||
import { messageStoreBus } from "./message-v2/bus"
|
||||
import { clearCacheForInstance } from "../lib/global-cache"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { mergeInstanceMetadata, clearInstanceMetadata } from "./instance-metadata"
|
||||
|
||||
const log = getLogger("api")
|
||||
|
||||
const [instances, setInstances] = createSignal<Map<string, Instance>>(new Map())
|
||||
|
||||
const [activeInstanceId, setActiveInstanceId] = createSignal<string | null>(null)
|
||||
const [instanceLogs, setInstanceLogs] = createSignal<Map<string, LogEntry[]>>(new Map())
|
||||
const [logStreamingState, setLogStreamingState] = createSignal<Map<string, boolean>>(new Map())
|
||||
|
||||
// Permission queue management per instance
|
||||
const [permissionQueues, setPermissionQueues] = createSignal<Map<string, Permission[]>>(new Map())
|
||||
const [activePermissionId, setActivePermissionId] = createSignal<Map<string, string | null>>(new Map())
|
||||
const permissionSessionCounts = new Map<string, Map<string, number>>()
|
||||
|
||||
function syncHasInstancesFlag() {
|
||||
const readyExists = Array.from(instances().values()).some((instance) => instance.status === "ready")
|
||||
setHasInstances(readyExists)
|
||||
}
|
||||
interface DisconnectedInstanceInfo {
|
||||
id: string
|
||||
folder: string
|
||||
reason: string
|
||||
}
|
||||
const [disconnectedInstance, setDisconnectedInstance] = createSignal<DisconnectedInstanceInfo | null>(null)
|
||||
|
||||
const MAX_LOG_ENTRIES = 1000
|
||||
|
||||
function workspaceDescriptorToInstance(descriptor: WorkspaceDescriptor): Instance {
|
||||
const existing = instances().get(descriptor.id)
|
||||
return {
|
||||
id: descriptor.id,
|
||||
folder: descriptor.path,
|
||||
port: descriptor.port ?? existing?.port ?? 0,
|
||||
pid: descriptor.pid ?? existing?.pid ?? 0,
|
||||
proxyPath: descriptor.proxyPath,
|
||||
status: descriptor.status,
|
||||
error: descriptor.error,
|
||||
client: existing?.client ?? null,
|
||||
metadata: existing?.metadata,
|
||||
binaryPath: descriptor.binaryId ?? descriptor.binaryLabel ?? existing?.binaryPath,
|
||||
binaryLabel: descriptor.binaryLabel,
|
||||
binaryVersion: descriptor.binaryVersion ?? existing?.binaryVersion,
|
||||
environmentVariables: existing?.environmentVariables ?? preferences().environmentVariables ?? {},
|
||||
}
|
||||
}
|
||||
|
||||
function upsertWorkspace(descriptor: WorkspaceDescriptor) {
|
||||
const mapped = workspaceDescriptorToInstance(descriptor)
|
||||
if (instances().has(descriptor.id)) {
|
||||
updateInstance(descriptor.id, mapped)
|
||||
} else {
|
||||
addInstance(mapped)
|
||||
}
|
||||
|
||||
if (descriptor.status === "ready") {
|
||||
attachClient(descriptor)
|
||||
}
|
||||
}
|
||||
|
||||
function attachClient(descriptor: WorkspaceDescriptor) {
|
||||
const instance = instances().get(descriptor.id)
|
||||
if (!instance) return
|
||||
|
||||
const nextPort = descriptor.port ?? instance.port
|
||||
const nextProxyPath = descriptor.proxyPath
|
||||
|
||||
if (instance.client && instance.proxyPath === nextProxyPath) {
|
||||
if (nextPort && instance.port !== nextPort) {
|
||||
updateInstance(descriptor.id, { port: nextPort })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (instance.client) {
|
||||
sdkManager.destroyClient(descriptor.id)
|
||||
}
|
||||
|
||||
const client = sdkManager.createClient(descriptor.id, nextProxyPath)
|
||||
updateInstance(descriptor.id, {
|
||||
client,
|
||||
port: nextPort ?? 0,
|
||||
proxyPath: nextProxyPath,
|
||||
status: "ready",
|
||||
})
|
||||
sseManager.seedStatus(descriptor.id, "connecting")
|
||||
void hydrateInstanceData(descriptor.id).catch((error) => {
|
||||
log.error("Failed to hydrate instance data", error)
|
||||
})
|
||||
}
|
||||
|
||||
function releaseInstanceResources(instanceId: string) {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance) return
|
||||
|
||||
if (instance.client) {
|
||||
sdkManager.destroyClient(instanceId)
|
||||
}
|
||||
sseManager.seedStatus(instanceId, "disconnected")
|
||||
}
|
||||
|
||||
async function hydrateInstanceData(instanceId: string) {
|
||||
try {
|
||||
await fetchSessions(instanceId)
|
||||
await fetchAgents(instanceId)
|
||||
await fetchProviders(instanceId)
|
||||
await ensureInstanceConfigLoaded(instanceId)
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance?.client) return
|
||||
await fetchCommands(instanceId, instance.client)
|
||||
} catch (error) {
|
||||
log.error("Failed to fetch initial data", error)
|
||||
}
|
||||
}
|
||||
|
||||
void (async function initializeWorkspaces() {
|
||||
try {
|
||||
const workspaces = await serverApi.fetchWorkspaces()
|
||||
workspaces.forEach((workspace) => upsertWorkspace(workspace))
|
||||
} catch (error) {
|
||||
log.error("Failed to load workspaces", error)
|
||||
}
|
||||
})()
|
||||
|
||||
serverEvents.on("*", (event) => handleWorkspaceEvent(event))
|
||||
|
||||
function handleWorkspaceEvent(event: WorkspaceEventPayload) {
|
||||
switch (event.type) {
|
||||
case "workspace.created":
|
||||
upsertWorkspace(event.workspace)
|
||||
break
|
||||
case "workspace.started":
|
||||
upsertWorkspace(event.workspace)
|
||||
break
|
||||
case "workspace.error":
|
||||
upsertWorkspace(event.workspace)
|
||||
break
|
||||
case "workspace.stopped":
|
||||
releaseInstanceResources(event.workspaceId)
|
||||
removeInstance(event.workspaceId)
|
||||
break
|
||||
case "workspace.log":
|
||||
handleWorkspaceLog(event.entry)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function handleWorkspaceLog(entry: WorkspaceLogEntry) {
|
||||
const logEntry: LogEntry = {
|
||||
timestamp: new Date(entry.timestamp).getTime(),
|
||||
level: (entry.level as LogEntry["level"]) ?? "info",
|
||||
message: entry.message,
|
||||
}
|
||||
addLog(entry.workspaceId, logEntry)
|
||||
}
|
||||
|
||||
function ensureLogContainer(id: string) {
|
||||
setInstanceLogs((prev) => {
|
||||
if (prev.has(id)) {
|
||||
return prev
|
||||
}
|
||||
const next = new Map(prev)
|
||||
next.set(id, [])
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function ensureLogStreamingState(id: string) {
|
||||
setLogStreamingState((prev) => {
|
||||
if (prev.has(id)) {
|
||||
return prev
|
||||
}
|
||||
const next = new Map(prev)
|
||||
next.set(id, false)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function removeLogContainer(id: string) {
|
||||
setInstanceLogs((prev) => {
|
||||
if (!prev.has(id)) {
|
||||
return prev
|
||||
}
|
||||
const next = new Map(prev)
|
||||
next.delete(id)
|
||||
return next
|
||||
})
|
||||
setLogStreamingState((prev) => {
|
||||
if (!prev.has(id)) {
|
||||
return prev
|
||||
}
|
||||
const next = new Map(prev)
|
||||
next.delete(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function getInstanceLogs(instanceId: string): LogEntry[] {
|
||||
return instanceLogs().get(instanceId) ?? []
|
||||
}
|
||||
|
||||
function isInstanceLogStreaming(instanceId: string): boolean {
|
||||
return logStreamingState().get(instanceId) ?? false
|
||||
}
|
||||
|
||||
function setInstanceLogStreaming(instanceId: string, enabled: boolean) {
|
||||
ensureLogStreamingState(instanceId)
|
||||
setLogStreamingState((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, enabled)
|
||||
return next
|
||||
})
|
||||
if (!enabled) {
|
||||
clearLogs(instanceId)
|
||||
}
|
||||
}
|
||||
|
||||
function addInstance(instance: Instance) {
|
||||
setInstances((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instance.id, instance)
|
||||
return next
|
||||
})
|
||||
ensureLogContainer(instance.id)
|
||||
ensureLogStreamingState(instance.id)
|
||||
syncHasInstancesFlag()
|
||||
}
|
||||
|
||||
function updateInstance(id: string, updates: Partial<Instance>) {
|
||||
setInstances((prev) => {
|
||||
const next = new Map(prev)
|
||||
const instance = next.get(id)
|
||||
if (instance) {
|
||||
next.set(id, { ...instance, ...updates })
|
||||
}
|
||||
return next
|
||||
})
|
||||
syncHasInstancesFlag()
|
||||
}
|
||||
|
||||
function removeInstance(id: string) {
|
||||
let nextActiveId: string | null = null
|
||||
|
||||
setInstances((prev) => {
|
||||
if (!prev.has(id)) {
|
||||
return prev
|
||||
}
|
||||
|
||||
const keys = Array.from(prev.keys())
|
||||
const index = keys.indexOf(id)
|
||||
const next = new Map(prev)
|
||||
next.delete(id)
|
||||
|
||||
if (activeInstanceId() === id) {
|
||||
if (index > 0) {
|
||||
const prevKey = keys[index - 1]
|
||||
nextActiveId = prevKey ?? null
|
||||
} else {
|
||||
const remainingKeys = Array.from(next.keys())
|
||||
nextActiveId = remainingKeys.length > 0 ? (remainingKeys[0] ?? null) : null
|
||||
}
|
||||
}
|
||||
|
||||
return next
|
||||
})
|
||||
|
||||
removeLogContainer(id)
|
||||
clearCommands(id)
|
||||
clearPermissionQueue(id)
|
||||
clearInstanceMetadata(id)
|
||||
|
||||
if (activeInstanceId() === id) {
|
||||
setActiveInstanceId(nextActiveId)
|
||||
}
|
||||
|
||||
// Clean up session indexes and drafts for removed instance
|
||||
clearCacheForInstance(id)
|
||||
messageStoreBus.unregisterInstance(id)
|
||||
clearInstanceDraftPrompts(id)
|
||||
syncHasInstancesFlag()
|
||||
}
|
||||
|
||||
async function createInstance(folder: string, _binaryPath?: string): Promise<string> {
|
||||
try {
|
||||
const workspace = await serverApi.createWorkspace({ path: folder })
|
||||
upsertWorkspace(workspace)
|
||||
setActiveInstanceId(workspace.id)
|
||||
return workspace.id
|
||||
} catch (error) {
|
||||
log.error("Failed to create workspace", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function stopInstance(id: string) {
|
||||
const instance = instances().get(id)
|
||||
if (!instance) return
|
||||
|
||||
releaseInstanceResources(id)
|
||||
|
||||
try {
|
||||
await serverApi.deleteWorkspace(id)
|
||||
} catch (error) {
|
||||
log.error("Failed to stop workspace", error)
|
||||
}
|
||||
|
||||
removeInstance(id)
|
||||
}
|
||||
|
||||
async function fetchLspStatus(instanceId: string): Promise<LspStatus[] | undefined> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance) {
|
||||
log.warn("[LSP] Skipping status fetch; instance not found", { instanceId })
|
||||
return undefined
|
||||
}
|
||||
if (!instance.client) {
|
||||
log.warn("[LSP] Skipping status fetch; client not ready", { instanceId })
|
||||
return undefined
|
||||
}
|
||||
const lsp = instance.client.lsp
|
||||
if (!lsp?.status) {
|
||||
log.warn("[LSP] Skipping status fetch; API unavailable", { instanceId })
|
||||
return undefined
|
||||
}
|
||||
log.info("lsp.status", { instanceId })
|
||||
const response = await lsp.status()
|
||||
return response.data ?? []
|
||||
}
|
||||
|
||||
function getActiveInstance(): Instance | null {
|
||||
const id = activeInstanceId()
|
||||
return id ? instances().get(id) || null : null
|
||||
}
|
||||
|
||||
function addLog(id: string, entry: LogEntry) {
|
||||
if (!isInstanceLogStreaming(id)) {
|
||||
return
|
||||
}
|
||||
|
||||
setInstanceLogs((prev) => {
|
||||
const next = new Map(prev)
|
||||
const existing = next.get(id) ?? []
|
||||
const updated = existing.length >= MAX_LOG_ENTRIES ? [...existing.slice(1), entry] : [...existing, entry]
|
||||
next.set(id, updated)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function clearLogs(id: string) {
|
||||
setInstanceLogs((prev) => {
|
||||
if (!prev.has(id)) {
|
||||
return prev
|
||||
}
|
||||
const next = new Map(prev)
|
||||
next.set(id, [])
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// Permission management functions
|
||||
function getPermissionQueue(instanceId: string): Permission[] {
|
||||
const queue = permissionQueues().get(instanceId)
|
||||
if (!queue) {
|
||||
return []
|
||||
}
|
||||
return queue
|
||||
}
|
||||
|
||||
function getPermissionQueueLength(instanceId: string): number {
|
||||
return getPermissionQueue(instanceId).length
|
||||
}
|
||||
|
||||
function incrementSessionPendingCount(instanceId: string, sessionId: string): void {
|
||||
let sessionCounts = permissionSessionCounts.get(instanceId)
|
||||
if (!sessionCounts) {
|
||||
sessionCounts = new Map()
|
||||
permissionSessionCounts.set(instanceId, sessionCounts)
|
||||
}
|
||||
const current = sessionCounts.get(sessionId) ?? 0
|
||||
sessionCounts.set(sessionId, current + 1)
|
||||
}
|
||||
|
||||
function decrementSessionPendingCount(instanceId: string, sessionId: string): number {
|
||||
const sessionCounts = permissionSessionCounts.get(instanceId)
|
||||
if (!sessionCounts) return 0
|
||||
const current = sessionCounts.get(sessionId) ?? 0
|
||||
if (current <= 1) {
|
||||
sessionCounts.delete(sessionId)
|
||||
if (sessionCounts.size === 0) {
|
||||
permissionSessionCounts.delete(instanceId)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
const nextValue = current - 1
|
||||
sessionCounts.set(sessionId, nextValue)
|
||||
return nextValue
|
||||
}
|
||||
|
||||
function clearSessionPendingCounts(instanceId: string): void {
|
||||
const sessionCounts = permissionSessionCounts.get(instanceId)
|
||||
if (!sessionCounts) return
|
||||
for (const sessionId of sessionCounts.keys()) {
|
||||
setSessionPendingPermission(instanceId, sessionId, false)
|
||||
}
|
||||
permissionSessionCounts.delete(instanceId)
|
||||
}
|
||||
|
||||
function addPermissionToQueue(instanceId: string, permission: Permission): void {
|
||||
let inserted = false
|
||||
|
||||
setPermissionQueues((prev) => {
|
||||
const next = new Map(prev)
|
||||
const queue = next.get(instanceId) ?? []
|
||||
|
||||
if (queue.some((p) => p.id === permission.id)) {
|
||||
return next
|
||||
}
|
||||
|
||||
const updatedQueue = [...queue, permission].sort((a, b) => a.time.created - b.time.created)
|
||||
next.set(instanceId, updatedQueue)
|
||||
inserted = true
|
||||
return next
|
||||
})
|
||||
|
||||
if (!inserted) {
|
||||
return
|
||||
}
|
||||
|
||||
setActivePermissionId((prev) => {
|
||||
const next = new Map(prev)
|
||||
if (!next.get(instanceId)) {
|
||||
next.set(instanceId, permission.id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
const sessionId = getPermissionSessionId(permission)
|
||||
incrementSessionPendingCount(instanceId, sessionId)
|
||||
setSessionPendingPermission(instanceId, sessionId, true)
|
||||
}
|
||||
|
||||
function removePermissionFromQueue(instanceId: string, permissionId: string): void {
|
||||
let removedPermission: Permission | null = null
|
||||
|
||||
setPermissionQueues((prev) => {
|
||||
const next = new Map(prev)
|
||||
const queue = next.get(instanceId) ?? []
|
||||
const filtered: Permission[] = []
|
||||
|
||||
for (const item of queue) {
|
||||
if (item.id === permissionId) {
|
||||
removedPermission = item
|
||||
continue
|
||||
}
|
||||
filtered.push(item)
|
||||
}
|
||||
|
||||
if (filtered.length > 0) {
|
||||
next.set(instanceId, filtered)
|
||||
} else {
|
||||
next.delete(instanceId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
const updatedQueue = getPermissionQueue(instanceId)
|
||||
|
||||
setActivePermissionId((prev) => {
|
||||
const next = new Map(prev)
|
||||
const activeId = next.get(instanceId)
|
||||
if (activeId === permissionId) {
|
||||
const nextPermission = updatedQueue.length > 0 ? (updatedQueue[0] as Permission) : null
|
||||
next.set(instanceId, nextPermission?.id ?? null)
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
const removed = removedPermission
|
||||
if (removed) {
|
||||
const removedSessionId = getPermissionSessionId(removed)
|
||||
const remaining = decrementSessionPendingCount(instanceId, removedSessionId)
|
||||
setSessionPendingPermission(instanceId, removedSessionId, remaining > 0)
|
||||
}
|
||||
}
|
||||
|
||||
function clearPermissionQueue(instanceId: string): void {
|
||||
setPermissionQueues((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(instanceId)
|
||||
return next
|
||||
})
|
||||
setActivePermissionId((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(instanceId)
|
||||
return next
|
||||
})
|
||||
clearSessionPendingCounts(instanceId)
|
||||
}
|
||||
|
||||
function getPermissionSessionId(permission: Permission): string {
|
||||
return (permission as any).sessionID
|
||||
}
|
||||
|
||||
async function sendPermissionResponse(
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
permissionId: string,
|
||||
response: "once" | "always" | "reject"
|
||||
): Promise<void> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance?.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
try {
|
||||
await instance.client.postSessionIdPermissionsPermissionId({
|
||||
path: { id: sessionId, permissionID: permissionId },
|
||||
body: { response },
|
||||
})
|
||||
|
||||
// Remove from queue after successful response
|
||||
removePermissionFromQueue(instanceId, permissionId)
|
||||
} catch (error) {
|
||||
log.error("Failed to send permission response", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
sseManager.onConnectionLost = (instanceId, reason) => {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance) {
|
||||
return
|
||||
}
|
||||
|
||||
setDisconnectedInstance({
|
||||
id: instanceId,
|
||||
folder: instance.folder,
|
||||
reason,
|
||||
})
|
||||
}
|
||||
|
||||
sseManager.onLspUpdated = async (instanceId) => {
|
||||
log.info("lsp.updated", { instanceId })
|
||||
try {
|
||||
const lspStatus = await fetchLspStatus(instanceId)
|
||||
if (!lspStatus) {
|
||||
return
|
||||
}
|
||||
mergeInstanceMetadata(instanceId, { lspStatus })
|
||||
} catch (error) {
|
||||
log.error("Failed to refresh LSP status", error)
|
||||
}
|
||||
}
|
||||
|
||||
async function acknowledgeDisconnectedInstance(): Promise<void> {
|
||||
const pending = disconnectedInstance()
|
||||
if (!pending) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await stopInstance(pending.id)
|
||||
} catch (error) {
|
||||
log.error("Failed to stop disconnected instance", error)
|
||||
} finally {
|
||||
setDisconnectedInstance(null)
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
instances,
|
||||
activeInstanceId,
|
||||
setActiveInstanceId,
|
||||
addInstance,
|
||||
updateInstance,
|
||||
removeInstance,
|
||||
createInstance,
|
||||
stopInstance,
|
||||
getActiveInstance,
|
||||
addLog,
|
||||
clearLogs,
|
||||
instanceLogs,
|
||||
getInstanceLogs,
|
||||
isInstanceLogStreaming,
|
||||
setInstanceLogStreaming,
|
||||
// Permission management
|
||||
permissionQueues,
|
||||
activePermissionId,
|
||||
getPermissionQueue,
|
||||
getPermissionQueueLength,
|
||||
addPermissionToQueue,
|
||||
removePermissionFromQueue,
|
||||
clearPermissionQueue,
|
||||
sendPermissionResponse,
|
||||
disconnectedInstance,
|
||||
acknowledgeDisconnectedInstance,
|
||||
fetchLspStatus,
|
||||
}
|
||||
35
packages/ui/src/stores/message-history.ts
Normal file
35
packages/ui/src/stores/message-history.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { InstanceData } from "../../../server/src/api-types"
|
||||
import {
|
||||
ensureInstanceConfigLoaded,
|
||||
getInstanceConfig,
|
||||
updateInstanceConfig,
|
||||
} from "./instance-config"
|
||||
|
||||
const MAX_HISTORY = 100
|
||||
|
||||
export async function addToHistory(instanceId: string, text: string): Promise<void> {
|
||||
if (!instanceId || !text) return
|
||||
await ensureInstanceConfigLoaded(instanceId)
|
||||
await updateInstanceConfig(instanceId, (draft) => {
|
||||
const nextHistory = [text, ...(draft.messageHistory ?? [])]
|
||||
if (nextHistory.length > MAX_HISTORY) {
|
||||
nextHistory.length = MAX_HISTORY
|
||||
}
|
||||
draft.messageHistory = nextHistory
|
||||
})
|
||||
}
|
||||
|
||||
export async function getHistory(instanceId: string): Promise<string[]> {
|
||||
if (!instanceId) return []
|
||||
await ensureInstanceConfigLoaded(instanceId)
|
||||
const data = getInstanceConfig(instanceId)
|
||||
return [...(data.messageHistory ?? [])]
|
||||
}
|
||||
|
||||
export async function clearHistory(instanceId: string): Promise<void> {
|
||||
if (!instanceId) return
|
||||
await ensureInstanceConfigLoaded(instanceId)
|
||||
await updateInstanceConfig(instanceId, (draft) => {
|
||||
draft.messageHistory = []
|
||||
})
|
||||
}
|
||||
166
packages/ui/src/stores/message-v2/bridge.ts
Normal file
166
packages/ui/src/stores/message-v2/bridge.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import type { Permission } from "@opencode-ai/sdk"
|
||||
import type { Message, MessageInfo, ClientPart } from "../../types/message"
|
||||
import type { Session } from "../../types/session"
|
||||
import { messageStoreBus } from "./bus"
|
||||
import type { MessageStatus, SessionRevertState } from "./types"
|
||||
|
||||
interface SessionMetadata {
|
||||
id: string
|
||||
title?: string
|
||||
parentId?: string | null
|
||||
}
|
||||
|
||||
function resolveSessionMetadata(session?: Session | null): SessionMetadata | undefined {
|
||||
if (!session) return undefined
|
||||
return {
|
||||
id: session.id,
|
||||
title: session.title,
|
||||
parentId: session.parentId ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeStatus(status: Message["status"]): MessageStatus {
|
||||
switch (status) {
|
||||
case "sending":
|
||||
case "sent":
|
||||
case "streaming":
|
||||
case "complete":
|
||||
case "error":
|
||||
return status
|
||||
default:
|
||||
return "complete"
|
||||
}
|
||||
}
|
||||
|
||||
export function seedSessionMessagesV2(
|
||||
instanceId: string,
|
||||
session: Session | SessionMetadata,
|
||||
messages: Message[],
|
||||
messageInfos?: Map<string, MessageInfo>,
|
||||
): void {
|
||||
if (!session || !Array.isArray(messages)) return
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
const metadata: SessionMetadata = "id" in session ? { id: session.id, title: session.title, parentId: session.parentId ?? null } : session
|
||||
|
||||
store.addOrUpdateSession({
|
||||
id: metadata.id,
|
||||
title: metadata.title,
|
||||
parentId: metadata.parentId ?? null,
|
||||
revert: (session as Session)?.revert ?? undefined,
|
||||
})
|
||||
|
||||
const normalizedMessages = messages.map((message) => ({
|
||||
id: message.id,
|
||||
sessionId: message.sessionId,
|
||||
role: message.type,
|
||||
status: normalizeStatus(message.status),
|
||||
createdAt: message.timestamp,
|
||||
updatedAt: message.timestamp,
|
||||
parts: message.parts,
|
||||
isEphemeral: message.status === "sending" || message.status === "streaming",
|
||||
bumpRevision: false,
|
||||
}))
|
||||
|
||||
store.hydrateMessages(metadata.id, normalizedMessages, messageInfos?.values())
|
||||
}
|
||||
|
||||
interface MessageInfoOptions {
|
||||
status?: MessageStatus
|
||||
bumpRevision?: boolean
|
||||
}
|
||||
|
||||
export function upsertMessageInfoV2(instanceId: string, info: MessageInfo | null | undefined, options?: MessageInfoOptions): void {
|
||||
if (!info || typeof info.id !== "string" || typeof info.sessionID !== "string") {
|
||||
return
|
||||
}
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
const timeInfo = (info.time ?? {}) as { created?: number; completed?: number }
|
||||
const createdAt = typeof timeInfo.created === "number" ? timeInfo.created : Date.now()
|
||||
const completedAt = typeof timeInfo.completed === "number" ? timeInfo.completed : undefined
|
||||
|
||||
store.upsertMessage({
|
||||
id: info.id,
|
||||
sessionId: info.sessionID,
|
||||
role: info.role === "user" ? "user" : "assistant",
|
||||
status: options?.status ?? "complete",
|
||||
createdAt,
|
||||
updatedAt: completedAt ?? createdAt,
|
||||
bumpRevision: Boolean(options?.bumpRevision),
|
||||
})
|
||||
store.setMessageInfo(info.id, info)
|
||||
}
|
||||
|
||||
export function applyPartUpdateV2(instanceId: string, part: ClientPart | null | undefined): void {
|
||||
if (!part || typeof part.messageID !== "string") {
|
||||
return
|
||||
}
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
store.applyPartUpdate({
|
||||
messageId: part.messageID,
|
||||
part,
|
||||
})
|
||||
}
|
||||
|
||||
export function replaceMessageIdV2(instanceId: string, oldId: string, newId: string): void {
|
||||
if (!oldId || !newId || oldId === newId) return
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
store.replaceMessageId({ oldId, newId })
|
||||
}
|
||||
|
||||
function extractPermissionMessageId(permission: Permission): string | undefined {
|
||||
return (permission as any).messageID || (permission as any).messageId
|
||||
}
|
||||
|
||||
function extractPermissionPartId(permission: Permission): string | undefined {
|
||||
const metadata = (permission as any).metadata || {}
|
||||
return (
|
||||
(permission as any).callID ||
|
||||
(permission as any).callId ||
|
||||
(permission as any).toolCallID ||
|
||||
(permission as any).toolCallId ||
|
||||
metadata.partId ||
|
||||
metadata.partID ||
|
||||
metadata.callID ||
|
||||
metadata.callId ||
|
||||
undefined
|
||||
)
|
||||
}
|
||||
|
||||
export function upsertPermissionV2(instanceId: string, permission: Permission): void {
|
||||
if (!permission) return
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
store.upsertPermission({
|
||||
permission,
|
||||
messageId: extractPermissionMessageId(permission),
|
||||
partId: extractPermissionPartId(permission),
|
||||
enqueuedAt: (permission as any).time?.created ?? Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
export function removePermissionV2(instanceId: string, permissionId: string): void {
|
||||
if (!permissionId) return
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
store.removePermission(permissionId)
|
||||
}
|
||||
|
||||
export function ensureSessionMetadataV2(instanceId: string, session: Session | null | undefined): void {
|
||||
if (!session) return
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
const existingMessageIds = store.getSessionMessageIds(session.id)
|
||||
store.addOrUpdateSession({
|
||||
id: session.id,
|
||||
title: session.title,
|
||||
parentId: session.parentId ?? null,
|
||||
messageIds: existingMessageIds,
|
||||
})
|
||||
}
|
||||
|
||||
export function getSessionMetadataFromStore(session?: Session | null): SessionMetadata | undefined {
|
||||
return resolveSessionMetadata(session ?? undefined)
|
||||
}
|
||||
|
||||
export function setSessionRevertV2(instanceId: string, sessionId: string, revert?: SessionRevertState | null): void {
|
||||
if (!sessionId) return
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
store.setSessionRevert(sessionId, revert ?? null)
|
||||
}
|
||||
89
packages/ui/src/stores/message-v2/bus.ts
Normal file
89
packages/ui/src/stores/message-v2/bus.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { createInstanceMessageStore } from "./instance-store"
|
||||
import type { InstanceMessageStore } from "./instance-store"
|
||||
import { clearCacheForInstance } from "../../lib/global-cache"
|
||||
import { getLogger } from "../../lib/logger"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
class MessageStoreBus {
|
||||
private stores = new Map<string, InstanceMessageStore>()
|
||||
private teardownHandlers = new Set<(instanceId: string) => void>()
|
||||
private sessionClearHandlers = new Set<(instanceId: string, sessionId: string) => void>()
|
||||
|
||||
registerInstance(instanceId: string, store?: InstanceMessageStore): InstanceMessageStore {
|
||||
if (this.stores.has(instanceId)) {
|
||||
return this.stores.get(instanceId) as InstanceMessageStore
|
||||
}
|
||||
|
||||
const resolved =
|
||||
store ??
|
||||
createInstanceMessageStore(instanceId, {
|
||||
onSessionCleared: (id, sessionId) => this.notifySessionCleared(id, sessionId),
|
||||
})
|
||||
this.stores.set(instanceId, resolved)
|
||||
return resolved
|
||||
}
|
||||
|
||||
onSessionCleared(handler: (instanceId: string, sessionId: string) => void): () => void {
|
||||
this.sessionClearHandlers.add(handler)
|
||||
return () => {
|
||||
this.sessionClearHandlers.delete(handler)
|
||||
}
|
||||
}
|
||||
|
||||
private notifySessionCleared(instanceId: string, sessionId: string) {
|
||||
for (const handler of this.sessionClearHandlers) {
|
||||
try {
|
||||
handler(instanceId, sessionId)
|
||||
} catch (error) {
|
||||
log.error("Failed to run session clear handler", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getInstance(instanceId: string): InstanceMessageStore | undefined {
|
||||
return this.stores.get(instanceId)
|
||||
}
|
||||
|
||||
getOrCreate(instanceId: string): InstanceMessageStore {
|
||||
return this.registerInstance(instanceId)
|
||||
}
|
||||
|
||||
onInstanceDestroyed(handler: (instanceId: string) => void): () => void {
|
||||
this.teardownHandlers.add(handler)
|
||||
return () => {
|
||||
this.teardownHandlers.delete(handler)
|
||||
}
|
||||
}
|
||||
|
||||
unregisterInstance(instanceId: string) {
|
||||
const store = this.stores.get(instanceId)
|
||||
if (store) {
|
||||
store.clearInstance()
|
||||
}
|
||||
clearCacheForInstance(instanceId)
|
||||
this.notifyInstanceDestroyed(instanceId)
|
||||
this.stores.delete(instanceId)
|
||||
}
|
||||
|
||||
clearAll() {
|
||||
for (const [instanceId, store] of this.stores.entries()) {
|
||||
store.clearInstance()
|
||||
clearCacheForInstance(instanceId)
|
||||
this.notifyInstanceDestroyed(instanceId)
|
||||
this.stores.delete(instanceId)
|
||||
}
|
||||
}
|
||||
|
||||
private notifyInstanceDestroyed(instanceId: string) {
|
||||
for (const handler of this.teardownHandlers) {
|
||||
try {
|
||||
handler(instanceId)
|
||||
} catch (error) {
|
||||
log.error("Failed to run message store teardown handler", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const messageStoreBus = new MessageStoreBus()
|
||||
895
packages/ui/src/stores/message-v2/instance-store.ts
Normal file
895
packages/ui/src/stores/message-v2/instance-store.ts
Normal file
@@ -0,0 +1,895 @@
|
||||
import { batch } from "solid-js"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import type { SetStoreFunction } from "solid-js/store"
|
||||
import { getLogger } from "../../lib/logger"
|
||||
import type { ClientPart, MessageInfo } from "../../types/message"
|
||||
import { clearRecordDisplayCacheForMessages } from "./record-display-cache"
|
||||
import type {
|
||||
InstanceMessageState,
|
||||
LatestTodoSnapshot,
|
||||
MessageRecord,
|
||||
MessageUpsertInput,
|
||||
PartUpdateInput,
|
||||
PendingPartEntry,
|
||||
PermissionEntry,
|
||||
ReplaceMessageIdOptions,
|
||||
ScrollSnapshot,
|
||||
SessionRecord,
|
||||
SessionUpsertInput,
|
||||
SessionUsageState,
|
||||
UsageEntry,
|
||||
} from "./types"
|
||||
|
||||
const storeLog = getLogger("session")
|
||||
|
||||
interface MessageStoreHooks {
|
||||
onSessionCleared?: (instanceId: string, sessionId: string) => void
|
||||
}
|
||||
|
||||
function createInitialState(instanceId: string): InstanceMessageState {
|
||||
return {
|
||||
instanceId,
|
||||
sessions: {},
|
||||
sessionOrder: [],
|
||||
messages: {},
|
||||
messageInfoVersion: {},
|
||||
pendingParts: {},
|
||||
sessionRevisions: {},
|
||||
permissions: {
|
||||
queue: [],
|
||||
active: null,
|
||||
byMessage: {},
|
||||
},
|
||||
usage: {},
|
||||
scrollState: {},
|
||||
latestTodos: {},
|
||||
}
|
||||
}
|
||||
|
||||
function ensurePartId(messageId: string, part: ClientPart, index: number): string {
|
||||
if (typeof part.id === "string" && part.id.length > 0) {
|
||||
return part.id
|
||||
}
|
||||
|
||||
const toolCallId =
|
||||
(part as any).callID ??
|
||||
(part as any).callId ??
|
||||
(part as any).toolCallID ??
|
||||
(part as any).toolCallId ??
|
||||
undefined
|
||||
|
||||
if (part.type === "tool" && typeof toolCallId === "string" && toolCallId.length > 0) {
|
||||
part.id = toolCallId
|
||||
return toolCallId
|
||||
}
|
||||
|
||||
const fallbackId = `${messageId}-part-${index}`
|
||||
part.id = fallbackId
|
||||
return fallbackId
|
||||
}
|
||||
|
||||
const PENDING_PART_MAX_AGE_MS = 30_000
|
||||
|
||||
function clonePart(part: ClientPart): ClientPart {
|
||||
// Cloning is intentionally disabled; message parts
|
||||
// are stored as received from the backend.
|
||||
return part
|
||||
}
|
||||
|
||||
function cloneStructuredValue<T>(value: T): T {
|
||||
// Legacy helper kept as a no-op to avoid deep copies.
|
||||
return value
|
||||
}
|
||||
|
||||
function areMessageIdListsEqual(a: string[], b: string[]): boolean {
|
||||
if (a.length !== b.length) {
|
||||
return false
|
||||
}
|
||||
for (let index = 0; index < a.length; index++) {
|
||||
if (a[index] !== b[index]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function createEmptyUsageState(): SessionUsageState {
|
||||
return {
|
||||
entries: {},
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
totalReasoningTokens: 0,
|
||||
totalCost: 0,
|
||||
actualUsageTokens: 0,
|
||||
latestMessageId: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function extractUsageEntry(info: MessageInfo | undefined): UsageEntry | null {
|
||||
if (!info || info.role !== "assistant") return null
|
||||
const messageId = typeof info.id === "string" ? info.id : undefined
|
||||
if (!messageId) return null
|
||||
const tokens = info.tokens
|
||||
if (!tokens) return null
|
||||
const inputTokens = tokens.input ?? 0
|
||||
const outputTokens = tokens.output ?? 0
|
||||
const reasoningTokens = tokens.reasoning ?? 0
|
||||
const cacheReadTokens = tokens.cache?.read ?? 0
|
||||
const cacheWriteTokens = tokens.cache?.write ?? 0
|
||||
if (inputTokens === 0 && outputTokens === 0 && reasoningTokens === 0 && cacheReadTokens === 0 && cacheWriteTokens === 0) {
|
||||
return null
|
||||
}
|
||||
const combinedTokens = info.summary ? outputTokens : inputTokens + cacheReadTokens + cacheWriteTokens + outputTokens + reasoningTokens
|
||||
return {
|
||||
messageId,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
reasoningTokens,
|
||||
cacheReadTokens,
|
||||
cacheWriteTokens,
|
||||
combinedTokens,
|
||||
cost: info.cost ?? 0,
|
||||
timestamp: info.time?.created ?? 0,
|
||||
hasContextUsage: inputTokens + cacheReadTokens + cacheWriteTokens > 0,
|
||||
}
|
||||
}
|
||||
|
||||
function applyUsageState(state: SessionUsageState, entry: UsageEntry | null) {
|
||||
if (!entry) return
|
||||
state.entries[entry.messageId] = entry
|
||||
state.totalInputTokens += entry.inputTokens
|
||||
state.totalOutputTokens += entry.outputTokens
|
||||
state.totalReasoningTokens += entry.reasoningTokens
|
||||
state.totalCost += entry.cost
|
||||
if (!state.latestMessageId || entry.timestamp >= (state.entries[state.latestMessageId]?.timestamp ?? 0)) {
|
||||
state.latestMessageId = entry.messageId
|
||||
state.actualUsageTokens = entry.combinedTokens
|
||||
}
|
||||
}
|
||||
|
||||
function removeUsageEntry(state: SessionUsageState, messageId: string | undefined) {
|
||||
if (!messageId) return
|
||||
const existing = state.entries[messageId]
|
||||
if (!existing) return
|
||||
state.totalInputTokens -= existing.inputTokens
|
||||
state.totalOutputTokens -= existing.outputTokens
|
||||
state.totalReasoningTokens -= existing.reasoningTokens
|
||||
state.totalCost -= existing.cost
|
||||
delete state.entries[messageId]
|
||||
if (state.latestMessageId === messageId) {
|
||||
state.latestMessageId = undefined
|
||||
state.actualUsageTokens = 0
|
||||
let latest: UsageEntry | null = null
|
||||
for (const candidate of Object.values(state.entries) as UsageEntry[]) {
|
||||
if (!latest || candidate.timestamp >= latest.timestamp) {
|
||||
latest = candidate
|
||||
}
|
||||
}
|
||||
if (latest) {
|
||||
state.latestMessageId = latest.messageId
|
||||
state.actualUsageTokens = latest.combinedTokens
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rebuildUsageStateFromInfos(infos: Iterable<MessageInfo>): SessionUsageState {
|
||||
const usageState = createEmptyUsageState()
|
||||
for (const info of infos) {
|
||||
const entry = extractUsageEntry(info)
|
||||
if (entry) {
|
||||
applyUsageState(usageState, entry)
|
||||
}
|
||||
}
|
||||
return usageState
|
||||
}
|
||||
|
||||
export interface InstanceMessageStore {
|
||||
instanceId: string
|
||||
state: InstanceMessageState
|
||||
setState: SetStoreFunction<InstanceMessageState>
|
||||
addOrUpdateSession: (input: SessionUpsertInput) => void
|
||||
hydrateMessages: (sessionId: string, inputs: MessageUpsertInput[], infos?: Iterable<MessageInfo>) => void
|
||||
upsertMessage: (input: MessageUpsertInput) => void
|
||||
applyPartUpdate: (input: PartUpdateInput) => void
|
||||
bufferPendingPart: (entry: PendingPartEntry) => void
|
||||
flushPendingParts: (messageId: string) => void
|
||||
replaceMessageId: (options: ReplaceMessageIdOptions) => void
|
||||
setMessageInfo: (messageId: string, info: MessageInfo) => void
|
||||
getMessageInfo: (messageId: string) => MessageInfo | undefined
|
||||
upsertPermission: (entry: PermissionEntry) => void
|
||||
removePermission: (permissionId: string) => void
|
||||
getPermissionState: (messageId?: string, partId?: string) => { entry: PermissionEntry; active: boolean } | null
|
||||
setSessionRevert: (sessionId: string, revert?: SessionRecord["revert"] | null) => void
|
||||
getSessionRevert: (sessionId: string) => SessionRecord["revert"] | undefined | null
|
||||
rebuildUsage: (sessionId: string, infos: Iterable<MessageInfo>) => void
|
||||
getSessionUsage: (sessionId: string) => SessionUsageState | undefined
|
||||
setScrollSnapshot: (sessionId: string, scope: string, snapshot: Omit<ScrollSnapshot, "updatedAt">) => void
|
||||
getScrollSnapshot: (sessionId: string, scope: string) => ScrollSnapshot | undefined
|
||||
getSessionRevision: (sessionId: string) => number
|
||||
getSessionMessageIds: (sessionId: string) => string[]
|
||||
getMessage: (messageId: string) => MessageRecord | undefined
|
||||
getLatestTodoSnapshot: (sessionId: string) => LatestTodoSnapshot | undefined
|
||||
clearSession: (sessionId: string) => void
|
||||
clearInstance: () => void
|
||||
}
|
||||
|
||||
export function createInstanceMessageStore(instanceId: string, hooks?: MessageStoreHooks): InstanceMessageStore {
|
||||
const [state, setState] = createStore<InstanceMessageState>(createInitialState(instanceId))
|
||||
|
||||
const TODO_TOOL_NAME = "todowrite"
|
||||
|
||||
const messageInfoCache = new Map<string, MessageInfo>()
|
||||
|
||||
function isCompletedTodoPart(part: ClientPart | undefined): boolean {
|
||||
if (!part || (part as any).type !== "tool") {
|
||||
return false
|
||||
}
|
||||
const toolName = typeof (part as any).tool === "string" ? (part as any).tool : ""
|
||||
if (toolName !== TODO_TOOL_NAME) {
|
||||
return false
|
||||
}
|
||||
const toolState = (part as any).state
|
||||
if (!toolState || typeof toolState !== "object") {
|
||||
return false
|
||||
}
|
||||
return (toolState as { status?: string }).status === "completed"
|
||||
}
|
||||
|
||||
function recordLatestTodoSnapshot(sessionId: string, snapshot: LatestTodoSnapshot) {
|
||||
if (!sessionId) return
|
||||
setState("latestTodos", sessionId, (existing) => {
|
||||
if (existing && existing.timestamp > snapshot.timestamp) {
|
||||
return existing
|
||||
}
|
||||
return snapshot
|
||||
})
|
||||
}
|
||||
|
||||
function maybeUpdateLatestTodoFromRecord(record: MessageRecord | undefined) {
|
||||
if (!record || !Array.isArray(record.partIds) || record.partIds.length === 0) {
|
||||
return
|
||||
}
|
||||
for (let index = record.partIds.length - 1; index >= 0; index -= 1) {
|
||||
const partId = record.partIds[index]
|
||||
const partRecord = record.parts[partId]
|
||||
if (!partRecord) continue
|
||||
if (isCompletedTodoPart(partRecord.data)) {
|
||||
const timestamp = typeof record.updatedAt === "number" ? record.updatedAt : Date.now()
|
||||
recordLatestTodoSnapshot(record.sessionId, { messageId: record.id, partId, timestamp })
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearLatestTodoSnapshot(sessionId: string) {
|
||||
setState("latestTodos", sessionId, undefined)
|
||||
}
|
||||
|
||||
function bumpSessionRevision(sessionId: string) {
|
||||
if (!sessionId) return
|
||||
setState("sessionRevisions", sessionId, (value = 0) => value + 1)
|
||||
}
|
||||
|
||||
function getSessionRevisionValue(sessionId: string) {
|
||||
return state.sessionRevisions[sessionId] ?? 0
|
||||
}
|
||||
|
||||
function withUsageState(sessionId: string, updater: (draft: SessionUsageState) => void) {
|
||||
setState("usage", sessionId, (current) => {
|
||||
const draft = current
|
||||
? {
|
||||
...current,
|
||||
entries: { ...current.entries },
|
||||
}
|
||||
: createEmptyUsageState()
|
||||
updater(draft)
|
||||
return draft
|
||||
})
|
||||
}
|
||||
|
||||
function updateUsageWithInfo(info: MessageInfo | undefined) {
|
||||
if (!info || typeof info.sessionID !== "string") return
|
||||
const messageId = typeof info.id === "string" ? info.id : undefined
|
||||
if (!messageId) return
|
||||
withUsageState(info.sessionID, (draft) => {
|
||||
removeUsageEntry(draft, messageId)
|
||||
const entry = extractUsageEntry(info)
|
||||
if (entry) {
|
||||
applyUsageState(draft, entry)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function rebuildUsage(sessionId: string, infos: Iterable<MessageInfo>) {
|
||||
const usageState = rebuildUsageStateFromInfos(infos)
|
||||
setState("usage", sessionId, usageState)
|
||||
}
|
||||
|
||||
function getSessionUsage(sessionId: string) {
|
||||
return state.usage[sessionId]
|
||||
}
|
||||
|
||||
function ensureSessionEntry(sessionId: string): SessionRecord {
|
||||
const existing = state.sessions[sessionId]
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const session: SessionRecord = {
|
||||
id: sessionId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
messageIds: [],
|
||||
}
|
||||
|
||||
setState("sessions", sessionId, session)
|
||||
setState("sessionOrder", (order) => (order.includes(sessionId) ? order : [...order, sessionId]))
|
||||
return session
|
||||
}
|
||||
|
||||
function addOrUpdateSession(input: SessionUpsertInput) {
|
||||
const session = ensureSessionEntry(input.id)
|
||||
const previousIds = [...session.messageIds]
|
||||
const nextMessageIds = Array.isArray(input.messageIds) ? input.messageIds : session.messageIds
|
||||
|
||||
setState("sessions", input.id, {
|
||||
...session,
|
||||
title: input.title ?? session.title,
|
||||
parentId: input.parentId ?? session.parentId ?? null,
|
||||
updatedAt: Date.now(),
|
||||
messageIds: nextMessageIds,
|
||||
revert: input.revert ?? session.revert ?? null,
|
||||
})
|
||||
|
||||
if (Array.isArray(input.messageIds) && !areMessageIdListsEqual(previousIds, nextMessageIds)) {
|
||||
bumpSessionRevision(input.id)
|
||||
}
|
||||
}
|
||||
|
||||
function hydrateMessages(sessionId: string, inputs: MessageUpsertInput[], infos?: Iterable<MessageInfo>) {
|
||||
if (!Array.isArray(inputs) || inputs.length === 0) return
|
||||
|
||||
ensureSessionEntry(sessionId)
|
||||
|
||||
const incomingIds = inputs.map((item) => item.id)
|
||||
|
||||
const normalizedRecords: Record<string, MessageRecord> = {}
|
||||
const now = Date.now()
|
||||
|
||||
inputs.forEach((input) => {
|
||||
const normalizedParts = normalizeParts(input.id, input.parts)
|
||||
const shouldBump = Boolean(input.bumpRevision || normalizedParts)
|
||||
const previous = state.messages[input.id]
|
||||
normalizedRecords[input.id] = {
|
||||
id: input.id,
|
||||
sessionId: input.sessionId,
|
||||
role: input.role,
|
||||
status: input.status,
|
||||
createdAt: input.createdAt ?? previous?.createdAt ?? now,
|
||||
updatedAt: input.updatedAt ?? now,
|
||||
isEphemeral: input.isEphemeral ?? previous?.isEphemeral ?? false,
|
||||
revision: previous ? previous.revision + (shouldBump ? 1 : 0) : 0,
|
||||
partIds: normalizedParts ? normalizedParts.ids : previous?.partIds ?? [],
|
||||
parts: normalizedParts ? normalizedParts.map : previous?.parts ?? {},
|
||||
}
|
||||
})
|
||||
|
||||
const infoList = infos ? Array.from(infos) : undefined
|
||||
const usageState = infoList ? rebuildUsageStateFromInfos(infoList) : state.usage[sessionId]
|
||||
|
||||
const nextMessages: Record<string, MessageRecord> = { ...state.messages }
|
||||
const nextMessageInfoVersion: Record<string, number> = { ...state.messageInfoVersion }
|
||||
const nextPendingParts: Record<string, PendingPartEntry[]> = { ...state.pendingParts }
|
||||
const nextPermissionsByMessage: Record<string, Record<string, PermissionEntry>> = {
|
||||
...state.permissions.byMessage,
|
||||
}
|
||||
|
||||
Object.entries(normalizedRecords).forEach(([id, record]) => {
|
||||
nextMessages[id] = record
|
||||
})
|
||||
|
||||
if (infoList) {
|
||||
for (const info of infoList) {
|
||||
const messageId = info.id as string
|
||||
messageInfoCache.set(messageId, info)
|
||||
const currentVersion = nextMessageInfoVersion[messageId] ?? 0
|
||||
nextMessageInfoVersion[messageId] = currentVersion + 1
|
||||
}
|
||||
}
|
||||
|
||||
batch(() => {
|
||||
setState("messages", () => nextMessages)
|
||||
setState("messageInfoVersion", () => nextMessageInfoVersion)
|
||||
setState("pendingParts", () => nextPendingParts)
|
||||
setState("permissions", "byMessage", () => nextPermissionsByMessage)
|
||||
|
||||
if (usageState) {
|
||||
setState("usage", sessionId, usageState)
|
||||
}
|
||||
|
||||
setState("sessions", sessionId, (session) => ({
|
||||
...session,
|
||||
messageIds: incomingIds,
|
||||
updatedAt: Date.now(),
|
||||
}))
|
||||
|
||||
Object.values(normalizedRecords).forEach((record) => {
|
||||
maybeUpdateLatestTodoFromRecord(record)
|
||||
})
|
||||
|
||||
bumpSessionRevision(sessionId)
|
||||
})
|
||||
}
|
||||
|
||||
function insertMessageIntoSession(sessionId: string, messageId: string) {
|
||||
ensureSessionEntry(sessionId)
|
||||
setState("sessions", sessionId, "messageIds", (ids = []) => {
|
||||
if (ids.includes(messageId)) {
|
||||
return ids
|
||||
}
|
||||
return [...ids, messageId]
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeParts(messageId: string, parts: ClientPart[] | undefined) {
|
||||
if (!parts || parts.length === 0) {
|
||||
return null
|
||||
}
|
||||
const map: MessageRecord["parts"] = {}
|
||||
const ids: string[] = []
|
||||
|
||||
parts.forEach((part, index) => {
|
||||
const id = ensurePartId(messageId, part, index)
|
||||
const cloned = clonePart(part)
|
||||
map[id] = {
|
||||
id,
|
||||
data: cloned,
|
||||
revision: 0,
|
||||
}
|
||||
ids.push(id)
|
||||
})
|
||||
|
||||
return { map, ids }
|
||||
}
|
||||
|
||||
function upsertMessage(input: MessageUpsertInput) {
|
||||
const normalizedParts = normalizeParts(input.id, input.parts)
|
||||
const shouldBump = Boolean(input.bumpRevision || normalizedParts)
|
||||
const now = Date.now()
|
||||
|
||||
let nextRecord: MessageRecord | undefined
|
||||
|
||||
setState("messages", input.id, (previous) => {
|
||||
const revision = previous ? previous.revision + (shouldBump ? 1 : 0) : 0
|
||||
const record: MessageRecord = {
|
||||
id: input.id,
|
||||
sessionId: input.sessionId,
|
||||
role: input.role,
|
||||
status: input.status,
|
||||
createdAt: input.createdAt ?? previous?.createdAt ?? now,
|
||||
updatedAt: input.updatedAt ?? now,
|
||||
isEphemeral: input.isEphemeral ?? previous?.isEphemeral ?? false,
|
||||
revision,
|
||||
partIds: normalizedParts ? normalizedParts.ids : previous?.partIds ?? [],
|
||||
parts: normalizedParts ? normalizedParts.map : previous?.parts ?? {},
|
||||
}
|
||||
nextRecord = record
|
||||
return record
|
||||
})
|
||||
|
||||
if (nextRecord) {
|
||||
maybeUpdateLatestTodoFromRecord(nextRecord)
|
||||
}
|
||||
|
||||
insertMessageIntoSession(input.sessionId, input.id)
|
||||
flushPendingParts(input.id)
|
||||
bumpSessionRevision(input.sessionId)
|
||||
}
|
||||
|
||||
function bufferPendingPart(entry: PendingPartEntry) {
|
||||
setState("pendingParts", entry.messageId, (list = []) => [...list, entry])
|
||||
}
|
||||
|
||||
function clearPendingPartsForMessage(messageId: string) {
|
||||
setState("pendingParts", (prev) => {
|
||||
if (!prev[messageId]) {
|
||||
return prev
|
||||
}
|
||||
const next = { ...prev }
|
||||
delete next[messageId]
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function applyPartUpdate(input: PartUpdateInput) {
|
||||
const message = state.messages[input.messageId]
|
||||
if (!message) {
|
||||
bufferPendingPart({ messageId: input.messageId, part: input.part, receivedAt: Date.now() })
|
||||
return
|
||||
}
|
||||
|
||||
const partId = ensurePartId(input.messageId, input.part, message.partIds.length)
|
||||
const cloned = clonePart(input.part)
|
||||
|
||||
setState(
|
||||
"messages",
|
||||
input.messageId,
|
||||
produce((draft: MessageRecord) => {
|
||||
if (!draft.partIds.includes(partId)) {
|
||||
draft.partIds = [...draft.partIds, partId]
|
||||
}
|
||||
const existing = draft.parts[partId]
|
||||
const nextRevision = existing ? existing.revision + 1 : cloned.version ?? 0
|
||||
draft.parts[partId] = {
|
||||
id: partId,
|
||||
data: cloned,
|
||||
revision: nextRevision,
|
||||
}
|
||||
draft.updatedAt = Date.now()
|
||||
if (input.bumpRevision ?? true) {
|
||||
draft.revision += 1
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
if (isCompletedTodoPart(cloned)) {
|
||||
recordLatestTodoSnapshot(message.sessionId, {
|
||||
messageId: input.messageId,
|
||||
partId,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
// Any part update can change the rendered height of the message
|
||||
// list, so we treat it as a session revision for scroll purposes.
|
||||
bumpSessionRevision(message.sessionId)
|
||||
}
|
||||
|
||||
|
||||
function flushPendingParts(messageId: string) {
|
||||
const pending = state.pendingParts[messageId]
|
||||
if (!pending || pending.length === 0) {
|
||||
return
|
||||
}
|
||||
const now = Date.now()
|
||||
const validEntries = pending.filter((entry) => now - entry.receivedAt <= PENDING_PART_MAX_AGE_MS)
|
||||
if (validEntries.length === 0) {
|
||||
clearPendingPartsForMessage(messageId)
|
||||
return
|
||||
}
|
||||
validEntries.forEach((entry) => applyPartUpdate({ messageId, part: entry.part }))
|
||||
clearPendingPartsForMessage(messageId)
|
||||
}
|
||||
|
||||
function replaceMessageId(options: ReplaceMessageIdOptions) {
|
||||
if (options.oldId === options.newId) return
|
||||
const existing = state.messages[options.oldId]
|
||||
if (!existing) return
|
||||
|
||||
const cloned: MessageRecord = {
|
||||
...existing,
|
||||
id: options.newId,
|
||||
isEphemeral: false,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
|
||||
setState("messages", options.newId, cloned)
|
||||
setState("messages", (prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[options.oldId]
|
||||
return next
|
||||
})
|
||||
|
||||
const affectedSessions = new Set<string>()
|
||||
|
||||
Object.values(state.sessions).forEach((session) => {
|
||||
const index = session.messageIds.indexOf(options.oldId)
|
||||
if (index === -1) return
|
||||
setState("sessions", session.id, "messageIds", (ids) => {
|
||||
const next = [...ids]
|
||||
next[index] = options.newId
|
||||
return next
|
||||
})
|
||||
affectedSessions.add(session.id)
|
||||
})
|
||||
|
||||
affectedSessions.forEach((sessionId) => bumpSessionRevision(sessionId))
|
||||
|
||||
const infoEntry = messageInfoCache.get(options.oldId)
|
||||
if (infoEntry) {
|
||||
messageInfoCache.set(options.newId, infoEntry)
|
||||
messageInfoCache.delete(options.oldId)
|
||||
const version = state.messageInfoVersion[options.oldId] ?? 0
|
||||
setState("messageInfoVersion", options.newId, version)
|
||||
setState("messageInfoVersion", (prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[options.oldId]
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const permissionMap = state.permissions.byMessage[options.oldId]
|
||||
if (permissionMap) {
|
||||
setState("permissions", "byMessage", options.newId, permissionMap)
|
||||
setState("permissions", (prev) => {
|
||||
const next = { ...prev }
|
||||
const nextByMessage = { ...next.byMessage }
|
||||
delete nextByMessage[options.oldId]
|
||||
next.byMessage = nextByMessage
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const pending = state.pendingParts[options.oldId]
|
||||
if (pending) {
|
||||
setState("pendingParts", options.newId, pending)
|
||||
}
|
||||
clearPendingPartsForMessage(options.oldId)
|
||||
maybeUpdateLatestTodoFromRecord(cloned)
|
||||
}
|
||||
|
||||
function setMessageInfo(messageId: string, info: MessageInfo) {
|
||||
if (!messageId) return
|
||||
messageInfoCache.set(messageId, info)
|
||||
const nextVersion = (state.messageInfoVersion[messageId] ?? 0) + 1
|
||||
setState("messageInfoVersion", messageId, nextVersion)
|
||||
updateUsageWithInfo(info)
|
||||
}
|
||||
|
||||
function getMessageInfo(messageId: string) {
|
||||
void state.messageInfoVersion[messageId]
|
||||
return messageInfoCache.get(messageId)
|
||||
}
|
||||
|
||||
function upsertPermission(entry: PermissionEntry) {
|
||||
const messageKey = entry.messageId ?? "__global__"
|
||||
const partKey = entry.partId ?? "__global__"
|
||||
|
||||
setState(
|
||||
"permissions",
|
||||
produce((draft) => {
|
||||
draft.byMessage[messageKey] = draft.byMessage[messageKey] ?? {}
|
||||
draft.byMessage[messageKey][partKey] = entry
|
||||
const existingIndex = draft.queue.findIndex((item) => item.permission.id === entry.permission.id)
|
||||
if (existingIndex === -1) {
|
||||
draft.queue.push(entry)
|
||||
} else {
|
||||
draft.queue[existingIndex] = entry
|
||||
}
|
||||
if (!draft.active || draft.active.permission.id === entry.permission.id) {
|
||||
draft.active = entry
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function removePermission(permissionId: string) {
|
||||
setState(
|
||||
"permissions",
|
||||
produce((draft) => {
|
||||
draft.queue = draft.queue.filter((item) => item.permission.id !== permissionId)
|
||||
if (draft.active?.permission.id === permissionId) {
|
||||
draft.active = draft.queue[0] ?? null
|
||||
}
|
||||
Object.keys(draft.byMessage).forEach((messageKey) => {
|
||||
const partEntries = draft.byMessage[messageKey]
|
||||
Object.keys(partEntries).forEach((partKey) => {
|
||||
if (partEntries[partKey].permission.id === permissionId) {
|
||||
delete partEntries[partKey]
|
||||
}
|
||||
})
|
||||
if (Object.keys(partEntries).length === 0) {
|
||||
delete draft.byMessage[messageKey]
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function getPermissionState(messageId?: string, partId?: string) {
|
||||
const messageKey = messageId ?? "__global__"
|
||||
const partKey = partId ?? "__global__"
|
||||
const entry = state.permissions.byMessage[messageKey]?.[partKey]
|
||||
if (!entry) return null
|
||||
const active = state.permissions.active?.permission.id === entry.permission.id
|
||||
return { entry, active }
|
||||
}
|
||||
|
||||
function pruneMessagesAfterRevert(sessionId: string, revertMessageId: string) {
|
||||
const session = state.sessions[sessionId]
|
||||
if (!session) return
|
||||
const stopIndex = session.messageIds.indexOf(revertMessageId)
|
||||
if (stopIndex === -1) return
|
||||
const removedIds = session.messageIds.slice(stopIndex)
|
||||
const keptIds = session.messageIds.slice(0, stopIndex)
|
||||
if (removedIds.length === 0) return
|
||||
|
||||
setState("sessions", sessionId, "messageIds", keptIds)
|
||||
|
||||
setState("messages", (prev) => {
|
||||
const next = { ...prev }
|
||||
removedIds.forEach((id) => delete next[id])
|
||||
return next
|
||||
})
|
||||
|
||||
setState("messageInfoVersion", (prev) => {
|
||||
const next = { ...prev }
|
||||
removedIds.forEach((id) => delete next[id])
|
||||
return next
|
||||
})
|
||||
|
||||
removedIds.forEach((id) => messageInfoCache.delete(id))
|
||||
|
||||
setState("pendingParts", (prev) => {
|
||||
const next = { ...prev }
|
||||
removedIds.forEach((id) => {
|
||||
if (next[id]) delete next[id]
|
||||
})
|
||||
return next
|
||||
})
|
||||
|
||||
setState("permissions", "byMessage", (prev) => {
|
||||
const next = { ...prev }
|
||||
removedIds.forEach((id) => {
|
||||
if (next[id]) delete next[id]
|
||||
})
|
||||
return next
|
||||
})
|
||||
|
||||
withUsageState(sessionId, (draft) => {
|
||||
removedIds.forEach((id) => removeUsageEntry(draft, id))
|
||||
})
|
||||
|
||||
bumpSessionRevision(sessionId)
|
||||
}
|
||||
|
||||
function setSessionRevert(sessionId: string, revert?: SessionRecord["revert"] | null) {
|
||||
if (!sessionId) return
|
||||
ensureSessionEntry(sessionId)
|
||||
if (revert?.messageID) {
|
||||
pruneMessagesAfterRevert(sessionId, revert.messageID)
|
||||
}
|
||||
setState("sessions", sessionId, "revert", revert ?? null)
|
||||
}
|
||||
|
||||
function getSessionRevert(sessionId: string) {
|
||||
return state.sessions[sessionId]?.revert ?? null
|
||||
}
|
||||
|
||||
function makeScrollKey(sessionId: string, scope: string) {
|
||||
return `${sessionId}:${scope}`
|
||||
}
|
||||
|
||||
function setScrollSnapshot(sessionId: string, scope: string, snapshot: Omit<ScrollSnapshot, "updatedAt">) {
|
||||
const key = makeScrollKey(sessionId, scope)
|
||||
setState("scrollState", key, { ...snapshot, updatedAt: Date.now() })
|
||||
}
|
||||
|
||||
function getScrollSnapshot(sessionId: string, scope: string) {
|
||||
const key = makeScrollKey(sessionId, scope)
|
||||
return state.scrollState[key]
|
||||
}
|
||||
|
||||
function clearSession(sessionId: string) {
|
||||
if (!sessionId) return
|
||||
|
||||
const messageIds = Object.values(state.messages)
|
||||
.filter((record) => record.sessionId === sessionId)
|
||||
.map((record) => record.id)
|
||||
|
||||
storeLog.info("Clearing session data", { instanceId, sessionId, messageCount: messageIds.length })
|
||||
clearRecordDisplayCacheForMessages(instanceId, messageIds)
|
||||
|
||||
batch(() => {
|
||||
setState("messages", (prev) => {
|
||||
const next = { ...prev }
|
||||
messageIds.forEach((id) => delete next[id])
|
||||
return next
|
||||
})
|
||||
|
||||
setState("messageInfoVersion", (prev) => {
|
||||
const next = { ...prev }
|
||||
messageIds.forEach((id) => delete next[id])
|
||||
return next
|
||||
})
|
||||
|
||||
messageIds.forEach((id) => messageInfoCache.delete(id))
|
||||
|
||||
setState("pendingParts", (prev) => {
|
||||
const next = { ...prev }
|
||||
messageIds.forEach((id) => {
|
||||
if (next[id]) delete next[id]
|
||||
})
|
||||
return next
|
||||
})
|
||||
|
||||
setState("permissions", "byMessage", (prev) => {
|
||||
const next = { ...prev }
|
||||
messageIds.forEach((id) => {
|
||||
if (next[id]) delete next[id]
|
||||
})
|
||||
return next
|
||||
})
|
||||
|
||||
setState("usage", (prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[sessionId]
|
||||
return next
|
||||
})
|
||||
|
||||
setState("sessionRevisions", (prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[sessionId]
|
||||
return next
|
||||
})
|
||||
|
||||
setState("scrollState", (prev) => {
|
||||
const next = { ...prev }
|
||||
const prefix = `${sessionId}:`
|
||||
Object.keys(next).forEach((key) => {
|
||||
if (key.startsWith(prefix)) {
|
||||
delete next[key]
|
||||
}
|
||||
})
|
||||
return next
|
||||
})
|
||||
|
||||
setState("sessions", sessionId, (current) => {
|
||||
if (!current) return current
|
||||
return { ...current, messageIds: [] }
|
||||
})
|
||||
|
||||
setState("sessions", (prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[sessionId]
|
||||
return next
|
||||
})
|
||||
|
||||
setState("sessionOrder", (ids) => ids.filter((id) => id !== sessionId))
|
||||
})
|
||||
|
||||
clearLatestTodoSnapshot(sessionId)
|
||||
|
||||
hooks?.onSessionCleared?.(instanceId, sessionId)
|
||||
}
|
||||
|
||||
|
||||
function clearInstance() {
|
||||
messageInfoCache.clear()
|
||||
setState(reconcile(createInitialState(instanceId)))
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
instanceId,
|
||||
state,
|
||||
setState,
|
||||
addOrUpdateSession,
|
||||
hydrateMessages,
|
||||
upsertMessage,
|
||||
applyPartUpdate,
|
||||
bufferPendingPart,
|
||||
flushPendingParts,
|
||||
replaceMessageId,
|
||||
setMessageInfo,
|
||||
getMessageInfo,
|
||||
upsertPermission,
|
||||
removePermission,
|
||||
getPermissionState,
|
||||
setSessionRevert,
|
||||
getSessionRevert,
|
||||
rebuildUsage,
|
||||
getSessionUsage,
|
||||
setScrollSnapshot,
|
||||
getScrollSnapshot,
|
||||
getSessionRevision: getSessionRevisionValue,
|
||||
getSessionMessageIds: (sessionId: string) => state.sessions[sessionId]?.messageIds ?? [],
|
||||
getMessage: (messageId: string) => state.messages[messageId],
|
||||
getLatestTodoSnapshot: (sessionId: string) => state.latestTodos[sessionId],
|
||||
clearSession,
|
||||
clearInstance,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
100
packages/ui/src/stores/message-v2/normalizers.ts
Normal file
100
packages/ui/src/stores/message-v2/normalizers.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { decodeHtmlEntities } from "../../lib/markdown"
|
||||
|
||||
function decodeTextSegment(segment: any): any {
|
||||
if (typeof segment === "string") {
|
||||
return decodeHtmlEntities(segment)
|
||||
}
|
||||
|
||||
if (segment && typeof segment === "object") {
|
||||
const updated: Record<string, any> = { ...segment }
|
||||
|
||||
if (typeof updated.text === "string") {
|
||||
updated.text = decodeHtmlEntities(updated.text)
|
||||
}
|
||||
|
||||
if (typeof updated.value === "string") {
|
||||
updated.value = decodeHtmlEntities(updated.value)
|
||||
}
|
||||
|
||||
if (Array.isArray(updated.content)) {
|
||||
updated.content = updated.content.map((item: any) => decodeTextSegment(item))
|
||||
}
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
return segment
|
||||
}
|
||||
|
||||
function deriveToolPartId(part: any): string | undefined {
|
||||
if (!part || typeof part !== "object") {
|
||||
return undefined
|
||||
}
|
||||
if (part.type !== "tool") {
|
||||
return undefined
|
||||
}
|
||||
const callId =
|
||||
part.callID ??
|
||||
part.callId ??
|
||||
part.toolCallID ??
|
||||
part.toolCallId ??
|
||||
undefined
|
||||
if (typeof callId === "string" && callId.length > 0) {
|
||||
return callId
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function normalizeMessagePart(part: any): any {
|
||||
if (!part || typeof part !== "object") {
|
||||
return part
|
||||
}
|
||||
|
||||
if ((typeof part.id !== "string" || part.id.length === 0) && part.type === "tool") {
|
||||
const inferredId = deriveToolPartId(part)
|
||||
if (inferredId) {
|
||||
part = { ...part, id: inferredId }
|
||||
}
|
||||
}
|
||||
|
||||
if (part.type !== "text") {
|
||||
return part
|
||||
}
|
||||
|
||||
const normalized: Record<string, any> = { ...part, renderCache: undefined }
|
||||
|
||||
if (typeof normalized.text === "string") {
|
||||
normalized.text = decodeHtmlEntities(normalized.text)
|
||||
} else if (normalized.text && typeof normalized.text === "object") {
|
||||
const textObject: Record<string, any> = { ...normalized.text }
|
||||
|
||||
if (typeof textObject.value === "string") {
|
||||
textObject.value = decodeHtmlEntities(textObject.value)
|
||||
}
|
||||
|
||||
if (Array.isArray(textObject.content)) {
|
||||
textObject.content = textObject.content.map((item: any) => decodeTextSegment(item))
|
||||
}
|
||||
|
||||
if (typeof textObject.text === "string") {
|
||||
textObject.text = decodeHtmlEntities(textObject.text)
|
||||
}
|
||||
|
||||
normalized.text = textObject
|
||||
}
|
||||
|
||||
if (Array.isArray(normalized.content)) {
|
||||
normalized.content = normalized.content.map((item: any) => decodeTextSegment(item))
|
||||
}
|
||||
|
||||
if (normalized.thinking && typeof normalized.thinking === "object") {
|
||||
const thinking: Record<string, any> = { ...normalized.thinking }
|
||||
if (Array.isArray(thinking.content)) {
|
||||
thinking.content = thinking.content.map((item: any) => decodeTextSegment(item))
|
||||
}
|
||||
normalized.thinking = thinking
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
53
packages/ui/src/stores/message-v2/record-display-cache.ts
Normal file
53
packages/ui/src/stores/message-v2/record-display-cache.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { ClientPart } from "../../types/message"
|
||||
import type { MessageRecord } from "./types"
|
||||
|
||||
export interface RecordDisplayData {
|
||||
orderedParts: ClientPart[]
|
||||
}
|
||||
|
||||
interface RecordDisplayCacheEntry {
|
||||
revision: number
|
||||
data: RecordDisplayData
|
||||
}
|
||||
|
||||
const recordDisplayCache = new Map<string, RecordDisplayCacheEntry>()
|
||||
|
||||
function makeCacheKey(instanceId: string, messageId: string) {
|
||||
return `${instanceId}:${messageId}`
|
||||
}
|
||||
|
||||
export function buildRecordDisplayData(instanceId: string, record: MessageRecord): RecordDisplayData {
|
||||
const cacheKey = makeCacheKey(instanceId, record.id)
|
||||
const cached = recordDisplayCache.get(cacheKey)
|
||||
if (cached && cached.revision === record.revision) {
|
||||
return cached.data
|
||||
}
|
||||
|
||||
const orderedParts: ClientPart[] = []
|
||||
|
||||
for (const partId of record.partIds) {
|
||||
const entry = record.parts[partId]
|
||||
if (!entry?.data) continue
|
||||
orderedParts.push(entry.data)
|
||||
}
|
||||
|
||||
const data: RecordDisplayData = { orderedParts }
|
||||
recordDisplayCache.set(cacheKey, { revision: record.revision, data })
|
||||
return data
|
||||
}
|
||||
|
||||
export function clearRecordDisplayCacheForInstance(instanceId: string) {
|
||||
const prefix = `${instanceId}:`
|
||||
for (const key of recordDisplayCache.keys()) {
|
||||
if (key.startsWith(prefix)) {
|
||||
recordDisplayCache.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function clearRecordDisplayCacheForMessages(instanceId: string, messageIds: Iterable<string>) {
|
||||
for (const messageId of messageIds) {
|
||||
if (typeof messageId !== "string" || messageId.length === 0) continue
|
||||
recordDisplayCache.delete(makeCacheKey(instanceId, messageId))
|
||||
}
|
||||
}
|
||||
139
packages/ui/src/stores/message-v2/session-info.ts
Normal file
139
packages/ui/src/stores/message-v2/session-info.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import type { Provider } from "../../types/session"
|
||||
import { DEFAULT_MODEL_OUTPUT_LIMIT } from "../session-models"
|
||||
import { providers, sessions, sessionInfoByInstance, setSessionInfoByInstance } from "../session-state"
|
||||
import { messageStoreBus } from "./bus"
|
||||
import type { SessionUsageState } from "./types"
|
||||
|
||||
function getLatestUsageEntry(usage?: SessionUsageState) {
|
||||
if (!usage?.latestMessageId) return undefined
|
||||
return usage.entries[usage.latestMessageId]
|
||||
}
|
||||
|
||||
function resolveSelectedModel(instanceProviders: Provider[], providerId?: string, modelId?: string) {
|
||||
if (!providerId || !modelId) return undefined
|
||||
const provider = instanceProviders.find((p) => p.id === providerId)
|
||||
return provider?.models.find((m) => m.id === modelId)
|
||||
}
|
||||
|
||||
export function updateSessionInfo(instanceId: string, sessionId: string): void {
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
if (!instanceSessions) return
|
||||
const session = instanceSessions.get(sessionId)
|
||||
if (!session) return
|
||||
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
const usage = store.getSessionUsage(sessionId)
|
||||
const hasUsageEntries = Boolean(usage && Object.keys(usage.entries).length > 0)
|
||||
|
||||
let totalInputTokens = usage?.totalInputTokens ?? 0
|
||||
let totalOutputTokens = usage?.totalOutputTokens ?? 0
|
||||
let totalReasoningTokens = usage?.totalReasoningTokens ?? 0
|
||||
let totalCost = usage?.totalCost ?? 0
|
||||
let actualUsageTokens = usage?.actualUsageTokens ?? 0
|
||||
|
||||
const latestEntry = getLatestUsageEntry(usage)
|
||||
let latestHasContextUsage = latestEntry?.hasContextUsage ?? false
|
||||
|
||||
const previousInfo = sessionInfoByInstance().get(instanceId)?.get(sessionId)
|
||||
let contextWindow = 0
|
||||
let contextAvailableTokens: number | null = null
|
||||
let contextAvailableFromPrevious = false
|
||||
let isSubscriptionModel = false
|
||||
|
||||
if (!hasUsageEntries && previousInfo) {
|
||||
totalInputTokens = previousInfo.inputTokens
|
||||
totalOutputTokens = previousInfo.outputTokens
|
||||
totalReasoningTokens = previousInfo.reasoningTokens
|
||||
totalCost = previousInfo.cost
|
||||
actualUsageTokens = previousInfo.actualUsageTokens
|
||||
}
|
||||
|
||||
const instanceProviders = providers().get(instanceId) || []
|
||||
|
||||
const sessionModel = session.model
|
||||
const sessionProviderId = sessionModel?.providerId
|
||||
const sessionModelId = sessionModel?.modelId
|
||||
|
||||
const latestInfo = latestEntry?.messageId ? store.getMessageInfo(latestEntry.messageId) : undefined
|
||||
const latestProviderId = (latestInfo as any)?.providerID || (latestInfo as any)?.providerId || ""
|
||||
const latestModelId = (latestInfo as any)?.modelID || (latestInfo as any)?.modelId || ""
|
||||
|
||||
const selectedModel =
|
||||
resolveSelectedModel(instanceProviders, sessionProviderId, sessionModelId) ??
|
||||
resolveSelectedModel(instanceProviders, latestProviderId, latestModelId)
|
||||
|
||||
let modelOutputLimit = DEFAULT_MODEL_OUTPUT_LIMIT
|
||||
|
||||
if (selectedModel) {
|
||||
contextWindow = selectedModel.limit?.context ?? 0
|
||||
const outputLimit = selectedModel.limit?.output
|
||||
if (typeof outputLimit === "number" && outputLimit > 0) {
|
||||
modelOutputLimit = Math.min(outputLimit, DEFAULT_MODEL_OUTPUT_LIMIT)
|
||||
}
|
||||
if ((selectedModel.cost?.input ?? 0) === 0 && (selectedModel.cost?.output ?? 0) === 0) {
|
||||
isSubscriptionModel = true
|
||||
}
|
||||
}
|
||||
|
||||
if (contextWindow === 0 && previousInfo) {
|
||||
contextWindow = previousInfo.contextWindow
|
||||
}
|
||||
|
||||
modelOutputLimit = Math.min(modelOutputLimit, DEFAULT_MODEL_OUTPUT_LIMIT)
|
||||
|
||||
if (previousInfo) {
|
||||
const previousContextWindow = previousInfo.contextWindow
|
||||
const previousContextAvailable = previousInfo.contextAvailableTokens ?? null
|
||||
const previousHasContextUsage = previousContextAvailable !== null && previousContextWindow > 0
|
||||
? previousContextAvailable < previousContextWindow
|
||||
: false
|
||||
|
||||
if (contextWindow !== previousContextWindow) {
|
||||
contextAvailableTokens = null
|
||||
contextAvailableFromPrevious = false
|
||||
latestHasContextUsage = previousHasContextUsage
|
||||
} else {
|
||||
contextAvailableTokens = previousContextAvailable
|
||||
contextAvailableFromPrevious = true
|
||||
latestHasContextUsage = previousHasContextUsage
|
||||
}
|
||||
|
||||
if (!hasUsageEntries) {
|
||||
isSubscriptionModel = previousInfo.isSubscriptionModel
|
||||
} else if (!isSubscriptionModel) {
|
||||
isSubscriptionModel = previousInfo.isSubscriptionModel
|
||||
}
|
||||
}
|
||||
|
||||
const outputBudget = Math.min(modelOutputLimit, DEFAULT_MODEL_OUTPUT_LIMIT)
|
||||
|
||||
if (!contextAvailableFromPrevious) {
|
||||
if (contextWindow > 0) {
|
||||
if (latestHasContextUsage && actualUsageTokens > 0) {
|
||||
contextAvailableTokens = Math.max(contextWindow - (actualUsageTokens + outputBudget), 0)
|
||||
} else {
|
||||
contextAvailableTokens = contextWindow
|
||||
}
|
||||
} else {
|
||||
contextAvailableTokens = null
|
||||
}
|
||||
}
|
||||
|
||||
setSessionInfoByInstance((prev) => {
|
||||
const next = new Map(prev)
|
||||
const instanceInfo = new Map(prev.get(instanceId))
|
||||
instanceInfo.set(sessionId, {
|
||||
cost: totalCost,
|
||||
contextWindow,
|
||||
isSubscriptionModel,
|
||||
inputTokens: totalInputTokens,
|
||||
outputTokens: totalOutputTokens,
|
||||
reasoningTokens: totalReasoningTokens,
|
||||
actualUsageTokens,
|
||||
modelOutputLimit,
|
||||
contextAvailableTokens,
|
||||
})
|
||||
next.set(instanceId, instanceInfo)
|
||||
return next
|
||||
})
|
||||
}
|
||||
146
packages/ui/src/stores/message-v2/types.ts
Normal file
146
packages/ui/src/stores/message-v2/types.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import type { ClientPart } from "../../types/message"
|
||||
import type { Permission } from "@opencode-ai/sdk"
|
||||
|
||||
export type MessageStatus = "sending" | "sent" | "streaming" | "complete" | "error"
|
||||
export type MessageRole = "user" | "assistant"
|
||||
|
||||
export interface NormalizedPartRecord {
|
||||
id: string
|
||||
data: ClientPart
|
||||
revision: number
|
||||
}
|
||||
|
||||
export interface MessageRecord {
|
||||
id: string
|
||||
sessionId: string
|
||||
role: MessageRole
|
||||
status: MessageStatus
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
revision: number
|
||||
isEphemeral?: boolean
|
||||
partIds: string[]
|
||||
parts: Record<string, NormalizedPartRecord>
|
||||
}
|
||||
|
||||
export interface SessionRevertState {
|
||||
messageID?: string
|
||||
partID?: string
|
||||
snapshot?: string
|
||||
diff?: string
|
||||
}
|
||||
|
||||
export interface SessionRecord {
|
||||
id: string
|
||||
title?: string
|
||||
parentId?: string | null
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
messageIds: string[]
|
||||
revert?: SessionRevertState | null
|
||||
}
|
||||
|
||||
export interface PendingPartEntry {
|
||||
messageId: string
|
||||
part: ClientPart
|
||||
receivedAt: number
|
||||
}
|
||||
|
||||
export interface PermissionEntry {
|
||||
permission: Permission
|
||||
messageId?: string
|
||||
partId?: string
|
||||
enqueuedAt: number
|
||||
}
|
||||
|
||||
export interface InstancePermissionState {
|
||||
queue: PermissionEntry[]
|
||||
active: PermissionEntry | null
|
||||
byMessage: Record<string, Record<string, PermissionEntry>>
|
||||
}
|
||||
|
||||
export interface ScrollSnapshot {
|
||||
scrollTop: number
|
||||
atBottom: boolean
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface UsageEntry {
|
||||
messageId: string
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
reasoningTokens: number
|
||||
cacheReadTokens: number
|
||||
cacheWriteTokens: number
|
||||
combinedTokens: number
|
||||
cost: number
|
||||
timestamp: number
|
||||
hasContextUsage: boolean
|
||||
}
|
||||
|
||||
export interface SessionUsageState {
|
||||
entries: Record<string, UsageEntry>
|
||||
totalInputTokens: number
|
||||
totalOutputTokens: number
|
||||
totalReasoningTokens: number
|
||||
totalCost: number
|
||||
actualUsageTokens: number
|
||||
latestMessageId?: string
|
||||
}
|
||||
|
||||
export interface LatestTodoSnapshot {
|
||||
messageId: string
|
||||
partId: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface InstanceMessageState {
|
||||
instanceId: string
|
||||
sessions: Record<string, SessionRecord>
|
||||
sessionOrder: string[]
|
||||
messages: Record<string, MessageRecord>
|
||||
messageInfoVersion: Record<string, number>
|
||||
pendingParts: Record<string, PendingPartEntry[]>
|
||||
sessionRevisions: Record<string, number>
|
||||
permissions: InstancePermissionState
|
||||
usage: Record<string, SessionUsageState>
|
||||
scrollState: Record<string, ScrollSnapshot>
|
||||
latestTodos: Record<string, LatestTodoSnapshot | undefined>
|
||||
}
|
||||
|
||||
export interface SessionUpsertInput {
|
||||
id: string
|
||||
title?: string
|
||||
parentId?: string | null
|
||||
messageIds?: string[]
|
||||
revert?: SessionRevertState | null
|
||||
}
|
||||
|
||||
export interface MessageUpsertInput {
|
||||
id: string
|
||||
sessionId: string
|
||||
role: MessageRole
|
||||
status: MessageStatus
|
||||
parts?: ClientPart[]
|
||||
createdAt?: number
|
||||
updatedAt?: number
|
||||
isEphemeral?: boolean
|
||||
bumpRevision?: boolean
|
||||
}
|
||||
|
||||
export interface PartUpdateInput {
|
||||
messageId: string
|
||||
part: ClientPart
|
||||
bumpRevision?: boolean
|
||||
}
|
||||
|
||||
export interface ReplaceMessageIdOptions {
|
||||
oldId: string
|
||||
newId: string
|
||||
}
|
||||
|
||||
export interface ScrollCacheKey {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
scope: string
|
||||
}
|
||||
543
packages/ui/src/stores/preferences.tsx
Normal file
543
packages/ui/src/stores/preferences.tsx
Normal file
@@ -0,0 +1,543 @@
|
||||
import { createContext, createMemo, createSignal, onMount, useContext } from "solid-js"
|
||||
import type { Accessor, ParentComponent } from "solid-js"
|
||||
import { storage, type ConfigData } from "../lib/storage"
|
||||
import {
|
||||
ensureInstanceConfigLoaded,
|
||||
getInstanceConfig,
|
||||
updateInstanceConfig as updateInstanceData,
|
||||
} from "./instance-config"
|
||||
import { getLogger } from "../lib/logger"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
type DeepReadonly<T> = T extends (...args: any[]) => unknown
|
||||
? T
|
||||
: T extends Array<infer U>
|
||||
? ReadonlyArray<DeepReadonly<U>>
|
||||
: T extends object
|
||||
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
|
||||
: T
|
||||
|
||||
export interface ModelPreference {
|
||||
providerId: string
|
||||
modelId: string
|
||||
}
|
||||
|
||||
export interface AgentModelSelections {
|
||||
[instanceId: string]: Record<string, ModelPreference>
|
||||
}
|
||||
|
||||
export type DiffViewMode = "split" | "unified"
|
||||
export type ExpansionPreference = "expanded" | "collapsed"
|
||||
|
||||
export type ListeningMode = "local" | "all"
|
||||
|
||||
export interface Preferences {
|
||||
showThinkingBlocks: boolean
|
||||
thinkingBlocksExpansion: ExpansionPreference
|
||||
showTimelineTools: boolean
|
||||
lastUsedBinary?: string
|
||||
environmentVariables: Record<string, string>
|
||||
modelRecents: ModelPreference[]
|
||||
diffViewMode: DiffViewMode
|
||||
toolOutputExpansion: ExpansionPreference
|
||||
diagnosticsExpansion: ExpansionPreference
|
||||
showUsageMetrics: boolean
|
||||
autoCleanupBlankSessions: boolean
|
||||
listeningMode: ListeningMode
|
||||
}
|
||||
|
||||
|
||||
export interface OpenCodeBinary {
|
||||
|
||||
path: string
|
||||
version?: string
|
||||
lastUsed: number
|
||||
}
|
||||
|
||||
export interface RecentFolder {
|
||||
path: string
|
||||
lastAccessed: number
|
||||
}
|
||||
|
||||
export type ThemePreference = NonNullable<ConfigData["theme"]>
|
||||
|
||||
const MAX_RECENT_FOLDERS = 20
|
||||
const MAX_RECENT_MODELS = 5
|
||||
|
||||
const defaultPreferences: Preferences = {
|
||||
showThinkingBlocks: false,
|
||||
thinkingBlocksExpansion: "expanded",
|
||||
showTimelineTools: true,
|
||||
environmentVariables: {},
|
||||
modelRecents: [],
|
||||
diffViewMode: "split",
|
||||
toolOutputExpansion: "expanded",
|
||||
diagnosticsExpansion: "expanded",
|
||||
showUsageMetrics: true,
|
||||
autoCleanupBlankSessions: true,
|
||||
listeningMode: "local",
|
||||
}
|
||||
|
||||
|
||||
function deepEqual(a: unknown, b: unknown): boolean {
|
||||
if (a === b) return true
|
||||
if (typeof a === "object" && a !== null && typeof b === "object" && b !== null) {
|
||||
try {
|
||||
return JSON.stringify(a) === JSON.stringify(b)
|
||||
} catch (error) {
|
||||
log.warn("Failed to compare preference values", error)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function normalizePreferences(pref?: Partial<Preferences> & { agentModelSelections?: unknown }): Preferences {
|
||||
const sanitized = pref ?? {}
|
||||
const environmentVariables = {
|
||||
...defaultPreferences.environmentVariables,
|
||||
...(sanitized.environmentVariables ?? {}),
|
||||
}
|
||||
|
||||
const sourceModelRecents = sanitized.modelRecents ?? defaultPreferences.modelRecents
|
||||
const modelRecents = sourceModelRecents.map((item) => ({ ...item }))
|
||||
|
||||
return {
|
||||
showThinkingBlocks: sanitized.showThinkingBlocks ?? defaultPreferences.showThinkingBlocks,
|
||||
thinkingBlocksExpansion: sanitized.thinkingBlocksExpansion ?? defaultPreferences.thinkingBlocksExpansion,
|
||||
showTimelineTools: sanitized.showTimelineTools ?? defaultPreferences.showTimelineTools,
|
||||
lastUsedBinary: sanitized.lastUsedBinary ?? defaultPreferences.lastUsedBinary,
|
||||
environmentVariables,
|
||||
modelRecents,
|
||||
diffViewMode: sanitized.diffViewMode ?? defaultPreferences.diffViewMode,
|
||||
toolOutputExpansion: sanitized.toolOutputExpansion ?? defaultPreferences.toolOutputExpansion,
|
||||
diagnosticsExpansion: sanitized.diagnosticsExpansion ?? defaultPreferences.diagnosticsExpansion,
|
||||
showUsageMetrics: sanitized.showUsageMetrics ?? defaultPreferences.showUsageMetrics,
|
||||
autoCleanupBlankSessions: sanitized.autoCleanupBlankSessions ?? defaultPreferences.autoCleanupBlankSessions,
|
||||
listeningMode: sanitized.listeningMode ?? defaultPreferences.listeningMode,
|
||||
}
|
||||
}
|
||||
|
||||
const [internalConfig, setInternalConfig] = createSignal<ConfigData>(buildFallbackConfig())
|
||||
|
||||
const config = createMemo<DeepReadonly<ConfigData>>(() => internalConfig())
|
||||
const [isConfigLoaded, setIsConfigLoaded] = createSignal(false)
|
||||
const preferences = createMemo<Preferences>(() => internalConfig().preferences)
|
||||
const recentFolders = createMemo<RecentFolder[]>(() => internalConfig().recentFolders ?? [])
|
||||
const opencodeBinaries = createMemo<OpenCodeBinary[]>(() => internalConfig().opencodeBinaries ?? [])
|
||||
const themePreference = createMemo<ThemePreference>(() => internalConfig().theme ?? "dark")
|
||||
let loadPromise: Promise<void> | null = null
|
||||
|
||||
function normalizeConfig(config?: ConfigData | null): ConfigData {
|
||||
return {
|
||||
preferences: normalizePreferences(config?.preferences),
|
||||
recentFolders: (config?.recentFolders ?? []).map((folder) => ({ ...folder })),
|
||||
opencodeBinaries: (config?.opencodeBinaries ?? []).map((binary) => ({ ...binary })),
|
||||
theme: config?.theme ?? "dark",
|
||||
}
|
||||
}
|
||||
|
||||
function buildFallbackConfig(): ConfigData {
|
||||
return normalizeConfig()
|
||||
}
|
||||
|
||||
function removeLegacyAgentSelections(config?: ConfigData | null): { cleaned: ConfigData; migrated: boolean } {
|
||||
const migrated = Boolean((config?.preferences as { agentModelSelections?: unknown } | undefined)?.agentModelSelections)
|
||||
const cleanedConfig = normalizeConfig(config)
|
||||
return { cleaned: cleanedConfig, migrated }
|
||||
}
|
||||
|
||||
async function syncConfig(source?: ConfigData): Promise<void> {
|
||||
try {
|
||||
const loaded = source ?? (await storage.loadConfig())
|
||||
const { cleaned, migrated } = removeLegacyAgentSelections(loaded)
|
||||
applyConfig(cleaned)
|
||||
if (migrated) {
|
||||
void storage.updateConfig(cleaned).catch((error: unknown) => {
|
||||
log.error("Failed to persist legacy config cleanup", error)
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to load config", error)
|
||||
applyConfig(buildFallbackConfig())
|
||||
}
|
||||
}
|
||||
|
||||
function applyConfig(next: ConfigData) {
|
||||
setInternalConfig(normalizeConfig(next))
|
||||
setIsConfigLoaded(true)
|
||||
}
|
||||
|
||||
function cloneConfigForUpdate(): ConfigData {
|
||||
return normalizeConfig(internalConfig())
|
||||
}
|
||||
|
||||
function logConfigDiff(previous: ConfigData, next: ConfigData) {
|
||||
if (deepEqual(previous, next)) {
|
||||
return
|
||||
}
|
||||
const changes = diffObjects(previous, next)
|
||||
if (changes.length > 0) {
|
||||
log.info("[Config] Changes", changes)
|
||||
}
|
||||
}
|
||||
|
||||
function diffObjects(previous: unknown, next: unknown, path: string[] = []): string[] {
|
||||
if (previous === next) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (typeof previous !== "object" || previous === null || typeof next !== "object" || next === null) {
|
||||
return [path.join(".")]
|
||||
}
|
||||
|
||||
const prevKeys = Object.keys(previous as Record<string, unknown>)
|
||||
const nextKeys = Object.keys(next as Record<string, unknown>)
|
||||
const allKeys = new Set([...prevKeys, ...nextKeys])
|
||||
const changes: string[] = []
|
||||
|
||||
for (const key of allKeys) {
|
||||
const childPath = [...path, key]
|
||||
const prevValue = (previous as Record<string, unknown>)[key]
|
||||
const nextValue = (next as Record<string, unknown>)[key]
|
||||
changes.push(...diffObjects(prevValue, nextValue, childPath))
|
||||
}
|
||||
|
||||
return changes
|
||||
}
|
||||
|
||||
function updateConfig(mutator: (draft: ConfigData) => void): void {
|
||||
const previous = internalConfig()
|
||||
const draft = cloneConfigForUpdate()
|
||||
mutator(draft)
|
||||
logConfigDiff(previous, draft)
|
||||
applyConfig(draft)
|
||||
void persistFullConfig(draft)
|
||||
}
|
||||
|
||||
async function persistFullConfig(next: ConfigData): Promise<void> {
|
||||
try {
|
||||
await ensureConfigLoaded()
|
||||
await storage.updateConfig(next)
|
||||
} catch (error) {
|
||||
log.error("Failed to save config", error)
|
||||
void syncConfig().catch((syncError: unknown) => {
|
||||
log.error("Failed to refresh config", syncError)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function setThemePreference(preference: ThemePreference): void {
|
||||
if (themePreference() === preference) {
|
||||
return
|
||||
}
|
||||
updateConfig((draft) => {
|
||||
draft.theme = preference
|
||||
})
|
||||
}
|
||||
|
||||
async function ensureConfigLoaded(): Promise<void> {
|
||||
if (isConfigLoaded()) return
|
||||
if (!loadPromise) {
|
||||
loadPromise = syncConfig().finally(() => {
|
||||
loadPromise = null
|
||||
})
|
||||
}
|
||||
await loadPromise
|
||||
}
|
||||
|
||||
function buildRecentFolderList(path: string, source: RecentFolder[]): RecentFolder[] {
|
||||
const folders = source.filter((f) => f.path !== path)
|
||||
folders.unshift({ path, lastAccessed: Date.now() })
|
||||
return folders.slice(0, MAX_RECENT_FOLDERS)
|
||||
}
|
||||
|
||||
function buildBinaryList(path: string, version: string | undefined, source: OpenCodeBinary[]): OpenCodeBinary[] {
|
||||
const timestamp = Date.now()
|
||||
const existing = source.find((b) => b.path === path)
|
||||
if (existing) {
|
||||
const updatedEntry: OpenCodeBinary = { ...existing, lastUsed: timestamp }
|
||||
const remaining = source.filter((b) => b.path !== path)
|
||||
return [updatedEntry, ...remaining]
|
||||
}
|
||||
const nextEntry: OpenCodeBinary = version ? { path, version, lastUsed: timestamp } : { path, lastUsed: timestamp }
|
||||
return [nextEntry, ...source].slice(0, 10)
|
||||
}
|
||||
|
||||
function updatePreferences(updates: Partial<Preferences>): void {
|
||||
const current = internalConfig().preferences
|
||||
const merged = normalizePreferences({ ...current, ...updates })
|
||||
if (deepEqual(current, merged)) {
|
||||
return
|
||||
}
|
||||
updateConfig((draft) => {
|
||||
draft.preferences = merged
|
||||
})
|
||||
}
|
||||
|
||||
function setListeningMode(mode: ListeningMode): void {
|
||||
if (preferences().listeningMode === mode) return
|
||||
updatePreferences({ listeningMode: mode })
|
||||
}
|
||||
|
||||
function setDiffViewMode(mode: DiffViewMode): void {
|
||||
if (preferences().diffViewMode === mode) return
|
||||
updatePreferences({ diffViewMode: mode })
|
||||
}
|
||||
|
||||
function setToolOutputExpansion(mode: ExpansionPreference): void {
|
||||
if (preferences().toolOutputExpansion === mode) return
|
||||
updatePreferences({ toolOutputExpansion: mode })
|
||||
}
|
||||
|
||||
function setDiagnosticsExpansion(mode: ExpansionPreference): void {
|
||||
if (preferences().diagnosticsExpansion === mode) return
|
||||
updatePreferences({ diagnosticsExpansion: mode })
|
||||
}
|
||||
|
||||
function setThinkingBlocksExpansion(mode: ExpansionPreference): void {
|
||||
if (preferences().thinkingBlocksExpansion === mode) return
|
||||
updatePreferences({ thinkingBlocksExpansion: mode })
|
||||
}
|
||||
|
||||
function toggleShowThinkingBlocks(): void {
|
||||
updatePreferences({ showThinkingBlocks: !preferences().showThinkingBlocks })
|
||||
}
|
||||
|
||||
function toggleShowTimelineTools(): void {
|
||||
updatePreferences({ showTimelineTools: !preferences().showTimelineTools })
|
||||
}
|
||||
|
||||
function toggleUsageMetrics(): void {
|
||||
updatePreferences({ showUsageMetrics: !preferences().showUsageMetrics })
|
||||
}
|
||||
|
||||
function toggleAutoCleanupBlankSessions(): void {
|
||||
const nextValue = !preferences().autoCleanupBlankSessions
|
||||
log.info("toggle auto cleanup", { value: nextValue })
|
||||
updatePreferences({ autoCleanupBlankSessions: nextValue })
|
||||
}
|
||||
|
||||
function addRecentFolder(path: string): void {
|
||||
updateConfig((draft) => {
|
||||
draft.recentFolders = buildRecentFolderList(path, draft.recentFolders)
|
||||
})
|
||||
}
|
||||
|
||||
function removeRecentFolder(path: string): void {
|
||||
updateConfig((draft) => {
|
||||
draft.recentFolders = draft.recentFolders.filter((f) => f.path !== path)
|
||||
})
|
||||
}
|
||||
|
||||
function addOpenCodeBinary(path: string, version?: string): void {
|
||||
updateConfig((draft) => {
|
||||
draft.opencodeBinaries = buildBinaryList(path, version, draft.opencodeBinaries)
|
||||
})
|
||||
}
|
||||
|
||||
function removeOpenCodeBinary(path: string): void {
|
||||
updateConfig((draft) => {
|
||||
draft.opencodeBinaries = draft.opencodeBinaries.filter((b) => b.path !== path)
|
||||
})
|
||||
}
|
||||
|
||||
function updateLastUsedBinary(path: string): void {
|
||||
const target = path || preferences().lastUsedBinary || "opencode"
|
||||
updateConfig((draft) => {
|
||||
draft.preferences = normalizePreferences({ ...draft.preferences, lastUsedBinary: target })
|
||||
draft.opencodeBinaries = buildBinaryList(target, undefined, draft.opencodeBinaries)
|
||||
})
|
||||
}
|
||||
|
||||
function recordWorkspaceLaunch(folderPath: string, binaryPath?: string): void {
|
||||
updateConfig((draft) => {
|
||||
const targetBinary = binaryPath && binaryPath.trim().length > 0 ? binaryPath : draft.preferences.lastUsedBinary || "opencode"
|
||||
draft.recentFolders = buildRecentFolderList(folderPath, draft.recentFolders)
|
||||
draft.preferences = normalizePreferences({ ...draft.preferences, lastUsedBinary: targetBinary })
|
||||
draft.opencodeBinaries = buildBinaryList(targetBinary, undefined, draft.opencodeBinaries)
|
||||
})
|
||||
}
|
||||
|
||||
function updateEnvironmentVariables(envVars: Record<string, string>): void {
|
||||
updatePreferences({ environmentVariables: envVars })
|
||||
}
|
||||
|
||||
function addEnvironmentVariable(key: string, value: string): void {
|
||||
const current = preferences().environmentVariables || {}
|
||||
const updated = { ...current, [key]: value }
|
||||
updateEnvironmentVariables(updated)
|
||||
}
|
||||
|
||||
function removeEnvironmentVariable(key: string): void {
|
||||
const current = preferences().environmentVariables || {}
|
||||
const { [key]: removed, ...rest } = current
|
||||
updateEnvironmentVariables(rest)
|
||||
}
|
||||
|
||||
function addRecentModelPreference(model: ModelPreference): void {
|
||||
if (!model.providerId || !model.modelId) return
|
||||
const recents = preferences().modelRecents ?? []
|
||||
const filtered = recents.filter((item) => item.providerId !== model.providerId || item.modelId !== model.modelId)
|
||||
const updated = [model, ...filtered].slice(0, MAX_RECENT_MODELS)
|
||||
updatePreferences({ modelRecents: updated })
|
||||
}
|
||||
|
||||
async function setAgentModelPreference(instanceId: string, agent: string, model: ModelPreference): Promise<void> {
|
||||
if (!instanceId || !agent || !model.providerId || !model.modelId) return
|
||||
await ensureInstanceConfigLoaded(instanceId)
|
||||
await updateInstanceData(instanceId, (draft) => {
|
||||
const selections = { ...(draft.agentModelSelections ?? {}) }
|
||||
const existing = selections[agent]
|
||||
if (existing && existing.providerId === model.providerId && existing.modelId === model.modelId) {
|
||||
return
|
||||
}
|
||||
selections[agent] = model
|
||||
draft.agentModelSelections = selections
|
||||
})
|
||||
}
|
||||
|
||||
async function getAgentModelPreference(instanceId: string, agent: string): Promise<ModelPreference | undefined> {
|
||||
if (!instanceId || !agent) return undefined
|
||||
await ensureInstanceConfigLoaded(instanceId)
|
||||
const selections = getInstanceConfig(instanceId).agentModelSelections ?? {}
|
||||
return selections[agent]
|
||||
}
|
||||
|
||||
void ensureConfigLoaded().catch((error: unknown) => {
|
||||
log.error("Failed to initialize config", error)
|
||||
})
|
||||
|
||||
interface ConfigContextValue {
|
||||
isLoaded: Accessor<boolean>
|
||||
config: typeof config
|
||||
preferences: typeof preferences
|
||||
recentFolders: typeof recentFolders
|
||||
opencodeBinaries: typeof opencodeBinaries
|
||||
themePreference: typeof themePreference
|
||||
setThemePreference: typeof setThemePreference
|
||||
updateConfig: typeof updateConfig
|
||||
toggleShowThinkingBlocks: typeof toggleShowThinkingBlocks
|
||||
toggleShowTimelineTools: typeof toggleShowTimelineTools
|
||||
toggleUsageMetrics: typeof toggleUsageMetrics
|
||||
toggleAutoCleanupBlankSessions: typeof toggleAutoCleanupBlankSessions
|
||||
|
||||
setDiffViewMode: typeof setDiffViewMode
|
||||
setToolOutputExpansion: typeof setToolOutputExpansion
|
||||
setDiagnosticsExpansion: typeof setDiagnosticsExpansion
|
||||
setThinkingBlocksExpansion: typeof setThinkingBlocksExpansion
|
||||
setListeningMode: typeof setListeningMode
|
||||
addRecentFolder: typeof addRecentFolder
|
||||
removeRecentFolder: typeof removeRecentFolder
|
||||
addOpenCodeBinary: typeof addOpenCodeBinary
|
||||
removeOpenCodeBinary: typeof removeOpenCodeBinary
|
||||
updateLastUsedBinary: typeof updateLastUsedBinary
|
||||
recordWorkspaceLaunch: typeof recordWorkspaceLaunch
|
||||
updatePreferences: typeof updatePreferences
|
||||
updateEnvironmentVariables: typeof updateEnvironmentVariables
|
||||
addEnvironmentVariable: typeof addEnvironmentVariable
|
||||
removeEnvironmentVariable: typeof removeEnvironmentVariable
|
||||
addRecentModelPreference: typeof addRecentModelPreference
|
||||
setAgentModelPreference: typeof setAgentModelPreference
|
||||
getAgentModelPreference: typeof getAgentModelPreference
|
||||
}
|
||||
|
||||
const ConfigContext = createContext<ConfigContextValue>()
|
||||
|
||||
const configContextValue: ConfigContextValue = {
|
||||
isLoaded: isConfigLoaded,
|
||||
config,
|
||||
preferences,
|
||||
recentFolders,
|
||||
opencodeBinaries,
|
||||
themePreference,
|
||||
setThemePreference,
|
||||
updateConfig,
|
||||
toggleShowThinkingBlocks,
|
||||
toggleShowTimelineTools,
|
||||
toggleUsageMetrics,
|
||||
toggleAutoCleanupBlankSessions,
|
||||
setDiffViewMode,
|
||||
setToolOutputExpansion,
|
||||
setDiagnosticsExpansion,
|
||||
setThinkingBlocksExpansion,
|
||||
setListeningMode,
|
||||
addRecentFolder,
|
||||
removeRecentFolder,
|
||||
addOpenCodeBinary,
|
||||
removeOpenCodeBinary,
|
||||
updateLastUsedBinary,
|
||||
recordWorkspaceLaunch,
|
||||
updatePreferences,
|
||||
updateEnvironmentVariables,
|
||||
addEnvironmentVariable,
|
||||
removeEnvironmentVariable,
|
||||
addRecentModelPreference,
|
||||
setAgentModelPreference,
|
||||
getAgentModelPreference,
|
||||
}
|
||||
|
||||
const ConfigProvider: ParentComponent = (props) => {
|
||||
onMount(() => {
|
||||
ensureConfigLoaded().catch((error: unknown) => {
|
||||
log.error("Failed to initialize config", error)
|
||||
})
|
||||
|
||||
const unsubscribe = storage.onConfigChanged((config) => {
|
||||
syncConfig(config).catch((error: unknown) => {
|
||||
log.error("Failed to refresh config", error)
|
||||
})
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsubscribe()
|
||||
}
|
||||
})
|
||||
|
||||
return <ConfigContext.Provider value={configContextValue}>{props.children}</ConfigContext.Provider>
|
||||
}
|
||||
|
||||
function useConfig(): ConfigContextValue {
|
||||
const context = useContext(ConfigContext)
|
||||
if (!context) {
|
||||
throw new Error("useConfig must be used within ConfigProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export {
|
||||
ConfigProvider,
|
||||
useConfig,
|
||||
config,
|
||||
preferences,
|
||||
updateConfig,
|
||||
updatePreferences,
|
||||
toggleShowThinkingBlocks,
|
||||
toggleShowTimelineTools,
|
||||
toggleAutoCleanupBlankSessions,
|
||||
toggleUsageMetrics,
|
||||
recentFolders,
|
||||
addRecentFolder,
|
||||
removeRecentFolder,
|
||||
opencodeBinaries,
|
||||
addOpenCodeBinary,
|
||||
removeOpenCodeBinary,
|
||||
updateLastUsedBinary,
|
||||
updateEnvironmentVariables,
|
||||
addEnvironmentVariable,
|
||||
removeEnvironmentVariable,
|
||||
addRecentModelPreference,
|
||||
setAgentModelPreference,
|
||||
getAgentModelPreference,
|
||||
setDiffViewMode,
|
||||
setToolOutputExpansion,
|
||||
setDiagnosticsExpansion,
|
||||
setThinkingBlocksExpansion,
|
||||
setListeningMode,
|
||||
themePreference,
|
||||
setThemePreference,
|
||||
recordWorkspaceLaunch,
|
||||
}
|
||||
|
||||
|
||||
|
||||
95
packages/ui/src/stores/releases.ts
Normal file
95
packages/ui/src/stores/releases.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { createEffect, createSignal } from "solid-js"
|
||||
import type { LatestReleaseInfo, WorkspaceEventPayload } from "../../../server/src/api-types"
|
||||
import { getServerMeta } from "../lib/server-meta"
|
||||
import { serverEvents } from "../lib/server-events"
|
||||
import { showToastNotification, ToastHandle } from "../lib/notifications"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { hasInstances, showFolderSelection } from "./ui"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
const [availableRelease, setAvailableRelease] = createSignal<LatestReleaseInfo | null>(null)
|
||||
|
||||
let initialized = false
|
||||
let visibilityEffectInitialized = false
|
||||
let activeToast: ToastHandle | null = null
|
||||
let activeToastVersion: string | null = null
|
||||
|
||||
function dismissActiveToast() {
|
||||
if (activeToast) {
|
||||
activeToast.dismiss()
|
||||
activeToast = null
|
||||
activeToastVersion = null
|
||||
}
|
||||
}
|
||||
|
||||
function ensureVisibilityEffect() {
|
||||
if (visibilityEffectInitialized) {
|
||||
return
|
||||
}
|
||||
visibilityEffectInitialized = true
|
||||
|
||||
createEffect(() => {
|
||||
const release = availableRelease()
|
||||
const shouldShow = Boolean(release) && (!hasInstances() || showFolderSelection())
|
||||
|
||||
if (!shouldShow || !release) {
|
||||
dismissActiveToast()
|
||||
return
|
||||
}
|
||||
|
||||
if (!activeToast || activeToastVersion !== release.version) {
|
||||
dismissActiveToast()
|
||||
activeToast = showToastNotification({
|
||||
title: `CodeNomad ${release.version}`,
|
||||
message: release.channel === "dev" ? "Dev release build available." : "New stable build on GitHub.",
|
||||
variant: "info",
|
||||
duration: Number.POSITIVE_INFINITY,
|
||||
position: "bottom-right",
|
||||
action: {
|
||||
label: "View release",
|
||||
href: release.url,
|
||||
},
|
||||
})
|
||||
activeToastVersion = release.version
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function initReleaseNotifications() {
|
||||
if (initialized) {
|
||||
return
|
||||
}
|
||||
initialized = true
|
||||
|
||||
ensureVisibilityEffect()
|
||||
void refreshFromMeta()
|
||||
|
||||
serverEvents.on("app.releaseAvailable", (event) => {
|
||||
const typedEvent = event as Extract<WorkspaceEventPayload, { type: "app.releaseAvailable" }>
|
||||
applyRelease(typedEvent.release)
|
||||
})
|
||||
}
|
||||
|
||||
async function refreshFromMeta() {
|
||||
try {
|
||||
const meta = await getServerMeta(true)
|
||||
if (meta.latestRelease) {
|
||||
applyRelease(meta.latestRelease)
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn("Unable to load server metadata for release info", error)
|
||||
}
|
||||
}
|
||||
|
||||
function applyRelease(release: LatestReleaseInfo | null | undefined) {
|
||||
if (!release) {
|
||||
setAvailableRelease(null)
|
||||
return
|
||||
}
|
||||
setAvailableRelease(release)
|
||||
}
|
||||
|
||||
export function useAvailableRelease() {
|
||||
return availableRelease
|
||||
}
|
||||
374
packages/ui/src/stores/session-actions.ts
Normal file
374
packages/ui/src/stores/session-actions.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
|
||||
import { instances } from "./instances"
|
||||
|
||||
import { addRecentModelPreference, setAgentModelPreference } from "./preferences"
|
||||
import { sessions, withSession } from "./session-state"
|
||||
import { getDefaultModel, isModelValid } from "./session-models"
|
||||
import { updateSessionInfo } from "./message-v2/session-info"
|
||||
import { messageStoreBus } from "./message-v2/bus"
|
||||
import { getLogger } from "../lib/logger"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
const ID_LENGTH = 26
|
||||
const BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
|
||||
let lastTimestamp = 0
|
||||
let localCounter = 0
|
||||
|
||||
function randomBase62(length: number): string {
|
||||
let result = ""
|
||||
const cryptoObj = (globalThis as unknown as { crypto?: Crypto }).crypto
|
||||
if (cryptoObj && typeof cryptoObj.getRandomValues === "function") {
|
||||
const bytes = new Uint8Array(length)
|
||||
cryptoObj.getRandomValues(bytes)
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += BASE62_CHARS[bytes[i] % BASE62_CHARS.length]
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < length; i++) {
|
||||
const idx = Math.floor(Math.random() * BASE62_CHARS.length)
|
||||
result += BASE62_CHARS[idx]
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function createId(prefix: string): string {
|
||||
const timestamp = Date.now()
|
||||
if (timestamp !== lastTimestamp) {
|
||||
lastTimestamp = timestamp
|
||||
localCounter = 0
|
||||
}
|
||||
localCounter++
|
||||
|
||||
const value = (BigInt(timestamp) << BigInt(12)) + BigInt(localCounter)
|
||||
const bytes = new Array<number>(6)
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const shift = BigInt(8 * (5 - i))
|
||||
bytes[i] = Number((value >> shift) & BigInt(0xff))
|
||||
}
|
||||
const hex = bytes.map((b) => b.toString(16).padStart(2, "0")).join("")
|
||||
const random = randomBase62(ID_LENGTH - 12)
|
||||
|
||||
return `${prefix}_${hex}${random}`
|
||||
}
|
||||
|
||||
async function sendMessage(
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
prompt: string,
|
||||
attachments: any[] = [],
|
||||
): Promise<void> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
const session = instanceSessions?.get(sessionId)
|
||||
if (!session) {
|
||||
throw new Error("Session not found")
|
||||
}
|
||||
|
||||
const messageId = createId("msg")
|
||||
const textPartId = createId("part")
|
||||
|
||||
const resolvedPrompt = resolvePastedPlaceholders(prompt, attachments)
|
||||
|
||||
const optimisticParts: any[] = [
|
||||
{
|
||||
id: textPartId,
|
||||
type: "text" as const,
|
||||
text: resolvedPrompt,
|
||||
synthetic: true,
|
||||
renderCache: undefined,
|
||||
},
|
||||
]
|
||||
|
||||
const requestParts: any[] = [
|
||||
{
|
||||
id: textPartId,
|
||||
type: "text" as const,
|
||||
text: resolvedPrompt,
|
||||
},
|
||||
]
|
||||
|
||||
if (attachments.length > 0) {
|
||||
for (const att of attachments) {
|
||||
const source = att.source
|
||||
if (source.type === "file") {
|
||||
const partId = createId("part")
|
||||
requestParts.push({
|
||||
id: partId,
|
||||
type: "file" as const,
|
||||
url: att.url,
|
||||
mime: source.mime,
|
||||
filename: att.filename,
|
||||
})
|
||||
optimisticParts.push({
|
||||
id: partId,
|
||||
type: "file" as const,
|
||||
url: att.url,
|
||||
mime: source.mime,
|
||||
filename: att.filename,
|
||||
synthetic: true,
|
||||
})
|
||||
} else if (source.type === "text") {
|
||||
const display: string | undefined = att.display
|
||||
const value: unknown = source.value
|
||||
const isPastedPlaceholder = typeof display === "string" && /^pasted #\d+/.test(display)
|
||||
|
||||
if (isPastedPlaceholder || typeof value !== "string") {
|
||||
continue
|
||||
}
|
||||
|
||||
const partId = createId("part")
|
||||
requestParts.push({
|
||||
id: partId,
|
||||
type: "text" as const,
|
||||
text: value,
|
||||
})
|
||||
optimisticParts.push({
|
||||
id: partId,
|
||||
type: "text" as const,
|
||||
text: value,
|
||||
synthetic: true,
|
||||
renderCache: undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
const createdAt = Date.now()
|
||||
|
||||
store.upsertMessage({
|
||||
id: messageId,
|
||||
sessionId,
|
||||
role: "user",
|
||||
status: "sending",
|
||||
parts: optimisticParts,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
isEphemeral: true,
|
||||
})
|
||||
|
||||
withSession(instanceId, sessionId, () => {
|
||||
/* trigger reactivity for legacy session data */
|
||||
})
|
||||
|
||||
const requestBody = {
|
||||
messageID: messageId,
|
||||
parts: requestParts,
|
||||
...(session.agent && { agent: session.agent }),
|
||||
...(session.model.providerId &&
|
||||
session.model.modelId && {
|
||||
model: {
|
||||
providerID: session.model.providerId,
|
||||
modelID: session.model.modelId,
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
log.info("sendMessage", {
|
||||
instanceId,
|
||||
sessionId,
|
||||
requestBody,
|
||||
})
|
||||
|
||||
try {
|
||||
log.info("session.promptAsync", { instanceId, sessionId, requestBody })
|
||||
const response = await instance.client.session.promptAsync({
|
||||
path: { id: sessionId },
|
||||
body: requestBody,
|
||||
})
|
||||
|
||||
log.info("sendMessage response", response)
|
||||
|
||||
if (response.error) {
|
||||
log.error("sendMessage server error", response.error)
|
||||
throw new Error(JSON.stringify(response.error) || "Failed to send message")
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to send prompt", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function executeCustomCommand(
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
commandName: string,
|
||||
args: string,
|
||||
): Promise<void> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const session = sessions().get(instanceId)?.get(sessionId)
|
||||
if (!session) {
|
||||
throw new Error("Session not found")
|
||||
}
|
||||
|
||||
const body: {
|
||||
command: string
|
||||
arguments: string
|
||||
messageID: string
|
||||
agent?: string
|
||||
model?: string
|
||||
} = {
|
||||
command: commandName,
|
||||
arguments: args,
|
||||
messageID: createId("msg"),
|
||||
}
|
||||
|
||||
if (session.agent) {
|
||||
body.agent = session.agent
|
||||
}
|
||||
|
||||
if (session.model.providerId && session.model.modelId) {
|
||||
body.model = `${session.model.providerId}/${session.model.modelId}`
|
||||
}
|
||||
|
||||
await instance.client.session.command({
|
||||
path: { id: sessionId },
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
async function runShellCommand(instanceId: string, sessionId: string, command: string): Promise<void> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const session = sessions().get(instanceId)?.get(sessionId)
|
||||
if (!session) {
|
||||
throw new Error("Session not found")
|
||||
}
|
||||
|
||||
const agent = session.agent || "build"
|
||||
|
||||
await instance.client.session.shell({
|
||||
path: { id: sessionId },
|
||||
body: {
|
||||
agent,
|
||||
command,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function abortSession(instanceId: string, sessionId: string): Promise<void> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
log.info("abortSession", { instanceId, sessionId })
|
||||
|
||||
try {
|
||||
log.info("session.abort", { instanceId, sessionId })
|
||||
await instance.client.session.abort({
|
||||
path: { id: sessionId },
|
||||
})
|
||||
log.info("abortSession complete", { instanceId, sessionId })
|
||||
} catch (error) {
|
||||
log.error("Failed to abort session", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function updateSessionAgent(instanceId: string, sessionId: string, agent: string): Promise<void> {
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
const session = instanceSessions?.get(sessionId)
|
||||
if (!session) {
|
||||
throw new Error("Session not found")
|
||||
}
|
||||
|
||||
const nextModel = await getDefaultModel(instanceId, agent)
|
||||
const shouldApplyModel = isModelValid(instanceId, nextModel)
|
||||
|
||||
withSession(instanceId, sessionId, (current) => {
|
||||
current.agent = agent
|
||||
if (shouldApplyModel) {
|
||||
current.model = nextModel
|
||||
}
|
||||
})
|
||||
|
||||
if (agent && shouldApplyModel) {
|
||||
await setAgentModelPreference(instanceId, agent, nextModel)
|
||||
}
|
||||
|
||||
if (shouldApplyModel) {
|
||||
updateSessionInfo(instanceId, sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
async function updateSessionModel(
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
model: { providerId: string; modelId: string },
|
||||
): Promise<void> {
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
const session = instanceSessions?.get(sessionId)
|
||||
if (!session) {
|
||||
throw new Error("Session not found")
|
||||
}
|
||||
|
||||
if (!isModelValid(instanceId, model)) {
|
||||
log.warn("Invalid model selection", model)
|
||||
return
|
||||
}
|
||||
|
||||
withSession(instanceId, sessionId, (current) => {
|
||||
current.model = model
|
||||
})
|
||||
|
||||
if (session.agent) {
|
||||
await setAgentModelPreference(instanceId, session.agent, model)
|
||||
}
|
||||
addRecentModelPreference(model)
|
||||
|
||||
updateSessionInfo(instanceId, sessionId)
|
||||
}
|
||||
|
||||
async function renameSession(instanceId: string, sessionId: string, nextTitle: string): Promise<void> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const session = sessions().get(instanceId)?.get(sessionId)
|
||||
if (!session) {
|
||||
throw new Error("Session not found")
|
||||
}
|
||||
|
||||
const trimmedTitle = nextTitle.trim()
|
||||
if (!trimmedTitle) {
|
||||
throw new Error("Session title is required")
|
||||
}
|
||||
|
||||
await instance.client.session.update({
|
||||
path: { id: sessionId },
|
||||
body: { title: trimmedTitle },
|
||||
})
|
||||
|
||||
withSession(instanceId, sessionId, (current) => {
|
||||
current.title = trimmedTitle
|
||||
const time = { ...(current.time ?? {}) }
|
||||
time.updated = Date.now()
|
||||
current.time = time
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
abortSession,
|
||||
executeCustomCommand,
|
||||
renameSession,
|
||||
runShellCommand,
|
||||
sendMessage,
|
||||
updateSessionAgent,
|
||||
updateSessionModel,
|
||||
}
|
||||
635
packages/ui/src/stores/session-api.ts
Normal file
635
packages/ui/src/stores/session-api.ts
Normal file
@@ -0,0 +1,635 @@
|
||||
import type { Session } from "../types/session"
|
||||
import type { Message } from "../types/message"
|
||||
|
||||
import { instances } from "./instances"
|
||||
import { preferences, setAgentModelPreference } from "./preferences"
|
||||
import { setSessionCompactionState } from "./session-compaction"
|
||||
import {
|
||||
activeSessionId,
|
||||
agents,
|
||||
clearSessionDraftPrompt,
|
||||
getChildSessions,
|
||||
isBlankSession,
|
||||
messagesLoaded,
|
||||
pruneDraftPrompts,
|
||||
providers,
|
||||
setActiveSessionId,
|
||||
setAgents,
|
||||
setMessagesLoaded,
|
||||
setProviders,
|
||||
setSessionInfoByInstance,
|
||||
setSessions,
|
||||
sessions,
|
||||
loading,
|
||||
setLoading,
|
||||
cleanupBlankSessions,
|
||||
} from "./session-state"
|
||||
import { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, isModelValid } from "./session-models"
|
||||
import { normalizeMessagePart } from "./message-v2/normalizers"
|
||||
import { updateSessionInfo } from "./message-v2/session-info"
|
||||
import { seedSessionMessagesV2 } from "./message-v2/bridge"
|
||||
import { messageStoreBus } from "./message-v2/bus"
|
||||
import { clearCacheForSession } from "../lib/global-cache"
|
||||
import { getLogger } from "../lib/logger"
|
||||
|
||||
const log = getLogger("api")
|
||||
|
||||
interface SessionForkResponse {
|
||||
id: string
|
||||
title?: string
|
||||
parentID?: string | null
|
||||
agent?: string
|
||||
model?: {
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
}
|
||||
time?: {
|
||||
created?: number
|
||||
updated?: number
|
||||
}
|
||||
revert?: {
|
||||
messageID?: string
|
||||
partID?: string
|
||||
snapshot?: string
|
||||
diff?: string
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSessions(instanceId: string): Promise<void> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
setLoading((prev) => {
|
||||
const next = { ...prev }
|
||||
next.fetchingSessions.set(instanceId, true)
|
||||
return next
|
||||
})
|
||||
|
||||
try {
|
||||
log.info("session.list", { instanceId })
|
||||
const response = await instance.client.session.list()
|
||||
|
||||
const sessionMap = new Map<string, Session>()
|
||||
|
||||
if (!response.data || !Array.isArray(response.data)) {
|
||||
return
|
||||
}
|
||||
|
||||
const existingSessions = sessions().get(instanceId)
|
||||
|
||||
for (const apiSession of response.data) {
|
||||
const existingSession = existingSessions?.get(apiSession.id)
|
||||
|
||||
sessionMap.set(apiSession.id, {
|
||||
id: apiSession.id,
|
||||
instanceId,
|
||||
title: apiSession.title || "Untitled",
|
||||
parentId: apiSession.parentID || null,
|
||||
agent: existingSession?.agent ?? "",
|
||||
model: existingSession?.model ?? { providerId: "", modelId: "" },
|
||||
version: apiSession.version,
|
||||
time: {
|
||||
...apiSession.time,
|
||||
},
|
||||
revert: apiSession.revert
|
||||
? {
|
||||
messageID: apiSession.revert.messageID,
|
||||
partID: apiSession.revert.partID,
|
||||
snapshot: apiSession.revert.snapshot,
|
||||
diff: apiSession.revert.diff,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const validSessionIds = new Set(sessionMap.keys())
|
||||
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, sessionMap)
|
||||
return next
|
||||
})
|
||||
|
||||
setMessagesLoaded((prev) => {
|
||||
const next = new Map(prev)
|
||||
const loadedSet = next.get(instanceId)
|
||||
if (loadedSet) {
|
||||
const filtered = new Set<string>()
|
||||
for (const id of loadedSet) {
|
||||
if (validSessionIds.has(id)) {
|
||||
filtered.add(id)
|
||||
}
|
||||
}
|
||||
next.set(instanceId, filtered)
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
for (const session of sessionMap.values()) {
|
||||
const flag = (session.time as (Session["time"] & { compacting?: number | boolean }) | undefined)?.compacting
|
||||
const active = typeof flag === "number" ? flag > 0 : Boolean(flag)
|
||||
setSessionCompactionState(instanceId, session.id, active)
|
||||
}
|
||||
|
||||
pruneDraftPrompts(instanceId, new Set(sessionMap.keys()))
|
||||
} catch (error) {
|
||||
log.error("Failed to fetch sessions:", error)
|
||||
throw error
|
||||
} finally {
|
||||
setLoading((prev) => {
|
||||
const next = { ...prev }
|
||||
next.fetchingSessions.set(instanceId, false)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function createSession(instanceId: string, agent?: string): Promise<Session> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const instanceAgents = agents().get(instanceId) || []
|
||||
const nonSubagents = instanceAgents.filter((a) => a.mode !== "subagent")
|
||||
const selectedAgent = agent || (nonSubagents.length > 0 ? nonSubagents[0].name : "")
|
||||
|
||||
const defaultModel = await getDefaultModel(instanceId, selectedAgent)
|
||||
|
||||
if (selectedAgent && isModelValid(instanceId, defaultModel)) {
|
||||
await setAgentModelPreference(instanceId, selectedAgent, defaultModel)
|
||||
}
|
||||
|
||||
setLoading((prev) => {
|
||||
const next = { ...prev }
|
||||
next.creatingSession.set(instanceId, true)
|
||||
return next
|
||||
})
|
||||
|
||||
try {
|
||||
log.info(`[HTTP] POST /session.create for instance ${instanceId}`)
|
||||
const response = await instance.client.session.create()
|
||||
|
||||
if (!response.data) {
|
||||
throw new Error("Failed to create session: No data returned")
|
||||
}
|
||||
|
||||
const session: Session = {
|
||||
id: response.data.id,
|
||||
instanceId,
|
||||
title: response.data.title || "New Session",
|
||||
parentId: null,
|
||||
agent: selectedAgent,
|
||||
model: defaultModel,
|
||||
version: response.data.version,
|
||||
time: {
|
||||
...response.data.time,
|
||||
},
|
||||
revert: response.data.revert
|
||||
? {
|
||||
messageID: response.data.revert.messageID,
|
||||
partID: response.data.revert.partID,
|
||||
snapshot: response.data.revert.snapshot,
|
||||
diff: response.data.revert.diff,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
const instanceSessions = next.get(instanceId) || new Map()
|
||||
instanceSessions.set(session.id, session)
|
||||
next.set(instanceId, instanceSessions)
|
||||
return next
|
||||
})
|
||||
|
||||
const instanceProviders = providers().get(instanceId) || []
|
||||
const initialProvider = instanceProviders.find((p) => p.id === session.model.providerId)
|
||||
const initialModel = initialProvider?.models.find((m) => m.id === session.model.modelId)
|
||||
const initialContextWindow = initialModel?.limit?.context ?? 0
|
||||
const initialSubscriptionModel = initialModel?.cost?.input === 0 && initialModel?.cost?.output === 0
|
||||
const initialOutputLimit =
|
||||
initialModel?.limit?.output && initialModel.limit.output > 0
|
||||
? initialModel.limit.output
|
||||
: DEFAULT_MODEL_OUTPUT_LIMIT
|
||||
const initialContextAvailable = initialContextWindow > 0 ? initialContextWindow : null
|
||||
|
||||
setSessionInfoByInstance((prev) => {
|
||||
const next = new Map(prev)
|
||||
const instanceInfo = new Map(prev.get(instanceId))
|
||||
instanceInfo.set(session.id, {
|
||||
cost: 0,
|
||||
contextWindow: initialContextWindow,
|
||||
isSubscriptionModel: Boolean(initialSubscriptionModel),
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
actualUsageTokens: 0,
|
||||
modelOutputLimit: initialOutputLimit,
|
||||
contextAvailableTokens: initialContextAvailable,
|
||||
})
|
||||
next.set(instanceId, instanceInfo)
|
||||
return next
|
||||
})
|
||||
|
||||
if (preferences().autoCleanupBlankSessions) {
|
||||
await cleanupBlankSessions(instanceId, session.id)
|
||||
}
|
||||
|
||||
return session
|
||||
} catch (error) {
|
||||
log.error("Failed to create session:", error)
|
||||
throw error
|
||||
} finally {
|
||||
setLoading((prev) => {
|
||||
const next = { ...prev }
|
||||
next.creatingSession.set(instanceId, false)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function forkSession(
|
||||
instanceId: string,
|
||||
sourceSessionId: string,
|
||||
options?: { messageId?: string },
|
||||
): Promise<Session> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const request: {
|
||||
path: { id: string }
|
||||
body?: { messageID: string }
|
||||
} = {
|
||||
path: { id: sourceSessionId },
|
||||
}
|
||||
|
||||
if (options?.messageId) {
|
||||
request.body = { messageID: options.messageId }
|
||||
}
|
||||
|
||||
log.info(`[HTTP] POST /session.fork for instance ${instanceId}`, request)
|
||||
const response = await instance.client.session.fork(request)
|
||||
|
||||
if (!response.data) {
|
||||
throw new Error("Failed to fork session: No data returned")
|
||||
}
|
||||
|
||||
const info = response.data as SessionForkResponse
|
||||
const forkedSession = {
|
||||
id: info.id,
|
||||
instanceId,
|
||||
title: info.title || "Forked Session",
|
||||
parentId: info.parentID || null,
|
||||
agent: info.agent || "",
|
||||
model: {
|
||||
providerId: info.model?.providerID || "",
|
||||
modelId: info.model?.modelID || "",
|
||||
},
|
||||
version: "0",
|
||||
time: info.time ? { ...info.time } : { created: Date.now(), updated: Date.now() },
|
||||
revert: info.revert
|
||||
? {
|
||||
messageID: info.revert.messageID,
|
||||
partID: info.revert.partID,
|
||||
snapshot: info.revert.snapshot,
|
||||
diff: info.revert.diff,
|
||||
}
|
||||
: undefined,
|
||||
} as unknown as Session
|
||||
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
const instanceSessions = next.get(instanceId) || new Map()
|
||||
instanceSessions.set(forkedSession.id, forkedSession)
|
||||
next.set(instanceId, instanceSessions)
|
||||
return next
|
||||
})
|
||||
|
||||
const instanceProviders = providers().get(instanceId) || []
|
||||
const forkProvider = instanceProviders.find((p) => p.id === forkedSession.model.providerId)
|
||||
const forkModel = forkProvider?.models.find((m) => m.id === forkedSession.model.modelId)
|
||||
const forkContextWindow = forkModel?.limit?.context ?? 0
|
||||
const forkSubscriptionModel = forkModel?.cost?.input === 0 && forkModel?.cost?.output === 0
|
||||
const forkOutputLimit =
|
||||
forkModel?.limit?.output && forkModel.limit.output > 0 ? forkModel.limit.output : DEFAULT_MODEL_OUTPUT_LIMIT
|
||||
const forkContextAvailable = forkContextWindow > 0 ? forkContextWindow : null
|
||||
|
||||
setSessionInfoByInstance((prev) => {
|
||||
const next = new Map(prev)
|
||||
const instanceInfo = new Map(prev.get(instanceId))
|
||||
instanceInfo.set(forkedSession.id, {
|
||||
cost: 0,
|
||||
contextWindow: forkContextWindow,
|
||||
isSubscriptionModel: Boolean(forkSubscriptionModel),
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
actualUsageTokens: 0,
|
||||
modelOutputLimit: forkOutputLimit,
|
||||
contextAvailableTokens: forkContextAvailable,
|
||||
})
|
||||
next.set(instanceId, instanceInfo)
|
||||
return next
|
||||
})
|
||||
|
||||
return forkedSession
|
||||
}
|
||||
|
||||
async function deleteSession(instanceId: string, sessionId: string): Promise<void> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
setLoading((prev) => {
|
||||
const next = { ...prev }
|
||||
const deleting = next.deletingSession.get(instanceId) || new Set()
|
||||
deleting.add(sessionId)
|
||||
next.deletingSession.set(instanceId, deleting)
|
||||
return next
|
||||
})
|
||||
|
||||
try {
|
||||
log.info(`[HTTP] DELETE /session.delete for instance ${instanceId}`, { sessionId })
|
||||
await instance.client.session.delete({ path: { id: sessionId } })
|
||||
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
const instanceSessions = next.get(instanceId)
|
||||
if (instanceSessions) {
|
||||
instanceSessions.delete(sessionId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
setSessionCompactionState(instanceId, sessionId, false)
|
||||
clearSessionDraftPrompt(instanceId, sessionId)
|
||||
|
||||
// Drop normalized message state and caches for this session
|
||||
messageStoreBus.getOrCreate(instanceId).clearSession(sessionId)
|
||||
clearCacheForSession(instanceId, sessionId)
|
||||
|
||||
setSessionInfoByInstance((prev) => {
|
||||
const next = new Map(prev)
|
||||
const instanceInfo = next.get(instanceId)
|
||||
if (instanceInfo) {
|
||||
const updatedInstanceInfo = new Map(instanceInfo)
|
||||
updatedInstanceInfo.delete(sessionId)
|
||||
if (updatedInstanceInfo.size === 0) {
|
||||
next.delete(instanceId)
|
||||
} else {
|
||||
next.set(instanceId, updatedInstanceInfo)
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
if (activeSessionId().get(instanceId) === sessionId) {
|
||||
setActiveSessionId((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(instanceId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to delete session:", error)
|
||||
throw error
|
||||
} finally {
|
||||
setLoading((prev) => {
|
||||
const next = { ...prev }
|
||||
const deleting = next.deletingSession.get(instanceId)
|
||||
if (deleting) {
|
||||
deleting.delete(sessionId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAgents(instanceId: string): Promise<void> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
try {
|
||||
log.info(`[HTTP] GET /app.agents for instance ${instanceId}`)
|
||||
const response = await instance.client.app.agents()
|
||||
const agentList = (response.data ?? []).map((agent) => ({
|
||||
name: agent.name,
|
||||
description: agent.description || "",
|
||||
mode: agent.mode,
|
||||
model: agent.model?.modelID
|
||||
? {
|
||||
providerId: agent.model.providerID || "",
|
||||
modelId: agent.model.modelID,
|
||||
}
|
||||
: undefined,
|
||||
}))
|
||||
|
||||
setAgents((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, agentList)
|
||||
return next
|
||||
})
|
||||
} catch (error) {
|
||||
log.error("Failed to fetch agents:", error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProviders(instanceId: string): Promise<void> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
try {
|
||||
log.info(`[HTTP] GET /config.providers for instance ${instanceId}`)
|
||||
const response = await instance.client.config.providers()
|
||||
if (!response.data) return
|
||||
|
||||
const providerList = response.data.providers.map((provider) => ({
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
defaultModelId: response.data?.default?.[provider.id],
|
||||
models: Object.entries(provider.models).map(([id, model]) => ({
|
||||
id,
|
||||
name: model.name,
|
||||
providerId: provider.id,
|
||||
limit: model.limit,
|
||||
cost: model.cost,
|
||||
})),
|
||||
}))
|
||||
|
||||
setProviders((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, providerList)
|
||||
return next
|
||||
})
|
||||
} catch (error) {
|
||||
log.error("Failed to fetch providers:", error)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMessages(instanceId: string, sessionId: string, force = false): Promise<void> {
|
||||
if (force) {
|
||||
setMessagesLoaded((prev) => {
|
||||
const next = new Map(prev)
|
||||
const loadedSet = next.get(instanceId)
|
||||
if (loadedSet) {
|
||||
loadedSet.delete(sessionId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const alreadyLoaded = messagesLoaded().get(instanceId)?.has(sessionId)
|
||||
if (alreadyLoaded && !force) {
|
||||
return
|
||||
}
|
||||
|
||||
const isLoading = loading().loadingMessages.get(instanceId)?.has(sessionId)
|
||||
if (isLoading) {
|
||||
return
|
||||
}
|
||||
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
const session = instanceSessions?.get(sessionId)
|
||||
if (!session) {
|
||||
throw new Error("Session not found")
|
||||
}
|
||||
|
||||
setLoading((prev) => {
|
||||
const next = { ...prev }
|
||||
const loadingSet = next.loadingMessages.get(instanceId) || new Set()
|
||||
loadingSet.add(sessionId)
|
||||
next.loadingMessages.set(instanceId, loadingSet)
|
||||
return next
|
||||
})
|
||||
|
||||
try {
|
||||
log.info(`[HTTP] GET /session.${"messages"} for instance ${instanceId}`, { sessionId })
|
||||
const response = await instance.client.session["messages"]({ path: { id: sessionId } })
|
||||
|
||||
if (!response.data || !Array.isArray(response.data)) {
|
||||
return
|
||||
}
|
||||
|
||||
const messagesInfo = new Map<string, any>()
|
||||
const messages: Message[] = response.data.map((apiMessage: any) => {
|
||||
const info = apiMessage.info || apiMessage
|
||||
const role = info.role || "assistant"
|
||||
const messageId = info.id || String(Date.now())
|
||||
|
||||
messagesInfo.set(messageId, info)
|
||||
|
||||
const parts: any[] = (apiMessage.parts || []).map((part: any) => normalizeMessagePart(part))
|
||||
|
||||
const message: Message = {
|
||||
id: messageId,
|
||||
sessionId,
|
||||
type: role === "user" ? "user" : "assistant",
|
||||
parts,
|
||||
timestamp: info.time?.created || Date.now(),
|
||||
status: "complete" as const,
|
||||
version: 0,
|
||||
}
|
||||
|
||||
return message
|
||||
})
|
||||
|
||||
let agentName = ""
|
||||
let providerID = ""
|
||||
let modelID = ""
|
||||
|
||||
for (let i = response.data.length - 1; i >= 0; i--) {
|
||||
const apiMessage = response.data[i]
|
||||
const info = apiMessage.info || apiMessage
|
||||
|
||||
if (info.role === "assistant") {
|
||||
agentName = (info as any).mode || (info as any).agent || ""
|
||||
providerID = (info as any).providerID || ""
|
||||
modelID = (info as any).modelID || ""
|
||||
if (agentName && providerID && modelID) break
|
||||
}
|
||||
}
|
||||
|
||||
if (!agentName && !providerID && !modelID) {
|
||||
const defaultModel = await getDefaultModel(instanceId, session.agent)
|
||||
agentName = session.agent
|
||||
providerID = defaultModel.providerId
|
||||
modelID = defaultModel.modelId
|
||||
}
|
||||
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
const nextInstanceSessions = next.get(instanceId)
|
||||
if (nextInstanceSessions) {
|
||||
const existingSession = nextInstanceSessions.get(sessionId)
|
||||
if (existingSession) {
|
||||
const updatedSession = {
|
||||
...existingSession,
|
||||
agent: agentName || existingSession.agent,
|
||||
model: providerID && modelID ? { providerId: providerID, modelId: modelID } : existingSession.model,
|
||||
}
|
||||
const updatedInstanceSessions = new Map(nextInstanceSessions)
|
||||
updatedInstanceSessions.set(sessionId, updatedSession)
|
||||
next.set(instanceId, updatedInstanceSessions)
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
setMessagesLoaded((prev) => {
|
||||
const next = new Map(prev)
|
||||
const loadedSet = next.get(instanceId) || new Set()
|
||||
loadedSet.add(sessionId)
|
||||
next.set(instanceId, loadedSet)
|
||||
return next
|
||||
})
|
||||
|
||||
const sessionForV2 = sessions().get(instanceId)?.get(sessionId) ?? {
|
||||
id: sessionId,
|
||||
title: session?.title,
|
||||
parentId: session?.parentId ?? null,
|
||||
revert: session?.revert,
|
||||
}
|
||||
seedSessionMessagesV2(instanceId, sessionForV2, messages, messagesInfo)
|
||||
|
||||
} catch (error) {
|
||||
log.error("Failed to load messages:", error)
|
||||
throw error
|
||||
} finally {
|
||||
setLoading((prev) => {
|
||||
const next = { ...prev }
|
||||
const loadingSet = next.loadingMessages.get(instanceId)
|
||||
if (loadingSet) {
|
||||
loadingSet.delete(sessionId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
updateSessionInfo(instanceId, sessionId)
|
||||
}
|
||||
|
||||
export {
|
||||
createSession,
|
||||
deleteSession,
|
||||
fetchAgents,
|
||||
fetchProviders,
|
||||
|
||||
fetchSessions,
|
||||
forkSession,
|
||||
loadMessages,
|
||||
}
|
||||
24
packages/ui/src/stores/session-compaction.ts
Normal file
24
packages/ui/src/stores/session-compaction.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { createSignal } from "solid-js"
|
||||
|
||||
function makeKey(instanceId: string, sessionId: string): string {
|
||||
return `${instanceId}:${sessionId}`
|
||||
}
|
||||
|
||||
const [compactingSessions, setCompactingSessions] = createSignal<Map<string, boolean>>(new Map())
|
||||
|
||||
export function setSessionCompactionState(instanceId: string, sessionId: string, isCompacting: boolean): void {
|
||||
setCompactingSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
const key = makeKey(instanceId, sessionId)
|
||||
if (isCompacting) {
|
||||
next.set(key, true)
|
||||
} else {
|
||||
next.delete(key)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
export function isSessionCompactionActive(instanceId: string, sessionId: string): boolean {
|
||||
return compactingSessions().get(makeKey(instanceId, sessionId)) ?? false
|
||||
}
|
||||
369
packages/ui/src/stores/session-events.ts
Normal file
369
packages/ui/src/stores/session-events.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
import type {
|
||||
MessageInfo,
|
||||
MessagePartRemovedEvent,
|
||||
MessagePartUpdatedEvent,
|
||||
MessageRemovedEvent,
|
||||
MessageUpdateEvent,
|
||||
} from "../types/message"
|
||||
import type {
|
||||
EventPermissionReplied,
|
||||
EventPermissionUpdated,
|
||||
EventSessionCompacted,
|
||||
EventSessionError,
|
||||
EventSessionIdle,
|
||||
EventSessionUpdated,
|
||||
} from "@opencode-ai/sdk"
|
||||
import type { MessageStatus } from "./message-v2/types"
|
||||
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { showToastNotification, ToastVariant } from "../lib/notifications"
|
||||
import { instances, addPermissionToQueue, removePermissionFromQueue } from "./instances"
|
||||
import { showAlertDialog } from "./alerts"
|
||||
import { sessions, setSessions, withSession } from "./session-state"
|
||||
import { normalizeMessagePart } from "./message-v2/normalizers"
|
||||
import { updateSessionInfo } from "./message-v2/session-info"
|
||||
|
||||
const log = getLogger("sse")
|
||||
import { loadMessages } from "./session-api"
|
||||
import { setSessionCompactionState } from "./session-compaction"
|
||||
import {
|
||||
applyPartUpdateV2,
|
||||
replaceMessageIdV2,
|
||||
upsertMessageInfoV2,
|
||||
upsertPermissionV2,
|
||||
removePermissionV2,
|
||||
setSessionRevertV2,
|
||||
} from "./message-v2/bridge"
|
||||
import { messageStoreBus } from "./message-v2/bus"
|
||||
import type { InstanceMessageStore } from "./message-v2/instance-store"
|
||||
|
||||
interface TuiToastEvent {
|
||||
type: "tui.toast.show"
|
||||
properties: {
|
||||
title?: string
|
||||
message: string
|
||||
variant: "info" | "success" | "warning" | "error"
|
||||
duration?: number
|
||||
}
|
||||
}
|
||||
|
||||
const ALLOWED_TOAST_VARIANTS = new Set<ToastVariant>(["info", "success", "warning", "error"])
|
||||
|
||||
type MessageRole = "user" | "assistant"
|
||||
|
||||
function resolveMessageRole(info?: MessageInfo | null): MessageRole {
|
||||
return info?.role === "user" ? "user" : "assistant"
|
||||
}
|
||||
|
||||
function findPendingMessageId(
|
||||
store: InstanceMessageStore,
|
||||
sessionId: string,
|
||||
role: MessageRole,
|
||||
): string | undefined {
|
||||
const messageIds = store.getSessionMessageIds(sessionId)
|
||||
const lastId = messageIds[messageIds.length - 1]
|
||||
if (!lastId) return undefined
|
||||
const record = store.getMessage(lastId)
|
||||
if (!record) return undefined
|
||||
if (record.sessionId !== sessionId) return undefined
|
||||
if (record.role !== role) return undefined
|
||||
return record.status === "sending" ? record.id : undefined
|
||||
}
|
||||
|
||||
function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | MessagePartUpdatedEvent): void {
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
if (!instanceSessions) return
|
||||
|
||||
if (event.type === "message.part.updated") {
|
||||
const rawPart = event.properties?.part
|
||||
if (!rawPart) return
|
||||
|
||||
const part = normalizeMessagePart(rawPart)
|
||||
const messageInfo = (event as any)?.properties?.message as MessageInfo | undefined
|
||||
|
||||
const fallbackSessionId = typeof messageInfo?.sessionID === "string" ? messageInfo.sessionID : undefined
|
||||
const fallbackMessageId = typeof messageInfo?.id === "string" ? messageInfo.id : undefined
|
||||
|
||||
const sessionId = typeof part.sessionID === "string" ? part.sessionID : fallbackSessionId
|
||||
const messageId = typeof part.messageID === "string" ? part.messageID : fallbackMessageId
|
||||
if (!sessionId || !messageId) return
|
||||
|
||||
const session = instanceSessions.get(sessionId)
|
||||
if (!session) return
|
||||
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
const role: MessageRole = resolveMessageRole(messageInfo)
|
||||
const createdAt = typeof messageInfo?.time?.created === "number" ? messageInfo.time.created : Date.now()
|
||||
|
||||
|
||||
let record = store.getMessage(messageId)
|
||||
if (!record) {
|
||||
const pendingId = findPendingMessageId(store, sessionId, role)
|
||||
if (pendingId && pendingId !== messageId) {
|
||||
replaceMessageIdV2(instanceId, pendingId, messageId)
|
||||
record = store.getMessage(messageId)
|
||||
}
|
||||
}
|
||||
|
||||
if (!record) {
|
||||
store.upsertMessage({
|
||||
id: messageId,
|
||||
sessionId,
|
||||
role,
|
||||
status: "streaming",
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
isEphemeral: true,
|
||||
})
|
||||
}
|
||||
|
||||
if (messageInfo) {
|
||||
upsertMessageInfoV2(instanceId, messageInfo, { status: "streaming" })
|
||||
}
|
||||
|
||||
applyPartUpdateV2(instanceId, { ...part, sessionID: sessionId, messageID: messageId })
|
||||
|
||||
|
||||
updateSessionInfo(instanceId, sessionId)
|
||||
} else if (event.type === "message.updated") {
|
||||
const info = event.properties?.info
|
||||
if (!info) return
|
||||
|
||||
const sessionId = typeof info.sessionID === "string" ? info.sessionID : undefined
|
||||
const messageId = typeof info.id === "string" ? info.id : undefined
|
||||
if (!sessionId || !messageId) return
|
||||
|
||||
const session = instanceSessions.get(sessionId)
|
||||
if (!session) return
|
||||
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
const role: MessageRole = info.role === "user" ? "user" : "assistant"
|
||||
const hasError = Boolean((info as any).error)
|
||||
const status: MessageStatus = hasError ? "error" : "complete"
|
||||
|
||||
let record = store.getMessage(messageId)
|
||||
if (!record) {
|
||||
const pendingId = findPendingMessageId(store, sessionId, role)
|
||||
if (pendingId && pendingId !== messageId) {
|
||||
replaceMessageIdV2(instanceId, pendingId, messageId)
|
||||
record = store.getMessage(messageId)
|
||||
}
|
||||
}
|
||||
|
||||
if (!record) {
|
||||
const createdAt = info.time?.created ?? Date.now()
|
||||
const completedAt = (info.time as { completed?: number } | undefined)?.completed
|
||||
store.upsertMessage({
|
||||
id: messageId,
|
||||
sessionId,
|
||||
role,
|
||||
status,
|
||||
createdAt,
|
||||
updatedAt: completedAt ?? createdAt,
|
||||
})
|
||||
}
|
||||
|
||||
upsertMessageInfoV2(instanceId, info, { status, bumpRevision: true })
|
||||
|
||||
updateSessionInfo(instanceId, sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): void {
|
||||
const info = event.properties?.info
|
||||
|
||||
if (!info) return
|
||||
|
||||
const compactingFlag = info.time?.compacting
|
||||
const isCompacting = typeof compactingFlag === "number" ? compactingFlag > 0 : Boolean(compactingFlag)
|
||||
setSessionCompactionState(instanceId, info.id, isCompacting)
|
||||
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
if (!instanceSessions) return
|
||||
|
||||
const existingSession = instanceSessions.get(info.id)
|
||||
|
||||
if (!existingSession) {
|
||||
const newSession = {
|
||||
id: info.id,
|
||||
instanceId,
|
||||
title: info.title || "Untitled",
|
||||
parentId: info.parentID || null,
|
||||
agent: "",
|
||||
model: {
|
||||
providerId: "",
|
||||
modelId: "",
|
||||
},
|
||||
version: info.version || "0",
|
||||
time: info.time
|
||||
? { ...info.time }
|
||||
: {
|
||||
created: Date.now(),
|
||||
updated: Date.now(),
|
||||
},
|
||||
} as any
|
||||
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
const updated = new Map(prev.get(instanceId))
|
||||
updated.set(newSession.id, newSession)
|
||||
next.set(instanceId, updated)
|
||||
return next
|
||||
})
|
||||
setSessionRevertV2(instanceId, info.id, info.revert ?? null)
|
||||
|
||||
log.info(`[SSE] New session created: ${info.id}`, newSession)
|
||||
} else {
|
||||
const mergedTime = {
|
||||
...existingSession.time,
|
||||
...(info.time ?? {}),
|
||||
}
|
||||
if (!info.time?.updated) {
|
||||
mergedTime.updated = Date.now()
|
||||
}
|
||||
|
||||
const updatedSession = {
|
||||
...existingSession,
|
||||
title: info.title || existingSession.title,
|
||||
time: mergedTime,
|
||||
revert: info.revert
|
||||
? {
|
||||
messageID: info.revert.messageID,
|
||||
partID: info.revert.partID,
|
||||
snapshot: info.revert.snapshot,
|
||||
diff: info.revert.diff,
|
||||
}
|
||||
: existingSession.revert,
|
||||
}
|
||||
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
const updated = new Map(prev.get(instanceId))
|
||||
updated.set(existingSession.id, updatedSession)
|
||||
next.set(instanceId, updated)
|
||||
return next
|
||||
})
|
||||
setSessionRevertV2(instanceId, info.id, info.revert ?? null)
|
||||
}
|
||||
}
|
||||
|
||||
function handleSessionIdle(_instanceId: string, event: EventSessionIdle): void {
|
||||
const sessionId = event.properties?.sessionID
|
||||
if (!sessionId) return
|
||||
|
||||
log.info(`[SSE] Session idle: ${sessionId}`)
|
||||
}
|
||||
|
||||
function handleSessionCompacted(instanceId: string, event: EventSessionCompacted): void {
|
||||
const sessionID = event.properties?.sessionID
|
||||
if (!sessionID) return
|
||||
|
||||
log.info(`[SSE] Session compacted: ${sessionID}`)
|
||||
|
||||
setSessionCompactionState(instanceId, sessionID, false)
|
||||
|
||||
withSession(instanceId, sessionID, (session) => {
|
||||
const time = { ...(session.time ?? {}) }
|
||||
time.compacting = 0
|
||||
session.time = time
|
||||
})
|
||||
|
||||
loadMessages(instanceId, sessionID, true).catch((error) => log.error("Failed to reload session after compaction", error))
|
||||
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
const session = instanceSessions?.get(sessionID)
|
||||
const label = session?.title?.trim() ? session.title : sessionID
|
||||
const instanceFolder = instances().get(instanceId)?.folder ?? instanceId
|
||||
const instanceName = instanceFolder.split(/[\\/]/).filter(Boolean).pop() ?? instanceFolder
|
||||
|
||||
showToastNotification({
|
||||
title: instanceName,
|
||||
message: `Session ${label ? `"${label}"` : sessionID} was compacted`,
|
||||
variant: "info",
|
||||
duration: 10000,
|
||||
})
|
||||
}
|
||||
|
||||
function handleSessionError(_instanceId: string, event: EventSessionError): void {
|
||||
const error = event.properties?.error
|
||||
log.error(`[SSE] Session error:`, error)
|
||||
|
||||
let message = "Unknown error"
|
||||
|
||||
if (error) {
|
||||
if ("data" in error && error.data && typeof error.data === "object" && "message" in error.data) {
|
||||
message = error.data.message as string
|
||||
} else if ("message" in error && typeof error.message === "string") {
|
||||
message = error.message
|
||||
}
|
||||
}
|
||||
|
||||
showAlertDialog(`Error: ${message}`, {
|
||||
title: "Session error",
|
||||
variant: "error",
|
||||
})
|
||||
}
|
||||
|
||||
function handleMessageRemoved(instanceId: string, event: MessageRemovedEvent): void {
|
||||
const sessionID = event.properties?.sessionID
|
||||
if (!sessionID) return
|
||||
|
||||
log.info(`[SSE] Message removed from session ${sessionID}, reloading messages`)
|
||||
loadMessages(instanceId, sessionID, true).catch((error) => log.error("Failed to reload messages after removal", error))
|
||||
}
|
||||
|
||||
function handleMessagePartRemoved(instanceId: string, event: MessagePartRemovedEvent): void {
|
||||
const sessionID = event.properties?.sessionID
|
||||
if (!sessionID) return
|
||||
|
||||
log.info(`[SSE] Message part removed from session ${sessionID}, reloading messages`)
|
||||
loadMessages(instanceId, sessionID, true).catch((error) => log.error("Failed to reload messages after part removal", error))
|
||||
}
|
||||
|
||||
function handleTuiToast(_instanceId: string, event: TuiToastEvent): void {
|
||||
const payload = event?.properties
|
||||
if (!payload || typeof payload.message !== "string" || typeof payload.variant !== "string") return
|
||||
if (!payload.message.trim()) return
|
||||
|
||||
const variant: ToastVariant = ALLOWED_TOAST_VARIANTS.has(payload.variant as ToastVariant)
|
||||
? (payload.variant as ToastVariant)
|
||||
: "info"
|
||||
|
||||
showToastNotification({
|
||||
title: typeof payload.title === "string" ? payload.title : undefined,
|
||||
message: payload.message,
|
||||
variant,
|
||||
duration: typeof payload.duration === "number" ? payload.duration : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
function handlePermissionUpdated(instanceId: string, event: EventPermissionUpdated): void {
|
||||
const permission = event.properties
|
||||
if (!permission) return
|
||||
|
||||
log.info(`[SSE] Permission updated: ${permission.id} (${permission.type})`)
|
||||
addPermissionToQueue(instanceId, permission)
|
||||
upsertPermissionV2(instanceId, permission)
|
||||
}
|
||||
|
||||
function handlePermissionReplied(instanceId: string, event: EventPermissionReplied): void {
|
||||
const { permissionID } = event.properties
|
||||
if (!permissionID) return
|
||||
|
||||
log.info(`[SSE] Permission replied: ${permissionID}`)
|
||||
removePermissionFromQueue(instanceId, permissionID)
|
||||
removePermissionV2(instanceId, permissionID)
|
||||
}
|
||||
|
||||
export {
|
||||
handleMessagePartRemoved,
|
||||
handleMessageRemoved,
|
||||
handleMessageUpdate,
|
||||
handlePermissionReplied,
|
||||
handlePermissionUpdated,
|
||||
handleSessionCompacted,
|
||||
handleSessionError,
|
||||
handleSessionIdle,
|
||||
handleSessionUpdate,
|
||||
handleTuiToast,
|
||||
}
|
||||
81
packages/ui/src/stores/session-models.ts
Normal file
81
packages/ui/src/stores/session-models.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { agents, providers } from "./session-state"
|
||||
import { preferences, getAgentModelPreference } from "./preferences"
|
||||
|
||||
const DEFAULT_MODEL_OUTPUT_LIMIT = 32_000
|
||||
|
||||
function isModelValid(
|
||||
instanceId: string,
|
||||
model?: { providerId: string; modelId: string } | null,
|
||||
): model is { providerId: string; modelId: string } {
|
||||
if (!model?.providerId || !model.modelId) return false
|
||||
const instanceProviders = providers().get(instanceId) || []
|
||||
const provider = instanceProviders.find((p) => p.id === model.providerId)
|
||||
if (!provider) return false
|
||||
return provider.models.some((item) => item.id === model.modelId)
|
||||
}
|
||||
|
||||
function getRecentModelPreferenceForInstance(
|
||||
instanceId: string,
|
||||
): { providerId: string; modelId: string } | undefined {
|
||||
const recents = preferences().modelRecents ?? []
|
||||
for (const item of recents) {
|
||||
if (isModelValid(instanceId, item)) {
|
||||
return item
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getDefaultModel(
|
||||
instanceId: string,
|
||||
agentName?: string,
|
||||
): Promise<{ providerId: string; modelId: string }> {
|
||||
const instanceProviders = providers().get(instanceId) || []
|
||||
const instanceAgents = agents().get(instanceId) || []
|
||||
|
||||
if (agentName) {
|
||||
const agent = instanceAgents.find((a) => a.name === agentName)
|
||||
if (agent && agent.model && isModelValid(instanceId, agent.model)) {
|
||||
return {
|
||||
providerId: agent.model.providerId,
|
||||
modelId: agent.model.modelId,
|
||||
}
|
||||
}
|
||||
|
||||
const stored = await getAgentModelPreference(instanceId, agentName)
|
||||
if (isModelValid(instanceId, stored)) {
|
||||
return stored
|
||||
}
|
||||
}
|
||||
|
||||
const recent = getRecentModelPreferenceForInstance(instanceId)
|
||||
if (recent) {
|
||||
return recent
|
||||
}
|
||||
|
||||
for (const provider of instanceProviders) {
|
||||
if (provider.defaultModelId) {
|
||||
const model = provider.models.find((m) => m.id === provider.defaultModelId)
|
||||
if (model) {
|
||||
return {
|
||||
providerId: provider.id,
|
||||
modelId: model.id,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (instanceProviders.length > 0) {
|
||||
const firstProvider = instanceProviders[0]
|
||||
const firstModel = firstProvider.models[0]
|
||||
if (firstModel) {
|
||||
return {
|
||||
providerId: firstProvider.id,
|
||||
modelId: firstModel.id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { providerId: "", modelId: "" }
|
||||
}
|
||||
|
||||
export { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, getRecentModelPreferenceForInstance, isModelValid }
|
||||
399
packages/ui/src/stores/session-state.ts
Normal file
399
packages/ui/src/stores/session-state.ts
Normal file
@@ -0,0 +1,399 @@
|
||||
import { createSignal } from "solid-js"
|
||||
|
||||
import type { Session, Agent, Provider } from "../types/session"
|
||||
import { deleteSession, loadMessages } from "./session-api"
|
||||
import { showToastNotification } from "../lib/notifications"
|
||||
import { messageStoreBus } from "./message-v2/bus"
|
||||
import { instances } from "./instances"
|
||||
import { showConfirmDialog } from "./alerts"
|
||||
import { getLogger } from "../lib/logger"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
export interface SessionInfo {
|
||||
cost: number
|
||||
contextWindow: number
|
||||
isSubscriptionModel: boolean
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
reasoningTokens: number
|
||||
actualUsageTokens: number
|
||||
modelOutputLimit: number
|
||||
contextAvailableTokens: number | null
|
||||
}
|
||||
|
||||
const [sessions, setSessions] = createSignal<Map<string, Map<string, Session>>>(new Map())
|
||||
const [activeSessionId, setActiveSessionId] = createSignal<Map<string, string>>(new Map())
|
||||
const [activeParentSessionId, setActiveParentSessionId] = createSignal<Map<string, string>>(new Map())
|
||||
const [agents, setAgents] = createSignal<Map<string, Agent[]>>(new Map())
|
||||
const [providers, setProviders] = createSignal<Map<string, Provider[]>>(new Map())
|
||||
const [sessionDraftPrompts, setSessionDraftPrompts] = createSignal<Map<string, string>>(new Map())
|
||||
|
||||
const [loading, setLoading] = createSignal({
|
||||
fetchingSessions: new Map<string, boolean>(),
|
||||
creatingSession: new Map<string, boolean>(),
|
||||
deletingSession: new Map<string, Set<string>>(),
|
||||
loadingMessages: new Map<string, Set<string>>(),
|
||||
})
|
||||
|
||||
const [messagesLoaded, setMessagesLoaded] = createSignal<Map<string, Set<string>>>(new Map())
|
||||
const [sessionInfoByInstance, setSessionInfoByInstance] = createSignal<Map<string, Map<string, SessionInfo>>>(new Map())
|
||||
|
||||
function clearLoadedFlag(instanceId: string, sessionId: string) {
|
||||
if (!instanceId || !sessionId) return
|
||||
setMessagesLoaded((prev) => {
|
||||
const existing = prev.get(instanceId)
|
||||
if (!existing || !existing.has(sessionId)) {
|
||||
return prev
|
||||
}
|
||||
const next = new Map(prev)
|
||||
const updated = new Set(existing)
|
||||
updated.delete(sessionId)
|
||||
if (updated.size === 0) {
|
||||
next.delete(instanceId)
|
||||
} else {
|
||||
next.set(instanceId, updated)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
messageStoreBus.onSessionCleared((instanceId, sessionId) => {
|
||||
clearLoadedFlag(instanceId, sessionId)
|
||||
})
|
||||
|
||||
function getDraftKey(instanceId: string, sessionId: string): string {
|
||||
|
||||
return `${instanceId}:${sessionId}`
|
||||
}
|
||||
|
||||
function getSessionDraftPrompt(instanceId: string, sessionId: string): string {
|
||||
if (!instanceId || !sessionId) return ""
|
||||
const key = getDraftKey(instanceId, sessionId)
|
||||
return sessionDraftPrompts().get(key) ?? ""
|
||||
}
|
||||
|
||||
function setSessionDraftPrompt(instanceId: string, sessionId: string, value: string) {
|
||||
const key = getDraftKey(instanceId, sessionId)
|
||||
setSessionDraftPrompts((prev) => {
|
||||
const next = new Map(prev)
|
||||
if (!value) {
|
||||
next.delete(key)
|
||||
} else {
|
||||
next.set(key, value)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function clearSessionDraftPrompt(instanceId: string, sessionId: string) {
|
||||
const key = getDraftKey(instanceId, sessionId)
|
||||
setSessionDraftPrompts((prev) => {
|
||||
if (!prev.has(key)) return prev
|
||||
const next = new Map(prev)
|
||||
next.delete(key)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function clearInstanceDraftPrompts(instanceId: string) {
|
||||
if (!instanceId) return
|
||||
setSessionDraftPrompts((prev) => {
|
||||
let changed = false
|
||||
const next = new Map(prev)
|
||||
const prefix = `${instanceId}:`
|
||||
for (const key of Array.from(next.keys())) {
|
||||
if (key.startsWith(prefix)) {
|
||||
next.delete(key)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed ? next : prev
|
||||
})
|
||||
}
|
||||
|
||||
function pruneDraftPrompts(instanceId: string, validSessionIds: Set<string>) {
|
||||
setSessionDraftPrompts((prev) => {
|
||||
let changed = false
|
||||
const next = new Map(prev)
|
||||
const prefix = `${instanceId}:`
|
||||
for (const key of Array.from(next.keys())) {
|
||||
if (key.startsWith(prefix)) {
|
||||
const sessionId = key.slice(prefix.length)
|
||||
if (!validSessionIds.has(sessionId)) {
|
||||
next.delete(key)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return changed ? next : prev
|
||||
})
|
||||
}
|
||||
|
||||
function withSession(instanceId: string, sessionId: string, updater: (session: Session) => void) {
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
if (!instanceSessions) return
|
||||
|
||||
const session = instanceSessions.get(sessionId)
|
||||
if (!session) return
|
||||
|
||||
updater(session)
|
||||
|
||||
const updatedSession = {
|
||||
...session,
|
||||
}
|
||||
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
const newInstanceSessions = new Map(instanceSessions)
|
||||
newInstanceSessions.set(sessionId, updatedSession)
|
||||
next.set(instanceId, newInstanceSessions)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function setSessionCompactionState(instanceId: string, sessionId: string, isCompacting: boolean): void {
|
||||
withSession(instanceId, sessionId, (session) => {
|
||||
const time = { ...(session.time ?? {}) }
|
||||
time.compacting = isCompacting ? Date.now() : 0
|
||||
session.time = time
|
||||
})
|
||||
}
|
||||
|
||||
function setSessionPendingPermission(instanceId: string, sessionId: string, pending: boolean): void {
|
||||
withSession(instanceId, sessionId, (session) => {
|
||||
if (session.pendingPermission === pending) return
|
||||
session.pendingPermission = pending
|
||||
})
|
||||
}
|
||||
|
||||
function setActiveSession(instanceId: string, sessionId: string): void {
|
||||
setActiveSessionId((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, sessionId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function setActiveParentSession(instanceId: string, parentSessionId: string): void {
|
||||
setActiveParentSessionId((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, parentSessionId)
|
||||
return next
|
||||
})
|
||||
|
||||
setActiveSession(instanceId, parentSessionId)
|
||||
}
|
||||
|
||||
function clearActiveParentSession(instanceId: string): void {
|
||||
setActiveParentSessionId((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(instanceId)
|
||||
return next
|
||||
})
|
||||
|
||||
setActiveSessionId((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(instanceId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function getActiveParentSession(instanceId: string): Session | null {
|
||||
const parentId = activeParentSessionId().get(instanceId)
|
||||
if (!parentId) return null
|
||||
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
return instanceSessions?.get(parentId) || null
|
||||
}
|
||||
|
||||
function getActiveSession(instanceId: string): Session | null {
|
||||
const sessionId = activeSessionId().get(instanceId)
|
||||
if (!sessionId) return null
|
||||
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
return instanceSessions?.get(sessionId) || null
|
||||
}
|
||||
|
||||
function getSessions(instanceId: string): Session[] {
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
return instanceSessions ? Array.from(instanceSessions.values()) : []
|
||||
}
|
||||
|
||||
function getParentSessions(instanceId: string): Session[] {
|
||||
const allSessions = getSessions(instanceId)
|
||||
return allSessions.filter((s) => s.parentId === null)
|
||||
}
|
||||
|
||||
function getChildSessions(instanceId: string, parentId: string): Session[] {
|
||||
const allSessions = getSessions(instanceId)
|
||||
return allSessions.filter((s) => s.parentId === parentId)
|
||||
}
|
||||
|
||||
function getSessionFamily(instanceId: string, parentId: string): Session[] {
|
||||
const parent = sessions().get(instanceId)?.get(parentId)
|
||||
if (!parent) return []
|
||||
|
||||
const children = getChildSessions(instanceId, parentId)
|
||||
return [parent, ...children]
|
||||
}
|
||||
|
||||
function isSessionBusy(instanceId: string, sessionId: string): boolean {
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
if (!instanceSessions) return false
|
||||
if (!instanceSessions.has(sessionId)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function isSessionMessagesLoading(instanceId: string, sessionId: string): boolean {
|
||||
return Boolean(loading().loadingMessages.get(instanceId)?.has(sessionId))
|
||||
}
|
||||
|
||||
function getSessionInfo(instanceId: string, sessionId: string): SessionInfo | undefined {
|
||||
return sessionInfoByInstance().get(instanceId)?.get(sessionId)
|
||||
}
|
||||
|
||||
async function isBlankSession(session: Session, instanceId: string, fetchIfNeeded = false): Promise<boolean> {
|
||||
const created = session.time?.created || 0
|
||||
const updated = session.time?.updated || 0
|
||||
const hasChildren = getChildSessions(instanceId, session.id).length > 0
|
||||
const isFreshSession = created === updated && !hasChildren
|
||||
|
||||
// Common short-circuit: fresh sessions without children
|
||||
if (!fetchIfNeeded) {
|
||||
return isFreshSession
|
||||
}
|
||||
|
||||
// For a more thorough deep clean, we need to look at actual messages
|
||||
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance?.client) {
|
||||
return isFreshSession
|
||||
}
|
||||
let messages: any[] = []
|
||||
try {
|
||||
const response = await instance.client.session.messages({ path: { id: session.id } })
|
||||
messages = response.data || []
|
||||
} catch (error) {
|
||||
log.error(`Failed to fetch messages for session ${session.id}`, error)
|
||||
return isFreshSession
|
||||
}
|
||||
|
||||
// Specific logic by session type
|
||||
if (session.parentId === null) {
|
||||
// Parent: blank if no messages and no children (fresh !== blank sometimes!)
|
||||
const hasChildren = getChildSessions(instanceId, session.id).length > 0
|
||||
return messages.length === 0 && !hasChildren
|
||||
} else if (session.title?.includes("subagent)")) {
|
||||
// Subagent: "blank" (really: finished doing its job) if actually blank...
|
||||
// ... OR no streaming, no pending perms, no tool parts
|
||||
if (messages.length === 0) return true
|
||||
|
||||
const hasStreaming = messages.some((msg) => {
|
||||
const info = msg.info.status || msg.status
|
||||
return info === "streaming" || info === "sending"
|
||||
})
|
||||
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
const lastParts = lastMessage?.parts || []
|
||||
const hasToolPart = lastParts.some((part: any) =>
|
||||
part.type === "tool" || part.data?.type === "tool"
|
||||
)
|
||||
|
||||
return !hasStreaming && !session.pendingPermission && !hasToolPart
|
||||
} else {
|
||||
// Fork: blank if somehow has no messages or at revert point
|
||||
if (messages.length === 0) return true
|
||||
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
const lastInfo = lastMessage?.info || lastMessage
|
||||
return lastInfo?.id === session.revert?.messageID
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function cleanupBlankSessions(instanceId: string, excludeSessionId?: string, fetchIfNeeded = false): Promise<void> {
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
if (!instanceSessions) return
|
||||
|
||||
if (fetchIfNeeded) {
|
||||
const confirmed = await showConfirmDialog(
|
||||
"This cleanup may be slow, and may delete sessions you didn't intend to delete. Are you sure?",
|
||||
{
|
||||
title: "Deep Clean Sessions",
|
||||
detail: "Deep Clean Sessions will delete all sessions that have no messages, remove any finished sub-agent sessions, and clear out any unused forks of a session.",
|
||||
confirmLabel: "Continue",
|
||||
cancelLabel: "Cancel"
|
||||
}
|
||||
)
|
||||
if (!confirmed) return
|
||||
}
|
||||
|
||||
const cleanupPromises = Array.from(instanceSessions)
|
||||
.filter(([sessionId]) => sessionId !== excludeSessionId)
|
||||
.map(async ([sessionId, session]) => {
|
||||
const isBlank = await isBlankSession(session, instanceId, fetchIfNeeded)
|
||||
if (!isBlank) return false
|
||||
|
||||
await deleteSession(instanceId, sessionId).catch((error: Error) => {
|
||||
log.error(`Failed to delete blank session ${sessionId}`, error)
|
||||
})
|
||||
return true
|
||||
})
|
||||
|
||||
if (cleanupPromises.length > 0) {
|
||||
log.info(`Cleaning up ${cleanupPromises.length} blank sessions`)
|
||||
const deletionResults = await Promise.all(cleanupPromises)
|
||||
const deletedCount = deletionResults.filter(Boolean).length
|
||||
|
||||
if (deletedCount > 0) {
|
||||
showToastNotification({
|
||||
message: `Cleaned up ${deletedCount} blank session${deletedCount === 1 ? "" : "s"}`,
|
||||
variant: "info"
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
sessions,
|
||||
setSessions,
|
||||
activeSessionId,
|
||||
setActiveSessionId,
|
||||
activeParentSessionId,
|
||||
setActiveParentSessionId,
|
||||
agents,
|
||||
setAgents,
|
||||
providers,
|
||||
setProviders,
|
||||
loading,
|
||||
setLoading,
|
||||
messagesLoaded,
|
||||
setMessagesLoaded,
|
||||
sessionInfoByInstance,
|
||||
setSessionInfoByInstance,
|
||||
getSessionDraftPrompt,
|
||||
setSessionDraftPrompt,
|
||||
clearSessionDraftPrompt,
|
||||
clearInstanceDraftPrompts,
|
||||
pruneDraftPrompts,
|
||||
withSession,
|
||||
setSessionCompactionState,
|
||||
setSessionPendingPermission,
|
||||
setActiveSession,
|
||||
|
||||
setActiveParentSession,
|
||||
|
||||
clearActiveParentSession,
|
||||
getActiveSession,
|
||||
getActiveParentSession,
|
||||
getSessions,
|
||||
getParentSessions,
|
||||
getChildSessions,
|
||||
getSessionFamily,
|
||||
isSessionBusy,
|
||||
isSessionMessagesLoading,
|
||||
getSessionInfo,
|
||||
isBlankSession,
|
||||
cleanupBlankSessions,
|
||||
}
|
||||
168
packages/ui/src/stores/session-status.ts
Normal file
168
packages/ui/src/stores/session-status.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import type { Session, SessionStatus } from "../types/session"
|
||||
import type { MessageInfo } from "../types/message"
|
||||
import type { MessageRecord } from "./message-v2/types"
|
||||
import { sessions } from "./sessions"
|
||||
import { isSessionCompactionActive } from "./session-compaction"
|
||||
import { messageStoreBus } from "./message-v2/bus"
|
||||
|
||||
function getSession(instanceId: string, sessionId: string): Session | null {
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
return instanceSessions?.get(sessionId) ?? null
|
||||
}
|
||||
|
||||
function isSessionCompacting(session: Session): boolean {
|
||||
const time = (session.time as (Session["time"] & { compacting?: number }) | undefined)
|
||||
const compactingFlag = time?.compacting
|
||||
if (typeof compactingFlag === "number") {
|
||||
return compactingFlag > 0
|
||||
}
|
||||
return Boolean(compactingFlag)
|
||||
}
|
||||
|
||||
function getLatestInfoFromStore(instanceId: string, sessionId: string, role?: MessageInfo["role"]): MessageInfo | undefined {
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
const messageIds = store.getSessionMessageIds(sessionId)
|
||||
let latest: MessageInfo | undefined
|
||||
let latestTimestamp = Number.NEGATIVE_INFINITY
|
||||
for (const id of messageIds) {
|
||||
const info = store.getMessageInfo(id)
|
||||
if (!info) continue
|
||||
if (role && info.role !== role) continue
|
||||
const timestamp = info.time?.created ?? 0
|
||||
if (timestamp >= latestTimestamp) {
|
||||
latest = info
|
||||
latestTimestamp = timestamp
|
||||
}
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
function getLastMessageFromStore(instanceId: string, sessionId: string): MessageRecord | undefined {
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
const messageIds = store.getSessionMessageIds(sessionId)
|
||||
let latest: MessageRecord | undefined
|
||||
let latestTimestamp = Number.NEGATIVE_INFINITY
|
||||
for (const id of messageIds) {
|
||||
const record = store.getMessage(id)
|
||||
if (!record) continue
|
||||
const info = store.getMessageInfo(id)
|
||||
const timestamp = info?.time?.created ?? record.createdAt ?? Number.NEGATIVE_INFINITY
|
||||
if (timestamp >= latestTimestamp) {
|
||||
latest = record
|
||||
latestTimestamp = timestamp
|
||||
}
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
|
||||
function getInfoCreatedTimestamp(info?: MessageInfo): number {
|
||||
if (!info) {
|
||||
return Number.NEGATIVE_INFINITY
|
||||
}
|
||||
const created = info.time?.created
|
||||
if (typeof created === "number" && Number.isFinite(created)) {
|
||||
return created
|
||||
}
|
||||
return Number.NEGATIVE_INFINITY
|
||||
}
|
||||
|
||||
function getAssistantCompletionTimestamp(info?: MessageInfo): number {
|
||||
if (!info) {
|
||||
return Number.NEGATIVE_INFINITY
|
||||
}
|
||||
const completed = (info.time as { completed?: number } | undefined)?.completed
|
||||
if (typeof completed === "number" && Number.isFinite(completed)) {
|
||||
return completed
|
||||
}
|
||||
return Number.NEGATIVE_INFINITY
|
||||
}
|
||||
|
||||
function isAssistantInfoPending(info?: MessageInfo): boolean {
|
||||
if (!info) {
|
||||
return false
|
||||
}
|
||||
const completed = (info.time as { completed?: number } | undefined)?.completed
|
||||
if (completed === undefined || completed === null) {
|
||||
return true
|
||||
}
|
||||
const created = getInfoCreatedTimestamp(info)
|
||||
return completed < created
|
||||
}
|
||||
|
||||
function isAssistantStillGeneratingRecord(record: MessageRecord, info?: MessageInfo): boolean {
|
||||
if (record.role !== "assistant") {
|
||||
return false
|
||||
}
|
||||
|
||||
if (record.status === "error") {
|
||||
return false
|
||||
}
|
||||
|
||||
if (record.status === "streaming" || record.status === "sending") {
|
||||
return true
|
||||
}
|
||||
|
||||
const completedAt = (info?.time as { completed?: number } | undefined)?.completed
|
||||
if (completedAt !== undefined && completedAt !== null) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !(record.status === "complete" || record.status === "sent")
|
||||
}
|
||||
|
||||
|
||||
export function getSessionStatus(instanceId: string, sessionId: string): SessionStatus {
|
||||
const session = getSession(instanceId, sessionId)
|
||||
if (!session) {
|
||||
return "idle"
|
||||
}
|
||||
|
||||
const store = messageStoreBus.getOrCreate(instanceId)
|
||||
|
||||
if (isSessionCompactionActive(instanceId, sessionId) || isSessionCompacting(session)) {
|
||||
return "compacting"
|
||||
}
|
||||
|
||||
const latestUserInfo = getLatestInfoFromStore(instanceId, sessionId, "user")
|
||||
const latestAssistantInfo = getLatestInfoFromStore(instanceId, sessionId, "assistant")
|
||||
|
||||
const lastRecord = getLastMessageFromStore(instanceId, sessionId)
|
||||
|
||||
if (!lastRecord) {
|
||||
const latestInfo = latestUserInfo ?? latestAssistantInfo
|
||||
if (!latestInfo) {
|
||||
return "idle"
|
||||
}
|
||||
if (latestInfo.role === "user") {
|
||||
return "working"
|
||||
}
|
||||
const infoCompleted = latestInfo.time?.completed
|
||||
return infoCompleted ? "idle" : "working"
|
||||
}
|
||||
|
||||
if (lastRecord.role === "user") {
|
||||
return "working"
|
||||
}
|
||||
const infoForRecord = store.getMessageInfo(lastRecord.id) ?? latestAssistantInfo
|
||||
if (infoForRecord && isAssistantStillGeneratingRecord(lastRecord, infoForRecord)) {
|
||||
return "working"
|
||||
}
|
||||
|
||||
if (isAssistantInfoPending(latestAssistantInfo)) {
|
||||
return "working"
|
||||
}
|
||||
|
||||
const userTimestamp = getInfoCreatedTimestamp(latestUserInfo)
|
||||
const assistantCompletedAt = getAssistantCompletionTimestamp(latestAssistantInfo)
|
||||
if (userTimestamp > assistantCompletedAt) {
|
||||
return "working"
|
||||
}
|
||||
|
||||
return "idle"
|
||||
}
|
||||
|
||||
export function isSessionBusy(instanceId: string, sessionId: string): boolean {
|
||||
const status = getSessionStatus(instanceId, sessionId)
|
||||
return status === "working" || status === "compacting"
|
||||
}
|
||||
115
packages/ui/src/stores/sessions.ts
Normal file
115
packages/ui/src/stores/sessions.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { SessionInfo } from "./session-state"
|
||||
|
||||
import { sseManager } from "../lib/sse-manager"
|
||||
|
||||
import {
|
||||
activeParentSessionId,
|
||||
activeSessionId,
|
||||
agents,
|
||||
clearActiveParentSession,
|
||||
clearInstanceDraftPrompts,
|
||||
clearSessionDraftPrompt,
|
||||
getActiveParentSession,
|
||||
getActiveSession,
|
||||
getChildSessions,
|
||||
getParentSessions,
|
||||
getSessionDraftPrompt,
|
||||
getSessionFamily,
|
||||
getSessionInfo,
|
||||
getSessions,
|
||||
isSessionBusy,
|
||||
isSessionMessagesLoading,
|
||||
loading,
|
||||
providers,
|
||||
sessionInfoByInstance,
|
||||
sessions,
|
||||
setActiveParentSession,
|
||||
setActiveSession,
|
||||
setSessionDraftPrompt,
|
||||
} from "./session-state"
|
||||
|
||||
import { getDefaultModel } from "./session-models"
|
||||
import {
|
||||
createSession,
|
||||
deleteSession,
|
||||
fetchAgents,
|
||||
fetchProviders,
|
||||
fetchSessions,
|
||||
forkSession,
|
||||
loadMessages,
|
||||
} from "./session-api"
|
||||
import {
|
||||
abortSession,
|
||||
executeCustomCommand,
|
||||
renameSession,
|
||||
runShellCommand,
|
||||
sendMessage,
|
||||
updateSessionAgent,
|
||||
updateSessionModel,
|
||||
} from "./session-actions"
|
||||
import {
|
||||
handleMessagePartRemoved,
|
||||
handleMessageRemoved,
|
||||
handleMessageUpdate,
|
||||
handlePermissionReplied,
|
||||
handlePermissionUpdated,
|
||||
handleSessionCompacted,
|
||||
handleSessionError,
|
||||
handleSessionIdle,
|
||||
handleSessionUpdate,
|
||||
handleTuiToast,
|
||||
} from "./session-events"
|
||||
|
||||
sseManager.onMessageUpdate = handleMessageUpdate
|
||||
sseManager.onMessagePartUpdated = handleMessageUpdate
|
||||
sseManager.onMessageRemoved = handleMessageRemoved
|
||||
sseManager.onMessagePartRemoved = handleMessagePartRemoved
|
||||
sseManager.onSessionUpdate = handleSessionUpdate
|
||||
sseManager.onSessionCompacted = handleSessionCompacted
|
||||
sseManager.onSessionError = handleSessionError
|
||||
sseManager.onSessionIdle = handleSessionIdle
|
||||
sseManager.onTuiToast = handleTuiToast
|
||||
sseManager.onPermissionUpdated = handlePermissionUpdated
|
||||
sseManager.onPermissionReplied = handlePermissionReplied
|
||||
|
||||
export {
|
||||
abortSession,
|
||||
activeParentSessionId,
|
||||
activeSessionId,
|
||||
agents,
|
||||
clearActiveParentSession,
|
||||
clearInstanceDraftPrompts,
|
||||
clearSessionDraftPrompt,
|
||||
createSession,
|
||||
deleteSession,
|
||||
executeCustomCommand,
|
||||
renameSession,
|
||||
runShellCommand,
|
||||
fetchAgents,
|
||||
fetchProviders,
|
||||
fetchSessions,
|
||||
forkSession,
|
||||
getActiveParentSession,
|
||||
getActiveSession,
|
||||
getChildSessions,
|
||||
getDefaultModel,
|
||||
getParentSessions,
|
||||
getSessionDraftPrompt,
|
||||
getSessionFamily,
|
||||
getSessionInfo,
|
||||
getSessions,
|
||||
isSessionBusy,
|
||||
isSessionMessagesLoading,
|
||||
loadMessages,
|
||||
loading,
|
||||
providers,
|
||||
sendMessage,
|
||||
sessionInfoByInstance,
|
||||
sessions,
|
||||
setActiveParentSession,
|
||||
setActiveSession,
|
||||
setSessionDraftPrompt,
|
||||
updateSessionAgent,
|
||||
updateSessionModel,
|
||||
}
|
||||
export type { SessionInfo }
|
||||
36
packages/ui/src/stores/tool-call-state.ts
Normal file
36
packages/ui/src/stores/tool-call-state.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { createSignal } from "solid-js"
|
||||
|
||||
const [expandedItems, setExpandedItems] = createSignal<Set<string>>(new Set())
|
||||
|
||||
export function isItemExpanded(itemId: string): boolean {
|
||||
return expandedItems().has(itemId)
|
||||
}
|
||||
|
||||
export function toggleItemExpanded(itemId: string): void {
|
||||
setExpandedItems((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(itemId)) {
|
||||
next.delete(itemId)
|
||||
} else {
|
||||
next.add(itemId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
export function setItemExpanded(itemId: string, expanded: boolean): void {
|
||||
setExpandedItems((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (expanded) {
|
||||
next.add(itemId)
|
||||
} else {
|
||||
next.delete(itemId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// Backward compatibility aliases
|
||||
export const isToolCallExpanded = isItemExpanded
|
||||
export const toggleToolCallExpanded = toggleItemExpanded
|
||||
export const setToolCallExpanded = setItemExpanded
|
||||
38
packages/ui/src/stores/ui.ts
Normal file
38
packages/ui/src/stores/ui.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { createSignal } from "solid-js"
|
||||
|
||||
const [hasInstances, setHasInstances] = createSignal(false)
|
||||
const [selectedFolder, setSelectedFolder] = createSignal<string | null>(null)
|
||||
const [isSelectingFolder, setIsSelectingFolder] = createSignal(false)
|
||||
const [showFolderSelection, setShowFolderSelection] = createSignal(false)
|
||||
|
||||
const [instanceTabOrder, setInstanceTabOrder] = createSignal<string[]>([])
|
||||
const [sessionTabOrder, setSessionTabOrder] = createSignal<Map<string, string[]>>(new Map())
|
||||
|
||||
function reorderInstanceTabs(newOrder: string[]) {
|
||||
setInstanceTabOrder(newOrder)
|
||||
}
|
||||
|
||||
function reorderSessionTabs(instanceId: string, newOrder: string[]) {
|
||||
setSessionTabOrder((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, newOrder)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
hasInstances,
|
||||
setHasInstances,
|
||||
selectedFolder,
|
||||
setSelectedFolder,
|
||||
isSelectingFolder,
|
||||
setIsSelectingFolder,
|
||||
showFolderSelection,
|
||||
setShowFolderSelection,
|
||||
instanceTabOrder,
|
||||
setInstanceTabOrder,
|
||||
sessionTabOrder,
|
||||
setSessionTabOrder,
|
||||
reorderInstanceTabs,
|
||||
reorderSessionTabs,
|
||||
}
|
||||
Reference in New Issue
Block a user