Split workspace into electron and ui packages
This commit is contained in:
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 }
|
||||
640
packages/ui/src/stores/instances.ts
Normal file
640
packages/ui/src/stores/instances.ts
Normal file
@@ -0,0 +1,640 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import type { Instance, LogEntry } from "../types/instance"
|
||||
import type { LspStatus, Permission } from "@opencode-ai/sdk"
|
||||
import type { ClientPart, Message } from "../types/message"
|
||||
import { sdkManager } from "../lib/sdk-manager"
|
||||
import { sseManager } from "../lib/sse-manager"
|
||||
import {
|
||||
fetchSessions,
|
||||
fetchAgents,
|
||||
fetchProviders,
|
||||
removeSessionIndexes,
|
||||
clearInstanceDraftPrompts,
|
||||
} from "./sessions"
|
||||
import { fetchCommands, clearCommands } from "./commands"
|
||||
import { preferences, updateLastUsedBinary } from "./preferences"
|
||||
import { computeDisplayParts } from "./session-messages"
|
||||
import { withSession, setSessionPendingPermission } from "./session-state"
|
||||
import { setHasInstances } from "./ui"
|
||||
|
||||
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>>()
|
||||
interface DisconnectedInstanceInfo {
|
||||
id: string
|
||||
folder: string
|
||||
reason: string
|
||||
}
|
||||
const [disconnectedInstance, setDisconnectedInstance] = createSignal<DisconnectedInstanceInfo | null>(null)
|
||||
|
||||
const MAX_LOG_ENTRIES = 1000
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
if (activeInstanceId() === id) {
|
||||
setActiveInstanceId(nextActiveId)
|
||||
}
|
||||
|
||||
// Clean up session indexes and drafts for removed instance
|
||||
removeSessionIndexes(id)
|
||||
clearInstanceDraftPrompts(id)
|
||||
}
|
||||
|
||||
async function createInstance(folder: string, binaryPath?: string): Promise<string> {
|
||||
const id = `instance-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
|
||||
const instance: Instance = {
|
||||
id,
|
||||
folder,
|
||||
port: 0,
|
||||
pid: 0,
|
||||
status: "starting",
|
||||
client: null,
|
||||
environmentVariables: preferences().environmentVariables ?? {},
|
||||
}
|
||||
|
||||
addInstance(instance)
|
||||
|
||||
// Update last used binary
|
||||
if (binaryPath) {
|
||||
updateLastUsedBinary(binaryPath)
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
id: returnedId,
|
||||
port,
|
||||
pid,
|
||||
binaryPath: actualBinaryPath,
|
||||
} = await window.electronAPI.createInstance(id, folder, binaryPath, preferences().environmentVariables)
|
||||
|
||||
const client = sdkManager.createClient(port)
|
||||
|
||||
updateInstance(id, {
|
||||
port,
|
||||
pid,
|
||||
client,
|
||||
status: "ready",
|
||||
binaryPath: actualBinaryPath,
|
||||
})
|
||||
|
||||
setActiveInstanceId(id)
|
||||
sseManager.connect(id, port)
|
||||
|
||||
try {
|
||||
await fetchSessions(id)
|
||||
await fetchAgents(id)
|
||||
await fetchProviders(id)
|
||||
await fetchCommands(id, client)
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch initial data:", error)
|
||||
}
|
||||
|
||||
return id
|
||||
} catch (error) {
|
||||
updateInstance(id, {
|
||||
status: "error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function stopInstance(id: string) {
|
||||
const instance = instances().get(id)
|
||||
if (!instance) return
|
||||
|
||||
sseManager.disconnect(id)
|
||||
|
||||
if (instance.port) {
|
||||
sdkManager.destroyClient(instance.port)
|
||||
}
|
||||
|
||||
if (instance.pid) {
|
||||
await window.electronAPI.stopInstance(instance.pid)
|
||||
}
|
||||
|
||||
removeInstance(id)
|
||||
}
|
||||
|
||||
async function fetchLspStatus(instanceId: string): Promise<LspStatus[] | undefined> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance) {
|
||||
console.warn(`[LSP] Skipping fetch; instance ${instanceId} not found`)
|
||||
return undefined
|
||||
}
|
||||
if (!instance.client) {
|
||||
console.warn(`[LSP] Skipping fetch; instance ${instanceId} client not ready`)
|
||||
return undefined
|
||||
}
|
||||
const lsp = instance.client.lsp
|
||||
if (!lsp?.status) {
|
||||
console.warn(`[LSP] Skipping fetch; lsp.status API unavailable for instance ${instanceId}`)
|
||||
return undefined
|
||||
}
|
||||
console.log(`[HTTP] GET /lsp.status for instance ${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)
|
||||
|
||||
const isActive = getActivePermission(instanceId)?.id === permission.id
|
||||
attachPermissionToToolPart(instanceId, permission, isActive)
|
||||
}
|
||||
|
||||
function getActivePermission(instanceId: string): Permission | null {
|
||||
const activeId = activePermissionId().get(instanceId)
|
||||
if (!activeId) return null
|
||||
|
||||
const queue = getPermissionQueue(instanceId)
|
||||
return queue.find(p => p.id === activeId) ?? null
|
||||
}
|
||||
|
||||
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) {
|
||||
clearPermissionFromToolPart(instanceId, removed)
|
||||
const removedSessionId = getPermissionSessionId(removed)
|
||||
const remaining = decrementSessionPendingCount(instanceId, removedSessionId)
|
||||
setSessionPendingPermission(instanceId, removedSessionId, remaining > 0)
|
||||
}
|
||||
|
||||
const nextActivePermission = getActivePermission(instanceId)
|
||||
if (nextActivePermission) {
|
||||
attachPermissionToToolPart(instanceId, nextActivePermission, true)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function findToolPartForPermission(message: Message, permission: Permission): ClientPart | null {
|
||||
const expectedCallId = permission.callID
|
||||
for (const part of message.parts) {
|
||||
if (part.type !== "tool") continue
|
||||
const toolCallId = (part as any).callID
|
||||
if (expectedCallId) {
|
||||
if (toolCallId === expectedCallId) {
|
||||
return part as ClientPart
|
||||
}
|
||||
if (!toolCallId && (part.id === expectedCallId || part.messageID === permission.messageID)) {
|
||||
return part as ClientPart
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if ((toolCallId && toolCallId === permission.id) || part.id === permission.id || part.messageID === permission.messageID) {
|
||||
return part as ClientPart
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function mutateToolPartPermission(
|
||||
instanceId: string,
|
||||
permission: Permission,
|
||||
mutator: (part: ClientPart, message: Message) => boolean,
|
||||
): void {
|
||||
const permissionSessionId = getPermissionSessionId(permission)
|
||||
withSession(instanceId, permissionSessionId, (session) => {
|
||||
const message = session.messages.find((msg) => msg.id === permission.messageID)
|
||||
if (!message) return
|
||||
const targetPart = findToolPartForPermission(message, permission)
|
||||
if (!targetPart) return
|
||||
|
||||
const changed = mutator(targetPart, message)
|
||||
if (!changed) return
|
||||
|
||||
const nextPartVersion = typeof targetPart.version === "number" ? targetPart.version + 1 : 1
|
||||
targetPart.version = nextPartVersion
|
||||
message.version = (message.version ?? 0) + 1
|
||||
message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks)
|
||||
})
|
||||
}
|
||||
|
||||
function attachPermissionToToolPart(instanceId: string, permission: Permission, active: boolean): void {
|
||||
mutateToolPartPermission(instanceId, permission, (part) => {
|
||||
const existing = part.pendingPermission
|
||||
if (existing && existing.permission.id === permission.id && existing.active === active) {
|
||||
return false
|
||||
}
|
||||
part.pendingPermission = { permission, active }
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function clearPermissionFromToolPart(instanceId: string, permission: Permission): void {
|
||||
mutateToolPartPermission(instanceId, permission, (part) => {
|
||||
if (!part.pendingPermission || part.pendingPermission.permission.id !== permission.id) {
|
||||
return false
|
||||
}
|
||||
delete part.pendingPermission
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function refreshPermissionsForSession(instanceId: string, sessionId: string): void {
|
||||
const queue = getPermissionQueue(instanceId)
|
||||
if (queue.length === 0) {
|
||||
setSessionPendingPermission(instanceId, sessionId, false)
|
||||
return
|
||||
}
|
||||
|
||||
const activeId = activePermissionId().get(instanceId)
|
||||
|
||||
for (const permission of queue) {
|
||||
if (getPermissionSessionId(permission) !== sessionId) continue
|
||||
const isActive = permission.id === activeId
|
||||
attachPermissionToToolPart(instanceId, permission, isActive)
|
||||
}
|
||||
|
||||
const pendingCount = permissionSessionCounts.get(instanceId)?.get(sessionId) ?? 0
|
||||
setSessionPendingPermission(instanceId, sessionId, pendingCount > 0)
|
||||
}
|
||||
|
||||
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) {
|
||||
console.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) => {
|
||||
console.log(`[LSP] Received lsp.updated event for instance ${instanceId}`)
|
||||
try {
|
||||
const lspStatus = await fetchLspStatus(instanceId)
|
||||
if (!lspStatus) {
|
||||
return
|
||||
}
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance) {
|
||||
console.warn(`[LSP] Instance ${instanceId} disappeared before metadata update`)
|
||||
return
|
||||
}
|
||||
updateInstance(instanceId, {
|
||||
metadata: {
|
||||
...(instance.metadata ?? {}),
|
||||
lspStatus,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.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) {
|
||||
console.error("Failed to stop disconnected instance:", error)
|
||||
} finally {
|
||||
setDisconnectedInstance(null)
|
||||
if (instances().size === 0) {
|
||||
setHasInstances(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
instances,
|
||||
activeInstanceId,
|
||||
setActiveInstanceId,
|
||||
addInstance,
|
||||
updateInstance,
|
||||
removeInstance,
|
||||
createInstance,
|
||||
stopInstance,
|
||||
getActiveInstance,
|
||||
addLog,
|
||||
clearLogs,
|
||||
instanceLogs,
|
||||
getInstanceLogs,
|
||||
isInstanceLogStreaming,
|
||||
setInstanceLogStreaming,
|
||||
// Permission management
|
||||
permissionQueues,
|
||||
activePermissionId,
|
||||
getPermissionQueue,
|
||||
getPermissionQueueLength,
|
||||
addPermissionToQueue,
|
||||
getActivePermission,
|
||||
removePermissionFromQueue,
|
||||
clearPermissionQueue,
|
||||
refreshPermissionsForSession,
|
||||
sendPermissionResponse,
|
||||
disconnectedInstance,
|
||||
acknowledgeDisconnectedInstance,
|
||||
fetchLspStatus,
|
||||
}
|
||||
58
packages/ui/src/stores/message-history.ts
Normal file
58
packages/ui/src/stores/message-history.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { storage, type InstanceData } from "../lib/storage"
|
||||
|
||||
const MAX_HISTORY = 100
|
||||
|
||||
const instanceHistories = new Map<string, string[]>()
|
||||
const historyLoaded = new Set<string>()
|
||||
|
||||
export async function addToHistory(instanceId: string, text: string): Promise<void> {
|
||||
await ensureHistoryLoaded(instanceId)
|
||||
|
||||
const history = instanceHistories.get(instanceId) || []
|
||||
|
||||
history.unshift(text)
|
||||
|
||||
if (history.length > MAX_HISTORY) {
|
||||
history.length = MAX_HISTORY
|
||||
}
|
||||
|
||||
instanceHistories.set(instanceId, history)
|
||||
|
||||
try {
|
||||
await storage.saveInstanceData(instanceId, { messageHistory: history })
|
||||
} catch (err) {
|
||||
console.warn("Failed to persist message history:", err)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getHistory(instanceId: string): Promise<string[]> {
|
||||
await ensureHistoryLoaded(instanceId)
|
||||
return instanceHistories.get(instanceId) || []
|
||||
}
|
||||
|
||||
export async function clearHistory(instanceId: string): Promise<void> {
|
||||
instanceHistories.delete(instanceId)
|
||||
historyLoaded.delete(instanceId)
|
||||
|
||||
try {
|
||||
await storage.saveInstanceData(instanceId, { messageHistory: [] })
|
||||
} catch (error) {
|
||||
console.warn("Failed to clear history:", error)
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureHistoryLoaded(instanceId: string): Promise<void> {
|
||||
if (historyLoaded.has(instanceId)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await storage.loadInstanceData(instanceId)
|
||||
instanceHistories.set(instanceId, data.messageHistory)
|
||||
historyLoaded.add(instanceId)
|
||||
} catch (error) {
|
||||
console.warn("Failed to load history:", error)
|
||||
instanceHistories.set(instanceId, [])
|
||||
historyLoaded.add(instanceId)
|
||||
}
|
||||
}
|
||||
339
packages/ui/src/stores/preferences.tsx
Normal file
339
packages/ui/src/stores/preferences.tsx
Normal file
@@ -0,0 +1,339 @@
|
||||
import { createContext, createSignal, onMount, useContext } from "solid-js"
|
||||
import type { Accessor, ParentComponent } from "solid-js"
|
||||
import { storage, type ConfigData } from "../lib/storage"
|
||||
|
||||
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 interface Preferences {
|
||||
showThinkingBlocks: boolean
|
||||
lastUsedBinary?: string
|
||||
environmentVariables?: Record<string, string>
|
||||
modelRecents?: ModelPreference[]
|
||||
agentModelSelections?: AgentModelSelections
|
||||
diffViewMode?: DiffViewMode
|
||||
toolOutputExpansion?: ExpansionPreference
|
||||
diagnosticsExpansion?: ExpansionPreference
|
||||
}
|
||||
|
||||
export interface OpenCodeBinary {
|
||||
path: string
|
||||
version?: string
|
||||
lastUsed: number
|
||||
}
|
||||
|
||||
export interface RecentFolder {
|
||||
path: string
|
||||
lastAccessed: number
|
||||
}
|
||||
|
||||
const MAX_RECENT_FOLDERS = 20
|
||||
const MAX_RECENT_MODELS = 5
|
||||
|
||||
const defaultPreferences: Preferences = {
|
||||
showThinkingBlocks: false,
|
||||
modelRecents: [],
|
||||
agentModelSelections: {},
|
||||
diffViewMode: "split",
|
||||
toolOutputExpansion: "expanded",
|
||||
diagnosticsExpansion: "expanded",
|
||||
}
|
||||
|
||||
const [preferences, setPreferences] = createSignal<Preferences>(defaultPreferences)
|
||||
const [recentFolders, setRecentFolders] = createSignal<RecentFolder[]>([])
|
||||
const [opencodeBinaries, setOpenCodeBinaries] = createSignal<OpenCodeBinary[]>([])
|
||||
const [isConfigLoaded, setIsConfigLoaded] = createSignal(false)
|
||||
let cachedConfig: ConfigData = {
|
||||
preferences: defaultPreferences,
|
||||
recentFolders: [],
|
||||
opencodeBinaries: [],
|
||||
}
|
||||
let loadPromise: Promise<void> | null = null
|
||||
|
||||
async function loadConfig(): Promise<void> {
|
||||
try {
|
||||
const config = await storage.loadConfig()
|
||||
cachedConfig = {
|
||||
...config,
|
||||
preferences: { ...defaultPreferences, ...config.preferences },
|
||||
recentFolders: config.recentFolders || [],
|
||||
opencodeBinaries: config.opencodeBinaries || [],
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load config:", error)
|
||||
cachedConfig = {
|
||||
...cachedConfig,
|
||||
preferences: { ...defaultPreferences },
|
||||
recentFolders: [],
|
||||
opencodeBinaries: [],
|
||||
}
|
||||
}
|
||||
|
||||
setPreferences(cachedConfig.preferences)
|
||||
setRecentFolders(cachedConfig.recentFolders)
|
||||
setOpenCodeBinaries(cachedConfig.opencodeBinaries)
|
||||
setIsConfigLoaded(true)
|
||||
}
|
||||
|
||||
async function saveConfig(): Promise<void> {
|
||||
try {
|
||||
await ensureConfigLoaded()
|
||||
const config: ConfigData = {
|
||||
...cachedConfig,
|
||||
preferences: preferences(),
|
||||
recentFolders: recentFolders(),
|
||||
opencodeBinaries: opencodeBinaries(),
|
||||
}
|
||||
cachedConfig = config
|
||||
await storage.saveConfig(config)
|
||||
} catch (error) {
|
||||
console.error("Failed to save config:", error)
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureConfigLoaded(): Promise<void> {
|
||||
if (isConfigLoaded()) return
|
||||
if (!loadPromise) {
|
||||
loadPromise = loadConfig().finally(() => {
|
||||
loadPromise = null
|
||||
})
|
||||
}
|
||||
await loadPromise
|
||||
}
|
||||
|
||||
|
||||
function updatePreferences(updates: Partial<Preferences>): void {
|
||||
const updated = { ...preferences(), ...updates }
|
||||
setPreferences(updated)
|
||||
saveConfig().catch(console.error)
|
||||
}
|
||||
|
||||
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 toggleShowThinkingBlocks(): void {
|
||||
updatePreferences({ showThinkingBlocks: !preferences().showThinkingBlocks })
|
||||
}
|
||||
|
||||
function addRecentFolder(path: string): void {
|
||||
const folders = recentFolders().filter((f) => f.path !== path)
|
||||
folders.unshift({ path, lastAccessed: Date.now() })
|
||||
|
||||
const trimmed = folders.slice(0, MAX_RECENT_FOLDERS)
|
||||
setRecentFolders(trimmed)
|
||||
saveConfig().catch(console.error)
|
||||
}
|
||||
|
||||
function removeRecentFolder(path: string): void {
|
||||
const folders = recentFolders().filter((f) => f.path !== path)
|
||||
setRecentFolders(folders)
|
||||
saveConfig().catch(console.error)
|
||||
}
|
||||
|
||||
function addOpenCodeBinary(path: string, version?: string): void {
|
||||
const binaries = opencodeBinaries().filter((b) => b.path !== path)
|
||||
const lastUsed = Date.now()
|
||||
const binaryEntry: OpenCodeBinary = version ? { path, version, lastUsed } : { path, lastUsed }
|
||||
binaries.unshift(binaryEntry)
|
||||
|
||||
const trimmed = binaries.slice(0, 10) // Keep max 10 binaries
|
||||
setOpenCodeBinaries(trimmed)
|
||||
saveConfig().catch(console.error)
|
||||
}
|
||||
|
||||
function removeOpenCodeBinary(path: string): void {
|
||||
const binaries = opencodeBinaries().filter((b) => b.path !== path)
|
||||
setOpenCodeBinaries(binaries)
|
||||
saveConfig().catch(console.error)
|
||||
}
|
||||
|
||||
function updateLastUsedBinary(path: string): void {
|
||||
updatePreferences({ lastUsedBinary: path })
|
||||
|
||||
const binaries = opencodeBinaries()
|
||||
let binary = binaries.find((b) => b.path === path)
|
||||
|
||||
// If binary not found in list, add it (for system PATH "opencode")
|
||||
if (!binary) {
|
||||
addOpenCodeBinary(path)
|
||||
binary = { path, lastUsed: Date.now() }
|
||||
} else {
|
||||
binary.lastUsed = Date.now()
|
||||
// Move to front
|
||||
const sorted = [binary, ...binaries.filter((b) => b.path !== path)]
|
||||
setOpenCodeBinaries(sorted)
|
||||
saveConfig().catch(console.error)
|
||||
}
|
||||
}
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
function setAgentModelPreference(instanceId: string, agent: string, model: ModelPreference): void {
|
||||
if (!instanceId || !agent || !model.providerId || !model.modelId) return
|
||||
const selections = preferences().agentModelSelections ?? {}
|
||||
const instanceSelections = selections[instanceId] ?? {}
|
||||
const existing = instanceSelections[agent]
|
||||
if (existing && existing.providerId === model.providerId && existing.modelId === model.modelId) {
|
||||
return
|
||||
}
|
||||
updatePreferences({
|
||||
agentModelSelections: {
|
||||
...selections,
|
||||
[instanceId]: {
|
||||
...instanceSelections,
|
||||
[agent]: model,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function getAgentModelPreference(instanceId: string, agent: string): ModelPreference | undefined {
|
||||
return preferences().agentModelSelections?.[instanceId]?.[agent]
|
||||
}
|
||||
|
||||
void ensureConfigLoaded().catch((error) => {
|
||||
console.error("Failed to initialize config:", error)
|
||||
})
|
||||
|
||||
interface ConfigContextValue {
|
||||
isLoaded: Accessor<boolean>
|
||||
preferences: typeof preferences
|
||||
recentFolders: typeof recentFolders
|
||||
opencodeBinaries: typeof opencodeBinaries
|
||||
toggleShowThinkingBlocks: typeof toggleShowThinkingBlocks
|
||||
setDiffViewMode: typeof setDiffViewMode
|
||||
setToolOutputExpansion: typeof setToolOutputExpansion
|
||||
setDiagnosticsExpansion: typeof setDiagnosticsExpansion
|
||||
addRecentFolder: typeof addRecentFolder
|
||||
removeRecentFolder: typeof removeRecentFolder
|
||||
addOpenCodeBinary: typeof addOpenCodeBinary
|
||||
removeOpenCodeBinary: typeof removeOpenCodeBinary
|
||||
updateLastUsedBinary: typeof updateLastUsedBinary
|
||||
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,
|
||||
preferences,
|
||||
recentFolders,
|
||||
opencodeBinaries,
|
||||
toggleShowThinkingBlocks,
|
||||
setDiffViewMode,
|
||||
setToolOutputExpansion,
|
||||
setDiagnosticsExpansion,
|
||||
addRecentFolder,
|
||||
removeRecentFolder,
|
||||
addOpenCodeBinary,
|
||||
removeOpenCodeBinary,
|
||||
updateLastUsedBinary,
|
||||
updatePreferences,
|
||||
updateEnvironmentVariables,
|
||||
addEnvironmentVariable,
|
||||
removeEnvironmentVariable,
|
||||
addRecentModelPreference,
|
||||
setAgentModelPreference,
|
||||
getAgentModelPreference,
|
||||
}
|
||||
|
||||
const ConfigProvider: ParentComponent = (props) => {
|
||||
onMount(() => {
|
||||
ensureConfigLoaded().catch((error) => {
|
||||
console.error("Failed to initialize config:", error)
|
||||
})
|
||||
|
||||
const unsubscribe = storage.onConfigChanged(() => {
|
||||
loadConfig().catch((error) => {
|
||||
console.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,
|
||||
preferences,
|
||||
updatePreferences,
|
||||
toggleShowThinkingBlocks,
|
||||
recentFolders,
|
||||
addRecentFolder,
|
||||
removeRecentFolder,
|
||||
opencodeBinaries,
|
||||
addOpenCodeBinary,
|
||||
removeOpenCodeBinary,
|
||||
updateLastUsedBinary,
|
||||
updateEnvironmentVariables,
|
||||
addEnvironmentVariable,
|
||||
removeEnvironmentVariable,
|
||||
addRecentModelPreference,
|
||||
setAgentModelPreference,
|
||||
getAgentModelPreference,
|
||||
setDiffViewMode,
|
||||
setToolOutputExpansion,
|
||||
setDiagnosticsExpansion,
|
||||
}
|
||||
352
packages/ui/src/stores/session-actions.ts
Normal file
352
packages/ui/src/stores/session-actions.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
import type { Message } from "../types/message"
|
||||
|
||||
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
|
||||
import { instances } from "./instances"
|
||||
|
||||
import {
|
||||
addRecentModelPreference,
|
||||
preferences,
|
||||
setAgentModelPreference,
|
||||
} from "./preferences"
|
||||
import { sessions, withSession } from "./session-state"
|
||||
import { getDefaultModel, isModelValid } from "./session-models"
|
||||
import {
|
||||
computeDisplayParts,
|
||||
getSessionIndex,
|
||||
initializePartVersion,
|
||||
updateSessionInfo,
|
||||
} from "./session-messages"
|
||||
|
||||
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 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 {
|
||||
console.log(`[HTTP] POST /session.prompt for instance ${instanceId}`, { sessionId, requestBody })
|
||||
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 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")
|
||||
}
|
||||
|
||||
console.log("[abortSession] Aborting session:", { instanceId, sessionId })
|
||||
|
||||
try {
|
||||
console.log(`[HTTP] POST /session.abort for instance ${instanceId}`, { sessionId })
|
||||
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)
|
||||
|
||||
withSession(instanceId, sessionId, (current) => {
|
||||
current.agent = agent
|
||||
if (shouldApplyModel) {
|
||||
current.model = nextModel
|
||||
}
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
withSession(instanceId, sessionId, (current) => {
|
||||
current.model = model
|
||||
})
|
||||
|
||||
if (session.agent) {
|
||||
setAgentModelPreference(instanceId, session.agent, model)
|
||||
}
|
||||
addRecentModelPreference(model)
|
||||
|
||||
updateSessionInfo(instanceId, sessionId)
|
||||
}
|
||||
|
||||
export {
|
||||
abortSession,
|
||||
executeCustomCommand,
|
||||
runShellCommand,
|
||||
sendMessage,
|
||||
updateSessionAgent,
|
||||
updateSessionModel,
|
||||
}
|
||||
625
packages/ui/src/stores/session-api.ts
Normal file
625
packages/ui/src/stores/session-api.ts
Normal file
@@ -0,0 +1,625 @@
|
||||
import type { Session } from "../types/session"
|
||||
import type { Message } from "../types/message"
|
||||
|
||||
import { instances, refreshPermissionsForSession } from "./instances"
|
||||
import { preferences, setAgentModelPreference } from "./preferences"
|
||||
import { setSessionCompactionState } from "./session-compaction"
|
||||
import {
|
||||
activeSessionId,
|
||||
agents,
|
||||
clearSessionDraftPrompt,
|
||||
messagesLoaded,
|
||||
providers,
|
||||
pruneDraftPrompts,
|
||||
setActiveSessionId,
|
||||
setAgents,
|
||||
setMessagesLoaded,
|
||||
setProviders,
|
||||
setSessionInfoByInstance,
|
||||
setSessions,
|
||||
sessions,
|
||||
loading,
|
||||
setLoading,
|
||||
} from "./session-state"
|
||||
import { getDefaultModel, isModelValid } from "./session-models"
|
||||
import {
|
||||
computeDisplayParts,
|
||||
clearSessionIndex,
|
||||
getSessionIndex,
|
||||
initializePartVersion,
|
||||
normalizeMessagePart,
|
||||
rebuildSessionIndex,
|
||||
updateSessionInfo,
|
||||
} from "./session-messages"
|
||||
|
||||
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 {
|
||||
console.log(`[HTTP] GET /session.list for instance ${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,
|
||||
messages: existingSession?.messages ?? [],
|
||||
messagesInfo: existingSession?.messagesInfo ?? new Map(),
|
||||
})
|
||||
}
|
||||
|
||||
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) {
|
||||
console.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)) {
|
||||
setAgentModelPreference(instanceId, selectedAgent, defaultModel)
|
||||
}
|
||||
|
||||
setLoading((prev) => {
|
||||
const next = { ...prev }
|
||||
next.creatingSession.set(instanceId, true)
|
||||
return next
|
||||
})
|
||||
|
||||
try {
|
||||
console.log(`[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,
|
||||
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
|
||||
})
|
||||
|
||||
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 initialContextPercent = initialContextWindow > 0 ? 0 : null
|
||||
|
||||
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: 0,
|
||||
contextUsagePercent: initialContextPercent,
|
||||
})
|
||||
next.set(instanceId, instanceInfo)
|
||||
return next
|
||||
})
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
console.log(`[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,
|
||||
messages: [],
|
||||
messagesInfo: new Map(),
|
||||
} 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 forkContextPercent = forkContextWindow > 0 ? 0 : null
|
||||
|
||||
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: 0,
|
||||
contextUsagePercent: forkContextPercent,
|
||||
})
|
||||
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 {
|
||||
console.log(`[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)
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
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 {
|
||||
console.log(`[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) {
|
||||
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 {
|
||||
console.log(`[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) {
|
||||
console.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 {
|
||||
console.log(`[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,
|
||||
}
|
||||
|
||||
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 nextInstanceSessions = next.get(instanceId)
|
||||
if (nextInstanceSessions) {
|
||||
const existingSession = nextInstanceSessions.get(sessionId)
|
||||
if (existingSession) {
|
||||
const updatedSession = {
|
||||
...existingSession,
|
||||
messages,
|
||||
messagesInfo,
|
||||
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
|
||||
})
|
||||
|
||||
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)
|
||||
refreshPermissionsForSession(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
|
||||
}
|
||||
507
packages/ui/src/stores/session-events.ts
Normal file
507
packages/ui/src/stores/session-events.ts
Normal file
@@ -0,0 +1,507 @@
|
||||
import type {
|
||||
MessagePartRemovedEvent,
|
||||
MessagePartUpdatedEvent,
|
||||
MessageRemovedEvent,
|
||||
MessageUpdateEvent,
|
||||
} from "../types/message"
|
||||
import type {
|
||||
EventPermissionReplied,
|
||||
EventPermissionUpdated,
|
||||
EventSessionCompacted,
|
||||
EventSessionError,
|
||||
EventSessionIdle,
|
||||
EventSessionUpdated,
|
||||
} from "@opencode-ai/sdk"
|
||||
|
||||
import { showToastNotification, ToastVariant } from "../lib/notifications"
|
||||
import { preferences } from "./preferences"
|
||||
import { instances, addPermissionToQueue, removePermissionFromQueue, refreshPermissionsForSession } from "./instances"
|
||||
import {
|
||||
sessions,
|
||||
setSessions,
|
||||
withSession,
|
||||
} from "./session-state"
|
||||
import {
|
||||
bumpPartVersion,
|
||||
computeDisplayParts,
|
||||
getSessionIndex,
|
||||
initializePartVersion,
|
||||
normalizeMessagePart,
|
||||
rebuildSessionIndex,
|
||||
updateSessionInfo,
|
||||
} from "./session-messages"
|
||||
import { loadMessages } from "./session-api"
|
||||
import { setSessionCompactionState } from "./session-compaction"
|
||||
|
||||
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"])
|
||||
|
||||
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 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) {
|
||||
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) {
|
||||
const newMessage: any = {
|
||||
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 {
|
||||
const message = session.messages[messageIndex]
|
||||
if (typeof message.version !== "number") {
|
||||
message.version = 0
|
||||
}
|
||||
|
||||
let filteredSynthetics = false
|
||||
if (message.parts.some((partItem: any) => partItem.synthetic === true)) {
|
||||
message.parts = message.parts.filter((partItem: any) => partItem.synthetic !== true)
|
||||
filteredSynthetics = true
|
||||
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
|
||||
baseParts.forEach((partItem: any) => {
|
||||
if (partItem.type === "text") {
|
||||
partItem.renderCache = undefined
|
||||
}
|
||||
})
|
||||
} else {
|
||||
baseParts = message.parts
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
if (filteredSynthetics || replacedTemp) {
|
||||
const refreshed = new Map<string, number>()
|
||||
message.parts.forEach((partItem, idx) => {
|
||||
if (partItem.id && typeof partItem.id === "string") {
|
||||
refreshed.set(partItem.id, idx)
|
||||
}
|
||||
})
|
||||
index.partIndex.set(message.id, refreshed)
|
||||
}
|
||||
}
|
||||
|
||||
withSession(instanceId, part.sessionID, () => {
|
||||
/* mutations already applied above */
|
||||
})
|
||||
|
||||
updateSessionInfo(instanceId, part.sessionID)
|
||||
refreshPermissionsForSession(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) {
|
||||
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) {
|
||||
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 {
|
||||
const newMessage: any = {
|
||||
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 {
|
||||
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, () => {
|
||||
/* ensure reactivity */
|
||||
})
|
||||
|
||||
updateSessionInfo(instanceId, info.sessionID)
|
||||
refreshPermissionsForSession(instanceId, info.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(),
|
||||
},
|
||||
messages: [],
|
||||
messagesInfo: new Map(),
|
||||
} 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
|
||||
})
|
||||
|
||||
console.log(`[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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function handleSessionIdle(_instanceId: string, event: EventSessionIdle): void {
|
||||
const sessionId = event.properties?.sessionID
|
||||
if (!sessionId) return
|
||||
|
||||
console.log(`[SSE] Session idle: ${sessionId}`)
|
||||
}
|
||||
|
||||
function handleSessionCompacted(instanceId: string, event: EventSessionCompacted): void {
|
||||
const sessionID = event.properties?.sessionID
|
||||
if (!sessionID) return
|
||||
|
||||
console.log(`[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(console.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
|
||||
console.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
|
||||
}
|
||||
}
|
||||
|
||||
alert(`Error: ${message}`)
|
||||
}
|
||||
|
||||
function handleMessageRemoved(instanceId: string, event: MessageRemovedEvent): 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: MessagePartRemovedEvent): 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: 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
|
||||
|
||||
console.log(`[SSE] Permission updated: ${permission.id} (${permission.type})`)
|
||||
addPermissionToQueue(instanceId, permission)
|
||||
}
|
||||
|
||||
function handlePermissionReplied(instanceId: string, event: EventPermissionReplied): void {
|
||||
const { permissionID } = event.properties
|
||||
if (!permissionID) return
|
||||
|
||||
console.log(`[SSE] Permission replied: ${permissionID}`)
|
||||
removePermissionFromQueue(instanceId, permissionID)
|
||||
}
|
||||
|
||||
export {
|
||||
handleMessagePartRemoved,
|
||||
handleMessageRemoved,
|
||||
handleMessageUpdate,
|
||||
handlePermissionReplied,
|
||||
handlePermissionUpdated,
|
||||
handleSessionCompacted,
|
||||
handleSessionError,
|
||||
handleSessionIdle,
|
||||
handleSessionUpdate,
|
||||
handleTuiToast,
|
||||
}
|
||||
295
packages/ui/src/stores/session-messages.ts
Normal file
295
packages/ui/src/stores/session-messages.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import type { Message, MessageDisplayParts } from "../types/message"
|
||||
import { partHasRenderableText } from "../types/message"
|
||||
import type { Provider } from "../types/session"
|
||||
|
||||
import { decodeHtmlEntities } from "../lib/markdown"
|
||||
import { providers, sessions, setSessionInfoByInstance } from "./session-state"
|
||||
import { DEFAULT_MODEL_OUTPUT_LIMIT } from "./session-models"
|
||||
|
||||
interface SessionIndexCache {
|
||||
messageIndex: Map<string, number>
|
||||
partIndex: Map<string, Map<string, number>>
|
||||
}
|
||||
|
||||
const sessionIndexes = new Map<string, Map<string, SessionIndexCache>>()
|
||||
|
||||
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 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
|
||||
if (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
|
||||
nextPart.version = nextVersion
|
||||
return nextVersion
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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 actualUsageTokens = 0
|
||||
let contextUsagePercent: number | null = null
|
||||
let hasContextUsage = false
|
||||
|
||||
if (session.messagesInfo.size > 0) {
|
||||
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) {
|
||||
const inputTokens = usage.input || 0
|
||||
const reasoningTokens = usage.reasoning || 0
|
||||
const cacheReadTokens = usage.cache?.read || 0
|
||||
const cacheWriteTokens = usage.cache?.write || 0
|
||||
const outputTokens = usage.output || 0
|
||||
|
||||
if (info.summary) {
|
||||
tokens = outputTokens
|
||||
} else {
|
||||
tokens = inputTokens + cacheReadTokens + cacheWriteTokens + outputTokens + reasoningTokens
|
||||
}
|
||||
|
||||
cost = info.cost || 0
|
||||
actualUsageTokens = tokens
|
||||
hasContextUsage = inputTokens + cacheReadTokens + cacheWriteTokens > 0
|
||||
|
||||
modelID = info.modelID || ""
|
||||
providerID = info.providerID || ""
|
||||
isSubscriptionModel = cost === 0
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 outputBudget = Math.min(modelOutputLimit, DEFAULT_MODEL_OUTPUT_LIMIT)
|
||||
let contextUsageTokens = 0
|
||||
|
||||
if (hasContextUsage && actualUsageTokens > 0) {
|
||||
contextUsageTokens = actualUsageTokens + outputBudget
|
||||
if (contextWindow > 0) {
|
||||
const percent = Math.round((contextUsageTokens / contextWindow) * 100)
|
||||
contextUsagePercent = Math.min(100, Math.max(0, percent))
|
||||
} else {
|
||||
contextUsagePercent = null
|
||||
}
|
||||
} else {
|
||||
contextUsagePercent = contextWindow > 0 ? 0 : null
|
||||
}
|
||||
|
||||
setSessionInfoByInstance((prev) => {
|
||||
const next = new Map(prev)
|
||||
const instanceInfo = new Map(prev.get(instanceId))
|
||||
instanceInfo.set(sessionId, {
|
||||
tokens,
|
||||
cost,
|
||||
contextWindow,
|
||||
isSubscriptionModel,
|
||||
contextUsageTokens,
|
||||
contextUsagePercent,
|
||||
})
|
||||
next.set(instanceId, instanceInfo)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
bumpPartVersion,
|
||||
clearSessionIndex,
|
||||
computeDisplayParts,
|
||||
getSessionIndex,
|
||||
initializePartVersion,
|
||||
normalizeMessagePart,
|
||||
rebuildSessionIndex,
|
||||
removeSessionIndexes,
|
||||
updateSessionInfo,
|
||||
}
|
||||
83
packages/ui/src/stores/session-models.ts
Normal file
83
packages/ui/src/stores/session-models.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
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 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: "" }
|
||||
}
|
||||
|
||||
export { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, getRecentModelPreferenceForInstance, isModelValid }
|
||||
261
packages/ui/src/stores/session-state.ts
Normal file
261
packages/ui/src/stores/session-state.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import { createSignal } from "solid-js"
|
||||
|
||||
import type { Session, Agent, Provider } from "../types/session"
|
||||
|
||||
export interface SessionInfo {
|
||||
tokens: number
|
||||
cost: number
|
||||
contextWindow: number
|
||||
isSubscriptionModel: boolean
|
||||
contextUsageTokens: number
|
||||
contextUsagePercent: 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 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,
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
166
packages/ui/src/stores/session-status.ts
Normal file
166
packages/ui/src/stores/session-status.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import type { Session, SessionStatus } from "../types/session"
|
||||
import type { Message, MessageInfo } from "../types/message"
|
||||
import { sessions } from "./sessions"
|
||||
import { isSessionCompactionActive } from "./session-compaction"
|
||||
|
||||
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 getMessageTimestamp(session: Session, message?: Message): number {
|
||||
if (!message) return Number.NEGATIVE_INFINITY
|
||||
if (typeof message.timestamp === "number" && Number.isFinite(message.timestamp)) {
|
||||
return message.timestamp
|
||||
}
|
||||
const info = session.messagesInfo.get(message.id)
|
||||
return info?.time?.created ?? Number.NEGATIVE_INFINITY
|
||||
}
|
||||
|
||||
function getLastMessage(session: Session): Message | undefined {
|
||||
let latest: Message | undefined
|
||||
let latestTimestamp = Number.NEGATIVE_INFINITY
|
||||
for (const message of session.messages) {
|
||||
if (!message) continue
|
||||
const timestamp = getMessageTimestamp(session, message)
|
||||
if (timestamp >= latestTimestamp) {
|
||||
latest = message
|
||||
latestTimestamp = timestamp
|
||||
}
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
function getLastMessageInfo(session: Session, role?: MessageInfo["role"]): MessageInfo | undefined {
|
||||
if (session.messagesInfo.size === 0) {
|
||||
return undefined
|
||||
}
|
||||
let latest: MessageInfo | undefined
|
||||
let latestTimestamp = Number.NEGATIVE_INFINITY
|
||||
for (const info of session.messagesInfo.values()) {
|
||||
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 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 isAssistantStillGenerating(message: Message, info?: MessageInfo): boolean {
|
||||
if (message.type !== "assistant") {
|
||||
return false
|
||||
}
|
||||
|
||||
if (message.status === "error") {
|
||||
return false
|
||||
}
|
||||
|
||||
if (message.status === "streaming" || message.status === "sending") {
|
||||
return true
|
||||
}
|
||||
|
||||
const completedAt = (info?.time as { completed?: number } | undefined)?.completed
|
||||
if (completedAt !== undefined && completedAt !== null) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !(message.status === "complete" || message.status === "sent")
|
||||
}
|
||||
|
||||
export function getSessionStatus(instanceId: string, sessionId: string): SessionStatus {
|
||||
const session = getSession(instanceId, sessionId)
|
||||
if (!session) {
|
||||
return "idle"
|
||||
}
|
||||
|
||||
if (isSessionCompactionActive(instanceId, sessionId) || isSessionCompacting(session)) {
|
||||
return "compacting"
|
||||
}
|
||||
|
||||
const latestUserInfo = getLastMessageInfo(session, "user")
|
||||
const latestAssistantInfo = getLastMessageInfo(session, "assistant")
|
||||
const lastMessage = getLastMessage(session)
|
||||
if (!lastMessage) {
|
||||
const latestInfo = getLastMessageInfo(session)
|
||||
if (!latestInfo) {
|
||||
return "idle"
|
||||
}
|
||||
if (latestInfo.role === "user") {
|
||||
return "working"
|
||||
}
|
||||
const infoCompleted = latestInfo.time?.completed
|
||||
return infoCompleted ? "idle" : "working"
|
||||
}
|
||||
|
||||
if (lastMessage.type === "user") {
|
||||
return "working"
|
||||
}
|
||||
|
||||
const infoForMessage = session.messagesInfo.get(lastMessage.id) ?? latestAssistantInfo
|
||||
if (isAssistantStillGenerating(lastMessage, infoForMessage)) {
|
||||
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 { computeDisplayParts, removeSessionIndexes } from "./session-messages"
|
||||
import {
|
||||
createSession,
|
||||
deleteSession,
|
||||
fetchAgents,
|
||||
fetchProviders,
|
||||
fetchSessions,
|
||||
forkSession,
|
||||
loadMessages,
|
||||
} from "./session-api"
|
||||
import {
|
||||
abortSession,
|
||||
executeCustomCommand,
|
||||
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,
|
||||
computeDisplayParts,
|
||||
createSession,
|
||||
deleteSession,
|
||||
executeCustomCommand,
|
||||
runShellCommand,
|
||||
fetchAgents,
|
||||
fetchProviders,
|
||||
fetchSessions,
|
||||
forkSession,
|
||||
getActiveParentSession,
|
||||
getActiveSession,
|
||||
getChildSessions,
|
||||
getDefaultModel,
|
||||
getParentSessions,
|
||||
getSessionDraftPrompt,
|
||||
getSessionFamily,
|
||||
getSessionInfo,
|
||||
getSessions,
|
||||
isSessionBusy,
|
||||
isSessionMessagesLoading,
|
||||
loadMessages,
|
||||
loading,
|
||||
providers,
|
||||
removeSessionIndexes,
|
||||
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