Files
CodeNomad/src/stores/sessions.ts
2025-10-30 16:31:13 +00:00

1435 lines
42 KiB
TypeScript

import { createSignal } from "solid-js"
import type { Session, Agent, Provider } from "../types/session"
import type { Message, MessageDisplayParts } from "../types/message"
import { partHasRenderableText } from "../types/message"
import { instances } from "./instances"
import { sseManager } from "../lib/sse-manager"
import { preferences } from "./preferences"
interface SessionInfo {
tokens: number
cost: number
contextWindow: number
isSubscriptionModel: boolean
}
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 [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())
// Message index cache structure: instanceId -> sessionId -> { messageIndex, partIndex }
const sessionIndexes = new Map<
string,
Map<string, { messageIndex: Map<string, number>; partIndex: Map<string, Map<string, number>> }>
>()
function getSessionIndex(instanceId: string, sessionId: string) {
let instanceMap = sessionIndexes.get(instanceId)
if (!instanceMap) {
instanceMap = new Map()
sessionIndexes.set(instanceId, instanceMap)
}
let sessionMap = instanceMap.get(sessionId)
if (!sessionMap) {
sessionMap = { messageIndex: new Map(), partIndex: new Map() }
instanceMap.set(sessionId, sessionMap)
}
return sessionMap
}
function rebuildSessionIndex(instanceId: string, sessionId: string, messages: Message[]) {
const index = getSessionIndex(instanceId, sessionId)
index.messageIndex.clear()
index.partIndex.clear()
messages.forEach((message, messageIdx) => {
index.messageIndex.set(message.id, messageIdx)
const partMap = new Map<string, number>()
message.parts.forEach((part, partIdx) => {
if (part.id && typeof part.id === "string") {
partMap.set(part.id, partIdx)
}
})
index.partIndex.set(message.id, partMap)
})
}
function clearSessionIndex(instanceId: string, sessionId: string) {
const instanceMap = sessionIndexes.get(instanceId)
if (instanceMap) {
instanceMap.delete(sessionId)
if (instanceMap.size === 0) {
sessionIndexes.delete(instanceId)
}
}
}
function removeSessionIndexes(instanceId: string) {
sessionIndexes.delete(instanceId)
}
export function computeDisplayParts(message: Message, showThinking: boolean): MessageDisplayParts {
const text: any[] = []
const tool: any[] = []
const reasoning: any[] = []
for (const part of message.parts) {
if (part.type === "text" && !part.synthetic && partHasRenderableText(part)) {
text.push(part)
} else if (part.type === "tool") {
tool.push(part)
} else if (part.type === "reasoning" && showThinking && partHasRenderableText(part)) {
reasoning.push(part)
}
}
const combined = reasoning.length > 0 ? [...text, ...reasoning] : [...text]
const version = typeof message.version === "number" ? message.version : 0
return { text, tool, reasoning, combined, showThinking, version }
}
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)
// Create new session object with fresh references to trigger reactivity
const updatedSession = {
...session,
messages: [...session.messages],
messagesInfo: new Map(session.messagesInfo),
}
setSessions((prev) => {
const next = new Map(prev)
const newInstanceSessions = new Map(instanceSessions)
newInstanceSessions.set(sessionId, updatedSession)
next.set(instanceId, newInstanceSessions)
return next
})
}
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 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 {
const response = await instance.client.session.list()
const sessionMap = new Map<string, Session>()
if (!response.data || !Array.isArray(response.data)) {
return
}
for (const apiSession of response.data) {
sessionMap.set(apiSession.id, {
id: apiSession.id,
instanceId,
title: apiSession.title || "Untitled",
parentId: apiSession.parentID || null,
agent: "",
model: { providerId: "", modelId: "" },
time: {
created: apiSession.time.created,
updated: apiSession.time.updated,
},
revert: apiSession.revert
? {
messageID: apiSession.revert.messageID,
partID: apiSession.revert.partID,
snapshot: apiSession.revert.snapshot,
diff: apiSession.revert.diff,
}
: undefined,
messages: [],
messagesInfo: new Map(),
})
}
setSessions((prev) => {
const next = new Map(prev)
next.set(instanceId, sessionMap)
return next
})
} catch (error) {
console.error("Failed to fetch sessions:", error)
throw error
} finally {
setLoading((prev) => {
const next = { ...prev }
next.fetchingSessions.set(instanceId, false)
return next
})
}
}
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?.model?.providerId && agent.model.modelId) {
return {
providerId: agent.model.providerId,
modelId: agent.model.modelId,
}
}
}
const anthropicProvider = instanceProviders.find((p) => p.id === "anthropic")
if (anthropicProvider) {
const defaultModelId = anthropicProvider.defaultModelId || anthropicProvider.models[0]?.id
if (defaultModelId) {
return {
providerId: "anthropic",
modelId: defaultModelId,
}
}
}
if (instanceProviders.length > 0) {
const firstProvider = instanceProviders[0]
const defaultModelId = firstProvider.defaultModelId || firstProvider.models[0]?.id
if (defaultModelId) {
return {
providerId: firstProvider.id,
modelId: defaultModelId,
}
}
}
return { providerId: "", modelId: "" }
}
function getSessionInfo(instanceId: string, sessionId: string): SessionInfo | undefined {
return sessionInfoByInstance().get(instanceId)?.get(sessionId)
}
function updateSessionInfo(instanceId: string, sessionId: string) {
const instanceSessions = sessions().get(instanceId)
if (!instanceSessions) return
const session = instanceSessions.get(sessionId)
if (!session) return
let tokens = 0
let cost = 0
let contextWindow = 0
let isSubscriptionModel = false
let modelID = ""
let providerID = ""
// Calculate from last assistant message in this session only
if (session.messagesInfo.size > 0) {
// Go backwards through messagesInfo to find the last relevant assistant message (like TUI)
const messageArray = Array.from(session.messagesInfo.values()).reverse()
for (const info of messageArray) {
if (info.role === "assistant" && info.tokens) {
const usage = info.tokens
if (usage.output > 0) {
if (info.summary) {
// If summary message, only count output tokens and stop (like TUI)
tokens = usage.output || 0
cost = info.cost || 0
} else {
// Regular message - count all token types (like TUI)
tokens =
(usage.input || 0) +
(usage.cache?.read || 0) +
(usage.cache?.write || 0) +
(usage.output || 0) +
(usage.reasoning || 0)
cost = info.cost || 0
}
// Get model info for context window and subscription check
modelID = info.modelID || ""
providerID = info.providerID || ""
isSubscriptionModel = cost === 0
break // Break after finding the last assistant message
}
}
}
}
// Get context window from providers
if (modelID && providerID) {
const instanceProviders = providers().get(instanceId) || []
const provider = instanceProviders.find((p) => p.id === providerID)
if (provider) {
const model = provider.models.find((m) => m.id === modelID)
if (model?.limit?.context) {
contextWindow = model.limit.context
}
// Check if it's a subscription model (cost is 0 for both input and output)
if (model?.cost?.input === 0 && model?.cost?.output === 0) {
isSubscriptionModel = true
}
}
}
setSessionInfoByInstance((prev) => {
const next = new Map(prev)
const instanceInfo = new Map(prev.get(instanceId))
instanceInfo.set(sessionId, {
tokens,
cost,
contextWindow,
isSubscriptionModel,
})
next.set(instanceId, instanceInfo)
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)
setLoading((prev) => {
const next = { ...prev }
next.creatingSession.set(instanceId, true)
return next
})
try {
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,
time: {
created: response.data.time.created,
updated: response.data.time.updated,
},
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,
messages: [],
messagesInfo: new Map(),
}
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
})
// Initialize session info with zeros for the new session
setSessionInfoByInstance((prev) => {
const next = new Map(prev)
const instanceInfo = new Map(prev.get(instanceId))
instanceInfo.set(session.id, {
tokens: 0,
cost: 0,
contextWindow: 0,
isSubscriptionModel: false,
})
next.set(instanceId, instanceInfo)
return next
})
// Initialize cache entry for new session (empty messages)
getSessionIndex(instanceId, session.id)
return session
} catch (error) {
console.error("Failed to create session:", error)
throw error
} finally {
setLoading((prev) => {
const next = { ...prev }
next.creatingSession.set(instanceId, false)
return next
})
}
}
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 {
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
})
// Remove session info entry
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
})
// Clear cache entry for deleted session
clearSessionIndex(instanceId, sessionId)
if (activeSessionId().get(instanceId) === sessionId) {
setActiveSessionId((prev) => {
const next = new Map(prev)
next.delete(instanceId)
return next
})
}
} catch (error) {
console.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 {
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) {
console.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 {
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) {
console.error("Failed to fetch providers:", error)
}
}
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
}
async function loadMessages(instanceId: string, sessionId: string, force = false): Promise<void> {
// If force reload, clear the loaded cache
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 {
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)
// Clear render cache for all parts when loading messages
const parts = (apiMessage.parts || []).map((part: any) => {
if (part.type === "text") {
return { ...part, renderCache: undefined }
}
return 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,
}
message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks)
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 instanceSessions = next.get(instanceId)
if (instanceSessions) {
const session = instanceSessions.get(sessionId)
if (session) {
const updatedSession = {
...session,
messages,
messagesInfo,
agent: agentName || session.agent,
model: providerID && modelID ? { providerId: providerID, modelId: modelID } : session.model,
}
const updatedInstanceSessions = new Map(instanceSessions)
updatedInstanceSessions.set(sessionId, updatedSession)
next.set(instanceId, updatedInstanceSessions)
}
}
return next
})
// Rebuild index after loading messages
rebuildSessionIndex(instanceId, sessionId, messages)
setMessagesLoaded((prev) => {
const next = new Map(prev)
const loadedSet = next.get(instanceId) || new Set()
loadedSet.add(sessionId)
next.set(instanceId, loadedSet)
return next
})
} catch (error) {
console.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)
}
function handleMessageUpdate(instanceId: string, event: any): void {
const instanceSessions = sessions().get(instanceId)
if (!instanceSessions) return
if (event.type === "message.part.updated") {
const part = event.properties?.part
if (!part) return
const session = instanceSessions.get(part.sessionID)
if (!session) return
const index = getSessionIndex(instanceId, part.sessionID)
let messageIndex = index.messageIndex.get(part.messageID)
let replacedTemp = false
if (messageIndex === undefined) {
// Search for queued message with status 'sending' and no server id
for (let i = 0; i < session.messages.length; i++) {
const msg = session.messages[i]
if (msg.sessionId === part.sessionID && msg.status === "sending") {
messageIndex = i
replacedTemp = true
break
}
}
}
if (messageIndex === undefined) {
// Create new message
const newMessage: Message = {
id: part.messageID,
sessionId: part.sessionID,
type: "assistant" as const,
parts: [part],
timestamp: Date.now(),
status: "streaming" as const,
version: 0,
}
newMessage.displayParts = computeDisplayParts(newMessage, preferences().showThinkingBlocks)
let insertIndex = session.messages.length
for (let i = session.messages.length - 1; i >= 0; i--) {
if (session.messages[i].id < newMessage.id) {
insertIndex = i + 1
break
}
}
session.messages.splice(insertIndex, 0, newMessage)
rebuildSessionIndex(instanceId, part.sessionID, session.messages)
} else {
// Update existing message
const message = session.messages[messageIndex]
if (typeof message.version !== "number") {
message.version = 0
}
// Strip synthetic parts when real data arrives
let filteredSynthetics = false
if (message.parts.some((partItem: any) => partItem.synthetic === true)) {
message.parts = message.parts.filter((partItem: any) => partItem.synthetic !== true)
filteredSynthetics = true
// Clear render cache from remaining parts when synthetic parts are removed
message.parts.forEach((partItem: any) => {
if (partItem.type === "text") {
partItem.renderCache = undefined
}
})
}
let baseParts: any[]
if (replacedTemp) {
baseParts = message.parts.filter((partItem: any) => partItem.type !== "text")
message.parts = baseParts
// Clear render cache when replacing temp content
baseParts.forEach((partItem: any) => {
if (partItem.type === "text") {
partItem.renderCache = undefined
}
})
} else {
baseParts = message.parts
}
// Update part in place
let partMap = index.partIndex.get(message.id)
if (!partMap) {
partMap = new Map()
index.partIndex.set(message.id, partMap)
}
let shouldIncrementVersion = filteredSynthetics || replacedTemp
const partIndex = partMap.get(part.id)
if (partIndex === undefined) {
baseParts.push(part)
if (part.id && typeof part.id === "string") {
partMap.set(part.id, baseParts.length - 1)
}
shouldIncrementVersion = true
// Clear render cache for new text parts
if (part.type === "text") {
part.renderCache = undefined
}
} else {
const previousPart = baseParts[partIndex]
const textUnchanged =
!filteredSynthetics &&
!replacedTemp &&
part.type === "text" &&
previousPart?.type === "text" &&
previousPart.text === part.text
if (textUnchanged) {
return
}
baseParts[partIndex] = part
if (part.type !== "text" || !previousPart || previousPart.text !== part.text) {
shouldIncrementVersion = true
// Clear render cache when text changes
if (part.type === "text") {
part.renderCache = undefined
}
}
}
const oldId = message.id
message.id = replacedTemp ? part.messageID : message.id
message.status = message.status === "sending" ? "streaming" : message.status
message.parts = baseParts
if (shouldIncrementVersion) {
message.version += 1
message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks)
} else if (
!message.displayParts ||
message.displayParts.showThinking !== preferences().showThinkingBlocks ||
message.displayParts.version !== message.version
) {
message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks)
}
// Update message index if ID changed
if (oldId !== message.id) {
index.messageIndex.delete(oldId)
index.messageIndex.set(message.id, messageIndex)
const existingPartMap = index.partIndex.get(oldId)
if (existingPartMap) {
index.partIndex.delete(oldId)
index.partIndex.set(message.id, existingPartMap)
}
}
// Refresh part indexes after filtering synthetic parts or replacing optimistic content
if (filteredSynthetics || replacedTemp) {
const partMap = new Map<string, number>()
message.parts.forEach((partItem, idx) => {
if (partItem.id && typeof partItem.id === "string") {
partMap.set(partItem.id, idx)
}
})
index.partIndex.set(message.id, partMap)
}
}
withSession(instanceId, part.sessionID, (session) => {
// Session already mutated in place
})
updateSessionInfo(instanceId, part.sessionID)
} else if (event.type === "message.updated") {
const info = event.properties?.info
if (!info) return
const session = instanceSessions.get(info.sessionID)
if (!session) return
const index = getSessionIndex(instanceId, info.sessionID)
let messageIndex = index.messageIndex.get(info.id)
if (messageIndex === undefined) {
// Look for queued message to replace
let tempMessageIndex = -1
for (let i = 0; i < session.messages.length; i++) {
const msg = session.messages[i]
if (
msg.sessionId === info.sessionID &&
msg.type === (info.role === "user" ? "user" : "assistant") &&
msg.status === "sending"
) {
tempMessageIndex = i
break
}
}
if (tempMessageIndex === -1) {
for (let i = 0; i < session.messages.length; i++) {
const msg = session.messages[i]
if (msg.sessionId === info.sessionID && msg.status === "sending") {
tempMessageIndex = i
break
}
}
}
if (tempMessageIndex > -1) {
// Replace queued message
const message = session.messages[tempMessageIndex]
if (typeof message.version !== "number") {
message.version = 0
}
const oldId = message.id
message.id = info.id
message.type = (info.role === "user" ? "user" : "assistant") as "user" | "assistant"
message.timestamp = info.time?.created || Date.now()
message.status = "complete" as const
message.version += 1
message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks)
if (oldId !== message.id) {
index.messageIndex.delete(oldId)
index.messageIndex.set(message.id, tempMessageIndex)
const existingPartMap = index.partIndex.get(oldId)
if (existingPartMap) {
index.partIndex.delete(oldId)
index.partIndex.set(message.id, existingPartMap)
}
}
} else {
// Append new message
const newMessage: Message = {
id: info.id,
sessionId: info.sessionID,
type: (info.role === "user" ? "user" : "assistant") as "user" | "assistant",
parts: [],
timestamp: info.time?.created || Date.now(),
status: "complete" as const,
version: 0,
}
newMessage.displayParts = computeDisplayParts(newMessage, preferences().showThinkingBlocks)
let insertIndex = session.messages.length
for (let i = session.messages.length - 1; i >= 0; i--) {
if (session.messages[i].id < newMessage.id) {
insertIndex = i + 1
break
}
}
session.messages.splice(insertIndex, 0, newMessage)
rebuildSessionIndex(instanceId, info.sessionID, session.messages)
}
} else {
// Update existing message status
const message = session.messages[messageIndex]
if (typeof message.version !== "number") {
message.version = 0
}
message.status = "complete" as const
message.version += 1
message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks)
session.messagesInfo.set(info.id, info)
withSession(instanceId, info.sessionID, (session) => {
// Session already mutated in place
})
updateSessionInfo(instanceId, info.sessionID)
}
}
}
function handleSessionUpdate(instanceId: string, event: any): void {
const info = event.properties?.info
if (!info) return
const instanceSessions = sessions().get(instanceId)
if (!instanceSessions) return
const existingSession = instanceSessions.get(info.id)
if (!existingSession) {
const newSession: Session = {
id: info.id,
instanceId,
title: info.title || "Untitled",
parentId: info.parentID || null,
agent: info.agent || "",
model: {
providerId: info.model?.providerID || "",
modelId: info.model?.modelID || "",
},
time: {
created: info.time?.created || Date.now(),
updated: info.time?.updated || Date.now(),
},
messages: [],
messagesInfo: new Map(),
}
setSessions((prev) => {
const next = new Map(prev)
const instanceSessions = new Map(prev.get(instanceId))
instanceSessions.set(newSession.id, newSession)
next.set(instanceId, instanceSessions)
return next
})
console.log(`[SSE] New session created: ${info.id}`, newSession)
} else {
const updatedSession = {
...existingSession,
title: info.title || existingSession.title,
agent: info.agent || existingSession.agent,
model: info.model
? {
providerId: info.model.providerID || existingSession.model.providerId,
modelId: info.model.modelID || existingSession.model.modelId,
}
: existingSession.model,
time: {
...existingSession.time,
updated: info.time?.updated || Date.now(),
},
revert: info.revert
? {
messageID: info.revert.messageID,
partID: info.revert.partID,
snapshot: info.revert.snapshot,
diff: info.revert.diff,
}
: undefined,
}
setSessions((prev) => {
const next = new Map(prev)
const instanceSessions = new Map(prev.get(instanceId))
instanceSessions.set(existingSession.id, updatedSession)
next.set(instanceId, instanceSessions)
return next
})
}
}
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 optimisticParts: any[] = [
{
id: textPartId,
type: "text" as const,
text: prompt,
synthetic: true,
renderCache: undefined,
},
]
const optimisticMessage: Message = {
id: messageId,
sessionId,
type: "user",
parts: optimisticParts,
timestamp: Date.now(),
status: "sending",
version: 0,
}
optimisticMessage.displayParts = computeDisplayParts(optimisticMessage, preferences().showThinkingBlocks)
withSession(instanceId, sessionId, (session) => {
session.messages.push(optimisticMessage)
const index = getSessionIndex(instanceId, sessionId)
index.messageIndex.set(optimisticMessage.id, session.messages.length - 1)
})
const requestParts: any[] = [
{
id: textPartId,
type: "text" as const,
text: prompt,
},
]
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 partId = createId("part")
requestParts.push({
id: partId,
type: "text" as const,
text: source.value,
})
optimisticParts.push({
id: partId,
type: "text" as const,
text: source.value,
synthetic: true,
renderCache: undefined,
})
}
}
}
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,
},
}),
}
console.log("[sendMessage] Sending prompt:", {
sessionId,
requestBody,
})
try {
const response = await instance.client.session.prompt({
path: { id: sessionId },
body: requestBody,
})
console.log("[sendMessage] Response:", response)
if (response.error) {
console.error("[sendMessage] Server returned error:", response.error)
throw new Error(JSON.stringify(response.error) || "Failed to send message")
}
} catch (error) {
console.error("[sendMessage] Failed to send prompt:", error)
throw error
}
}
async function abortSession(instanceId: string, sessionId: string): Promise<void> {
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
}
console.log("[abortSession] Aborting session:", { instanceId, sessionId })
try {
await instance.client.session.abort({
path: { id: sessionId },
})
console.log("[abortSession] Session aborted successfully")
} catch (error) {
console.error("[abortSession] 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")
}
setSessions((prev) => {
const next = new Map(prev)
const instanceSessions = new Map(prev.get(instanceId))
const session = instanceSessions.get(sessionId)
if (session) {
instanceSessions.set(sessionId, { ...session, agent })
next.set(instanceId, instanceSessions)
}
return next
})
}
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")
}
setSessions((prev) => {
const next = new Map(prev)
const instanceSessions = new Map(prev.get(instanceId))
const session = instanceSessions.get(sessionId)
if (session) {
instanceSessions.set(sessionId, { ...session, model })
next.set(instanceId, instanceSessions)
}
return next
})
}
function handleSessionCompacted(instanceId: string, event: any): void {
const sessionID = event.properties?.sessionID
if (!sessionID) return
console.log(`[SSE] Session compacted: ${sessionID}`)
loadMessages(instanceId, sessionID, true).catch(console.error)
}
function handleSessionError(instanceId: string, event: any): void {
const error = event.properties?.error
const sessionID = event.properties?.sessionID
console.error(`[SSE] Session error:`, error)
let message = error?.data?.message || error?.message || "Unknown error"
if (error?.data?.responseBody) {
try {
const body = JSON.parse(error.data.responseBody)
if (body.error) {
message = body.error
}
} catch {}
}
alert(`Error: ${message}`)
}
function handleMessageRemoved(instanceId: string, event: any): void {
const sessionID = event.properties?.sessionID
if (!sessionID) return
console.log(`[SSE] Message removed from session ${sessionID}, reloading messages`)
loadMessages(instanceId, sessionID, true).catch(console.error)
}
function handleMessagePartRemoved(instanceId: string, event: any): void {
const sessionID = event.properties?.sessionID
if (!sessionID) return
console.log(`[SSE] Message part removed from session ${sessionID}, reloading messages`)
loadMessages(instanceId, sessionID, true).catch(console.error)
}
sseManager.onMessageUpdate = handleMessageUpdate
sseManager.onMessageRemoved = handleMessageRemoved
sseManager.onMessagePartRemoved = handleMessagePartRemoved
sseManager.onSessionUpdate = handleSessionUpdate
sseManager.onSessionCompacted = handleSessionCompacted
sseManager.onSessionError = handleSessionError
export {
sessions,
activeSessionId,
activeParentSessionId,
agents,
providers,
loading,
sessionInfoByInstance,
getSessionInfo,
fetchSessions,
createSession,
deleteSession,
fetchAgents,
fetchProviders,
loadMessages,
sendMessage,
abortSession,
setActiveSession,
setActiveParentSession,
clearActiveParentSession,
getActiveSession,
getActiveParentSession,
getSessions,
getParentSessions,
getChildSessions,
getSessionFamily,
isSessionBusy,
updateSessionAgent,
updateSessionModel,
getDefaultModel,
removeSessionIndexes,
}