1188 lines
36 KiB
TypeScript
1188 lines
36 KiB
TypeScript
import type { Session, Provider, Model } from "../types/session"
|
|
import type { Message } from "../types/message"
|
|
|
|
import { instances } from "./instances"
|
|
import { nativeSessionApi } from "../lib/lite-mode"
|
|
import { needsMigration, autoImportCachedSessions, markMigrated, cacheSDKSessions } from "./session-migration"
|
|
import { preferences, setAgentModelPreference, getAgentModelPreference } from "./preferences"
|
|
import { setSessionCompactionState } from "./session-compaction"
|
|
import {
|
|
activeSessionId,
|
|
agents,
|
|
clearSessionDraftPrompt,
|
|
getChildSessions,
|
|
isBlankSession,
|
|
messagesLoaded,
|
|
pruneDraftPrompts,
|
|
providers,
|
|
setActiveSessionId,
|
|
setAgents,
|
|
setMessagesLoaded,
|
|
setProviders,
|
|
setSessionInfoByInstance,
|
|
setSessions,
|
|
sessions,
|
|
loading,
|
|
setLoading,
|
|
cleanupBlankSessions,
|
|
} from "./session-state"
|
|
import { getInstanceConfig, ensureInstanceConfigLoaded } from "./instance-config"
|
|
import { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, isModelValid } from "./session-models"
|
|
import { normalizeMessagePart } from "./message-v2/normalizers"
|
|
import { updateSessionInfo } from "./message-v2/session-info"
|
|
import { seedSessionMessagesV2 } from "./message-v2/bridge"
|
|
import { messageStoreBus } from "./message-v2/bus"
|
|
import { clearCacheForSession } from "../lib/global-cache"
|
|
import { getLogger } from "../lib/logger"
|
|
import { showToastNotification } from "../lib/notifications"
|
|
import { getUserScopedKey } from "../lib/user-storage"
|
|
|
|
const log = getLogger("api")
|
|
|
|
type ProviderMap = Map<string, Provider>
|
|
|
|
async function fetchJson<T>(url: string): Promise<T | null> {
|
|
try {
|
|
const response = await fetch(url)
|
|
if (!response.ok) return null
|
|
return (await response.json()) as T
|
|
} catch (error) {
|
|
log.warn("Failed to fetch provider data", { url, error })
|
|
return null
|
|
}
|
|
}
|
|
|
|
function mergeProviders(base: Provider[], extras: Provider[]): Provider[] {
|
|
if (extras.length === 0) return base
|
|
const map: ProviderMap = new Map(base.map((provider) => [provider.id, { ...provider }]))
|
|
|
|
for (const extra of extras) {
|
|
const existing = map.get(extra.id)
|
|
if (!existing) {
|
|
map.set(extra.id, extra)
|
|
continue
|
|
}
|
|
|
|
const modelMap = new Map<string, Model>(existing.models.map((model) => [model.id, model]))
|
|
for (const model of extra.models) {
|
|
if (!modelMap.has(model.id)) {
|
|
modelMap.set(model.id, model)
|
|
}
|
|
}
|
|
existing.models = Array.from(modelMap.values())
|
|
}
|
|
|
|
return Array.from(map.values())
|
|
}
|
|
|
|
const OLLAMA_TOAST_COOLDOWN_MS = 30000
|
|
let lastOllamaToastAt = 0
|
|
let lastOllamaToastKey = ""
|
|
|
|
function showOllamaToastOnce(key: string, payload: Parameters<typeof showToastNotification>[0]) {
|
|
const now = Date.now()
|
|
if (lastOllamaToastKey === key && now - lastOllamaToastAt < OLLAMA_TOAST_COOLDOWN_MS) {
|
|
return
|
|
}
|
|
lastOllamaToastKey = key
|
|
lastOllamaToastAt = now
|
|
showToastNotification(payload)
|
|
}
|
|
|
|
async function fetchOllamaCloudProvider(): Promise<Provider | null> {
|
|
try {
|
|
const config = await fetchJson<{ config?: { enabled?: boolean } }>("/api/ollama/config")
|
|
if (config && config.config?.enabled === false) {
|
|
return null
|
|
}
|
|
|
|
const response = await fetch("/api/ollama/models")
|
|
if (!response.ok) {
|
|
const errorText = await response.text().catch(() => "")
|
|
showOllamaToastOnce(`ollama-unavailable-${response.status}`, {
|
|
title: "Ollama Cloud unavailable",
|
|
message: errorText
|
|
? `Unable to load Ollama Cloud models (${response.status}). ${errorText}`
|
|
: "Unable to load Ollama Cloud models. Check that the integration is enabled and the API key is valid.",
|
|
variant: "warning",
|
|
duration: 8000,
|
|
})
|
|
return null
|
|
}
|
|
const data = (await response.json()) as { models?: Array<{ name?: string } | string> }
|
|
const models = Array.isArray(data?.models) ? data.models : []
|
|
const modelNames = models
|
|
.map((model) => (typeof model === "string" ? model : model?.name))
|
|
.filter((name): name is string => Boolean(name))
|
|
if (modelNames.length === 0) {
|
|
showOllamaToastOnce("ollama-empty", {
|
|
title: "Ollama Cloud models unavailable",
|
|
message: "Ollama Cloud returned no models. Check your API key and endpoint.",
|
|
variant: "warning",
|
|
duration: 8000,
|
|
})
|
|
return null
|
|
}
|
|
|
|
return {
|
|
id: "ollama-cloud",
|
|
name: "Ollama Cloud",
|
|
models: modelNames.map((name) => ({
|
|
id: name,
|
|
name,
|
|
providerId: "ollama-cloud",
|
|
})),
|
|
}
|
|
} catch (error) {
|
|
log.warn("Failed to fetch Ollama Cloud models", { error })
|
|
showOllamaToastOnce("ollama-fetch-error", {
|
|
title: "Ollama Cloud unavailable",
|
|
message: "Unable to load Ollama Cloud models. Check that the integration is enabled and the API key is valid.",
|
|
variant: "warning",
|
|
duration: 8000,
|
|
})
|
|
return null
|
|
}
|
|
}
|
|
|
|
function getStoredQwenToken():
|
|
| { access_token: string; expires_in: number; created_at: number }
|
|
| null {
|
|
if (typeof window === "undefined") return null
|
|
try {
|
|
const raw = window.localStorage.getItem(getUserScopedKey("qwen_oauth_token"))
|
|
if (!raw) return null
|
|
return JSON.parse(raw)
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function isQwenTokenValid(token: { expires_in: number; created_at: number } | null): boolean {
|
|
if (!token) return false
|
|
const createdAt = token.created_at > 1e12 ? Math.floor(token.created_at / 1000) : token.created_at
|
|
const expiresAt = (createdAt + token.expires_in) * 1000 - 300000
|
|
return Date.now() < expiresAt
|
|
}
|
|
|
|
async function fetchQwenOAuthProvider(): Promise<Provider | null> {
|
|
const token = getStoredQwenToken()
|
|
if (!isQwenTokenValid(token)) return null
|
|
|
|
// Use actual Qwen model IDs that work with the DashScope API
|
|
const qwenModels: Model[] = [
|
|
{
|
|
id: "qwen-coder-plus-latest",
|
|
name: "Qwen Coder Plus (OAuth)",
|
|
providerId: "qwen-oauth",
|
|
limit: { context: 131072, output: 16384 },
|
|
},
|
|
{
|
|
id: "qwen-turbo-latest",
|
|
name: "Qwen Turbo (OAuth)",
|
|
providerId: "qwen-oauth",
|
|
limit: { context: 131072, output: 8192 },
|
|
},
|
|
{
|
|
id: "qwen-plus-latest",
|
|
name: "Qwen Plus (OAuth)",
|
|
providerId: "qwen-oauth",
|
|
limit: { context: 131072, output: 8192 },
|
|
},
|
|
{
|
|
id: "qwen-max-latest",
|
|
name: "Qwen Max (OAuth)",
|
|
providerId: "qwen-oauth",
|
|
limit: { context: 32768, output: 8192 },
|
|
},
|
|
]
|
|
|
|
return {
|
|
id: "qwen-oauth",
|
|
name: "Qwen OAuth",
|
|
models: qwenModels,
|
|
defaultModelId: "qwen-coder-plus-latest",
|
|
}
|
|
}
|
|
|
|
async function fetchOpenCodeZenProvider(): Promise<Provider | null> {
|
|
const data = await fetchJson<{ models?: Array<{ id: string; name: string; limit?: Model["limit"]; cost?: Model["cost"] }> }>(
|
|
"/api/opencode-zen/models",
|
|
)
|
|
const models = Array.isArray(data?.models) ? data?.models ?? [] : []
|
|
if (models.length === 0) return null
|
|
|
|
return {
|
|
id: "opencode-zen",
|
|
name: "OpenCode Zen",
|
|
models: models.map((model) => ({
|
|
id: model.id,
|
|
name: model.name,
|
|
providerId: "opencode-zen",
|
|
limit: model.limit,
|
|
cost: model.cost,
|
|
})),
|
|
}
|
|
}
|
|
|
|
async function fetchZAIProvider(): Promise<Provider | null> {
|
|
try {
|
|
const config = await fetchJson<{ config?: { enabled?: boolean } }>("/api/zai/config")
|
|
if (!config?.config?.enabled) return null
|
|
|
|
const data = await fetchJson<{ models?: Array<{ name: string; provider: string }> }>("/api/zai/models")
|
|
const models = Array.isArray(data?.models) ? data.models : []
|
|
if (models.length === 0) return null
|
|
|
|
return {
|
|
id: "zai",
|
|
name: "Z.AI Coding Plan",
|
|
models: models.map((model) => ({
|
|
id: model.name,
|
|
name: model.name.toUpperCase(),
|
|
providerId: "zai",
|
|
limit: { context: 131072, output: 8192 },
|
|
})),
|
|
defaultModelId: "glm-4.7",
|
|
}
|
|
} catch (error) {
|
|
log.warn("Failed to fetch Z.AI models", { error })
|
|
return null
|
|
}
|
|
}
|
|
|
|
function getStoredAntigravityToken():
|
|
| { access_token: string; expires_in: number; created_at: number }
|
|
| null {
|
|
if (typeof window === "undefined") return null
|
|
try {
|
|
const raw = window.localStorage.getItem(getUserScopedKey("antigravity_oauth_token"))
|
|
if (!raw) return null
|
|
return JSON.parse(raw)
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function isAntigravityTokenValid(token: { expires_in: number; created_at: number } | null): boolean {
|
|
if (!token) return false
|
|
const createdAt = token.created_at > 1e12 ? Math.floor(token.created_at / 1000) : token.created_at
|
|
const expiresAt = (createdAt + token.expires_in) * 1000 - 300000
|
|
return Date.now() < expiresAt
|
|
}
|
|
|
|
async function fetchAntigravityProvider(): Promise<Provider | null> {
|
|
// Check if user is authenticated with Antigravity (Google OAuth)
|
|
const token = getStoredAntigravityToken()
|
|
if (!isAntigravityTokenValid(token)) {
|
|
// Not authenticated - try to fetch models anyway (they show as available but require auth)
|
|
try {
|
|
const data = await fetchJson<{ models?: Array<{ id: string; name: string; limit?: Model["limit"] }> }>(
|
|
"/api/antigravity/models",
|
|
)
|
|
const models = Array.isArray(data?.models) ? data?.models ?? [] : []
|
|
if (models.length === 0) return null
|
|
|
|
return {
|
|
id: "antigravity",
|
|
name: "Antigravity (Google OAuth)",
|
|
models: models.map((model) => ({
|
|
id: model.id,
|
|
name: model.name,
|
|
providerId: "antigravity",
|
|
limit: model.limit,
|
|
})),
|
|
defaultModelId: "gemini-3-pro-high",
|
|
}
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
// User is authenticated - fetch full model list
|
|
const data = await fetchJson<{ models?: Array<{ id: string; name: string; limit?: Model["limit"] }> }>(
|
|
"/api/antigravity/models",
|
|
)
|
|
const models = Array.isArray(data?.models) ? data?.models ?? [] : []
|
|
if (models.length === 0) return null
|
|
|
|
return {
|
|
id: "antigravity",
|
|
name: "Antigravity (Google OAuth)",
|
|
models: models.map((model) => ({
|
|
id: model.id,
|
|
name: model.name,
|
|
providerId: "antigravity",
|
|
limit: model.limit,
|
|
})),
|
|
defaultModelId: "gemini-3-pro-high",
|
|
}
|
|
}
|
|
|
|
async function fetchExtraProviders(): Promise<Provider[]> {
|
|
const [ollama, zen, qwen, zai, antigravity] = await Promise.all([
|
|
fetchOllamaCloudProvider(),
|
|
fetchOpenCodeZenProvider(),
|
|
fetchQwenOAuthProvider(),
|
|
fetchZAIProvider(),
|
|
fetchAntigravityProvider(),
|
|
])
|
|
return [ollama, zen, qwen, zai, antigravity].filter((provider): provider is Provider => Boolean(provider))
|
|
}
|
|
|
|
function removeDuplicateProviders(base: Provider[], extras: Provider[]): Provider[] {
|
|
const extraModelIds = new Set(extras.flatMap((provider) => provider.models.map((model) => model.id)))
|
|
if (!extras.some((provider) => provider.id === "opencode-zen")) {
|
|
return base
|
|
}
|
|
|
|
return base.filter((provider) => {
|
|
if (provider.id === "opencode-zen") return false
|
|
if (provider.id === "opencode" && provider.models.every((model) => extraModelIds.has(model.id))) {
|
|
return false
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
|
|
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) {
|
|
throw new Error("Instance not ready")
|
|
}
|
|
|
|
const isNative = instance.binaryPath === "__nomadarch_native__"
|
|
|
|
if (!isNative && !instance.client) {
|
|
throw new Error("Instance client not ready")
|
|
}
|
|
|
|
setLoading((prev) => {
|
|
const next = { ...prev }
|
|
next.fetchingSessions.set(instanceId, true)
|
|
return next
|
|
})
|
|
|
|
try {
|
|
log.info("session.list", { instanceId, isNative })
|
|
|
|
let responseData: any[] = []
|
|
|
|
if (isNative) {
|
|
// Auto-sync SDK sessions from OpenCode's storage on native mode startup
|
|
if (needsMigration(instanceId)) {
|
|
try {
|
|
// First try to sync directly from OpenCode's storage (most reliable)
|
|
const folderPath = instance.folder
|
|
if (folderPath) {
|
|
log.info({ instanceId, folderPath }, "Syncing SDK sessions from OpenCode storage")
|
|
const syncResult = await nativeSessionApi.syncFromSdk(instanceId, folderPath)
|
|
if (syncResult.imported > 0) {
|
|
log.info({ instanceId, syncResult }, "Synced SDK sessions from OpenCode storage")
|
|
} else if (syncResult.message) {
|
|
log.info({ instanceId, message: syncResult.message }, "SDK sync info")
|
|
}
|
|
}
|
|
|
|
// Also try the localStorage cache as fallback
|
|
const cacheResult = await autoImportCachedSessions(instanceId)
|
|
if (cacheResult.imported > 0) {
|
|
log.info({ instanceId, cacheResult }, "Auto-imported cached SDK sessions")
|
|
}
|
|
} catch (error) {
|
|
log.error({ instanceId, error }, "Failed to sync SDK sessions")
|
|
markMigrated(instanceId) // Mark as migrated to prevent repeated failures
|
|
}
|
|
}
|
|
|
|
const nativeSessions = await nativeSessionApi.listSessions(instanceId)
|
|
responseData = nativeSessions.map(s => ({
|
|
id: s.id,
|
|
title: s.title,
|
|
parentID: s.parentId,
|
|
version: "0",
|
|
time: {
|
|
created: s.createdAt,
|
|
updated: s.updatedAt
|
|
},
|
|
model: s.model ? {
|
|
providerID: s.model.providerId,
|
|
modelID: s.model.modelId
|
|
} : undefined,
|
|
agent: s.agent
|
|
}))
|
|
} else {
|
|
const response = await instance.client!.session.list()
|
|
if (response.data && Array.isArray(response.data)) {
|
|
responseData = response.data
|
|
}
|
|
}
|
|
|
|
const sessionMap = new Map<string, Session>()
|
|
|
|
if (responseData.length === 0 && !isNative) {
|
|
// In SDK mode we still check response.data for empty
|
|
}
|
|
|
|
const existingSessions = sessions().get(instanceId)
|
|
|
|
// Load session tasks from storage
|
|
await ensureInstanceConfigLoaded(instanceId)
|
|
const instanceData = getInstanceConfig(instanceId)
|
|
const sessionTasks = instanceData.sessionTasks || {}
|
|
const sessionSkills = instanceData.sessionSkills || {}
|
|
|
|
for (const apiSession of responseData) {
|
|
const existingSession = existingSessions?.get(apiSession.id)
|
|
|
|
const existingModel = existingSession?.model ?? { providerId: "", modelId: "" }
|
|
const hasUserSelectedModel = existingModel.providerId && existingModel.modelId
|
|
const apiModel = apiSession.model?.providerID && apiSession.model?.modelID
|
|
? { providerId: apiSession.model.providerID, modelId: apiSession.model.modelID }
|
|
: { providerId: "", modelId: "" }
|
|
|
|
sessionMap.set(apiSession.id, {
|
|
id: apiSession.id,
|
|
instanceId,
|
|
title: apiSession.title || "Untitled",
|
|
parentId: apiSession.parentID || null,
|
|
agent: existingSession?.agent ?? apiSession.agent ?? "",
|
|
model: hasUserSelectedModel ? existingModel : apiModel,
|
|
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,
|
|
tasks: sessionTasks[apiSession.id] || [],
|
|
skills: sessionSkills[apiSession.id] || [],
|
|
})
|
|
}
|
|
|
|
const validSessionIds = new Set(sessionMap.keys())
|
|
|
|
setSessions((prev) => {
|
|
const next = new Map(prev)
|
|
next.set(instanceId, sessionMap)
|
|
return next
|
|
})
|
|
|
|
// Cache SDK sessions to localStorage for later migration to native mode
|
|
if (!isNative && sessionMap.size > 0) {
|
|
cacheSDKSessions(instanceId, Array.from(sessionMap.values()))
|
|
}
|
|
|
|
|
|
setMessagesLoaded((prev) => {
|
|
const next = new Map(prev)
|
|
const loadedSet = next.get(instanceId)
|
|
if (loadedSet) {
|
|
const filtered = new Set<string>()
|
|
for (const id of loadedSet) {
|
|
if (validSessionIds.has(id)) {
|
|
filtered.add(id)
|
|
}
|
|
}
|
|
next.set(instanceId, filtered)
|
|
}
|
|
return next
|
|
})
|
|
|
|
for (const session of sessionMap.values()) {
|
|
const flag = (session.time as (Session["time"] & { compacting?: number | boolean }) | undefined)?.compacting
|
|
const active = typeof flag === "number" ? flag > 0 : Boolean(flag)
|
|
setSessionCompactionState(instanceId, session.id, active)
|
|
}
|
|
|
|
pruneDraftPrompts(instanceId, new Set(sessionMap.keys()))
|
|
} catch (error) {
|
|
log.error("Failed to fetch sessions:", error)
|
|
throw error
|
|
} finally {
|
|
setLoading((prev) => {
|
|
const next = { ...prev }
|
|
next.fetchingSessions.set(instanceId, false)
|
|
return next
|
|
})
|
|
}
|
|
}
|
|
|
|
async function createSession(
|
|
instanceId: string,
|
|
agent?: string,
|
|
options?: { skipAutoCleanup?: boolean },
|
|
): Promise<Session> {
|
|
const instance = instances().get(instanceId)
|
|
if (!instance) {
|
|
throw new Error("Instance not ready")
|
|
}
|
|
|
|
const isNative = instance.binaryPath === "__nomadarch_native__"
|
|
if (!isNative && !instance.client) {
|
|
throw new Error("Instance client 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 agentModelPreference = await getAgentModelPreference(instanceId, selectedAgent)
|
|
const defaultModel = await getDefaultModel(instanceId, selectedAgent)
|
|
const sessionModel = agentModelPreference || defaultModel
|
|
|
|
if (selectedAgent && isModelValid(instanceId, sessionModel) && !agentModelPreference) {
|
|
await setAgentModelPreference(instanceId, selectedAgent, sessionModel)
|
|
}
|
|
|
|
setLoading((prev) => {
|
|
const next = { ...prev }
|
|
next.creatingSession.set(instanceId, true)
|
|
return next
|
|
})
|
|
|
|
try {
|
|
log.info(`[HTTP] POST session create for instance ${instanceId}, isNative: ${isNative}`)
|
|
|
|
let sessionData: any = null
|
|
|
|
if (isNative) {
|
|
const native = await nativeSessionApi.createSession(instanceId, {
|
|
agent: selectedAgent,
|
|
model: sessionModel
|
|
})
|
|
sessionData = {
|
|
id: native.id,
|
|
title: native.title || "New Session",
|
|
parentID: native.parentId,
|
|
version: "0",
|
|
time: {
|
|
created: native.createdAt,
|
|
updated: native.updatedAt
|
|
},
|
|
agent: native.agent,
|
|
model: native.model ? {
|
|
providerID: native.model.providerId,
|
|
modelID: native.model.modelId
|
|
} : undefined
|
|
}
|
|
} else {
|
|
const response = await instance.client!.session.create()
|
|
sessionData = response.data
|
|
}
|
|
|
|
if (!sessionData) {
|
|
throw new Error("Failed to create session: No data returned")
|
|
}
|
|
|
|
const session: Session = {
|
|
id: sessionData.id,
|
|
instanceId,
|
|
title: sessionData.title || "New Session",
|
|
parentId: null,
|
|
agent: selectedAgent,
|
|
model: sessionModel,
|
|
skills: [],
|
|
version: sessionData.version,
|
|
time: {
|
|
...sessionData.time,
|
|
},
|
|
revert: sessionData.revert
|
|
? {
|
|
messageID: sessionData.revert.messageID,
|
|
partID: sessionData.revert.partID,
|
|
snapshot: sessionData.revert.snapshot,
|
|
diff: sessionData.revert.diff,
|
|
}
|
|
: undefined,
|
|
}
|
|
|
|
setSessions((prev) => {
|
|
const next = new Map(prev)
|
|
const instanceSessions = next.get(instanceId) || new Map()
|
|
instanceSessions.set(session.id, session)
|
|
next.set(instanceId, instanceSessions)
|
|
return next
|
|
})
|
|
|
|
const instanceProviders = providers().get(instanceId) || []
|
|
const initialProvider = instanceProviders.find((p) => p.id === session.model.providerId)
|
|
const initialModel = initialProvider?.models.find((m) => m.id === session.model.modelId)
|
|
const initialContextWindow = initialModel?.limit?.context ?? 0
|
|
const initialSubscriptionModel = initialModel?.cost?.input === 0 && initialModel?.cost?.output === 0
|
|
const initialOutputLimit =
|
|
initialModel?.limit?.output && initialModel.limit.output > 0
|
|
? initialModel.limit.output
|
|
: DEFAULT_MODEL_OUTPUT_LIMIT
|
|
const initialContextAvailable = initialContextWindow > 0 ? initialContextWindow : null
|
|
|
|
setSessionInfoByInstance((prev) => {
|
|
const next = new Map(prev)
|
|
const instanceInfo = new Map(prev.get(instanceId))
|
|
instanceInfo.set(session.id, {
|
|
cost: 0,
|
|
contextWindow: initialContextWindow,
|
|
isSubscriptionModel: Boolean(initialSubscriptionModel),
|
|
inputTokens: 0,
|
|
outputTokens: 0,
|
|
reasoningTokens: 0,
|
|
cacheReadTokens: 0,
|
|
cacheWriteTokens: 0,
|
|
actualUsageTokens: 0,
|
|
modelOutputLimit: initialOutputLimit,
|
|
contextAvailableTokens: initialContextAvailable,
|
|
})
|
|
next.set(instanceId, instanceInfo)
|
|
return next
|
|
})
|
|
|
|
if (preferences().autoCleanupBlankSessions && !options?.skipAutoCleanup) {
|
|
await cleanupBlankSessions(instanceId, session.id)
|
|
}
|
|
|
|
return session
|
|
} catch (error) {
|
|
log.error("Failed to create session:", error)
|
|
throw error
|
|
} finally {
|
|
setLoading((prev) => {
|
|
const next = { ...prev }
|
|
next.creatingSession.set(instanceId, false)
|
|
return next
|
|
})
|
|
}
|
|
}
|
|
|
|
async function forkSession(
|
|
instanceId: string,
|
|
sourceSessionId: string,
|
|
options?: { messageId?: string },
|
|
): Promise<Session> {
|
|
const instance = instances().get(instanceId)
|
|
if (!instance || !instance.client) {
|
|
throw new Error("Instance not ready")
|
|
}
|
|
|
|
const request: {
|
|
path: { id: string }
|
|
body?: { messageID: string }
|
|
} = {
|
|
path: { id: sourceSessionId },
|
|
}
|
|
|
|
if (options?.messageId) {
|
|
request.body = { messageID: options.messageId }
|
|
}
|
|
|
|
log.info(`[HTTP] POST /session.fork for instance ${instanceId}`, request)
|
|
const response = await instance.client.session.fork(request)
|
|
|
|
if (!response.data) {
|
|
throw new Error("Failed to fork session: No data returned")
|
|
}
|
|
|
|
const sourceSession = sessions().get(instanceId)?.get(sourceSessionId)
|
|
const sourceModel = sourceSession?.model ?? { providerId: "", modelId: "" }
|
|
const sourceSkills = sourceSession?.skills ?? []
|
|
|
|
const info = response.data as SessionForkResponse
|
|
const forkedSession = {
|
|
id: info.id,
|
|
instanceId,
|
|
title: info.title || "Forked Session",
|
|
parentId: info.parentID || sourceSessionId, // Fallback to source session to ensure parent-child relationship
|
|
agent: info.agent || sourceSession?.agent || "",
|
|
model: sourceModel.providerId && sourceModel.modelId
|
|
? { providerId: sourceModel.providerId, modelId: sourceModel.modelId }
|
|
: {
|
|
providerId: info.model?.providerID || "",
|
|
modelId: info.model?.modelID || "",
|
|
},
|
|
skills: sourceSkills,
|
|
version: "0",
|
|
time: info.time ? { ...info.time } : { created: Date.now(), updated: Date.now() },
|
|
revert: info.revert
|
|
? {
|
|
messageID: info.revert.messageID,
|
|
partID: info.revert.partID,
|
|
snapshot: info.revert.snapshot,
|
|
diff: info.revert.diff,
|
|
}
|
|
: undefined,
|
|
} as unknown as Session
|
|
|
|
setSessions((prev) => {
|
|
const next = new Map(prev)
|
|
const instanceSessions = next.get(instanceId) || new Map()
|
|
instanceSessions.set(forkedSession.id, forkedSession)
|
|
next.set(instanceId, instanceSessions)
|
|
return next
|
|
})
|
|
|
|
const instanceProviders = providers().get(instanceId) || []
|
|
const forkProvider = instanceProviders.find((p) => p.id === forkedSession.model.providerId)
|
|
const forkModel = forkProvider?.models.find((m) => m.id === forkedSession.model.modelId)
|
|
const forkContextWindow = forkModel?.limit?.context ?? 0
|
|
const forkSubscriptionModel = forkModel?.cost?.input === 0 && forkModel?.cost?.output === 0
|
|
const forkOutputLimit =
|
|
forkModel?.limit?.output && forkModel.limit.output > 0 ? forkModel.limit.output : DEFAULT_MODEL_OUTPUT_LIMIT
|
|
const forkContextAvailable = forkContextWindow > 0 ? forkContextWindow : null
|
|
|
|
setSessionInfoByInstance((prev) => {
|
|
const next = new Map(prev)
|
|
const instanceInfo = new Map(prev.get(instanceId))
|
|
instanceInfo.set(forkedSession.id, {
|
|
cost: 0,
|
|
contextWindow: forkContextWindow,
|
|
isSubscriptionModel: Boolean(forkSubscriptionModel),
|
|
inputTokens: 0,
|
|
outputTokens: 0,
|
|
reasoningTokens: 0,
|
|
cacheReadTokens: 0,
|
|
cacheWriteTokens: 0,
|
|
actualUsageTokens: 0,
|
|
modelOutputLimit: forkOutputLimit,
|
|
contextAvailableTokens: forkContextAvailable,
|
|
})
|
|
next.set(instanceId, instanceInfo)
|
|
return next
|
|
})
|
|
|
|
return forkedSession
|
|
}
|
|
|
|
async function deleteSession(instanceId: string, sessionId: string): Promise<void> {
|
|
const instance = instances().get(instanceId)
|
|
if (!instance) return
|
|
|
|
const isNative = instance.binaryPath === "__nomadarch_native__"
|
|
if (!isNative && !instance.client) return
|
|
|
|
setLoading((prev) => {
|
|
const next = { ...prev }
|
|
const deleting = next.deletingSession.get(instanceId) || new Set()
|
|
deleting.add(sessionId)
|
|
next.deletingSession.set(instanceId, deleting)
|
|
return next
|
|
})
|
|
|
|
try {
|
|
log.info("session.delete", { instanceId, sessionId, isNative })
|
|
|
|
if (isNative) {
|
|
await nativeSessionApi.deleteSession(instanceId, sessionId)
|
|
} else {
|
|
await instance.client!.session.delete({ path: { id: sessionId } })
|
|
}
|
|
|
|
setSessions((prev) => {
|
|
const next = new Map(prev)
|
|
const instanceSessions = next.get(instanceId)
|
|
if (instanceSessions) {
|
|
instanceSessions.delete(sessionId)
|
|
}
|
|
return next
|
|
})
|
|
|
|
setSessionCompactionState(instanceId, sessionId, false)
|
|
clearSessionDraftPrompt(instanceId, sessionId)
|
|
|
|
// Drop normalized message state and caches for this session
|
|
messageStoreBus.getOrCreate(instanceId).clearSession(sessionId)
|
|
clearCacheForSession(instanceId, sessionId)
|
|
|
|
setSessionInfoByInstance((prev) => {
|
|
const next = new Map(prev)
|
|
const instanceInfo = next.get(instanceId)
|
|
if (instanceInfo) {
|
|
const updatedInstanceInfo = new Map(instanceInfo)
|
|
updatedInstanceInfo.delete(sessionId)
|
|
if (updatedInstanceInfo.size === 0) {
|
|
next.delete(instanceId)
|
|
} else {
|
|
next.set(instanceId, updatedInstanceInfo)
|
|
}
|
|
}
|
|
return next
|
|
})
|
|
|
|
if (activeSessionId().get(instanceId) === sessionId) {
|
|
setActiveSessionId((prev) => {
|
|
const next = new Map(prev)
|
|
next.delete(instanceId)
|
|
return next
|
|
})
|
|
}
|
|
} catch (error) {
|
|
log.error("Failed to delete session:", error)
|
|
throw error
|
|
} finally {
|
|
setLoading((prev) => {
|
|
const next = { ...prev }
|
|
const deleting = next.deletingSession.get(instanceId)
|
|
if (deleting) {
|
|
deleting.delete(sessionId)
|
|
}
|
|
return next
|
|
})
|
|
}
|
|
}
|
|
|
|
async function fetchAgents(instanceId: string): Promise<void> {
|
|
const instance = instances().get(instanceId)
|
|
if (!instance) {
|
|
throw new Error("Instance not ready")
|
|
}
|
|
|
|
const isNative = instance.binaryPath === "__nomadarch_native__"
|
|
if (!isNative && !instance.client) {
|
|
throw new Error("Instance client not ready")
|
|
}
|
|
|
|
try {
|
|
log.info("agents.list", { instanceId, isNative })
|
|
|
|
let agentList: any[] = []
|
|
|
|
if (isNative) {
|
|
// In native mode, we don't have agents from the SDK yet
|
|
// We can return a default agent or common agents
|
|
agentList = [{
|
|
name: "Assistant",
|
|
description: "Native assistant agent",
|
|
mode: "native"
|
|
}]
|
|
} else {
|
|
const response = await instance.client!.app.agents()
|
|
agentList = (response.data || []).map((agent: any) => ({
|
|
name: agent.name,
|
|
description: agent.description || "",
|
|
mode: agent.mode as "standard" | "subagent",
|
|
model: agent.model
|
|
? {
|
|
providerId: agent.model.providerID || "",
|
|
modelId: agent.model.modelID,
|
|
}
|
|
: undefined,
|
|
}))
|
|
}
|
|
|
|
const customAgents = getInstanceConfig(instanceId)?.customAgents ?? []
|
|
const customList = customAgents.map((agent) => ({
|
|
name: agent.name,
|
|
description: agent.description || "",
|
|
mode: "custom",
|
|
}))
|
|
|
|
setAgents((prev) => {
|
|
const next = new Map(prev)
|
|
next.set(instanceId, [...agentList, ...customList])
|
|
return next
|
|
})
|
|
} catch (error) {
|
|
log.error("Failed to fetch agents:", error)
|
|
}
|
|
}
|
|
|
|
async function fetchProviders(instanceId: string): Promise<void> {
|
|
const instance = instances().get(instanceId)
|
|
if (!instance) {
|
|
throw new Error("Instance not ready")
|
|
}
|
|
|
|
const isNative = instance.binaryPath === "__nomadarch_native__"
|
|
if (!isNative && !instance.client) {
|
|
throw new Error("Instance client not ready")
|
|
}
|
|
|
|
try {
|
|
log.info("config.providers", { instanceId, isNative })
|
|
|
|
let providerList: any[] = []
|
|
let defaultProviders: any = {}
|
|
|
|
if (isNative) {
|
|
// For native mode, we mainly rely on extra providers
|
|
// but we could add "zen" (OpenCode Zen) if it's available via server API
|
|
providerList = []
|
|
} else {
|
|
const response = await instance.client!.config.providers()
|
|
if (response.data) {
|
|
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,
|
|
})),
|
|
}))
|
|
defaultProviders = response.data.default || {}
|
|
}
|
|
}
|
|
|
|
// Filter out Z.AI providers from SDK to use our custom routing with full message history
|
|
const filteredBaseProviders = providerList.filter((provider) =>
|
|
!provider.id.toLowerCase().includes("zai") &&
|
|
!provider.id.toLowerCase().includes("z.ai") &&
|
|
!provider.id.toLowerCase().includes("glm")
|
|
)
|
|
|
|
const extraProviders = await fetchExtraProviders()
|
|
const baseProviders = removeDuplicateProviders(filteredBaseProviders, extraProviders)
|
|
const mergedProviders = mergeProviders(baseProviders, extraProviders)
|
|
|
|
setProviders((prev) => {
|
|
const next = new Map(prev)
|
|
next.set(instanceId, mergedProviders)
|
|
return next
|
|
})
|
|
} catch (error) {
|
|
log.error("Failed to fetch providers:", error)
|
|
}
|
|
}
|
|
|
|
async function loadMessages(instanceId: string, sessionId: string, force = false): Promise<void> {
|
|
if (force) {
|
|
setMessagesLoaded((prev) => {
|
|
const next = new Map(prev)
|
|
const loadedSet = next.get(instanceId)
|
|
if (loadedSet) {
|
|
loadedSet.delete(sessionId)
|
|
}
|
|
return next
|
|
})
|
|
}
|
|
|
|
const alreadyLoaded = messagesLoaded().get(instanceId)?.has(sessionId)
|
|
if (alreadyLoaded && !force) {
|
|
return
|
|
}
|
|
|
|
const isLoading = loading().loadingMessages.get(instanceId)?.has(sessionId)
|
|
if (isLoading) {
|
|
return
|
|
}
|
|
|
|
const instance = instances().get(instanceId)
|
|
if (!instance) {
|
|
throw new Error("Instance not ready")
|
|
}
|
|
|
|
const isNative = instance.binaryPath === "__nomadarch_native__"
|
|
if (!isNative && !instance.client) {
|
|
throw new Error("Instance client not ready")
|
|
}
|
|
|
|
const instanceSessions = sessions().get(instanceId)
|
|
const session = instanceSessions?.get(sessionId)
|
|
if (!session) {
|
|
throw new Error("Session not found")
|
|
}
|
|
|
|
setLoading((prev) => {
|
|
const next = { ...prev }
|
|
const loadingSet = next.loadingMessages.get(instanceId) || new Set()
|
|
loadingSet.add(sessionId)
|
|
next.loadingMessages.set(instanceId, loadingSet)
|
|
return next
|
|
})
|
|
|
|
try {
|
|
log.info("session.getMessages", { instanceId, sessionId, isNative })
|
|
|
|
let apiMessages: any[] = []
|
|
let apiMessagesInfo: any = {}
|
|
|
|
if (isNative) {
|
|
const nativeMessages = await nativeSessionApi.getMessages(instanceId, sessionId)
|
|
apiMessages = nativeMessages.map(m => ({
|
|
id: m.id,
|
|
role: m.role,
|
|
content: m.content || "",
|
|
createdAt: m.createdAt,
|
|
status: m.status,
|
|
info: {
|
|
id: m.id,
|
|
role: m.role,
|
|
time: { created: m.createdAt },
|
|
// Add other native message properties to info if needed for later processing
|
|
}
|
|
}))
|
|
} else {
|
|
const response = await instance.client!.session.messages({ path: { id: sessionId } })
|
|
if (!response.data || !Array.isArray(response.data)) {
|
|
return
|
|
}
|
|
apiMessages = response.data || []
|
|
apiMessagesInfo = (response as any).info || {} // Assuming 'info' might be on the response object itself for some cases
|
|
}
|
|
|
|
const messagesInfo = new Map<string, any>()
|
|
const messages: Message[] = apiMessages.map((apiMessage: any) => {
|
|
const info = apiMessage.info || apiMessage
|
|
const role = info.role || "assistant"
|
|
const messageId = info.id || String(Date.now())
|
|
|
|
messagesInfo.set(messageId, info)
|
|
|
|
const parts: any[] = (apiMessage.parts || []).map((part: any) => normalizeMessagePart(part))
|
|
|
|
const message: Message = {
|
|
id: messageId,
|
|
sessionId,
|
|
type: role === "user" ? "user" : "assistant",
|
|
parts,
|
|
timestamp: info.time?.created || Date.now(),
|
|
status: "complete" as const,
|
|
version: 0,
|
|
}
|
|
|
|
return message
|
|
})
|
|
|
|
let agentName = ""
|
|
let providerID = ""
|
|
let modelID = ""
|
|
|
|
for (let i = apiMessages.length - 1; i >= 0; i--) {
|
|
const apiMessage = apiMessages[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 currentModel = existingSession.model
|
|
const hasUserSelectedModel = currentModel.providerId && currentModel.modelId
|
|
|
|
const updatedSession = {
|
|
...existingSession,
|
|
agent: agentName || existingSession.agent,
|
|
model: hasUserSelectedModel ? currentModel : (providerID && modelID ? { providerId: providerID, modelId: modelID } : currentModel),
|
|
}
|
|
const updatedInstanceSessions = new Map(nextInstanceSessions)
|
|
updatedInstanceSessions.set(sessionId, updatedSession)
|
|
next.set(instanceId, updatedInstanceSessions)
|
|
}
|
|
}
|
|
return next
|
|
})
|
|
|
|
setMessagesLoaded((prev) => {
|
|
const next = new Map(prev)
|
|
const loadedSet = next.get(instanceId) || new Set()
|
|
loadedSet.add(sessionId)
|
|
next.set(instanceId, loadedSet)
|
|
return next
|
|
})
|
|
|
|
const sessionForV2 = sessions().get(instanceId)?.get(sessionId) ?? {
|
|
id: sessionId,
|
|
instanceId,
|
|
parentId: session?.parentId ?? null,
|
|
agent: "",
|
|
model: { providerId: "", modelId: "" },
|
|
version: "0",
|
|
time: { created: Date.now(), updated: Date.now() },
|
|
revert: session?.revert,
|
|
}
|
|
seedSessionMessagesV2(instanceId, sessionForV2, messages, messagesInfo)
|
|
|
|
} catch (error) {
|
|
log.error("Failed to load messages:", error)
|
|
throw error
|
|
} finally {
|
|
setLoading((prev) => {
|
|
const next = { ...prev }
|
|
const loadingSet = next.loadingMessages.get(instanceId)
|
|
if (loadingSet) {
|
|
loadingSet.delete(sessionId)
|
|
}
|
|
return next
|
|
})
|
|
}
|
|
|
|
updateSessionInfo(instanceId, sessionId)
|
|
}
|
|
|
|
async function syncSessionsFromSdk(instanceId: string): Promise<void> {
|
|
const instance = instances().get(instanceId)
|
|
if (!instance) throw new Error("Instance not ready")
|
|
|
|
const folderPath = instance.folder
|
|
if (!folderPath) throw new Error("No folder path for instance")
|
|
|
|
log.info({ instanceId, folderPath }, "Manual SDK sync requested")
|
|
|
|
try {
|
|
const result = await nativeSessionApi.syncFromSdk(instanceId, folderPath)
|
|
log.info({ instanceId, result }, "Manual SDK sync result")
|
|
|
|
// Refresh sessions after sync
|
|
await fetchSessions(instanceId)
|
|
} catch (error) {
|
|
log.error({ instanceId, error }, "Manual SDK sync failed")
|
|
throw error
|
|
}
|
|
}
|
|
|
|
export {
|
|
createSession,
|
|
deleteSession,
|
|
fetchAgents,
|
|
fetchProviders,
|
|
|
|
fetchSessions,
|
|
syncSessionsFromSdk,
|
|
forkSession,
|
|
loadMessages,
|
|
}
|