Ver Fonte

#5 Enhance IdeBridge with GUI-only session support and custom API handling

paviko há 2 meses atrás
pai
commit
802cbe895a

+ 10 - 3
hosts/vscode-plugin/src/ui/IdeBridgeServer.ts

@@ -14,10 +14,15 @@ export interface SessionHandlers {
   uiSetState?: (state: any) => Promise<void>
 }
 
+interface SessionMetadata {
+  guiOnly?: boolean
+}
+
 interface Session {
   id: string
   token: string
   handlers: SessionHandlers
+  metadata: SessionMetadata
   sseClients: Set<http.ServerResponse>
 }
 
@@ -75,7 +80,7 @@ class IdeBridgeServer {
     this.sessions.clear()
   }
 
-  async createSession(handlers: SessionHandlers): Promise<{ sessionId: string; baseUrl: string; token: string }> {
+  async createSession(handlers: SessionHandlers, metadata: SessionMetadata = {}): Promise<{ sessionId: string; baseUrl: string; token: string }> {
     await this.start() // ensure server is running
 
     const sessionId = crypto.randomUUID()
@@ -85,6 +90,7 @@ class IdeBridgeServer {
       id: sessionId,
       token,
       handlers,
+      metadata,
       sseClients: new Set(),
     })
 
@@ -169,9 +175,10 @@ class IdeBridgeServer {
 
     session.sseClients.add(res)
 
-    // Send initial connected event
+    // Send initial connected event with optional metadata
     try {
-      res.write("event: connected\ndata: {}\n\n")
+      const connected = session.metadata.guiOnly ? JSON.stringify({ customApi: false }) : "{}"
+      res.write(`event: connected\ndata: ${connected}\n\n`)
     } catch (e) {
       logger.appendLine(`IdeBridgeServer failed to init SSE: ${e}`)
     }

+ 7 - 3
hosts/vscode-plugin/src/ui/WebviewController.ts

@@ -32,6 +32,7 @@ export class WebviewController {
   private connection?: BackendConnection
   private disposables: vscode.Disposable[] = []
   private bridgeSessionId: string | null = null
+  private isGuiOnly = false
   private uiGetState?: () => Promise<any>
   private uiSetState?: (state: any) => Promise<void>
 
@@ -66,6 +67,10 @@ export class WebviewController {
       // Make PathInserter aware of the active communication bridge
       // NOTE: PathInserter is now set by container visibility (editor panel / sidebar).
 
+      // Determine UI source: embedded webgui (gui-only) or remote server (standard)
+      // NOTE: resolveUiBaseUrl sets this.isGuiOnly — call it before createSession
+      const uiBaseUrl = await this.resolveUiBaseUrl(connection)
+
       // Create bridge session with handlers from CommunicationBridge
       const session = await bridgeServer.createSession(
         {
@@ -78,6 +83,7 @@ export class WebviewController {
           uiGetState: this.uiGetState,
           uiSetState: this.uiSetState,
         },
+        { guiOnly: this.isGuiOnly },
       )
       this.bridgeSessionId = session.sessionId
 
@@ -106,9 +112,6 @@ export class WebviewController {
         logger.appendLine(`FileMonitor init failed: ${e}`)
       }
 
-      // Determine UI source: embedded webgui (gui-only) or remote server (standard)
-      const uiBaseUrl = await this.resolveUiBaseUrl(connection)
-
       // Use asExternalUri for Remote-SSH compatibility
       const externalUi = await vscode.env.asExternalUri(vscode.Uri.parse(uiBaseUrl))
       const externalBridge = await vscode.env.asExternalUri(vscode.Uri.parse(session.baseUrl))
@@ -252,6 +255,7 @@ export class WebviewController {
       const parsed = new URL(connection.uiBase)
       const serverRoot = parsed.origin
       logger.appendLine(`gui-only mode: serving embedded webgui, REST API at ${serverRoot}`)
+      this.isGuiOnly = true
       const base = await webguiServer.start(webguiDir, serverRoot)
       return `${base}/app`
     }

+ 0 - 36
packages/opencode/src/webgui/server/webgui.ts

@@ -355,42 +355,6 @@ export const WebGuiRoute = new Hono()
       return c.json({ status: session.status, result: session.result })
     },
   )
-  .get(
-    "/config/providers",
-    describeRoute({
-      description: "List all providers",
-      operationId: "config.providers",
-      responses: {
-        200: {
-          description: "List of providers",
-          content: {
-            "application/json": {
-              schema: resolver(
-                z.object({
-                  providers: ModelsDev.Provider.array(),
-                  default: z.record(z.string(), z.string()),
-                }),
-              ),
-            },
-          },
-        },
-      },
-    }),
-    async (c) => {
-      // Get providers directly from updated cache after refresh
-      const database = await ModelsDev.get()
-      const activeProviders = await Provider.list()
-
-      // Merge active providers into the full list from database
-      // This ensures we have all available providers, but with updated info for active ones
-      const allProviders = { ...database, ...activeProviders }
-
-      return c.json({
-        providers: Object.values(allProviders),
-        default: mapValues(activeProviders, (item) => Provider.sort(Object.values(item.models))[0]?.id ?? ""),
-      })
-    },
-  )
   .post(
     "/session/:sessionID/retry",
     describeRoute({

+ 3 - 1
packages/opencode/webgui/src/components/MessageList/SessionErrorPart.tsx

@@ -1,5 +1,6 @@
 import { useSession } from "../../state/SessionContext"
 import { useCallback } from "react"
+import { useCustomApi } from "../../state/IdeBridgeContext"
 import type { SessionErrorPart as SessionErrorPartType } from "../../types/messages"
 
 interface SessionErrorPartProps {
@@ -8,6 +9,7 @@ interface SessionErrorPartProps {
 
 export function SessionErrorPart({ part }: SessionErrorPartProps) {
   const { currentSession, retrySession, isIdle } = useSession()
+  const customApi = useCustomApi()
 
   const handleRetry = useCallback(() => {
     if (currentSession?.id) {
@@ -46,7 +48,7 @@ export function SessionErrorPart({ part }: SessionErrorPartProps) {
           </div>
         </div>
 
-        {isIdle && (
+        {isIdle && customApi && (
           <button
             onClick={handleRetry}
             className="shrink-0 flex items-center gap-1.5 px-2 py-1 text-[11px] font-medium bg-white dark:bg-white/5 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 rounded hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors shadow-sm"

+ 4 - 2
packages/opencode/webgui/src/components/SettingsPanel/TabBar.tsx

@@ -1,15 +1,17 @@
 interface TabBarProps {
   activeTab: "general" | "api-keys" | "models" | "advanced"
   onTabChange: (tab: "general" | "api-keys" | "models" | "advanced") => void
+  hideApiKeys?: boolean
 }
 
-export function TabBar({ activeTab, onTabChange }: TabBarProps) {
-  const tabs: { id: typeof activeTab; label: string; icon: string }[] = [
+export function TabBar({ activeTab, onTabChange, hideApiKeys }: TabBarProps) {
+  const all: { id: typeof activeTab; label: string; icon: string }[] = [
     { id: "general", label: "General", icon: "⚙️" },
     { id: "api-keys", label: "API Keys", icon: "🔑" },
     { id: "models", label: "Models", icon: "🤖" },
     { id: "advanced", label: "Advanced", icon: "🔧" },
   ]
+  const tabs = hideApiKeys ? all.filter((t) => t.id !== "api-keys") : all
 
   return (
     <div className="border-b border-gray-200 dark:border-gray-800">

+ 8 - 6
packages/opencode/webgui/src/components/SettingsPanel/hooks/useSettingsForm.ts

@@ -7,7 +7,7 @@ interface ProviderWithAuth extends Provider {
   authKey?: string
 }
 
-export function useSettingsForm(isOpen: boolean) {
+export function useSettingsForm(isOpen: boolean, customApi?: boolean) {
   const [formData, setFormData] = useState<Partial<Config>>({})
   const [originalFormData, setOriginalFormData] = useState<Partial<Config>>({})
   const [apiKeys, setApiKeys] = useState<Record<string, string>>({})
@@ -42,7 +42,7 @@ export function useSettingsForm(isOpen: boolean) {
         }
 
         // Fetch providers
-        const providersRes = await sdk.config.allProviders()
+        const providersRes = await sdk.config.providers()
         if (providersRes.error) {
           throw new Error("Failed to load providers")
         }
@@ -50,9 +50,11 @@ export function useSettingsForm(isOpen: boolean) {
           setProviders(providersRes.data.providers.sort((a, b) => a.name.localeCompare(b.name)))
         }
 
-        // Fetch configured providers
-        const authList = await sdk.auth.list()
-        setConfiguredProviders(Object.keys(authList))
+        // Fetch configured providers (requires custom API)
+        if (customApi !== false) {
+          const authList = await sdk.auth.list()
+          setConfiguredProviders(Object.keys(authList))
+        }
 
         // Reset API keys to empty (they should be entered fresh)
         setApiKeys({})
@@ -64,7 +66,7 @@ export function useSettingsForm(isOpen: boolean) {
     }
 
     fetchData()
-  }, [isOpen])
+  }, [isOpen, customApi])
 
   return {
     formData,

+ 17 - 13
packages/opencode/webgui/src/components/SettingsPanel/index.tsx

@@ -6,6 +6,7 @@ import { ApiKeysTab } from "../settings/ApiKeysTab"
 import { ModelsTab } from "../settings/ModelsTab"
 import { AdvancedTab } from "../settings/AdvancedTab"
 import { useProviders } from "../../state/ProvidersContext.tsx"
+import { useCustomApi } from "../../state/IdeBridgeContext"
 import { useSettingsForm } from "./hooks/useSettingsForm"
 import { useUnsavedChanges } from "./hooks/useUnsavedChanges"
 import { TabBar } from "./TabBar"
@@ -24,6 +25,7 @@ export function SettingsPanel({ isOpen, onClose }: SettingsPanelProps) {
   const [isSaving, setIsSaving] = useState(false)
   const [successMessage, setSuccessMessage] = useState<string | null>(null)
   const { markProvidersDirty } = useProviders()
+  const customApi = useCustomApi()
 
   const {
     formData,
@@ -39,7 +41,7 @@ export function SettingsPanel({ isOpen, onClose }: SettingsPanelProps) {
     setConfiguredProviders,
     isLoading,
     error,
-  } = useSettingsForm(isOpen)
+  } = useSettingsForm(isOpen, customApi)
 
   const { hasUnsavedChanges, showCloseConfirm, setShowCloseConfirm } = useUnsavedChanges(
     formData,
@@ -98,18 +100,20 @@ export function SettingsPanel({ isOpen, onClose }: SettingsPanelProps) {
         }
       }
 
-      // Save API keys
-      const apiKeyEntries = Object.entries(apiKeys).filter(([_, key]) => key && key.trim())
+      // Save API keys (only when custom API is available)
+      if (customApi) {
+        const apiKeyEntries = Object.entries(apiKeys).filter(([_, key]) => key && key.trim())
 
-      for (const [providerID, key] of apiKeyEntries) {
-        await sdk.auth.set(providerID, {
-          type: "api",
-          key: key.trim(),
-        })
-      }
+        for (const [providerID, key] of apiKeyEntries) {
+          await sdk.auth.set(providerID, {
+            type: "api",
+            key: key.trim(),
+          })
+        }
 
-      // Clear API keys after successful save
-      setApiKeys({})
+        // Clear API keys after successful save
+        setApiKeys({})
+      }
 
       setSuccessMessage("Settings saved successfully")
       markProvidersDirty()
@@ -132,7 +136,7 @@ export function SettingsPanel({ isOpen, onClose }: SettingsPanelProps) {
         <div className="modern-card w-full max-w-3xl mx-4 max-h-[90vh] flex flex-col shadow-2xl">
           <SettingsHeader onClose={handleClose} />
 
-          <TabBar activeTab={activeTab} onTabChange={setActiveTab} />
+          <TabBar activeTab={activeTab} onTabChange={setActiveTab} hideApiKeys={!customApi} />
 
           {/* Content */}
           <div className="flex-1 overflow-y-auto px-3 py-3">
@@ -148,7 +152,7 @@ export function SettingsPanel({ isOpen, onClose }: SettingsPanelProps) {
               <>
                 {activeTab === "general" && <GeneralTab formData={formData} setFormData={setFormData} />}
 
-                {activeTab === "api-keys" && (
+                {customApi && activeTab === "api-keys" && (
                   <ApiKeysTab
                     providers={providers}
                     configuredProviders={configuredProviders}

+ 1 - 26
packages/opencode/webgui/src/lib/api/sdkClient.ts

@@ -7,7 +7,7 @@
  * relative URLs are used — identical to the original behaviour.
  */
 
-import { createOpencodeClient, type Provider } from "@opencode-ai/sdk/client"
+import { createOpencodeClient } from "@opencode-ai/sdk/client"
 import { ideBridge } from "../ideBridge"
 
 export const serverBase: string =
@@ -15,11 +15,6 @@ export const serverBase: string =
 
 const baseClient = createOpencodeClient({ baseUrl: serverBase || "/" })
 
-interface ProvidersResponse {
-  providers: Provider[]
-  default: Record<string, string>
-}
-
 interface ModelEntry {
   providerID: string
   modelID: string
@@ -72,26 +67,6 @@ export const sdk = {
     get: baseClient.config.get.bind(baseClient.config),
     update: baseClient.config.update.bind(baseClient.config),
     providers: baseClient.config.providers.bind(baseClient.config),
-    allProviders: async () => {
-      try {
-        const response = await fetch(`${serverBase}/app/api/config/providers`, {
-          method: "GET",
-          headers: { "Content-Type": "application/json" },
-        })
-
-        if (!response.ok) {
-          return { error: { message: "Failed to load providers" }, data: null as ProvidersResponse | null }
-        }
-
-        const data = (await response.json()) as ProvidersResponse
-        return { data, error: null as { message: string } | null }
-      } catch (error) {
-        return {
-          error: { message: error instanceof Error ? error.message : "Unknown error" },
-          data: null as ProvidersResponse | null,
-        }
-      }
-    },
   },
   path: {
     get: async () => {

+ 5 - 2
packages/opencode/webgui/src/lib/ideBridge.ts

@@ -17,6 +17,7 @@ const token = params.get("ideBridgeToken")
 
 class IdeBridge {
   ready = false
+  customApi = true
   private queue: Message[] = []
   private handlers: Set<Handler> = new Set()
   private pending = new Map<string, { resolve: (m: Message) => void; reject: (e: any) => void }>()
@@ -49,8 +50,10 @@ class IdeBridge {
 
     this.eventSource.addEventListener("connected", (ev: MessageEvent) => {
       try {
-        // ignore payload (kept for compatibility)
-        JSON.parse(String(ev.data))
+        const data = JSON.parse(String(ev.data))
+        if (typeof data.customApi === "boolean") {
+          this.customApi = data.customApi
+        }
       } catch {
       }
     })

+ 11 - 1
packages/opencode/webgui/src/state/IdeBridgeContext.tsx

@@ -7,6 +7,7 @@ interface IdeBridgeState {
   openedFiles: string[]
   currentFile: string | null
   timestamp: number | null
+  customApi: boolean
 }
 
 const Ctx = createContext<IdeBridgeState | null>(null)
@@ -17,6 +18,10 @@ export function useIdeBridgeState() {
   return ctx
 }
 
+export function useCustomApi() {
+  return useIdeBridgeState().customApi
+}
+
 interface ProviderProps {
   children: ReactNode
 }
@@ -25,10 +30,15 @@ export function IdeBridgeProvider({ children }: ProviderProps) {
   const [openedFiles, setOpenedFiles] = useState<string[]>([])
   const [currentFile, setCurrentFile] = useState<string | null>(null)
   const [timestamp, setTimestamp] = useState<number | null>(null)
+  const [customApi, setCustomApi] = useState(true)
   const { worktree } = useProject()
 
   const rel = (p: string): string => toProjectRelative(p, worktree)
 
+  useEffect(() => {
+    setCustomApi(ideBridge.customApi)
+  }, [])
+
   useEffect(() => {
     const handler = (msg: any) => {
       if (!msg || typeof msg !== "object") return
@@ -61,7 +71,7 @@ export function IdeBridgeProvider({ children }: ProviderProps) {
     return () => ideBridge.off(handler)
   }, [worktree])
 
-  const value = useMemo(() => ({ openedFiles, currentFile, timestamp }), [openedFiles, currentFile, timestamp])
+  const value = useMemo(() => ({ openedFiles, currentFile, timestamp, customApi }), [openedFiles, currentFile, timestamp, customApi])
 
   return <Ctx.Provider value={value}>{children}</Ctx.Provider>
 }