Add skills catalog and sidebar tooling

This commit is contained in:
Gemini AI
2025-12-24 14:23:51 +04:00
Unverified
parent d153892bdf
commit f9748391a9
13 changed files with 1178 additions and 106 deletions

View File

@@ -1,8 +1,8 @@
import type { Session } from "../types/session"
import type { Session, Provider, Model } from "../types/session"
import type { Message } from "../types/message"
import { instances } from "./instances"
import { preferences, setAgentModelPreference } from "./preferences"
import { preferences, setAgentModelPreference, getAgentModelPreference } from "./preferences"
import { setSessionCompactionState } from "./session-compaction"
import {
activeSessionId,
@@ -32,9 +32,247 @@ 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"
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("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
}
}
async function fetchExtraProviders(): Promise<Provider[]> {
const [ollama, zen, qwen, zai] = await Promise.all([
fetchOllamaCloudProvider(),
fetchOpenCodeZenProvider(),
fetchQwenOAuthProvider(),
fetchZAIProvider(),
])
return [ollama, zen, qwen, zai].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
@@ -84,30 +322,38 @@ async function fetchSessions(instanceId: string): Promise<void> {
await ensureInstanceConfigLoaded(instanceId)
const instanceData = getInstanceConfig(instanceId)
const sessionTasks = instanceData.sessionTasks || {}
const sessionSkills = instanceData.sessionSkills || {}
for (const apiSession of response.data) {
const existingSession = existingSessions?.get(apiSession.id)
const existingModel = existingSession?.model ?? { providerId: "", modelId: "" }
const hasUserSelectedModel = existingModel.providerId && existingModel.modelId
const apiModel = (apiSession as any).model?.providerID && (apiSession as any).model?.modelID
? { providerId: (apiSession as any).model.providerID, modelId: (apiSession as any).model.modelID }
: { providerId: "", modelId: "" }
sessionMap.set(apiSession.id, {
id: apiSession.id,
instanceId,
title: apiSession.title || "Untitled",
parentId: apiSession.parentID || null,
agent: existingSession?.agent ?? "",
model: existingSession?.model ?? { providerId: "", modelId: "" },
agent: existingSession?.agent ?? (apiSession as any).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,
}
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] || [],
})
}
@@ -153,7 +399,11 @@ async function fetchSessions(instanceId: string): Promise<void> {
}
}
async function createSession(instanceId: string, agent?: string): Promise<Session> {
async function createSession(
instanceId: string,
agent?: string,
options?: { skipAutoCleanup?: boolean },
): Promise<Session> {
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
@@ -163,10 +413,12 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
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, defaultModel)) {
await setAgentModelPreference(instanceId, selectedAgent, defaultModel)
if (selectedAgent && isModelValid(instanceId, sessionModel) && !agentModelPreference) {
await setAgentModelPreference(instanceId, selectedAgent, sessionModel)
}
setLoading((prev) => {
@@ -189,18 +441,19 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
title: response.data.title || "New Session",
parentId: null,
agent: selectedAgent,
model: defaultModel,
model: sessionModel,
skills: [],
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,
}
messageID: response.data.revert.messageID,
partID: response.data.revert.partID,
snapshot: response.data.revert.snapshot,
diff: response.data.revert.diff,
}
: undefined,
}
@@ -243,7 +496,7 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
return next
})
if (preferences().autoCleanupBlankSessions) {
if (preferences().autoCleanupBlankSessions && !options?.skipAutoCleanup) {
await cleanupBlankSessions(instanceId, session.id)
}
@@ -288,26 +541,33 @@ async function forkSession(
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 || "",
model: {
providerId: info.model?.providerID || "",
modelId: info.model?.modelID || "",
},
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,
}
messageID: info.revert.messageID,
partID: info.revert.partID,
snapshot: info.revert.snapshot,
diff: info.revert.diff,
}
: undefined,
} as unknown as Session
@@ -437,9 +697,9 @@ async function fetchAgents(instanceId: string): Promise<void> {
mode: agent.mode,
model: agent.model?.modelID
? {
providerId: agent.model.providerID || "",
modelId: agent.model.modelID,
}
providerId: agent.model.providerID || "",
modelId: agent.model.modelID,
}
: undefined,
}))
@@ -477,9 +737,15 @@ async function fetchProviders(instanceId: string): Promise<void> {
})),
}))
const filteredBaseProviders = providerList.filter((provider) => provider.id !== "zai")
const extraProviders = await fetchExtraProviders()
const baseProviders = removeDuplicateProviders(filteredBaseProviders, extraProviders)
const mergedProviders = mergeProviders(baseProviders, extraProviders)
setProviders((prev) => {
const next = new Map(prev)
next.set(instanceId, providerList)
next.set(instanceId, mergedProviders)
return next
})
} catch (error) {
@@ -588,10 +854,13 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
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: providerID && modelID ? { providerId: providerID, modelId: modelID } : existingSession.model,
model: hasUserSelectedModel ? currentModel : (providerID && modelID ? { providerId: providerID, modelId: modelID } : currentModel),
}
const updatedInstanceSessions = new Map(nextInstanceSessions)
updatedInstanceSessions.set(sessionId, updatedSession)
@@ -611,8 +880,12 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
const sessionForV2 = sessions().get(instanceId)?.get(sessionId) ?? {
id: sessionId,
title: session?.title,
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)