Przeglądaj źródła

#5 Added config persistence via IdeBridge for IDE plugins

paviko 6 dni temu
rodzic
commit
93a7c40c74

+ 87 - 0
hosts/jetbrains-plugin/src/main/kotlin/paviko/opencode/ui/IdeBridge.kt

@@ -1,6 +1,7 @@
 package paviko.opencode.ui
 
 import com.google.gson.Gson
+import com.google.gson.JsonArray
 import com.google.gson.JsonObject
 import com.intellij.ide.BrowserUtil
 import com.intellij.openapi.application.ApplicationManager
@@ -13,6 +14,7 @@ import com.intellij.openapi.project.Project
 import com.intellij.openapi.vfs.LocalFileSystem
 import com.sun.net.httpserver.HttpExchange
 import com.sun.net.httpserver.HttpServer
+import java.io.File
 import java.io.OutputStreamWriter
 import java.net.InetSocketAddress
 import java.net.URLDecoder
@@ -293,6 +295,74 @@ object IdeBridge {
                         replyError(session, id, "Missing path")
                     }
                 }
+                "kv.get" -> {
+                    val file = File(statePath, "kv.json")
+                    val data = try {
+                        if (file.exists()) gson.fromJson(file.readText(), JsonObject::class.java) ?: JsonObject()
+                        else JsonObject()
+                    } catch (_: Throwable) { JsonObject() }
+                    replyWithPayload(session, id, data)
+                }
+
+                "kv.update" -> {
+                    val file = File(statePath, "kv.json")
+                    val existing = try {
+                        if (file.exists()) gson.fromJson(file.readText(), JsonObject::class.java) ?: JsonObject()
+                        else JsonObject()
+                    } catch (_: Throwable) { JsonObject() }
+                    payload?.entrySet()?.forEach { (k, v) -> existing.add(k, v) }
+                    statePath.mkdirs()
+                    file.writeText(gson.toJson(existing))
+                    replyWithPayload(session, id, existing)
+                }
+
+                "model.get" -> {
+                    val file = File(statePath, "model.json")
+                    val data = try {
+                        if (file.exists()) {
+                            val raw = gson.fromJson(file.readText(), JsonObject::class.java) ?: JsonObject()
+                            JsonObject().apply {
+                                add("recent", if (raw.has("recent") && raw.get("recent").isJsonArray) raw.getAsJsonArray("recent") else JsonArray())
+                                add("favorite", if (raw.has("favorite") && raw.get("favorite").isJsonArray) raw.getAsJsonArray("favorite") else JsonArray())
+                                add("variant", if (raw.has("variant") && raw.get("variant").isJsonObject) raw.getAsJsonObject("variant") else JsonObject())
+                            }
+                        } else {
+                            JsonObject().apply {
+                                add("recent", JsonArray())
+                                add("favorite", JsonArray())
+                                add("variant", JsonObject())
+                            }
+                        }
+                    } catch (_: Throwable) {
+                        JsonObject().apply {
+                            add("recent", JsonArray())
+                            add("favorite", JsonArray())
+                            add("variant", JsonObject())
+                        }
+                    }
+                    replyWithPayload(session, id, data)
+                }
+
+                "model.update" -> {
+                    val file = File(statePath, "model.json")
+                    val existing = try {
+                        if (file.exists()) gson.fromJson(file.readText(), JsonObject::class.java) ?: JsonObject()
+                        else JsonObject()
+                    } catch (_: Throwable) { JsonObject() }
+                    if (!existing.has("recent") || !existing.get("recent").isJsonArray) existing.add("recent", JsonArray())
+                    if (!existing.has("favorite") || !existing.get("favorite").isJsonArray) existing.add("favorite", JsonArray())
+                    if (!existing.has("variant") || !existing.get("variant").isJsonObject) existing.add("variant", JsonObject())
+                    if (payload?.has("recent") == true) existing.add("recent", payload.get("recent"))
+                    if (payload?.has("favorite") == true) existing.add("favorite", payload.get("favorite"))
+                    if (payload?.has("variant") == true) {
+                        val current = existing.getAsJsonObject("variant")
+                        payload.getAsJsonObject("variant").entrySet().forEach { (k, v) -> current.add(k, v) }
+                    }
+                    statePath.mkdirs()
+                    file.writeText(gson.toJson(existing))
+                    replyWithPayload(session, id, existing)
+                }
+
                 else -> replyError(session, id, "Unknown type: $type")
             }
 
@@ -304,6 +374,23 @@ object IdeBridge {
         exchange.close()
     }
 
+    private val statePath: File
+        get() = File(
+            System.getenv("XDG_STATE_HOME") ?: "${System.getProperty("user.home")}/.local/state",
+            "opencode"
+        )
+
+    private fun replyWithPayload(session: Session, id: String?, payload: Any) {
+        if (id == null) return
+        val msg = JsonObject().apply {
+            addProperty("replyTo", id)
+            addProperty("ok", true)
+            add("payload", gson.toJsonTree(payload))
+            addProperty("timestamp", System.currentTimeMillis())
+        }
+        broadcastSSE(session, gson.toJson(msg))
+    }
+
     private fun replyOk(session: Session, id: String?) {
         if (id == null) return
         val msg = JsonObject().apply {

+ 7 - 12
hosts/scripts/build_vscode.sh

@@ -251,18 +251,13 @@ build_variant() {
     elif [ "$variant" = "gui-only" ]; then
         print_status "=== Packaging GUI-ONLY variant ==="
 
-        # Build webgui if webgui-dist doesn't exist yet
-        if [ ! -d "$WEBGUI_DIST" ] || [ -z "$(ls -A "$WEBGUI_DIST" 2>/dev/null)" ]; then
-            print_status "Building webgui..."
-            (
-                cd "$WEBGUI_DIR"
-                run_install
-                if $PNPM_AVAILABLE; then
-                    pnpm run build
-                else
-                    npm run build
-                fi
-            )
+        # Always rebuild webgui to pick up source changes
+        # The monorepo uses bun workspaces – deps are already installed at root level
+        print_status "Building webgui..."
+        if command -v bun >/dev/null 2>&1; then
+            (cd "$WEBGUI_DIR" && bun run build)
+        else
+            (cd "$WEBGUI_DIR" && npm run build)
         fi
 
         if [ ! -d "$WEBGUI_DIST" ]; then

+ 90 - 0
hosts/vscode-plugin/src/ui/IdeBridgeServer.ts

@@ -1,5 +1,8 @@
 import * as http from "http"
 import * as crypto from "crypto"
+import * as fs from "fs"
+import * as path from "path"
+import * as os from "os"
 import { logger } from "../globals"
 
 export interface SessionHandlers {
@@ -249,6 +252,76 @@ class IdeBridgeServer {
           break
         }
 
+        case "kv.get": {
+          const file = path.join(this.statePath(), "kv.json")
+          try {
+            if (fs.existsSync(file)) {
+              this.replyWithPayload(session, id, JSON.parse(fs.readFileSync(file, "utf-8")))
+            } else {
+              this.replyWithPayload(session, id, {})
+            }
+          } catch {
+            this.replyWithPayload(session, id, {})
+          }
+          break
+        }
+
+        case "kv.update": {
+          const dir = this.statePath()
+          const file = path.join(dir, "kv.json")
+          let existing: Record<string, any> = {}
+          try {
+            if (fs.existsSync(file)) existing = JSON.parse(fs.readFileSync(file, "utf-8"))
+          } catch {}
+          const merged = { ...existing, ...(payload ?? {}) }
+          fs.mkdirSync(dir, { recursive: true })
+          fs.writeFileSync(file, JSON.stringify(merged, null, 2))
+          this.replyWithPayload(session, id, merged)
+          break
+        }
+
+        case "model.get": {
+          const file = path.join(this.statePath(), "model.json")
+          try {
+            if (fs.existsSync(file)) {
+              const data = JSON.parse(fs.readFileSync(file, "utf-8"))
+              this.replyWithPayload(session, id, {
+                recent: Array.isArray(data.recent) ? data.recent : [],
+                favorite: Array.isArray(data.favorite) ? data.favorite : [],
+                variant: typeof data.variant === "object" && data.variant !== null ? data.variant : {},
+              })
+            } else {
+              this.replyWithPayload(session, id, { recent: [], favorite: [], variant: {} })
+            }
+          } catch {
+            this.replyWithPayload(session, id, { recent: [], favorite: [], variant: {} })
+          }
+          break
+        }
+
+        case "model.update": {
+          const dir = this.statePath()
+          const file = path.join(dir, "model.json")
+          let existing = { recent: [] as any[], favorite: [] as any[], variant: {} as Record<string, string> }
+          try {
+            if (fs.existsSync(file)) {
+              const data = JSON.parse(fs.readFileSync(file, "utf-8"))
+              existing = {
+                recent: Array.isArray(data.recent) ? data.recent : [],
+                favorite: Array.isArray(data.favorite) ? data.favorite : [],
+                variant: typeof data.variant === "object" && data.variant !== null ? data.variant : {},
+              }
+            }
+          } catch {}
+          if (payload?.recent !== undefined) existing.recent = payload.recent
+          if (payload?.favorite !== undefined) existing.favorite = payload.favorite
+          if (payload?.variant !== undefined) existing.variant = { ...existing.variant, ...payload.variant }
+          fs.mkdirSync(dir, { recursive: true })
+          fs.writeFileSync(file, JSON.stringify(existing))
+          this.replyWithPayload(session, id, existing)
+          break
+        }
+
         case "uiSetState": {
           if (!session.handlers.uiSetState) {
             this.replyError(session, id, "uiSetState not supported")
@@ -271,6 +344,23 @@ class IdeBridgeServer {
     res.end()
   }
 
+  private statePath(): string {
+    return path.join(process.env.XDG_STATE_HOME || path.join(os.homedir(), ".local", "state"), "opencode")
+  }
+
+  private replyWithPayload(session: Session, id: string | undefined, payload: any): void {
+    if (!id) return
+    this.broadcastSSE(
+      session,
+      JSON.stringify({
+        replyTo: id,
+        ok: true,
+        payload,
+        timestamp: Date.now(),
+      }),
+    )
+  }
+
   private replyOk(session: Session, id?: string): void {
     if (!id) return
     this.broadcastSSE(

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

@@ -3,8 +3,6 @@ import { Hono } from "hono"
 import { validator, describeRoute, resolver } from "hono-openapi"
 import { stream } from "hono/streaming"
 import { z } from "zod"
-import path from "path"
-import { Global } from "../../global"
 import { ModelsDev } from "../../provider/models"
 import { Auth } from "../../auth"
 import { Instance } from "../../project/instance"
@@ -393,150 +391,6 @@ export const WebGuiRoute = new Hono()
       })
     },
   )
-  .get(
-    "/kv",
-    describeRoute({
-      description: "Get key-value preferences from kv.json (shared with CLI)",
-      operationId: "kv.get",
-      responses: {
-        200: {
-          description: "KV store contents",
-          content: {
-            "application/json": {
-              schema: resolver(z.record(z.string(), z.any())),
-            },
-          },
-        },
-      },
-    }),
-    async (c) => {
-      const file = Bun.file(path.join(Global.Path.state, "kv.json"))
-      if (!(await file.exists())) return c.json({})
-      try {
-        return c.json(await file.json())
-      } catch {
-        return c.json({})
-      }
-    },
-  )
-  .patch(
-    "/kv",
-    describeRoute({
-      description: "Update key-value preferences in kv.json (shared with CLI)",
-      operationId: "kv.update",
-      responses: {
-        200: {
-          description: "Updated KV store",
-          content: {
-            "application/json": {
-              schema: resolver(z.record(z.string(), z.any())),
-            },
-          },
-        },
-      },
-    }),
-    validator("json", z.record(z.string(), z.any())),
-    async (c) => {
-      const partial = c.req.valid("json")
-      const file = Bun.file(path.join(Global.Path.state, "kv.json"))
-      let existing: Record<string, any> = {}
-      try {
-        if (await file.exists()) existing = await file.json()
-      } catch {}
-      const merged = { ...existing, ...partial }
-      await Bun.write(file, JSON.stringify(merged, null, 2))
-      return c.json(merged)
-    },
-  )
-  .get(
-    "/model",
-    describeRoute({
-      description: "Get model preferences (recent, favorite, variant) from model.json",
-      operationId: "model.get",
-      responses: {
-        200: {
-          description: "Model preferences",
-          content: {
-            "application/json": {
-              schema: resolver(
-                z.object({
-                  recent: z.array(z.object({ providerID: z.string(), modelID: z.string() })),
-                  favorite: z.array(z.object({ providerID: z.string(), modelID: z.string() })),
-                  variant: z.record(z.string(), z.string()).optional(),
-                }),
-              ),
-            },
-          },
-        },
-      },
-    }),
-    async (c) => {
-      const file = Bun.file(path.join(Global.Path.state, "model.json"))
-      const exists = await file.exists()
-      if (!exists) return c.json({ recent: [], favorite: [], variant: {} })
-      try {
-        const data = await file.json()
-        return c.json({
-          recent: Array.isArray(data.recent) ? data.recent : [],
-          favorite: Array.isArray(data.favorite) ? data.favorite : [],
-          variant: typeof data.variant === "object" && data.variant !== null ? data.variant : {},
-        })
-      } catch {
-        return c.json({ recent: [], favorite: [], variant: {} })
-      }
-    },
-  )
-  .patch(
-    "/model",
-    describeRoute({
-      description: "Update model preferences (recent, favorite, variant) in model.json",
-      operationId: "model.update",
-      responses: {
-        200: {
-          description: "Updated model preferences",
-          content: {
-            "application/json": {
-              schema: resolver(
-                z.object({
-                  recent: z.array(z.object({ providerID: z.string(), modelID: z.string() })),
-                  favorite: z.array(z.object({ providerID: z.string(), modelID: z.string() })),
-                  variant: z.record(z.string(), z.string()).optional(),
-                }),
-              ),
-            },
-          },
-        },
-      },
-    }),
-    validator(
-      "json",
-      z.object({
-        recent: z.array(z.object({ providerID: z.string(), modelID: z.string() })).optional(),
-        favorite: z.array(z.object({ providerID: z.string(), modelID: z.string() })).optional(),
-        variant: z.record(z.string(), z.string()).optional(),
-      }),
-    ),
-    async (c) => {
-      const partial = c.req.valid("json")
-      const file = Bun.file(path.join(Global.Path.state, "model.json"))
-      let existing = { recent: [] as any[], favorite: [] as any[], variant: {} as Record<string, string> }
-      try {
-        if (await file.exists()) {
-          const data = await file.json()
-          existing = {
-            recent: Array.isArray(data.recent) ? data.recent : [],
-            favorite: Array.isArray(data.favorite) ? data.favorite : [],
-            variant: typeof data.variant === "object" && data.variant !== null ? data.variant : {},
-          }
-        }
-      } catch {}
-      if (partial.recent !== undefined) existing.recent = partial.recent
-      if (partial.favorite !== undefined) existing.favorite = partial.favorite
-      if (partial.variant !== undefined) existing.variant = { ...existing.variant, ...partial.variant }
-      await Bun.write(file, JSON.stringify(existing))
-      return c.json(existing)
-    },
-  )
   .post(
     "/session/:sessionID/retry",
     describeRoute({

+ 13 - 38
packages/opencode/webgui/src/lib/api/sdkClient.ts

@@ -8,6 +8,7 @@
  */
 
 import { createOpencodeClient, type Provider } from "@opencode-ai/sdk/client"
+import { ideBridge } from "../ideBridge"
 
 export const serverBase: string =
   ((globalThis as any).__OPENCODE_SERVER_URL__ as string | undefined)?.replace(/\/$/, "") || ""
@@ -214,32 +215,19 @@ export const sdk = {
   },
   model: {
     get: async () => {
+      if (!ideBridge.isInstalled()) return { data: { recent: [], favorite: [], variant: {} } as ModelPreferences, error: null as { message: string } | null }
       try {
-        const response = await fetch(`${serverBase}/app/api/model`, {
-          method: "GET",
-          headers: { "Content-Type": "application/json" },
-        })
-        if (!response.ok) {
-          return { error: { message: "Failed to fetch model preferences" }, data: null as ModelPreferences | null }
-        }
-        const data = (await response.json()) as ModelPreferences
-        return { data, error: null as { message: string } | null }
+        const res = await ideBridge.request("model.get")
+        return { data: (res.payload ?? { recent: [], favorite: [], variant: {} }) as ModelPreferences, error: null as { message: string } | null }
       } catch (error) {
         return { error: { message: error instanceof Error ? error.message : "Unknown error" }, data: null as ModelPreferences | null }
       }
     },
     update: async (options: { body: Partial<ModelPreferences> }) => {
+      if (!ideBridge.isInstalled()) return { data: null as ModelPreferences | null, error: { message: "IdeBridge not available" } }
       try {
-        const response = await fetch(`${serverBase}/app/api/model`, {
-          method: "PATCH",
-          headers: { "Content-Type": "application/json" },
-          body: JSON.stringify(options.body),
-        })
-        if (!response.ok) {
-          return { error: { message: "Failed to update model preferences" }, data: null as ModelPreferences | null }
-        }
-        const data = (await response.json()) as ModelPreferences
-        return { data, error: null as { message: string } | null }
+        const res = await ideBridge.request("model.update", options.body)
+        return { data: (res.payload ?? { recent: [], favorite: [], variant: {} }) as ModelPreferences, error: null as { message: string } | null }
       } catch (error) {
         return { error: { message: error instanceof Error ? error.message : "Unknown error" }, data: null as ModelPreferences | null }
       }
@@ -247,32 +235,19 @@ export const sdk = {
   },
   kv: {
     get: async () => {
+      if (!ideBridge.isInstalled()) return { data: {} as Record<string, any>, error: null as { message: string } | null }
       try {
-        const response = await fetch(`${serverBase}/app/api/kv`, {
-          method: "GET",
-          headers: { "Content-Type": "application/json" },
-        })
-        if (!response.ok) {
-          return { error: { message: "Failed to fetch kv" }, data: null as Record<string, any> | null }
-        }
-        const data = (await response.json()) as Record<string, any>
-        return { data, error: null as { message: string } | null }
+        const res = await ideBridge.request("kv.get")
+        return { data: (res.payload ?? {}) as Record<string, any>, error: null as { message: string } | null }
       } catch (error) {
         return { error: { message: error instanceof Error ? error.message : "Unknown error" }, data: null as Record<string, any> | null }
       }
     },
     update: async (options: { body: Record<string, any> }) => {
+      if (!ideBridge.isInstalled()) return { data: null as Record<string, any> | null, error: { message: "IdeBridge not available" } }
       try {
-        const response = await fetch(`${serverBase}/app/api/kv`, {
-          method: "PATCH",
-          headers: { "Content-Type": "application/json" },
-          body: JSON.stringify(options.body),
-        })
-        if (!response.ok) {
-          return { error: { message: "Failed to update kv" }, data: null as Record<string, any> | null }
-        }
-        const data = (await response.json()) as Record<string, any>
-        return { data, error: null as { message: string } | null }
+        const res = await ideBridge.request("kv.update", options.body)
+        return { data: (res.payload ?? {}) as Record<string, any>, error: null as { message: string } | null }
       } catch (error) {
         return { error: { message: error instanceof Error ? error.message : "Unknown error" }, data: null as Record<string, any> | null }
       }