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

@@ -22,6 +22,26 @@ export interface SessionTasks {
[sessionId: string]: Task[]
}
export interface SkillSelection {
id: string
name: string
description?: string
}
export interface SkillDescriptor {
id: string
name: string
description?: string
}
export interface SkillDetail extends SkillDescriptor {
content: string
}
export interface SkillCatalogResponse {
skills: SkillDescriptor[]
}
/**
* Canonical HTTP/SSE contract for the CLI server.
* These types are consumed by both the CLI implementation and any UI clients.
@@ -120,10 +140,25 @@ export interface WorkspaceFileResponse {
export type WorkspaceFileSearchResponse = FileSystemEntry[]
export interface WorkspaceGitStatusEntry {
path: string
status: string
}
export interface WorkspaceGitStatus {
isRepo: boolean
branch: string | null
ahead: number
behind: number
changes: WorkspaceGitStatusEntry[]
error?: string
}
export interface InstanceData {
messageHistory: string[]
agentModelSelections: AgentModelSelection
sessionTasks?: SessionTasks // Multi-task chat support: tasks per session
sessionSkills?: Record<string, SkillSelection[]> // Selected skills per session
}
export type InstanceStreamStatus = "connecting" | "connected" | "error" | "disconnected"

View File

@@ -22,6 +22,7 @@ import { registerOllamaRoutes } from "./routes/ollama"
import { registerQwenRoutes } from "./routes/qwen"
import { registerZAIRoutes } from "./routes/zai"
import { registerOpenCodeZenRoutes } from "./routes/opencode-zen"
import { registerSkillsRoutes } from "./routes/skills"
import { ServerMeta } from "../api-types"
import { InstanceStore } from "../storage/instance-store"
@@ -118,6 +119,7 @@ export function createHttpServer(deps: HttpServerDeps) {
registerQwenRoutes(app, { logger: deps.logger })
registerZAIRoutes(app, { logger: deps.logger })
registerOpenCodeZenRoutes(app, { logger: deps.logger })
await registerSkillsRoutes(app)
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })

View File

@@ -0,0 +1,141 @@
import { FastifyInstance } from "fastify"
import { z } from "zod"
import type { SkillCatalogResponse, SkillDetail, SkillDescriptor } from "../../api-types"
const SKILLS_OWNER = "anthropics"
const SKILLS_REPO = "skills"
const SKILLS_BRANCH = "main"
const SKILLS_ROOT = "skills"
const CATALOG_TTL_MS = 30 * 60 * 1000
const DETAIL_TTL_MS = 30 * 60 * 1000
type CachedCatalog = { skills: SkillDescriptor[]; fetchedAt: number }
type CachedDetail = { detail: SkillDetail; fetchedAt: number }
let catalogCache: CachedCatalog | null = null
const detailCache = new Map<string, CachedDetail>()
interface RepoEntry {
name: string
path: string
type: "file" | "dir"
}
function parseFrontmatter(markdown: string): { attributes: Record<string, string>; body: string } {
if (!markdown.startsWith("---")) {
return { attributes: {}, body: markdown.trim() }
}
const end = markdown.indexOf("\n---", 3)
if (end === -1) {
return { attributes: {}, body: markdown.trim() }
}
const frontmatter = markdown.slice(3, end).trim()
const body = markdown.slice(end + 4).trimStart()
const attributes: Record<string, string> = {}
for (const line of frontmatter.split(/\r?\n/)) {
const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/)
if (!match) continue
const key = match[1].trim()
const value = match[2]?.trim() ?? ""
attributes[key] = value
}
return { attributes, body }
}
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url, {
headers: { "User-Agent": "NomadArch-Skills" },
})
if (!response.ok) {
const message = await response.text().catch(() => "")
throw new Error(message || `Request failed (${response.status})`)
}
return (await response.json()) as T
}
async function fetchText(url: string): Promise<string> {
const response = await fetch(url, {
headers: { "User-Agent": "NomadArch-Skills" },
})
if (!response.ok) {
const message = await response.text().catch(() => "")
throw new Error(message || `Request failed (${response.status})`)
}
return await response.text()
}
async function fetchCatalog(): Promise<SkillDescriptor[]> {
const url = `https://api.github.com/repos/${SKILLS_OWNER}/${SKILLS_REPO}/contents/${SKILLS_ROOT}?ref=${SKILLS_BRANCH}`
const entries = await fetchJson<RepoEntry[]>(url)
const directories = entries.filter((entry) => entry.type === "dir")
const results: SkillDescriptor[] = []
for (const dir of directories) {
try {
const skill = await fetchSkillDetail(dir.name)
results.push({ id: skill.id, name: skill.name, description: skill.description })
} catch {
results.push({ id: dir.name, name: dir.name, description: "" })
}
}
return results
}
async function fetchSkillDetail(id: string): Promise<SkillDetail> {
const markdownUrl = `https://raw.githubusercontent.com/${SKILLS_OWNER}/${SKILLS_REPO}/${SKILLS_BRANCH}/${SKILLS_ROOT}/${id}/SKILL.md`
const markdown = await fetchText(markdownUrl)
const parsed = parseFrontmatter(markdown)
const name = parsed.attributes.name || id
const description = parsed.attributes.description || ""
return {
id,
name,
description,
content: parsed.body.trim(),
}
}
async function getCatalogCached(): Promise<SkillDescriptor[]> {
const now = Date.now()
if (catalogCache && now - catalogCache.fetchedAt < CATALOG_TTL_MS) {
return catalogCache.skills
}
const skills = await fetchCatalog()
catalogCache = { skills, fetchedAt: now }
return skills
}
async function getDetailCached(id: string): Promise<SkillDetail> {
const now = Date.now()
const cached = detailCache.get(id)
if (cached && now - cached.fetchedAt < DETAIL_TTL_MS) {
return cached.detail
}
const detail = await fetchSkillDetail(id)
detailCache.set(id, { detail, fetchedAt: now })
return detail
}
export async function registerSkillsRoutes(app: FastifyInstance) {
app.get("/api/skills/catalog", async (): Promise<SkillCatalogResponse> => {
const skills = await getCatalogCached()
return { skills }
})
app.get<{ Querystring: { id?: string } }>("/api/skills/detail", async (request, reply): Promise<SkillDetail> => {
const query = z.object({ id: z.string().min(1) }).parse(request.query ?? {})
try {
return await getDetailCached(query.id)
} catch (error) {
request.log.error({ err: error, skillId: query.id }, "Failed to fetch skill detail")
reply.code(502)
return {
id: query.id,
name: query.id,
description: "",
content: "Unable to load skill content.",
}
}
})
}

View File

@@ -1,4 +1,5 @@
import { FastifyInstance, FastifyReply } from "fastify"
import { spawnSync } from "child_process"
import { z } from "zod"
import { WorkspaceManager } from "../../workspaces/manager"
@@ -100,6 +101,68 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
return handleWorkspaceError(error, reply)
}
})
app.get<{ Params: { id: string } }>("/api/workspaces/:id/git/status", async (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404)
return { error: "Workspace not found" }
}
const result = spawnSync("git", ["-C", workspace.path, "status", "--porcelain=v1", "-b"], { encoding: "utf8" })
if (result.error) {
return {
isRepo: false,
branch: null,
ahead: 0,
behind: 0,
changes: [],
error: result.error.message,
}
}
if (result.status !== 0) {
const stderr = (result.stderr || "").toLowerCase()
if (stderr.includes("not a git repository")) {
return { isRepo: false, branch: null, ahead: 0, behind: 0, changes: [] }
}
reply.code(400)
return {
isRepo: false,
branch: null,
ahead: 0,
behind: 0,
changes: [],
error: result.stderr || "Unable to read git status",
}
}
const lines = (result.stdout || "").split(/\r?\n/).filter((line) => line.trim().length > 0)
let branch: string | null = null
let ahead = 0
let behind = 0
const changes: Array<{ path: string; status: string }> = []
for (const line of lines) {
if (line.startsWith("##")) {
const header = line.replace(/^##\s*/, "")
const [branchPart, trackingPart] = header.split("...")
branch = branchPart?.trim() || null
const tracking = trackingPart || ""
const aheadMatch = tracking.match(/ahead\s+(\d+)/)
const behindMatch = tracking.match(/behind\s+(\d+)/)
ahead = aheadMatch ? Number(aheadMatch[1]) : 0
behind = behindMatch ? Number(behindMatch[1]) : 0
continue
}
const status = line.slice(0, 2).trim() || line.slice(0, 2)
const path = line.slice(3).trim()
changes.push({ path, status })
}
return { isRepo: true, branch, ahead, behind, changes }
})
}

View File

@@ -33,6 +33,7 @@ import {
activeSessionId as activeSessionMap,
getSessionFamily,
getSessionInfo,
sessions,
setActiveSession,
executeCustomCommand,
runShellCommand,
@@ -60,10 +61,11 @@ import SessionView from "../session/session-view"
import { Sidebar, type FileNode } from "./sidebar"
import { Editor } from "./editor"
import { serverApi } from "../../lib/api-client"
import { Sparkles, Layout as LayoutIcon, Terminal as TerminalIcon, Search, Loader2, Zap, Shield } from "lucide-solid"
import { Sparkles, Layout as LayoutIcon, Terminal as TerminalIcon, Search, Loader2, Zap, Shield, Settings } from "lucide-solid"
import { formatTokenTotal } from "../../lib/formatters"
import { sseManager } from "../../lib/sse-manager"
import { getLogger } from "../../lib/logger"
import AdvancedSettingsModal from "../advanced-settings-modal"
import {
getSoloState,
toggleAutonomous,
@@ -138,12 +140,19 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const [rightDrawerContentEl, setRightDrawerContentEl] = createSignal<HTMLElement | null>(null)
const [leftToggleButtonEl, setLeftToggleButtonEl] = createSignal<HTMLElement | null>(null)
const [rightToggleButtonEl, setRightToggleButtonEl] = createSignal<HTMLElement | null>(null)
const [activeResizeSide, setActiveResizeSide] = createSignal<"left" | "right" | null>(null)
const [activeResizeSide, setActiveResizeSide] = createSignal<"left" | "right" | "chat" | "terminal" | null>(null)
const [resizeStartX, setResizeStartX] = createSignal(0)
const [resizeStartWidth, setResizeStartWidth] = createSignal(0)
const [resizeStartY, setResizeStartY] = createSignal(0)
const [resizeStartHeight, setResizeStartHeight] = createSignal(0)
const [chatPanelWidth, setChatPanelWidth] = createSignal(600)
const [terminalPanelHeight, setTerminalPanelHeight] = createSignal(200)
const [terminalOpen, setTerminalOpen] = createSignal(false)
const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal<string[]>(["lsp", "mcp", "plan"])
const [currentFile, setCurrentFile] = createSignal<FileNode | null>(null)
const [isSoloOpen, setIsSoloOpen] = createSignal(true)
const [showAdvancedSettings, setShowAdvancedSettings] = createSignal(false)
const [selectedBinary, setSelectedBinary] = createSignal("opencode")
// Handler to load file content when selected
const handleFileSelect = async (file: FileNode) => {
@@ -264,6 +273,17 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
onCleanup(() => window.removeEventListener(SESSION_SIDEBAR_EVENT, handler))
})
onMount(() => {
if (typeof window === "undefined") return
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ instanceId: string; sessionId: string }>).detail
if (!detail || detail.instanceId !== props.instance.id) return
setShowAdvancedSettings(true)
}
window.addEventListener("open-advanced-settings", handler)
onCleanup(() => window.removeEventListener("open-advanced-settings", handler))
})
createEffect(() => {
if (typeof window === "undefined") return
window.localStorage.setItem(LEFT_DRAWER_STORAGE_KEY, sessionSidebarWidth().toString())
@@ -297,7 +317,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const activeSessionForInstance = createMemo(() => {
const sessionId = activeSessionIdForInstance()
if (!sessionId || sessionId === "info") return null
return activeSessions().get(sessionId) ?? null
const instanceSessions = sessions().get(props.instance.id)
return instanceSessions?.get(sessionId) ?? null
})
const activeSessionUsage = createMemo(() => {
@@ -626,14 +647,35 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
scheduleDrawerMeasure()
}
const handleDrawerPointerMove = (clientX: number) => {
const applyPanelSize = (type: "chat" | "terminal", size: number) => {
if (type === "chat") {
setChatPanelWidth(size)
} else {
setTerminalPanelHeight(size)
}
}
const handlePointerMove = (clientX: number, clientY: number) => {
const side = activeResizeSide()
if (!side) return
const startWidth = resizeStartWidth()
const clamp = side === "left" ? clampWidth : clampRightWidth
const delta = side === "left" ? clientX - resizeStartX() : resizeStartX() - clientX
const nextWidth = clamp(startWidth + delta)
applyDrawerWidth(side, nextWidth)
if (side === "left" || side === "right") {
const startWidth = resizeStartWidth()
const clamp = side === "left" ? clampWidth : clampRightWidth
const delta = side === "left" ? clientX - resizeStartX() : resizeStartX() - clientX
const nextWidth = clamp(startWidth + delta)
applyDrawerWidth(side, nextWidth)
} else if (side === "chat") {
const startWidth = resizeStartWidth()
const delta = resizeStartX() - clientX // Dragging left increases width
const nextWidth = Math.max(300, Math.min(window.innerWidth - 300, startWidth + delta))
applyPanelSize("chat", nextWidth)
} else if (side === "terminal") {
const startHeight = resizeStartHeight()
const delta = resizeStartY() - clientY // Dragging up increases height
const nextHeight = Math.max(100, Math.min(window.innerHeight - 200, startHeight + delta))
applyPanelSize("terminal", nextHeight)
}
}
function stopDrawerResize() {
@@ -646,7 +688,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
function drawerMouseMove(event: MouseEvent) {
event.preventDefault()
handleDrawerPointerMove(event.clientX)
handlePointerMove(event.clientX, event.clientY)
}
function drawerMouseUp() {
@@ -657,33 +699,39 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const touch = event.touches[0]
if (!touch) return
event.preventDefault()
handleDrawerPointerMove(touch.clientX)
handlePointerMove(touch.clientX, touch.clientY)
}
function drawerTouchEnd() {
stopDrawerResize()
}
const startDrawerResize = (side: "left" | "right", clientX: number) => {
const startResize = (side: "left" | "right" | "chat" | "terminal", clientX: number, clientY: number) => {
setActiveResizeSide(side)
setResizeStartX(clientX)
setResizeStartWidth(side === "left" ? sessionSidebarWidth() : rightDrawerWidth())
setResizeStartY(clientY)
if (side === "left") setResizeStartWidth(sessionSidebarWidth())
else if (side === "right") setResizeStartWidth(rightDrawerWidth())
else if (side === "chat") setResizeStartWidth(chatPanelWidth())
else if (side === "terminal") setResizeStartHeight(terminalPanelHeight())
document.addEventListener("mousemove", drawerMouseMove)
document.addEventListener("mouseup", drawerMouseUp)
document.addEventListener("touchmove", drawerTouchMove, { passive: false })
document.addEventListener("touchend", drawerTouchEnd)
}
const handleDrawerResizeMouseDown = (side: "left" | "right") => (event: MouseEvent) => {
const handleResizeMouseDown = (side: "left" | "right" | "chat" | "terminal") => (event: MouseEvent) => {
event.preventDefault()
startDrawerResize(side, event.clientX)
startResize(side, event.clientX, event.clientY)
}
const handleDrawerResizeTouchStart = (side: "left" | "right") => (event: TouchEvent) => {
const touch = event.touches[0]
if (!touch) return
event.preventDefault()
startDrawerResize(side, touch.clientX)
startResize(side, touch.clientX, touch.clientY)
}
onCleanup(() => {
@@ -850,6 +898,10 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
sessions={Array.from(activeSessions().values())}
activeSessionId={activeSessionIdForInstance() || undefined}
onSessionSelect={handleSessionSelect}
onOpenCommandPalette={handleCommandPaletteClick}
onToggleTerminal={() => setTerminalOpen((current) => !current)}
isTerminalOpen={terminalOpen()}
onOpenAdvancedSettings={() => setShowAdvancedSettings(true)}
/>
)
@@ -858,7 +910,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
if (sessionId && sessionId !== "info") {
return (
<div class="flex flex-col h-full" ref={setRightDrawerContentEl}>
<div class="flex flex-col h-full relative" ref={setRightDrawerContentEl}>
<MultiTaskChat instanceId={props.instance.id} sessionId={sessionId} />
</div>
)
@@ -990,7 +1042,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
>
<div
class="session-resize-handle session-resize-handle--left"
onMouseDown={handleDrawerResizeMouseDown("left")}
onMouseDown={handleResizeMouseDown("left")}
onTouchStart={handleDrawerResizeTouchStart("left")}
role="presentation"
aria-hidden="true"
@@ -1052,7 +1104,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
>
<div
class="session-resize-handle session-resize-handle--right"
onMouseDown={handleDrawerResizeMouseDown("right")}
onMouseDown={handleResizeMouseDown("right")}
onTouchStart={handleDrawerResizeTouchStart("right")}
role="presentation"
aria-hidden="true"
@@ -1140,15 +1192,24 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
<div class="flex items-center space-x-4">
<Show when={activeSessionIdForInstance() && activeSessionIdForInstance() !== "info"}>
<ModelStatusSelector
instanceId={props.instance.id}
sessionId={activeSessionIdForInstance()!}
currentModel={activeSessionForInstance()?.model || { providerId: "", modelId: "" }}
onModelChange={async (model) => {
const sid = activeSessionIdForInstance()
if (sid) await props.handleSidebarModelChange(sid, model)
}}
/>
<div class="flex items-center space-x-2">
<ModelStatusSelector
instanceId={props.instance.id}
sessionId={activeSessionIdForInstance()!}
currentModel={activeSessionForInstance()?.model || { providerId: "", modelId: "" }}
onModelChange={async (model) => {
const sid = activeSessionIdForInstance()
if (sid) await props.handleSidebarModelChange(sid, model)
}}
/>
<button
onClick={() => setShowAdvancedSettings(true)}
class="p-2 text-zinc-500 hover:text-blue-400 transition-all hover:bg-blue-500/10 rounded-full"
title="AI Settings: Manage model providers and API keys"
>
<Settings size={14} strokeWidth={2} />
</button>
</div>
</Show>
{/* SmartX Mode Buttons (Integrated HUD) */}
@@ -1156,7 +1217,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
<button
onClick={handleSmartFix}
disabled={isFixing()}
title="Smart Fix"
title="Smart Fix: Automatically detect and fix issues in your code"
class={`transition-all flex items-center space-x-1.5 px-2 py-1 rounded-full hover:bg-white/10 ${isFixing() ? "text-blue-500" : "text-zinc-400 hover:text-white"}`}
>
<Show when={isFixing()} fallback={<Sparkles size={14} class="text-blue-400" />}>
@@ -1170,7 +1231,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
<button
onClick={handleBuild}
disabled={isBuilding()}
title="Build"
title="Build: Build and deploy your application"
class={`transition-all flex items-center space-x-1.5 px-2 py-1 rounded-full hover:bg-white/10 ${isBuilding() ? "text-indigo-500" : "text-zinc-400 hover:text-white"}`}
>
<Show when={isBuilding()} fallback={<TerminalIcon size={14} />}>
@@ -1186,7 +1247,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
<div class="flex items-center bg-white/5 border border-white/5 rounded-full px-1.5 py-1 space-x-1">
<button
onClick={() => toggleAutonomous(props.instance.id)}
title="Toggle Autonomous Mode (SOLO)"
title="Autonomous Mode (SOLO): Enable autonomous AI agent operations"
class={`flex items-center space-x-1.5 px-2 py-0.5 rounded-full transition-all ${getSoloState(props.instance.id).isAutonomous
? "bg-blue-500/20 text-blue-400 border border-blue-500/30"
: "text-zinc-500 hover:text-zinc-300"
@@ -1197,7 +1258,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</button>
<button
onClick={() => toggleAutoApproval(props.instance.id)}
title="Toggle Auto-Approval (SHIELD)"
title="Auto-Approval (SHIELD): Automatically approve AI agent actions"
class={`flex items-center space-x-1.5 px-2 py-0.5 rounded-full transition-all ${getSoloState(props.instance.id).autoApproval
? "bg-emerald-500/20 text-emerald-400 border border-emerald-500/30"
: "text-zinc-500 hover:text-zinc-300"
@@ -1242,17 +1303,29 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
sx={{ flexGrow: 1, minHeight: 0, display: "flex", flexDirection: "column", overflowX: "hidden" }}
class="content-area relative"
>
<div class="flex-1 flex overflow-hidden">
<Editor file={currentFile()} />
<div class="flex-1 flex overflow-hidden min-h-0">
<Show when={!isPhoneLayout()}>
<Editor file={currentFile()} />
</Show>
<div class="flex-1 flex flex-col relative border-l border-white/5">
<div
class="flex flex-col relative border-l border-white/5 min-h-0 overflow-hidden min-w-0"
style={{
width: isPhoneLayout() ? "100%" : `${chatPanelWidth()}px`,
"flex-shrink": isPhoneLayout() ? 1 : 0,
}}
>
<div
class="absolute -left-1 top-0 bottom-0 w-2 cursor-col-resize z-20 hover:bg-white/5 active:bg-white/10 transition-colors"
onMouseDown={handleResizeMouseDown("chat")}
/>
<Show when={isSoloOpen()}>
<div class="flex-1 flex flex-col min-h-0">
<div class="flex-1 flex flex-col min-h-0 relative">
<MultiTaskChat instanceId={props.instance.id} sessionId={activeSessionIdForInstance() || ""} />
</div>
</Show>
<div class="flex-1 flex flex-col relative"
<div class="flex-1 flex flex-col relative min-h-0"
style={{ display: isSoloOpen() ? "none" : "flex" }}>
<Show
when={showingInfoView()}
@@ -1305,28 +1378,57 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</div>
{/* Bottom Toolbar/Terminal Area */}
<footer class="h-8 glass border-t border-white/5 flex items-center justify-between px-3 text-[10px] text-zinc-500 tracking-wide z-10 shrink-0">
<div class="flex items-center space-x-4">
<div class="flex items-center space-x-1.5 cursor-pointer hover:text-zinc-300">
<TerminalIcon size={12} />
<span>TERMINAL</span>
{/* Bottom Toolbar/Terminal Area */}
<div
class="flex flex-col border-t border-white/5 relative bg-[#09090b] z-10 shrink-0 overflow-hidden"
style={{
height: terminalOpen() ? `${terminalPanelHeight()}px` : "32px",
transition: activeResizeSide() === 'terminal' ? 'none' : 'height 0.2s cubic-bezier(0.4, 0, 0.2, 1)'
}}
>
<Show when={terminalOpen()}>
<div
class="absolute -top-1 left-0 right-0 h-2 cursor-row-resize z-20 hover:bg-white/5 active:bg-white/10 transition-colors"
onMouseDown={handleResizeMouseDown("terminal")}
/>
<div class="flex-1 min-h-0 overflow-hidden p-4 bg-[#0d0d0d]">
<div class="font-mono text-xs text-zinc-400">
<div class="mb-2 text-zinc-600">// Terminal functionality coming soon</div>
<div class="text-emerald-500/80 flex items-center gap-2">
<span></span>
<span>~</span>
<span class="animate-pulse">_</span>
</div>
</div>
</div>
</div>
<div class="flex items-center space-x-4 uppercase font-bold">
<div class="flex items-center space-x-1">
<span class="w-1.5 h-1.5 rounded-full bg-green-500 shadow-[0_0_5px_rgba(34,197,94,0.5)]" />
<span>Sync Active</span>
</Show>
<footer class="h-8 flex items-center justify-between px-3 text-[10px] text-zinc-500 tracking-wide shrink-0 border-t border-white/5 bg-[#09090b]">
<div class="flex items-center space-x-4">
<button
class={`flex items-center space-x-1.5 cursor-pointer hover:text-zinc-300 transition-colors outline-none ${terminalOpen() ? 'text-indigo-400 font-bold' : ''}`}
onClick={() => setTerminalOpen(!terminalOpen())}
>
<TerminalIcon size={12} />
<span>TERMINAL</span>
</button>
</div>
<Show when={activeSessionForInstance()}>
{(session) => (
<>
<span class="hover:text-zinc-300 cursor-pointer">{session().model.modelId}</span>
<span class="hover:text-zinc-300 cursor-pointer">{session().agent}</span>
</>
)}
</Show>
</div>
</footer>
<div class="flex items-center space-x-4 uppercase font-bold">
<div class="flex items-center space-x-1">
<span class="w-1.5 h-1.5 rounded-full bg-green-500 shadow-[0_0_5px_rgba(34,197,94,0.5)]" />
<span>Sync Active</span>
</div>
<Show when={activeSessionForInstance()}>
{(session) => (
<>
<span class="hover:text-zinc-300 cursor-pointer">{session().model.modelId}</span>
<span class="hover:text-zinc-300 cursor-pointer">{session().agent}</span>
</>
)}
</Show>
</div>
</footer>
</div>
</Box>
{renderRightPanel()}
@@ -1350,6 +1452,12 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
commands={instancePaletteCommands()}
onExecute={props.onExecuteCommand}
/>
<AdvancedSettingsModal
open={showAdvancedSettings()}
onClose={() => setShowAdvancedSettings(false)}
selectedBinary={selectedBinary()}
onBinaryChange={(binary) => setSelectedBinary(binary)}
/>
</>
)
}

View File

@@ -1,10 +1,12 @@
import { Component, createSignal, For, Show, createEffect } from "solid-js"
import { Component, createSignal, For, Show, createEffect, createMemo } from "solid-js"
import {
Files,
Search,
GitBranch,
Play,
Settings,
Plug,
Sparkles,
ChevronRight,
ChevronDown,
Folder,
@@ -15,6 +17,9 @@ import {
Image as ImageIcon,
} from "lucide-solid"
import { serverApi } from "../../lib/api-client"
import InstanceServiceStatus from "../instance-service-status"
import { catalog, catalogLoading, catalogError, loadCatalog } from "../../stores/skills"
import { getSessionSkills, setSessionSkills } from "../../stores/session-state"
export interface FileNode {
name: string
@@ -32,6 +37,10 @@ interface SidebarProps {
sessions: any[] // Existing sessions to display in one of the tabs
activeSessionId?: string
onSessionSelect: (id: string) => void
onOpenCommandPalette?: () => void
onToggleTerminal?: () => void
isTerminalOpen?: boolean
onOpenAdvancedSettings?: () => void
}
const getFileIcon = (fileName: string) => {
@@ -119,6 +128,19 @@ const FileTree: Component<{
export const Sidebar: Component<SidebarProps> = (props) => {
const [activeTab, setActiveTab] = createSignal("files")
const [rootFiles, setRootFiles] = createSignal<FileNode[]>([])
const [searchQuery, setSearchQuery] = createSignal("")
const [searchResults, setSearchResults] = createSignal<FileNode[]>([])
const [searchLoading, setSearchLoading] = createSignal(false)
const [gitStatus, setGitStatus] = createSignal<{
isRepo: boolean
branch: string | null
ahead: number
behind: number
changes: Array<{ path: string; status: string }>
error?: string
} | null>(null)
const [gitLoading, setGitLoading] = createSignal(false)
const [skillsFilter, setSkillsFilter] = createSignal("")
createEffect(async () => {
if (props.instanceId) {
@@ -135,6 +157,89 @@ export const Sidebar: Component<SidebarProps> = (props) => {
}
})
createEffect(() => {
if (activeTab() === "skills") {
loadCatalog()
}
})
const filteredSkills = createMemo(() => {
const term = skillsFilter().trim().toLowerCase()
if (!term) return catalog()
return catalog().filter((skill) => {
const name = skill.name?.toLowerCase() ?? ""
const description = skill.description?.toLowerCase() ?? ""
return name.includes(term) || description.includes(term) || skill.id.toLowerCase().includes(term)
})
})
const selectedSkills = createMemo(() => {
if (!props.activeSessionId) return []
return getSessionSkills(props.instanceId, props.activeSessionId)
})
const toggleSkillSelection = (skillId: string) => {
if (!props.activeSessionId) return
const current = selectedSkills()
const exists = current.some((skill) => skill.id === skillId)
const next = exists
? current.filter((skill) => skill.id !== skillId)
: (() => {
const found = catalog().find((skill) => skill.id === skillId)
if (!found) return current
return [...current, { id: found.id, name: found.name, description: found.description }]
})()
setSessionSkills(props.instanceId, props.activeSessionId, next)
}
const handleSearch = async () => {
const query = searchQuery().trim()
if (!query) {
setSearchResults([])
return
}
setSearchLoading(true)
try {
const results = await serverApi.searchWorkspaceFiles(props.instanceId, query, { limit: 50, type: "all" })
setSearchResults(
results.map((entry) => ({
name: entry.name,
type: entry.type,
path: entry.path,
})),
)
} catch (error) {
console.error("Failed to search files", error)
} finally {
setSearchLoading(false)
}
}
const refreshGitStatus = async () => {
setGitLoading(true)
try {
const status = await serverApi.fetchWorkspaceGitStatus(props.instanceId)
setGitStatus(status)
} catch (error) {
setGitStatus({
isRepo: false,
branch: null,
ahead: 0,
behind: 0,
changes: [],
error: error instanceof Error ? error.message : "Unable to load git status",
})
} finally {
setGitLoading(false)
}
}
createEffect(() => {
if (activeTab() === "git") {
refreshGitStatus()
}
})
return (
<div
class={`flex bg-[#111111] border-r border-white/5 transition-all duration-300 ease-in-out h-full ${props.isOpen ? "w-72" : "w-0 overflow-hidden"}`}
@@ -148,6 +253,9 @@ export const Sidebar: Component<SidebarProps> = (props) => {
{ id: "search", icon: Search },
{ id: "git", icon: GitBranch },
{ id: "debug", icon: Play },
{ id: "mcp", icon: Plug },
{ id: "skills", icon: Sparkles },
{ id: "settings", icon: Settings },
]}
>
{(item) => (
@@ -162,11 +270,6 @@ export const Sidebar: Component<SidebarProps> = (props) => {
</button>
)}
</For>
<div class="mt-auto pb-2">
<button class="text-zinc-500 hover:text-white transition-colors">
<Settings size={22} strokeWidth={1.5} />
</button>
</div>
</div>
{/* Side Pane */}
@@ -196,6 +299,187 @@ export const Sidebar: Component<SidebarProps> = (props) => {
</For>
</div>
</Show>
<Show when={activeTab() === "search"}>
<div class="flex flex-col gap-3">
<div class="flex items-center gap-2">
<input
value={searchQuery()}
onInput={(event) => setSearchQuery(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
handleSearch()
}
}}
placeholder="Search files..."
class="flex-1 rounded-md bg-white/5 border border-white/10 px-3 py-2 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/60"
/>
<button
onClick={handleSearch}
class="px-3 py-2 text-xs font-semibold uppercase tracking-wide rounded-md bg-blue-500/20 text-blue-300 border border-blue-500/30 hover:bg-blue-500/30"
>
Search
</button>
</div>
<Show when={searchLoading()}>
<div class="text-xs text-zinc-500">Searching...</div>
</Show>
<Show when={!searchLoading() && searchResults().length === 0 && searchQuery().trim().length > 0}>
<div class="text-xs text-zinc-500">No results found.</div>
</Show>
<div class="flex flex-col gap-1">
<For each={searchResults()}>
{(result) => (
<div
onClick={() => props.onFileSelect(result)}
class="flex items-center gap-2 px-3 py-2 text-xs text-zinc-300 rounded-md hover:bg-white/5 cursor-pointer"
>
<span class="text-zinc-500">{result.type === "directory" ? "DIR" : "FILE"}</span>
<span class="truncate">{result.path}</span>
</div>
)}
</For>
</div>
</div>
</Show>
<Show when={activeTab() === "git"}>
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between">
<span class="text-xs uppercase tracking-wide text-zinc-500">Repository Status</span>
<button
onClick={refreshGitStatus}
class="px-2 py-1 text-[10px] font-semibold uppercase tracking-wide rounded-md border border-white/10 text-zinc-300 hover:text-white"
>
Refresh
</button>
</div>
<Show when={gitLoading()}>
<div class="text-xs text-zinc-500">Loading git status...</div>
</Show>
<Show when={!gitLoading() && gitStatus()}>
{(status) => (
<div class="flex flex-col gap-3">
<Show when={!status().isRepo}>
<div class="text-xs text-zinc-500">
{status().error ? `Git unavailable: ${status().error}` : "No git repository detected."}
</div>
</Show>
<Show when={status().isRepo}>
<div class="rounded-md border border-white/10 bg-white/5 px-3 py-2 text-xs text-zinc-200">
<div class="flex items-center justify-between">
<span class="font-semibold">{status().branch || "Detached"}</span>
<span class="text-zinc-500">
{status().ahead ? `${status().ahead}` : ""}
{status().behind ? `${status().behind}` : ""}
</span>
</div>
<div class="text-[11px] text-zinc-500 mt-1">
{status().changes.length} change{status().changes.length === 1 ? "" : "s"}
</div>
</div>
<div class="flex flex-col gap-1">
<For each={status().changes}>
{(change) => (
<div class="flex items-center gap-2 text-xs text-zinc-300 px-3 py-1 rounded-md hover:bg-white/5">
<span class="text-zinc-500 w-6">{change.status}</span>
<span class="truncate">{change.path}</span>
</div>
)}
</For>
</div>
</Show>
</div>
)}
</Show>
</div>
</Show>
<Show when={activeTab() === "debug"}>
<div class="flex flex-col gap-3">
<div class="text-xs uppercase tracking-wide text-zinc-500">Tools</div>
<button
onClick={() => props.onOpenCommandPalette?.()}
class="px-3 py-2 text-xs font-semibold uppercase tracking-wide rounded-md bg-white/5 border border-white/10 text-zinc-300 hover:text-white"
>
Open Command Palette
</button>
<button
onClick={() => props.onToggleTerminal?.()}
class="px-3 py-2 text-xs font-semibold uppercase tracking-wide rounded-md bg-white/5 border border-white/10 text-zinc-300 hover:text-white"
>
{props.isTerminalOpen ? "Close Terminal" : "Open Terminal"}
</button>
</div>
</Show>
<Show when={activeTab() === "mcp"}>
<div class="flex flex-col gap-3">
<div class="text-xs uppercase tracking-wide text-zinc-500">MCP Servers</div>
<InstanceServiceStatus sections={["mcp"]} />
</div>
</Show>
<Show when={activeTab() === "skills"}>
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between">
<span class="text-xs uppercase tracking-wide text-zinc-500">Skills</span>
<span class="text-[10px] text-zinc-500">
{selectedSkills().length} selected
</span>
</div>
<Show when={!props.activeSessionId}>
<div class="text-xs text-zinc-500">Select a session to assign skills.</div>
</Show>
<input
value={skillsFilter()}
onInput={(event) => setSkillsFilter(event.currentTarget.value)}
placeholder="Filter skills..."
class="rounded-md bg-white/5 border border-white/10 px-3 py-2 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/60"
/>
<Show when={catalogLoading()}>
<div class="text-xs text-zinc-500">Loading skills...</div>
</Show>
<Show when={catalogError()}>
{(error) => <div class="text-xs text-amber-400">{error()}</div>}
</Show>
<div class="flex flex-col gap-2">
<For each={filteredSkills()}>
{(skill) => {
const isSelected = () => selectedSkills().some((item) => item.id === skill.id)
return (
<button
type="button"
onClick={() => toggleSkillSelection(skill.id)}
class={`w-full text-left px-3 py-2 rounded-md border transition-colors ${
isSelected()
? "border-blue-500/60 bg-blue-500/10 text-blue-200"
: "border-white/10 bg-white/5 text-zinc-300 hover:text-white"
}`}
>
<div class="text-xs font-semibold">{skill.name}</div>
<Show when={skill.description}>
<div class="text-[11px] text-zinc-500 mt-1">{skill.description}</div>
</Show>
</button>
)
}}
</For>
</div>
</div>
</Show>
<Show when={activeTab() === "settings"}>
<div class="flex flex-col gap-3">
<div class="text-xs uppercase tracking-wide text-zinc-500">Settings</div>
<button
onClick={() => props.onOpenAdvancedSettings?.()}
class="px-3 py-2 text-xs font-semibold uppercase tracking-wide rounded-md bg-white/5 border border-white/10 text-zinc-300 hover:text-white"
>
Open Advanced Settings
</button>
<button
onClick={() => props.onOpenCommandPalette?.()}
class="px-3 py-2 text-xs font-semibold uppercase tracking-wide rounded-md bg-white/5 border border-white/10 text-zinc-300 hover:text-white"
>
Open Command Palette
</button>
</div>
</Show>
</div>
</div>
</div>

View File

@@ -8,6 +8,8 @@ import type {
FileSystemListResponse,
InstanceData,
ServerMeta,
SkillCatalogResponse,
SkillDetail,
WorkspaceCreateRequest,
WorkspaceDescriptor,
WorkspaceFileResponse,
@@ -16,6 +18,7 @@ import type {
WorkspaceLogEntry,
WorkspaceEventPayload,
WorkspaceEventType,
WorkspaceGitStatus,
} from "../../../server/src/api-types"
import { getLogger } from "./logger"
@@ -152,6 +155,9 @@ export const serverApi = {
`/api/workspaces/${encodeURIComponent(id)}/files/content?${params.toString()}`,
)
},
fetchWorkspaceGitStatus(id: string): Promise<WorkspaceGitStatus> {
return request<WorkspaceGitStatus>(`/api/workspaces/${encodeURIComponent(id)}/git/status`)
},
fetchConfig(): Promise<AppConfig> {
return request<AppConfig>("/api/config/app")
@@ -228,6 +234,13 @@ export const serverApi = {
}
return source
},
fetchSkillsCatalog(): Promise<SkillCatalogResponse> {
return request<SkillCatalogResponse>("/api/skills/catalog")
},
fetchSkillDetail(id: string): Promise<SkillDetail> {
const params = new URLSearchParams({ id })
return request<SkillDetail>(`/api/skills/detail?${params.toString()}`)
},
}
export type { WorkspaceDescriptor, WorkspaceLogEntry, WorkspaceEventPayload, WorkspaceEventType }

View File

@@ -5,7 +5,12 @@ import { getLogger } from "../lib/logger"
const log = getLogger("api")
const DEFAULT_INSTANCE_DATA: InstanceData = { messageHistory: [], agentModelSelections: {}, sessionTasks: {} }
const DEFAULT_INSTANCE_DATA: InstanceData = {
messageHistory: [],
agentModelSelections: {},
sessionTasks: {},
sessionSkills: {},
}
const [instanceDataMap, setInstanceDataMap] = createSignal<Map<string, InstanceData>>(new Map())
const loadPromises = new Map<string, Promise<void>>()
@@ -18,6 +23,7 @@ function cloneInstanceData(data?: InstanceData | null): InstanceData {
messageHistory: Array.isArray(source.messageHistory) ? [...source.messageHistory] : [],
agentModelSelections: { ...(source.agentModelSelections ?? {}) },
sessionTasks: { ...(source.sessionTasks ?? {}) },
sessionSkills: { ...(source.sessionSkills ?? {}) },
}
}

View File

@@ -14,6 +14,7 @@ import { createSession, loadMessages } from "./session-api"
import { showToastNotification } from "../lib/notifications"
import { showConfirmDialog } from "./alerts"
import { QwenOAuthManager } from "../lib/integrations/qwen-oauth"
import { loadSkillDetails } from "./skills"
const log = getLogger("actions")
@@ -255,6 +256,46 @@ function buildLanguageSystemInstruction(prompt: string): string | undefined {
return "Respond in English unless the user explicitly requests another language."
}
function clampText(value: string, maxChars: number): string {
if (value.length <= maxChars) return value
return `${value.slice(0, Math.max(0, maxChars - 3))}...`
}
async function buildSkillsSystemInstruction(instanceId: string, sessionId: string): Promise<string | undefined> {
const session = sessions().get(instanceId)?.get(sessionId)
const selected = session?.skills ?? []
if (selected.length === 0) return undefined
const details = await loadSkillDetails(selected.map((skill) => skill.id))
if (details.length === 0) return undefined
const sections: string[] = []
for (const detail of details) {
const header = detail.name ? `# Skill: ${detail.name}` : `# Skill: ${detail.id}`
const content = detail.content ? clampText(detail.content.trim(), 4000) : ""
sections.push(`${header}\n${content}`.trim())
}
const payload = sections.join("\n\n")
if (!payload) return undefined
return `You have access to the following skills. Follow their instructions when relevant.\n\n${payload}`
}
async function mergeSystemInstructions(
instanceId: string,
sessionId: string,
prompt: string,
): Promise<string | undefined> {
const [languageSystem, skillsSystem] = await Promise.all([
Promise.resolve(buildLanguageSystemInstruction(prompt)),
buildSkillsSystemInstruction(instanceId, sessionId),
])
if (languageSystem && skillsSystem) {
return `${languageSystem}\n\n${skillsSystem}`
}
return languageSystem || skillsSystem
}
function extractPlainTextFromParts(parts: Array<{ type?: string; text?: unknown; filename?: string }>): string {
const segments: string[] = []
for (const part of parts) {
@@ -821,7 +862,7 @@ async function sendMessage(
})
const providerId = effectiveModel.providerId
const languageSystem = buildLanguageSystemInstruction(prompt)
const systemMessage = await mergeSystemInstructions(instanceId, sessionId, prompt)
if (providerId === "ollama-cloud" || providerId === "qwen-oauth" || providerId === "opencode-zen" || providerId === "zai") {
const store = messageStoreBus.getOrCreate(instanceId)
const now = Date.now()
@@ -861,7 +902,7 @@ async function sendMessage(
sessionId,
providerId,
effectiveModel.modelId,
languageSystem,
systemMessage,
messageId,
assistantMessageId,
assistantPartId,
@@ -872,7 +913,7 @@ async function sendMessage(
sessionId,
providerId,
effectiveModel.modelId,
languageSystem,
systemMessage,
messageId,
assistantMessageId,
assistantPartId,
@@ -883,7 +924,7 @@ async function sendMessage(
sessionId,
providerId,
effectiveModel.modelId,
languageSystem,
systemMessage,
messageId,
assistantMessageId,
assistantPartId,
@@ -921,7 +962,7 @@ async function sendMessage(
sessionId,
providerId,
effectiveModel.modelId,
languageSystem,
systemMessage,
token.access_token,
token.resource_url,
messageId,
@@ -989,7 +1030,7 @@ async function sendMessage(
modelID: effectiveModel.modelId,
},
}),
...(languageSystem && { system: languageSystem }),
...(systemMessage && { system: systemMessage }),
}
log.info("sendMessage", {

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)

View File

@@ -1,6 +1,6 @@
import { createSignal } from "solid-js"
import type { Session, Agent, Provider } from "../types/session"
import type { Session, Agent, Provider, SkillSelection } from "../types/session"
import { deleteSession, loadMessages } from "./session-api"
import { showToastNotification } from "../lib/notifications"
import { messageStoreBus } from "./message-v2/bus"
@@ -164,14 +164,19 @@ async function persistSessionTasks(instanceId: string) {
if (!instanceSessions) return
const sessionTasks: Record<string, any[]> = {}
const sessionSkills: Record<string, SkillSelection[]> = {}
for (const [sessionId, session] of instanceSessions) {
if (session.tasks && session.tasks.length > 0) {
sessionTasks[sessionId] = session.tasks
}
if (session.skills && session.skills.length > 0) {
sessionSkills[sessionId] = session.skills
}
}
await updateInstanceConfig(instanceId, (draft) => {
draft.sessionTasks = sessionTasks
draft.sessionSkills = sessionSkills
})
} catch (error) {
log.error("Failed to persist session tasks", error)
@@ -264,6 +269,17 @@ function getSessionFamily(instanceId: string, parentId: string): Session[] {
return [parent, ...children]
}
function getSessionSkills(instanceId: string, sessionId: string): SkillSelection[] {
const session = sessions().get(instanceId)?.get(sessionId)
return session?.skills ?? []
}
function setSessionSkills(instanceId: string, sessionId: string, skills: SkillSelection[]): void {
withSession(instanceId, sessionId, (session) => {
session.skills = skills
})
}
function isSessionBusy(instanceId: string, sessionId: string): boolean {
const instanceSessions = sessions().get(instanceId)
if (!instanceSessions) return false
@@ -283,8 +299,13 @@ async function isBlankSession(session: Session, instanceId: string, fetchIfNeede
const created = session.time?.created || 0
const updated = session.time?.updated || 0
const hasChildren = getChildSessions(instanceId, session.id).length > 0
const hasTasks = Boolean(session.tasks && session.tasks.length > 0)
const isFreshSession = created === updated && !hasChildren
if (hasTasks) {
return false
}
// Common short-circuit: fresh sessions without children
if (!fetchIfNeeded) {
return isFreshSession
@@ -423,4 +444,6 @@ export {
getSessionInfo,
isBlankSession,
cleanupBlankSessions,
getSessionSkills,
setSessionSkills,
}

View File

@@ -0,0 +1,75 @@
import { createSignal } from "solid-js"
import type { SkillCatalogResponse, SkillDescriptor, SkillDetail } from "../../../server/src/api-types"
import { serverApi } from "../lib/api-client"
import { getLogger } from "../lib/logger"
const log = getLogger("skills")
const [catalog, setCatalog] = createSignal<SkillDescriptor[]>([])
const [catalogLoading, setCatalogLoading] = createSignal(false)
const [catalogError, setCatalogError] = createSignal<string | null>(null)
const detailCache = new Map<string, SkillDetail>()
const detailPromises = new Map<string, Promise<SkillDetail>>()
async function loadCatalog(): Promise<SkillDescriptor[]> {
if (catalog().length > 0) return catalog()
if (catalogLoading()) return catalog()
setCatalogLoading(true)
setCatalogError(null)
try {
const response: SkillCatalogResponse = await serverApi.fetchSkillsCatalog()
const skills = Array.isArray(response.skills) ? response.skills : []
setCatalog(skills)
return skills
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load skills"
setCatalogError(message)
log.warn("Failed to load skills catalog", error)
return []
} finally {
setCatalogLoading(false)
}
}
async function loadSkillDetail(id: string): Promise<SkillDetail | null> {
if (!id) return null
if (detailCache.has(id)) return detailCache.get(id) || null
const pending = detailPromises.get(id)
if (pending) return pending
const promise = serverApi
.fetchSkillDetail(id)
.then((detail) => {
detailCache.set(id, detail)
return detail
})
.catch((error) => {
log.warn("Failed to load skill detail", { id, error })
return null
})
.finally(() => {
detailPromises.delete(id)
})
detailPromises.set(id, promise as Promise<SkillDetail>)
return promise
}
async function loadSkillDetails(ids: string[]): Promise<SkillDetail[]> {
const uniqueIds = Array.from(new Set(ids.filter(Boolean)))
if (uniqueIds.length === 0) return []
const results = await Promise.all(uniqueIds.map((id) => loadSkillDetail(id)))
return results.filter((detail): detail is SkillDetail => Boolean(detail))
}
export {
catalog,
catalogLoading,
catalogError,
loadCatalog,
loadSkillDetail,
loadSkillDetails,
}

View File

@@ -24,6 +24,13 @@ export interface Task {
timestamp: number
messageIds?: string[] // IDs of messages associated with this task
taskSessionId?: string // Backend session ID for this task
archived?: boolean
}
export interface SkillSelection {
id: string
name: string
description?: string
}
// Our client-specific Session interface extending SDK Session
@@ -40,6 +47,7 @@ export interface Session
pendingPermission?: boolean // Indicates if session is waiting on user permission
tasks?: Task[] // Multi-task chat support
activeTaskId?: string // Track the currently active task for message isolation
skills?: SkillSelection[] // Selected skills for this session
}
// Adapter function to convert SDK Session to client Session