Files
CodeNomad/src/stores/sessions.ts
2025-11-08 23:11:45 +00:00

1869 lines
54 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 { decodeHtmlEntities } from "../lib/markdown"
import { showToastNotification, ToastVariant } from "../lib/notifications"
import { preferences, addRecentModelPreference, getAgentModelPreference, setAgentModelPreference } from "./preferences"
interface SessionInfo {
tokens: number
cost: number
contextWindow: number
isSubscriptionModel: boolean
contextUsageTokens: number
}
const DEFAULT_MODEL_OUTPUT_LIMIT = 32_000
const ALLOWED_TOAST_VARIANTS = new Set<ToastVariant>(["info", "success", "warning", "error"])
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())
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
})
}
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())
if (typeof globalThis !== "undefined") {
const debugGlobal = globalThis as any
debugGlobal.__OPENCODE_DEBUG__ = {
...(debugGlobal.__OPENCODE_DEBUG__ ?? {}),
getSessions: () => sessions(),
}
}
// 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 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 normalizeMessagePart(part: any): any {
if (!part || typeof part !== "object") {
return part
}
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
}
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 initializePartVersion(part: any, version = 0) {
if (!part || typeof part !== "object") return
const partAny = part as any
partAny.version = typeof partAny.version === "number" ? partAny.version : version
}
function bumpPartVersion(previousPart: any, nextPart: any): number {
const prevVersion = typeof previousPart?.version === "number" ? previousPart.version : -1
const nextVersion = prevVersion + 1
initializePartVersion(nextPart, nextVersion)
return nextVersion
}
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
})
pruneDraftPrompts(instanceId, new Set(sessionMap.keys()))
} catch (error) {
console.error("Failed to fetch sessions:", error)
throw error
} finally {
setLoading((prev) => {
const next = { ...prev }
next.fetchingSessions.set(instanceId, false)
return next
})
}
}
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 stored = getAgentModelPreference(instanceId, agentName)
if (isModelValid(instanceId, stored)) {
return stored
}
}
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 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: "" }
}
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 = ""
let inputTokensForUsage = 0
let cacheReadTokensForUsage = 0
// 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
}
inputTokensForUsage = usage.input || 0
cacheReadTokensForUsage = usage.cache?.read || 0
// Get model info identifiers for context lookups
modelID = info.modelID || ""
providerID = info.providerID || ""
isSubscriptionModel = cost === 0
break // Break after finding the last assistant message
}
}
}
}
const instanceProviders = providers().get(instanceId) || []
const sessionModel = session.model
let selectedModel: Provider["models"][number] | undefined
if (sessionModel?.providerId && sessionModel?.modelId) {
const provider = instanceProviders.find((p) => p.id === sessionModel.providerId)
selectedModel = provider?.models.find((m) => m.id === sessionModel.modelId)
}
if (!selectedModel && modelID && providerID) {
const provider = instanceProviders.find((p) => p.id === providerID)
selectedModel = provider?.models.find((m) => m.id === modelID)
}
let modelOutputLimit = DEFAULT_MODEL_OUTPUT_LIMIT
if (selectedModel) {
if (selectedModel.limit?.context) {
contextWindow = selectedModel.limit.context
}
if (selectedModel.limit?.output && selectedModel.limit.output > 0) {
modelOutputLimit = selectedModel.limit.output
}
if (selectedModel.cost?.input === 0 && selectedModel.cost?.output === 0) {
isSubscriptionModel = true
}
}
const contextUsageTokens = inputTokensForUsage + cacheReadTokensForUsage + modelOutputLimit
setSessionInfoByInstance((prev) => {
const next = new Map(prev)
const instanceInfo = new Map(prev.get(instanceId))
instanceInfo.set(sessionId, {
tokens,
cost,
contextWindow,
isSubscriptionModel,
contextUsageTokens,
})
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)
if (selectedAgent && isModelValid(instanceId, defaultModel)) {
setAgentModelPreference(instanceId, selectedAgent, defaultModel)
}
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
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 initialOutputLimit =
initialModel?.limit?.output && initialModel.limit.output > 0
? initialModel.limit.output
: DEFAULT_MODEL_OUTPUT_LIMIT
const initialSubscriptionModel = initialModel?.cost?.input === 0 && initialModel?.cost?.output === 0
setSessionInfoByInstance((prev) => {
const next = new Map(prev)
const instanceInfo = new Map(prev.get(instanceId))
instanceInfo.set(session.id, {
tokens: 0,
cost: 0,
contextWindow: initialContextWindow,
isSubscriptionModel: Boolean(initialSubscriptionModel),
contextUsageTokens: initialOutputLimit,
})
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 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 }
}
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
const forkedSession: Session = {
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 || "",
},
time: {
created: info.time?.created || Date.now(),
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,
messages: [],
messagesInfo: new Map(),
}
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 forkOutputLimit =
forkModel?.limit?.output && forkModel.limit.output > 0 ? forkModel.limit.output : DEFAULT_MODEL_OUTPUT_LIMIT
const forkSubscriptionModel = forkModel?.cost?.input === 0 && forkModel?.cost?.output === 0
setSessionInfoByInstance((prev) => {
const next = new Map(prev)
const instanceInfo = new Map(prev.get(instanceId))
instanceInfo.set(forkedSession.id, {
tokens: 0,
cost: 0,
contextWindow: forkContextWindow,
isSubscriptionModel: Boolean(forkSubscriptionModel),
contextUsageTokens: forkOutputLimit,
})
next.set(instanceId, instanceInfo)
return next
})
getSessionIndex(instanceId, forkedSession.id)
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 {
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
})
clearSessionDraftPrompt(instanceId, sessionId)
// 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)
// Normalize parts to decode entities and clear caches for text segments
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,
}
parts.forEach((part: any) => initializePartVersion(part))
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 rawPart = event.properties?.part
if (!rawPart) return
const part = normalizeMessagePart(rawPart)
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,
}
initializePartVersion(part)
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) {
initializePartVersion(part)
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
}
bumpPartVersion(previousPart, part)
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
})
}
}
function resolvePastedPlaceholders(prompt: string, attachments: any[] = []): string {
if (!prompt || !prompt.includes("[pasted #")) {
return prompt
}
if (!attachments || attachments.length === 0) {
return prompt
}
const lookup = new Map<string, string>()
for (const attachment of attachments) {
const source = attachment?.source
if (!source || source.type !== "text") continue
const display: string | undefined = attachment?.display
const value: unknown = source.value
if (typeof display !== "string" || typeof value !== "string") continue
const match = display.match(/pasted #(\d+)/)
if (!match) continue
const placeholder = `[pasted #${match[1]}]`
if (!lookup.has(placeholder)) {
lookup.set(placeholder, value)
}
}
if (lookup.size === 0) {
return prompt
}
return prompt.replace(/\[pasted #(\d+)\]/g, (fullMatch) => {
const replacement = lookup.get(fullMatch)
return typeof replacement === "string" ? replacement : fullMatch
})
}
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 optimisticMessage: Message = {
id: messageId,
sessionId,
type: "user",
parts: optimisticParts,
timestamp: Date.now(),
status: "sending",
version: 0,
}
optimisticParts.forEach((part: any) => initializePartVersion(part))
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: 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 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")
}
const nextModel = await getDefaultModel(instanceId, agent)
const shouldApplyModel = isModelValid(instanceId, nextModel)
setSessions((prev) => {
const next = new Map(prev)
const map = new Map(prev.get(instanceId))
const current = map.get(sessionId)
if (current) {
map.set(sessionId, {
...current,
agent,
model: shouldApplyModel ? nextModel : current.model,
})
next.set(instanceId, map)
}
return next
})
if (agent && shouldApplyModel) {
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)) {
console.warn("Invalid model selection", model)
return
}
const currentAgent = session.agent
setSessions((prev) => {
const next = new Map(prev)
const map = new Map(prev.get(instanceId))
const existing = map.get(sessionId)
if (existing) {
map.set(sessionId, { ...existing, model })
next.set(instanceId, map)
}
return next
})
if (currentAgent) {
setAgentModelPreference(instanceId, currentAgent, model)
}
addRecentModelPreference(model)
updateSessionInfo(instanceId, sessionId)
}
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)
}
function handleTuiToast(_instanceId: string, event: any): 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,
})
}
sseManager.onMessageUpdate = handleMessageUpdate
sseManager.onMessageRemoved = handleMessageRemoved
sseManager.onMessagePartRemoved = handleMessagePartRemoved
sseManager.onSessionUpdate = handleSessionUpdate
sseManager.onSessionCompacted = handleSessionCompacted
sseManager.onSessionError = handleSessionError
sseManager.onTuiToast = handleTuiToast
export {
sessions,
activeSessionId,
activeParentSessionId,
agents,
providers,
loading,
sessionInfoByInstance,
getSessionInfo,
fetchSessions,
createSession,
forkSession,
deleteSession,
fetchAgents,
fetchProviders,
loadMessages,
sendMessage,
abortSession,
setActiveSession,
setActiveParentSession,
clearActiveParentSession,
getActiveSession,
getActiveParentSession,
getSessions,
getParentSessions,
getChildSessions,
getSessionFamily,
isSessionBusy,
updateSessionAgent,
updateSessionModel,
getDefaultModel,
removeSessionIndexes,
getSessionDraftPrompt,
setSessionDraftPrompt,
clearSessionDraftPrompt,
clearInstanceDraftPrompts,
}