diff --git a/opencode/packages/opencode/src/server/nine1bot-browser-extension-config.ts b/opencode/packages/opencode/src/server/nine1bot-browser-extension-config.ts new file mode 100644 index 00000000..34b9209a --- /dev/null +++ b/opencode/packages/opencode/src/server/nine1bot-browser-extension-config.ts @@ -0,0 +1,137 @@ +import { readFile, writeFile } from "fs/promises" +import { parse as parseJsonc } from "jsonc-parser" +import z from "zod" +import { RuntimeControllerProtocol } from "@/runtime/controller/protocol" + +export const BrowserExtensionModel = z.object({ + providerID: z.string().min(1), + modelID: z.string().min(1), +}) +export type BrowserExtensionModel = z.infer + +export const BrowserExtensionConfigPatch = z.object({ + model: BrowserExtensionModel.nullable().optional(), + prompt: z.string().nullable().optional(), + mcpServers: z.array(z.string()).nullable().optional(), + skills: z.array(z.string()).nullable().optional(), +}) +export type BrowserExtensionConfigPatch = z.infer + +export interface BrowserExtensionConfig { + model?: BrowserExtensionModel + prompt?: string + mcpServers?: string[] + skills?: string[] +} + +export function isBrowserExtensionEntry(entry?: RuntimeControllerProtocol.Entry): boolean { + return entry?.source === "browser-extension" || entry?.mode === "browser-sidepanel" +} + +function configPath() { + return process.env.NINE1BOT_CONFIG_PATH || "" +} + +async function readNine1botConfig(pathname: string): Promise> { + const text = await readFile(pathname, "utf-8") + return (parseJsonc(text) || {}) as Record +} + +async function writeNine1botConfig(pathname: string, nextConfig: Record) { + await writeFile(pathname, JSON.stringify(nextConfig, null, 2)) +} + +function parseModelString(value: unknown): BrowserExtensionModel | undefined { + if (typeof value !== "string") return undefined + const trimmed = value.trim() + if (!trimmed.includes("/")) return undefined + const [providerID, ...modelParts] = trimmed.split("/") + const modelID = modelParts.join("/") + if (!providerID || !modelID) return undefined + return { providerID, modelID } +} + +function formatModelString(model: BrowserExtensionModel) { + return `${model.providerID}/${model.modelID}` +} + +function normalizeStringList(value: unknown): string[] { + if (!Array.isArray(value)) return [] + return [...new Set(value + .filter((item): item is string => typeof item === "string") + .map((item) => item.trim()) + .filter(Boolean))] +} + +function normalizeBrowserExtensionConfig(config: Record): BrowserExtensionConfig { + const sidepanel = config.browser?.sidepanel + if (!sidepanel || typeof sidepanel !== "object" || Array.isArray(sidepanel)) return {} + + const model = parseModelString(sidepanel.model) + const prompt = typeof sidepanel.prompt === "string" && sidepanel.prompt.trim() + ? sidepanel.prompt + : undefined + const mcpServers = normalizeStringList(sidepanel.mcpServers) + const skills = normalizeStringList(sidepanel.skills) + + return { + ...(model ? { model } : {}), + ...(prompt ? { prompt } : {}), + ...(mcpServers.length > 0 ? { mcpServers } : {}), + ...(skills.length > 0 ? { skills } : {}), + } +} + +export async function readBrowserExtensionConfig(): Promise { + const pathname = configPath() + if (!pathname) return {} + const config = (await readNine1botConfig(pathname).catch(() => ({}))) as Record + return normalizeBrowserExtensionConfig(config) +} + +export async function patchBrowserExtensionConfig(patch: BrowserExtensionConfigPatch): Promise { + const pathname = configPath() + if (!pathname) { + throw new Error("No config path") + } + + const existing = (await readNine1botConfig(pathname).catch(() => ({}))) as Record + const browser = existing.browser && typeof existing.browser === "object" && !Array.isArray(existing.browser) + ? { ...existing.browser } + : {} + const sidepanel = browser.sidepanel && typeof browser.sidepanel === "object" && !Array.isArray(browser.sidepanel) + ? { ...browser.sidepanel } + : {} + + if ("model" in patch) { + if (patch.model === null) delete sidepanel.model + else if (patch.model) sidepanel.model = formatModelString(patch.model) + } + + if ("prompt" in patch) { + const prompt = patch.prompt?.trim() + if (prompt) sidepanel.prompt = prompt + else delete sidepanel.prompt + } + + if ("mcpServers" in patch) { + const mcpServers = normalizeStringList(patch.mcpServers) + if (mcpServers.length > 0) sidepanel.mcpServers = mcpServers + else delete sidepanel.mcpServers + } + + if ("skills" in patch) { + const skills = normalizeStringList(patch.skills) + if (skills.length > 0) sidepanel.skills = skills + else delete sidepanel.skills + } + + browser.sidepanel = sidepanel + const nextConfig = { + ...existing, + browser, + } + + await writeNine1botConfig(pathname, nextConfig) + return normalizeBrowserExtensionConfig(nextConfig) +} diff --git a/opencode/packages/opencode/src/server/routes/config.ts b/opencode/packages/opencode/src/server/routes/config.ts index cc21b850..289b86f4 100644 --- a/opencode/packages/opencode/src/server/routes/config.ts +++ b/opencode/packages/opencode/src/server/routes/config.ts @@ -9,6 +9,11 @@ import { mapValues } from "remeda" import { errors } from "../error" import { Log } from "../../util/log" import { lazy } from "../../util/lazy" +import { + BrowserExtensionConfigPatch, + patchBrowserExtensionConfig, + readBrowserExtensionConfig, +} from "../nine1bot-browser-extension-config" const log = Log.create({ service: "server" }) @@ -212,6 +217,27 @@ export const ConfigRoutes = lazy(() => return c.json({ error: e.message }, 500) } }) + .get("/nine1bot/browser-extension", async (c) => { + try { + return c.json(await readBrowserExtensionConfig()) + } catch (e: any) { + return c.json({ error: e.message }, 500) + } + }) + .patch( + "/nine1bot/browser-extension", + validator("json", BrowserExtensionConfigPatch), + async (c) => { + try { + const config = await patchBrowserExtensionConfig(c.req.valid("json")) + Config.refresh() + Provider.refresh() + return c.json(config) + } catch (e: any) { + return c.json({ error: e.message }, e.message === "No config path" ? 404 : 500) + } + }, + ) .get("/nine1bot/custom-providers", async (c) => { const configPath = process.env.NINE1BOT_CONFIG_PATH || "" if (!configPath) { diff --git a/opencode/packages/opencode/src/server/routes/nine1bot-agent.ts b/opencode/packages/opencode/src/server/routes/nine1bot-agent.ts index 4b074a9e..34a36f9d 100644 --- a/opencode/packages/opencode/src/server/routes/nine1bot-agent.ts +++ b/opencode/packages/opencode/src/server/routes/nine1bot-agent.ts @@ -27,6 +27,11 @@ import { Provider } from "@/provider/provider" import { Storage } from "@/storage/storage" import { ulid } from "ulid" import { prepareFeishuControllerMessageContext } from "../../../../../../packages/nine1bot/src/platform/feishu-context" +import { + type BrowserExtensionConfig, + isBrowserExtensionEntry, + readBrowserExtensionConfig, +} from "../nine1bot-browser-extension-config" const log = Log.create({ service: "server.nine1bot-agent" }) @@ -196,12 +201,60 @@ function parsePage(input: unknown) { return parsed.success ? parsed.data : undefined } +function clientFromEntry(entry?: RuntimeControllerProtocol.Entry): Session.Client | undefined { + if (!entry?.source) return undefined + const client: Session.Client = { source: entry.source } + if (entry.mode) client.mode = entry.mode + if (entry.platform) client.platform = entry.platform + return client +} + +function unique(values: string[] = []) { + return [...new Set(values.filter(Boolean))] +} + +function mergeBrowserExtensionResources( + current: RuntimeControllerProtocol.ResourceSelection, + config: BrowserExtensionConfig, +): RuntimeControllerProtocol.ResourceSelection { + if (!config.mcpServers?.length && !config.skills?.length) return current + + const merged: NonNullable = { + ...(current ?? {}), + } + if (config.mcpServers?.length) { + merged.mcp = { + ...(merged.mcp ?? {}), + servers: unique([...(merged.mcp?.servers ?? []), ...config.mcpServers]), + } + } + if (config.skills?.length) { + merged.skills = { + ...(merged.skills ?? {}), + skills: unique([...(merged.skills?.skills ?? []), ...config.skills]), + } + } + return merged +} + export async function createControllerSession(input?: RuntimeControllerProtocol.SessionCreateRequest) { - const model = input?.sessionChoice?.model + const browserExtensionEntry = isBrowserExtensionEntry(input?.entry) + const sidepanelConfig = browserExtensionEntry + ? await readBrowserExtensionConfig() + : {} + const model = input?.sessionChoice?.model ?? (browserExtensionEntry ? sidepanelConfig.model : undefined) + const resources = browserExtensionEntry + ? mergeBrowserExtensionResources(input?.sessionChoice?.resources, sidepanelConfig) + : input?.sessionChoice?.resources + const sessionChoice = model && !input?.sessionChoice?.model + ? { ...(input?.sessionChoice ?? {}), model, resources } + : resources + ? { ...(input?.sessionChoice ?? {}), resources } + : input?.sessionChoice const permission = parsePermission(input?.permission) const template = await ControllerTemplateResolver.resolve({ entry: input?.entry, - sessionChoice: input?.sessionChoice, + sessionChoice, clientCapabilities: input?.clientCapabilities, page: parsePage(input?.page), }) @@ -209,7 +262,7 @@ export async function createControllerSession(input?: RuntimeControllerProtocol. directory: input?.directory ?? Instance.directory, permission, source: "new-session", - agentName: input?.sessionChoice?.agent, + agentName: sessionChoice?.agent, profileTemplate: template.profileTemplate, }) const session = await Session.createNext({ @@ -218,6 +271,7 @@ export async function createControllerSession(input?: RuntimeControllerProtocol. permission, runtimeProfile: compiledProfile, runtimeCurrentModel: model ? SessionRuntimeProfile.currentModel(model, "session-choice") : undefined, + client: clientFromEntry(input?.entry), }) const profile = input?.debug?.profileSnapshot ? await SessionRuntimeProfile.read(session) : undefined return { @@ -235,6 +289,28 @@ export async function createControllerSession(input?: RuntimeControllerProtocol. } } +async function isBrowserExtensionSession(sessionID: string, entry?: RuntimeControllerProtocol.Entry) { + if (isBrowserExtensionEntry(entry)) return true + const session = await Session.get(sessionID).catch(() => undefined) + return session?.client?.source === "browser-extension" || session?.client?.mode === "browser-sidepanel" +} + +async function applyBrowserExtensionPrompt( + sessionID: string, + body: RuntimeControllerProtocol.MessageSendRequest, +): Promise { + if (!(await isBrowserExtensionSession(sessionID, body.entry))) return body + + const config = await readBrowserExtensionConfig() + const prompt = config.prompt?.trim() + if (!prompt) return body + + return { + ...body, + system: [prompt, body.system?.trim()].filter(Boolean).join("\n\n"), + } +} + async function compileControllerPrompt(input: { sessionID: string body: RuntimeControllerProtocol.MessageSendRequest @@ -277,7 +353,8 @@ export async function sendControllerMessage(sessionID: string, body: RuntimeCont let contextEnrichment: RuntimeControllerProtocol.ContextEnrichmentSummary | undefined try { SessionPrompt.assertNotBusy(sessionID) - const prepared = await prepareFeishuControllerMessageContext(body, { + const configuredBody = await applyBrowserExtensionPrompt(sessionID, body) + const prepared = await prepareFeishuControllerMessageContext(configuredBody, { cacheScope: sessionID, }) preparedBody = prepared.body diff --git a/opencode/packages/opencode/src/session/index.ts b/opencode/packages/opencode/src/session/index.ts index 8cd353e3..42bc2451 100644 --- a/opencode/packages/opencode/src/session/index.ts +++ b/opencode/packages/opencode/src/session/index.ts @@ -45,12 +45,20 @@ export namespace Session { ).test(title) } + export const Client = z.object({ + source: z.enum(["web", "browser-extension", "feishu", "api", "webhook", "schedule"]).optional(), + mode: z.string().optional(), + platform: z.string().optional(), + }) + export type Client = z.output + export const Info = z .object({ id: Identifier.schema("session"), slug: z.string(), projectID: z.string(), directory: z.string(), + client: Client.optional(), parentID: Identifier.schema("session").optional(), summary: z .object({ @@ -239,6 +247,7 @@ export namespace Session { runtimeProfile?: SessionProfileSnapshot runtimeCurrentModel?: SessionRuntimeProfile.CurrentModel runtimeAgentName?: string + client?: Client }) { const result: Info = { id: Identifier.descending("session", input.id), @@ -254,6 +263,7 @@ export namespace Session { updated: Date.now(), }, } + if (input.client) result.client = input.client if (await RuntimeFeatureFlags.profileSnapshotEnabled()) { const profile = input.runtimeProfile ?? diff --git a/opencode/packages/opencode/test/server/config-routes.test.ts b/opencode/packages/opencode/test/server/config-routes.test.ts index 57f011ac..def7ee09 100644 --- a/opencode/packages/opencode/test/server/config-routes.test.ts +++ b/opencode/packages/opencode/test/server/config-routes.test.ts @@ -68,6 +68,63 @@ describe("config routes reload behavior", () => { } }) + test("browser extension config route reads and writes sidepanel defaults", async () => { + const setup = await setupProject() + const disposed = collectInstanceDisposedEvents() + + try { + const empty = await request(setup.projectDir, "/config/nine1bot/browser-extension", { + method: "GET", + headers: jsonHeaders, + }) + expect(empty.status).toBe(200) + expect(await empty.json()).toEqual({}) + + const update = await request(setup.projectDir, "/config/nine1bot/browser-extension", { + method: "PATCH", + headers: jsonHeaders, + body: JSON.stringify({ + model: { providerID: "openai", modelID: "gpt-5" }, + prompt: "Use concise browser context.", + mcpServers: ["filesystem", "gitlab", "filesystem"], + skills: ["browser-review", "browser-review"], + }), + }) + expect(update.status).toBe(200) + expect(await update.json()).toEqual({ + model: { providerID: "openai", modelID: "gpt-5" }, + prompt: "Use concise browser context.", + mcpServers: ["filesystem", "gitlab"], + skills: ["browser-review"], + }) + expect(disposed.events).toEqual([]) + + const file = JSON.parse(await readFile(setup.nine1botConfigPath, "utf-8")) + expect(file.browser.sidepanel).toEqual({ + model: "openai/gpt-5", + prompt: "Use concise browser context.", + mcpServers: ["filesystem", "gitlab"], + skills: ["browser-review"], + }) + + const clearPrompt = await request(setup.projectDir, "/config/nine1bot/browser-extension", { + method: "PATCH", + headers: jsonHeaders, + body: JSON.stringify({ + prompt: null, + mcpServers: null, + skills: [], + }), + }) + expect(clearPrompt.status).toBe(200) + expect(await clearPrompt.json()).toEqual({ + model: { providerID: "openai", modelID: "gpt-5" }, + }) + } finally { + disposed.stop() + } + }) + test("custom provider upsert and delete refresh providers without disposing the current instance", async () => { const setup = await setupProject() const disposed = collectInstanceDisposedEvents() diff --git a/opencode/packages/opencode/test/server/nine1bot-agent.test.ts b/opencode/packages/opencode/test/server/nine1bot-agent.test.ts index 6dd1d0ae..448714b8 100644 --- a/opencode/packages/opencode/test/server/nine1bot-agent.test.ts +++ b/opencode/packages/opencode/test/server/nine1bot-agent.test.ts @@ -1,4 +1,6 @@ -import { describe, expect, test } from "bun:test" +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { mkdtemp, rm, writeFile } from "fs/promises" +import { tmpdir } from "os" import path from "path" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" @@ -14,6 +16,33 @@ const jsonHeaders = { } Log.init({ print: false }) +let envSnapshot: NodeJS.ProcessEnv +const tempDirs: string[] = [] + +beforeEach(async () => { + envSnapshot = { ...process.env } + const configDir = await mkdtemp(path.join(tmpdir(), "nine1bot-controller-api-")) + tempDirs.push(configDir) + const runtimeConfigPath = path.join(configDir, "config.json") + await writeFile( + runtimeConfigPath, + JSON.stringify({ + model: "openai/gpt-4o-mini", + }), + "utf-8", + ) + process.env.OPENCODE_CONFIG = runtimeConfigPath + process.env.OPENCODE_DISABLE_GLOBAL_CONFIG = "true" + process.env.OPENCODE_DISABLE_PROJECT_CONFIG = "true" + process.env.OPENCODE_DISABLE_PLUGIN_DEPENDENCY_INSTALL = "true" +}) + +afterEach(async () => { + await Instance.disposeAll().catch(() => undefined) + restoreEnv(envSnapshot) + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) +}) + describe("nine1bot controller api", () => { test("creates a profiled session and accepts a noReply message with a turn snapshot", async () => { await Instance.provide({ @@ -118,9 +147,21 @@ describe("nine1bot controller api", () => { blocks: Array<{ source: string; content: string }> } } + session?: { + client?: { + source?: string + platform?: string + mode?: string + } + } } expect(body.templateIds).toContain("feishu-chat") + expect(body.session?.client).toEqual({ + source: "feishu", + platform: "feishu", + mode: "feishu-private-chat", + }) expect(body.profileSnapshot?.sourceTemplateIds).toContain("feishu-chat") expect(body.profileSnapshot?.context.blocks.some((block) => block.source === "template.feishu-chat")).toBe(true) expect(JSON.stringify(body.profileSnapshot)).not.toContain("profileSnapshotId") @@ -130,6 +171,171 @@ describe("nine1bot controller api", () => { }) }) + test("applies browser extension sidepanel model and prompt only to extension sessions", async () => { + const envSnapshot = { ...process.env } + const tempDir = await mkdtemp(path.join(tmpdir(), "nine1bot-browser-extension-agent-")) + const configPath = path.join(tempDir, "nine1bot.config.jsonc") + await writeFile( + configPath, + JSON.stringify({ + browser: { + sidepanel: { + model: "openai/gpt-5", + prompt: "Browser-only controller prompt.", + mcpServers: ["filesystem"], + skills: ["browser-review"], + }, + }, + }), + "utf-8", + ) + process.env.NINE1BOT_CONFIG_PATH = configPath + + try { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const app = Server.App() + let browserSessionId = "" + let webSessionId = "" + + try { + const browserCreated = await app.request("/nine1bot/agent/sessions", { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify({ + title: "Browser extension configured session", + entry: { + source: "browser-extension", + mode: "browser-sidepanel", + }, + debug: { + profileSnapshot: true, + }, + }), + }) + expect(browserCreated.status).toBe(200) + const browserBody = (await browserCreated.json()) as { + sessionId: string + currentModel?: { + providerID: string + modelID: string + source: string + } + profileSnapshot?: { + resources?: { + mcp?: { servers?: string[] } + skills?: { skills?: string[] } + } + } + } + browserSessionId = browserBody.sessionId + expect(browserBody.currentModel).toMatchObject({ + providerID: "openai", + modelID: "gpt-5", + source: "session-choice", + }) + expect(browserBody.profileSnapshot?.resources?.mcp?.servers).toContain("filesystem") + expect(browserBody.profileSnapshot?.resources?.skills?.skills).toContain("browser-review") + + const browserSent = await app.request(`/nine1bot/agent/sessions/${browserSessionId}/messages`, { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify({ + noReply: true, + system: "Turn-specific browser prompt.", + parts: [ + { + type: "text", + text: "browser extension message", + }, + ], + }), + }) + expect(browserSent.status).toBe(202) + const browserMessages = await Session.messages({ sessionID: browserSessionId }) + const browserMessageInfo = browserMessages[0]?.info + expect(browserMessageInfo?.role).toBe("user") + if (browserMessageInfo?.role !== "user") throw new Error("expected browser message to be a user message") + expect(browserMessageInfo.system).toContain("Browser-only controller prompt.") + expect(browserMessageInfo.system).toContain("Turn-specific browser prompt.") + + const webCreated = await app.request("/nine1bot/agent/sessions", { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify({ + title: "Web configured session", + entry: { + source: "web", + }, + debug: { + profileSnapshot: true, + }, + }), + }) + expect(webCreated.status).toBe(200) + const webBody = (await webCreated.json()) as { + sessionId: string + currentModel?: { + providerID: string + modelID: string + source: string + } + profileSnapshot?: { + resources?: { + mcp?: { servers?: string[] } + skills?: { skills?: string[] } + } + } + } + webSessionId = webBody.sessionId + expect(webBody.currentModel).not.toMatchObject({ + providerID: "openai", + modelID: "gpt-5", + source: "session-choice", + }) + expect(webBody.profileSnapshot?.resources?.mcp?.servers ?? []).not.toContain("filesystem") + expect(webBody.profileSnapshot?.resources?.skills?.skills ?? []).not.toContain("browser-review") + + const webSent = await app.request(`/nine1bot/agent/sessions/${webSessionId}/messages`, { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify({ + noReply: true, + system: "Turn-specific web prompt.", + parts: [ + { + type: "text", + text: "web message", + }, + ], + }), + }) + expect(webSent.status).toBe(202) + const webMessages = await Session.messages({ sessionID: webSessionId }) + const webMessageInfo = webMessages[0]?.info + expect(webMessageInfo?.role).toBe("user") + if (webMessageInfo?.role !== "user") throw new Error("expected web message to be a user message") + expect(webMessageInfo.system).toContain("Turn-specific web prompt.") + expect(webMessageInfo.system ?? "").not.toContain("Browser-only controller prompt.") + } finally { + if (browserSessionId) await Session.remove(browserSessionId).catch(() => undefined) + if (webSessionId) await Session.remove(webSessionId).catch(() => undefined) + } + }, + }) + } finally { + for (const key of Object.keys(process.env)) { + if (!(key in envSnapshot)) delete process.env[key] + } + for (const [key, value] of Object.entries(envSnapshot)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + await rm(tempDir, { recursive: true, force: true }) + } + }) + test("busy controller message returns 409 before writing user message or page context", async () => { await Instance.provide({ directory: projectRoot, @@ -180,3 +386,13 @@ describe("nine1bot controller api", () => { }) }) }) + +function restoreEnv(snapshot: NodeJS.ProcessEnv) { + for (const key of Object.keys(process.env)) { + if (!(key in snapshot)) delete process.env[key] + } + for (const [key, value] of Object.entries(snapshot)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } +} diff --git a/packages/browser-extension/README.md b/packages/browser-extension/README.md index 1ddfe39c..fcacc52a 100644 --- a/packages/browser-extension/README.md +++ b/packages/browser-extension/README.md @@ -24,11 +24,7 @@ bun run build } ``` -扩展默认连接 `http://127.0.0.1:4096/browser/extension`。如果 Nine1Bot 运行在其他端口,可在扩展 service worker 控制台执行: - -```js -chrome.storage.sync.set({ serverOrigin: "http://127.0.0.1:4100" }) -``` +扩展默认连接 `http://127.0.0.1:4096/browser/extension`。如果浏览器 relay 在其他本地端口,打开扩展侧边栏的 Settings,修改 Browser relay origin,例如 `http://127.0.0.1:4100`,保存后扩展会自动重连并重新加载聊天界面。这只是扩展连接目标,不会修改 Nine1Bot 服务本身的端口配置。 不要再配置 `mcp.browser` 或 `mcp.browser-control`;Nine1Bot 会对这些旧配置给出明确迁移报错。 diff --git a/packages/browser-extension/sidepanel/index.html b/packages/browser-extension/sidepanel/index.html index f4d58fba..6191a10e 100644 --- a/packages/browser-extension/sidepanel/index.html +++ b/packages/browser-extension/sidepanel/index.html @@ -1,5 +1,5 @@ - + @@ -8,109 +8,492 @@ :root { color-scheme: light; font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - background: #f4f7fb; - color: #122033; + background: #f7f7f5; + color: #1f2328; + } + + * { + box-sizing: border-box; } body { margin: 0; min-height: 100vh; + background: #f7f7f5; + } + + button, + input, + textarea { + font: inherit; + } + + button { + border: 0; + cursor: pointer; + } + + button:disabled, + input:disabled, + textarea:disabled { + cursor: not-allowed; + opacity: 0.55; + } + + #app-frame { + display: block; + width: 100vw; + height: 100vh; + border: 0; + background: #ffffff; + } + + .fallback-app { + width: 100vw; + height: 100vh; display: flex; flex-direction: column; - background: - radial-gradient(circle at top right, rgba(79, 124, 255, 0.14), transparent 32%), - linear-gradient(180deg, #f8fbff 0%, #eef4fb 100%); + overflow: hidden; + background: #f7f7f5; } - header { + .fallback-header { + flex: 0 0 auto; display: flex; - justify-content: space-between; align-items: center; + justify-content: space-between; gap: 12px; - padding: 12px 14px; - border-bottom: 1px solid rgba(18, 32, 51, 0.08); - background: rgba(255, 255, 255, 0.84); - backdrop-filter: blur(8px); + min-height: 58px; + padding: 10px 12px; + border-bottom: 1px solid rgba(31, 35, 40, 0.1); + background: #f7f7f5; + } + + .header-main { + min-width: 0; + } + + .title-row { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; } .title { font-size: 14px; - font-weight: 700; + font-weight: 650; + } + + .badge { + padding: 1px 6px; + border-radius: 999px; + background: #e9ece7; + color: #586069; + font-size: 10px; + line-height: 16px; } #status { + margin-top: 2px; + color: #6b7280; font-size: 12px; - color: #6c7a90; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } #status.connected { color: #14774b; } - button { - border: 0; - border-radius: 999px; - padding: 8px 12px; - background: #1f5eff; - color: white; - cursor: pointer; + #status.error { + color: #b42318; + } + + .header-actions { + display: flex; + align-items: center; + gap: 6px; + } + + .icon-button, + .primary-button, + .secondary-button { + height: 32px; + border-radius: 8px; font-size: 12px; + font-weight: 600; + } + + .icon-button { + min-width: 32px; + padding: 0 10px; + background: transparent; + color: #4b5563; + } + + .icon-button:hover { + background: #ececea; + color: #111827; } - main { + .primary-button { + padding: 0 12px; + background: #1f5eff; + color: #ffffff; + } + + .secondary-button { + padding: 0 12px; + background: #ececea; + color: #374151; + } + + .fallback-body { position: relative; flex: 1; min-height: 0; + display: flex; + flex-direction: column; + padding: 18px 12px 12px; } - #app-frame { - border: 0; - width: 100%; - height: 100%; - min-height: calc(100vh - 62px); - background: white; + .disconnected-card { + margin: auto; + width: min(420px, 100%); + padding: 18px; + border: 1px solid rgba(31, 35, 40, 0.1); + border-radius: 10px; + background: #ffffff; + box-shadow: 0 10px 30px rgba(31, 35, 40, 0.06); + } + + .disconnected-card h1 { + margin: 0 0 8px; + font-size: 18px; + line-height: 1.3; + } + + .disconnected-card p { + margin: 0; + color: #6b7280; + font-size: 13px; + line-height: 1.55; + } + + .fake-input { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 8px; + min-height: 56px; + margin-top: 14px; + padding: 10px 12px; + border: 1px solid rgba(31, 35, 40, 0.1); + border-radius: 12px; + background: #ffffff; + color: #9ca3af; + font-size: 13px; } - #fallback { + .settings-overlay { + position: fixed; + inset: 0; + z-index: 20; display: none; - box-sizing: border-box; - margin: 16px; - padding: 16px; - border-radius: 16px; - background: rgba(255, 255, 255, 0.95); - border: 1px solid rgba(18, 32, 51, 0.08); + align-items: center; + justify-content: center; + padding: 18px; + background: rgba(0, 0, 0, 0.46); + } + + .settings-overlay.visible { + display: flex; + } + + .settings-modal { + width: min(760px, 100%); + max-height: min(82vh, 760px); + display: flex; + flex-direction: column; + overflow: hidden; + border-radius: 12px; + background: #ffffff; + box-shadow: 0 24px 70px rgba(0, 0, 0, 0.24); + } + + .modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 18px 22px 14px; + border-bottom: 1px solid rgba(31, 35, 40, 0.08); + } + + .modal-title { + margin: 0; + font-size: 15px; + font-weight: 650; + } + + .close-button { + width: 30px; + height: 30px; + border-radius: 8px; + background: transparent; + color: #4b5563; + font-size: 20px; + line-height: 1; + } + + .close-button:hover { + background: #f0f0ee; + } + + .settings-tabs { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 4px; + margin: 0 22px; + padding: 4px; + border-radius: 8px; + background: #e8e5df; + } + + .tab { + min-width: 0; + height: 28px; + border-radius: 6px; + background: transparent; + color: #57534e; + font-size: 12px; + } + + .tab.active { + background: #ffffff; + color: #1f2328; + box-shadow: 0 1px 2px rgba(31, 35, 40, 0.08); } - #fallback.visible { + .modal-body { + flex: 1; + min-height: 0; + overflow: auto; + padding: 24px 22px; + } + + .settings-section { + display: none; + } + + .settings-section.active { display: block; } + .section-title { + margin: 0 0 4px; + font-size: 15px; + font-weight: 650; + } + + .section-desc { + margin: 0 0 18px; + color: #8a8f98; + font-size: 13px; + line-height: 1.5; + } + + .field + .field { + margin-top: 14px; + } + + label { + display: block; + margin-bottom: 6px; + color: #374151; + font-size: 12px; + font-weight: 650; + } + + input, + textarea { + width: 100%; + border: 1px solid rgba(31, 35, 40, 0.15); + border-radius: 8px; + padding: 10px 11px; + background: #ffffff; + color: #1f2328; + font-size: 13px; + outline: none; + } + + input:focus, + textarea:focus { + border-color: #1f5eff; + box-shadow: 0 0 0 3px rgba(31, 94, 255, 0.1); + } + + textarea { + min-height: 120px; + resize: vertical; + line-height: 1.5; + } + + .actions { + display: flex; + gap: 8px; + margin-top: 14px; + } + + .message { + min-height: 18px; + margin-top: 12px; + color: #6b7280; + font-size: 12px; + line-height: 1.5; + } + + .message.success { + color: #14774b; + } + + .message.error { + color: #b42318; + } + + .endpoint-list { + margin-top: 18px; + padding-top: 14px; + border-top: 1px solid rgba(31, 35, 40, 0.08); + color: #6b7280; + font-size: 12px; + } + + .endpoint-row { + display: grid; + grid-template-columns: 82px 1fr; + gap: 8px; + align-items: start; + margin-top: 8px; + } + code { display: inline-block; - margin-top: 8px; padding: 4px 6px; - border-radius: 8px; - background: #eef3ff; + border-radius: 6px; + background: #f0f0ee; + color: #374151; word-break: break-all; } + + .disabled-note { + padding: 14px; + border-radius: 8px; + background: #f7f7f5; + color: #6b7280; + font-size: 13px; + line-height: 1.5; + } + + .hidden { + display: none !important; + } -
-
-
Nine1Bot Side Panel
-
checking relay...
-
- -
-
-
- Unable to load the embedded UI. -
The side panel will retry the configured server URL below.
- + + +
+ + diff --git a/packages/browser-extension/src/background/index.ts b/packages/browser-extension/src/background/index.ts index 05132ea9..3bbc2407 100644 --- a/packages/browser-extension/src/background/index.ts +++ b/packages/browser-extension/src/background/index.ts @@ -5,7 +5,8 @@ * It initializes the Relay Client that connects to Nine1Bot's built-in /browser relay. */ -import { initRelayClient, isRelayConnected, connectToRelay } from './relay-client' +import { initRelayClient, isRelayConnected, connectToRelay, activateDedicatedNine1TabGroup } from './relay-client' +import { SIDE_PANEL_OPEN_NONCE_STORAGE_KEY } from '../shared/server-config' console.log('[Nine1Bot Browser Control] Service Worker starting...') @@ -32,13 +33,30 @@ chrome.runtime.onStartup.addListener(() => { }) // Handle browser action click (extension icon) +function createOpenNonce(): string { + return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}` +} + +async function markSidePanelOpened(): Promise { + await chrome.storage.sync.set({ [SIDE_PANEL_OPEN_NONCE_STORAGE_KEY]: createOpenNonce() }) +} + +async function openSidePanel(windowId: number): Promise { + await activateDedicatedNine1TabGroup(windowId).catch((error) => { + console.warn('[Nine1Bot Browser Control] Failed to activate dedicated tab group:', error) + }) + await markSidePanelOpened().catch((error) => { + console.warn('[Nine1Bot Browser Control] Failed to persist side panel open nonce:', error) + }) + await chrome.sidePanel.open({ windowId }) +} + chrome.action.onClicked.addListener((tab) => { console.log('[Nine1Bot Browser Control] Extension icon clicked, tab:', tab.id) // Prefer opening side panel on icon click const windowId = tab.windowId - chrome.sidePanel - .open({ windowId }) + openSidePanel(windowId) .catch((error) => { console.warn('[Nine1Bot Browser Control] Failed to open side panel:', error) }) @@ -48,7 +66,7 @@ chrome.commands.onCommand.addListener((command, tab) => { if (command !== 'open-side-panel') return const windowId = tab?.windowId if (windowId === undefined) return - chrome.sidePanel.open({ windowId }).catch((error) => { + openSidePanel(windowId).catch((error) => { console.warn('[Nine1Bot Browser Control] Failed to open side panel via command:', error) }) }) @@ -58,6 +76,14 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { sendResponse({ connected: isRelayConnected() }) return true } + if (message?.type === 'nine1bot-sidepanel-ensure-tab-group') { + activateDedicatedNine1TabGroup() + .then((result) => sendResponse({ ok: true, ...result })) + .catch((error) => { + sendResponse({ ok: false, error: error instanceof Error ? error.message : String(error) }) + }) + return true + } return false }) diff --git a/packages/browser-extension/src/background/relay-client.ts b/packages/browser-extension/src/background/relay-client.ts index c7361201..1b02f774 100644 --- a/packages/browser-extension/src/background/relay-client.ts +++ b/packages/browser-extension/src/background/relay-client.ts @@ -9,17 +9,26 @@ */ import { toolExecutors } from '../tools' +import { + DEFAULT_SERVER_ORIGIN, + SERVER_ORIGIN_STORAGE_KEY, + readStoredServerOrigin, + serverOriginToRelayUrl, + normalizeServerOrigin, +} from '../shared/server-config' import { isAbortError } from '../tools/execution-context' import { setupDiagnosticsListeners } from './diagnostics-buffer' import { addTabToNine1Group, + getDefaultNine1Tab, + getTabsInActiveNine1Group, getTabsInGroupByTab, + isTabInActiveNine1Group, setNine1GroupActive, setNine1GroupIdle, setupTabGroupCleanup, } from './tab-group-manager' -const DEFAULT_SERVER_ORIGIN = 'http://127.0.0.1:4096' const EXTENSION_PROTOCOL_VERSION = '2026-03-15' const RECONNECT_BASE_INTERVAL = 5000 @@ -31,62 +40,6 @@ const DEFAULT_TOOL_TIMEOUT_MS = 30000 let configuredServerOrigin = DEFAULT_SERVER_ORIGIN let pairedInstanceId: string | null = null -function normalizeServerOrigin(serverOrigin: string): string { - try { - const parsed = new URL(serverOrigin) - return `${parsed.protocol}//${parsed.host}` - } catch { - return DEFAULT_SERVER_ORIGIN - } -} - -function serverOriginToRelayUrl(serverOrigin: string): string { - const parsed = new URL(serverOrigin) - const protocol = parsed.protocol === 'https:' ? 'wss:' : 'ws:' - return `${protocol}//${parsed.host}/browser/extension` -} - -function relayUrlToServerOrigin(relayUrl: string): string { - try { - const parsed = new URL(relayUrl) - const protocol = parsed.protocol === 'wss:' ? 'https:' : 'http:' - return `${protocol}//${parsed.host}` - } catch { - return DEFAULT_SERVER_ORIGIN - } -} - -async function migrateLegacyServerConfig(): Promise { - try { - const stored = await chrome.storage.sync.get({ - serverOrigin: '', - relayUrl: '', - webUiUrl: '', - }) - - const fromServerOrigin = typeof stored.serverOrigin === 'string' && stored.serverOrigin.trim() - ? normalizeServerOrigin(stored.serverOrigin) - : '' - const fromWebUi = typeof stored.webUiUrl === 'string' && stored.webUiUrl.trim() - ? normalizeServerOrigin(stored.webUiUrl) - : '' - const fromRelay = typeof stored.relayUrl === 'string' && stored.relayUrl.trim() - ? relayUrlToServerOrigin(stored.relayUrl) - : '' - - const serverOrigin = fromServerOrigin || fromWebUi || fromRelay || DEFAULT_SERVER_ORIGIN - - if (serverOrigin !== stored.serverOrigin || stored.relayUrl || stored.webUiUrl) { - await chrome.storage.sync.set({ serverOrigin }) - await chrome.storage.sync.remove(['relayUrl', 'webUiUrl']) - } - - return serverOrigin - } catch { - return DEFAULT_SERVER_ORIGIN - } -} - async function fetchBootstrap(serverOrigin: string): Promise<{ serverOrigin?: string; instanceId?: string } | null> { try { const response = await fetch(`${serverOrigin}/browser/bootstrap`) @@ -98,13 +51,13 @@ async function fetchBootstrap(serverOrigin: string): Promise<{ serverOrigin?: st } async function getConfiguredRelayUrl(): Promise { - const storedServerOrigin = await migrateLegacyServerConfig() + const storedServerOrigin = await readStoredServerOrigin().catch(() => DEFAULT_SERVER_ORIGIN) const bootstrap = await fetchBootstrap(storedServerOrigin) configuredServerOrigin = normalizeServerOrigin(bootstrap?.serverOrigin ?? storedServerOrigin) pairedInstanceId = typeof bootstrap?.instanceId === 'string' ? bootstrap.instanceId : null try { - await chrome.storage.sync.set({ serverOrigin: configuredServerOrigin }) + await chrome.storage.sync.set({ [SERVER_ORIGIN_STORAGE_KEY]: configuredServerOrigin }) } catch { // ignore storage sync failures } @@ -136,6 +89,7 @@ let lastPongAt = 0 // 当前活动的标签页 session const activeSessions = new Map() // tabId -> sessionId +const attachedTabs = new Set() // 命令状态 const runningCommands = new Map() @@ -170,6 +124,67 @@ function forwardCdpEvent(method: string, params?: unknown, sessionId?: string): }) } +function isAutomatableTabUrl(url?: string): boolean { + if (!url) return false + try { + const parsed = new URL(url) + return ['http:', 'https:', 'file:'].includes(parsed.protocol) + } catch { + return false + } +} + +function targetInfoForTab(tab: chrome.tabs.Tab) { + return { + targetId: String(tab.id), + type: 'page', + title: tab.title || '', + url: tab.url || '', + attached: true, + } +} + +function detachManagedTarget(tabId: number, reason = 'target_detached'): void { + const sessionId = activeSessions.get(tabId) + if (!sessionId) return + + forwardCdpEvent('Target.detachedFromTarget', { + sessionId, + targetId: String(tabId), + }) + activeSessions.delete(tabId) + attachedTabs.delete(tabId) + cancelRunningCommands({ tabId, reason }) + tabActiveCommandCount.delete(tabId) + tabStopRequestedAt.delete(tabId) +} + +function detachAllActiveSessions(): void { + for (const tabId of Array.from(activeSessions.keys())) { + detachManagedTarget(tabId, 'tab_group_changed') + } +} + +async function attachManagedTarget(tab: chrome.tabs.Tab): Promise { + if (!tab.id) return null + if (!isAutomatableTabUrl(tab.url)) return null + if (!await isTabInActiveNine1Group(tab.id)) return null + + const existing = activeSessions.get(tab.id) + if (existing) return existing + + const sessionId = generateSessionId() + activeSessions.set(tab.id, sessionId) + + forwardCdpEvent('Target.attachedToTarget', { + sessionId, + targetInfo: targetInfoForTab(tab), + waitingForDebugger: false, + }) + + return sessionId +} + function sendExtensionHello(): void { sendToRelay({ method: 'extension.hello', @@ -405,6 +420,38 @@ async function executeExtensionToolCommand(options: { } } +async function resolveManagedCommandTab(sessionId?: string, targetId?: string): Promise { + if (sessionId) { + for (const [tid, sid] of activeSessions) { + if (sid === sessionId) { + if (!await isTabInActiveNine1Group(tid)) { + throw new Error(`Browser session is outside the active Nine1Bot tab group: ${sessionId}`) + } + return tid + } + } + throw new Error(`Browser session not found in active Nine1Bot tab group: ${sessionId}`) + } + + if (typeof targetId === 'string' && /^\d+$/.test(targetId)) { + const tabId = Number(targetId) + if (!await isTabInActiveNine1Group(tabId)) { + throw new Error(`Browser target is outside the active Nine1Bot tab group: ${targetId}`) + } + return tabId + } + + const tab = await getDefaultNine1Tab() + if (!tab?.id) { + throw new Error('No active Nine1Bot tab group. Open the Nine1Bot side panel from the extension icon first.') + } + if (!isAutomatableTabUrl(tab.url)) { + throw new Error('The active Nine1Bot tab group does not contain an automatable http/https/file tab.') + } + await attachManagedTarget(tab) + return tab.id +} + /** * 处理来自 Relay Server 的消息 */ @@ -458,26 +505,7 @@ async function handleRelayMessage(data: string): Promise { async function handleCdpCommand(commandId: number, method: string, params: any, sessionId?: string, targetId?: string): Promise { console.log('[Relay Client] Handling CDP command:', method, 'sessionId:', sessionId) - // 获取 tabId:明确指定 sessionId 时必须命中,避免误操作当前活动标签。 - let tabId: number | undefined - - if (sessionId) { - // 从 session 映射中查找 tabId - for (const [tid, sid] of activeSessions) { - if (sid === sessionId) { - tabId = tid - break - } - } - if (!tabId) { - throw new Error(`Browser session not found: ${sessionId}`) - } - } else if (typeof targetId === 'string' && /^\d+$/.test(targetId)) { - tabId = Number(targetId) - } else { - const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true }) - tabId = activeTab?.id - } + const tabId = await resolveManagedCommandTab(sessionId, targetId) // 根据 CDP method 调用相应的工具 switch (method) { @@ -648,9 +676,6 @@ async function handleCdpCommand(commandId: number, method: string, params: any, } } -// Debugger 管理 -const attachedTabs = new Set() - async function ensureDebuggerAttached(tabId: number): Promise { if (attachedTabs.has(tabId)) return @@ -671,107 +696,77 @@ async function ensureDebuggerAttached(tabId: number): Promise { function setupTabListeners(): void { // 新标签页创建 chrome.tabs.onCreated.addListener((tab) => { - if (tab.id && tab.url !== 'chrome://newtab/') { - const sessionId = generateSessionId() - activeSessions.set(tab.id, sessionId) - - forwardCdpEvent('Target.attachedToTarget', { - sessionId, - targetInfo: { - targetId: String(tab.id), - type: 'page', - title: tab.title || '', - url: tab.url || '', - attached: true, - }, - waitingForDebugger: false, - }) - } + if (!tab.id) return + setTimeout(() => { + chrome.tabs.get(tab.id!).then((freshTab) => attachManagedTarget(freshTab)).catch(() => {}) + }, 150) }) // 标签页更新(URL/标题变化) - chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { - const sessionId = activeSessions.get(tabId) - if (!sessionId) return + chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { + if (changeInfo.groupId !== undefined) { + if (activeSessions.has(tabId) && !await isTabInActiveNine1Group(tabId)) { + detachManagedTarget(tabId, 'tab_left_nine1_group') + return + } + await attachManagedTarget(tab).catch(() => {}) + } if (changeInfo.url || changeInfo.title) { + if (!await isTabInActiveNine1Group(tabId)) { + detachManagedTarget(tabId, 'tab_left_nine1_group') + return + } + const sessionId = activeSessions.get(tabId) + if (!sessionId) { + await attachManagedTarget(tab).catch(() => {}) + return + } forwardCdpEvent('Target.targetInfoChanged', { - targetInfo: { - targetId: String(tabId), - type: 'page', - title: tab.title || '', - url: tab.url || '', - attached: true, - }, + targetInfo: targetInfoForTab(tab), }) } }) // 标签页关闭 chrome.tabs.onRemoved.addListener((tabId) => { - const sessionId = activeSessions.get(tabId) - if (sessionId) { - forwardCdpEvent('Target.detachedFromTarget', { - sessionId, - targetId: String(tabId), - }) - activeSessions.delete(tabId) - attachedTabs.delete(tabId) - cancelRunningCommands({ tabId, reason: 'tab_removed' }) - tabActiveCommandCount.delete(tabId) - } + detachManagedTarget(tabId, 'tab_removed') }) // 标签页激活 chrome.tabs.onActivated.addListener(async (activeInfo) => { const tab = await chrome.tabs.get(activeInfo.tabId) - let sessionId = activeSessions.get(activeInfo.tabId) - - if (!sessionId) { - sessionId = generateSessionId() - activeSessions.set(activeInfo.tabId, sessionId) - - forwardCdpEvent('Target.attachedToTarget', { - sessionId, - targetInfo: { - targetId: String(activeInfo.tabId), - type: 'page', - title: tab.title || '', - url: tab.url || '', - attached: true, - }, - waitingForDebugger: false, - }) - } + await attachManagedTarget(tab) }) } /** - * 发送当前所有标签页信息 + * 发送当前 Nine1Bot 标签组信息 */ async function sendInitialTargets(): Promise { - const tabs = await chrome.tabs.query({}) - + const tabs = await getTabsInActiveNine1Group() for (const tab of tabs) { - if (!tab.id || tab.url?.startsWith('chrome://')) continue - - const sessionId = generateSessionId() - activeSessions.set(tab.id, sessionId) - - forwardCdpEvent('Target.attachedToTarget', { - sessionId, - targetInfo: { - targetId: String(tab.id), - type: 'page', - title: tab.title || '', - url: tab.url || '', - attached: true, - }, - waitingForDebugger: false, - }) + await attachManagedTarget(tab) } } +export async function activateDedicatedNine1TabGroup(windowId?: number): Promise<{ groupId: number | null; tabId?: number }> { + const [activeTab] = await chrome.tabs.query({ + active: true, + currentWindow: windowId === undefined, + ...(windowId !== undefined ? { windowId } : {}), + }) + + if (!activeTab?.id) { + return { groupId: null } + } + + const groupId = await addTabToNine1Group(activeTab.id) + detachAllActiveSessions() + await sendInitialTargets() + return { groupId, tabId: activeTab.id } +} + function setupRuntimeListeners(): void { chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message?.type !== 'nine1bot-agent-stop-request') return false @@ -795,6 +790,25 @@ function setupRuntimeListeners(): void { }) } +function setupServerConfigListener(): void { + chrome.storage.onChanged.addListener((changes, areaName) => { + if (areaName !== 'sync') return + if (!changes[SERVER_ORIGIN_STORAGE_KEY]) return + + const nextValue = changes[SERVER_ORIGIN_STORAGE_KEY].newValue + if (typeof nextValue !== 'string' || !nextValue.trim()) return + + const nextOrigin = normalizeServerOrigin(nextValue) + if (nextOrigin === configuredServerOrigin && isRelayConnected()) return + + console.log('[Relay Client] Server origin changed, reconnecting to:', nextOrigin) + disconnectFromRelay() + configuredServerOrigin = nextOrigin + pairedInstanceId = null + connectToRelay() + }) +} + /** * 连接到 Relay Server */ @@ -936,6 +950,7 @@ export function initRelayClient(): void { setupTabListeners() setupRuntimeListeners() + setupServerConfigListener() setupDiagnosticsListeners() setupTabGroupCleanup() console.log('[Relay Client] Tab/runtime listeners set up') diff --git a/packages/browser-extension/src/background/tab-group-manager.ts b/packages/browser-extension/src/background/tab-group-manager.ts index f4c2a43b..03efe1cc 100644 --- a/packages/browser-extension/src/background/tab-group-manager.ts +++ b/packages/browser-extension/src/background/tab-group-manager.ts @@ -1,7 +1,10 @@ +import { ACTIVE_NINE1_TAB_GROUP_STORAGE_KEY } from '../shared/tab-group' + const NINE1_TAB_GROUP_TITLE = 'Nine1Bot' const NINE1_TAB_GROUP_COLOR: chrome.tabGroups.ColorEnum = 'blue' let cleanupInstalled = false +let activeNine1GroupId: number | null = null function formatGroupTitle(taskLabel?: string): string { if (!taskLabel) return NINE1_TAB_GROUP_TITLE @@ -19,6 +22,32 @@ async function getGroupIdForTab(tabId: number): Promise { } } +async function getTab(tabId: number): Promise { + try { + return await chrome.tabs.get(tabId) + } catch { + return null + } +} + +async function getGroupWindowId(groupId: number): Promise { + try { + const group = await chrome.tabGroups.get(groupId) + return typeof group.windowId === 'number' ? group.windowId : null + } catch { + return null + } +} + +async function groupExists(groupId: number): Promise { + try { + await chrome.tabGroups.get(groupId) + return true + } catch { + return false + } +} + async function updateGroup(groupId: number, options: { collapsed?: boolean taskLabel?: string @@ -30,15 +59,71 @@ async function updateGroup(groupId: number, options: { }) } +async function persistActiveGroup(groupId: number | null): Promise { + activeNine1GroupId = groupId + try { + if (groupId === null) { + await chrome.storage.local.remove(ACTIVE_NINE1_TAB_GROUP_STORAGE_KEY) + } else { + await chrome.storage.local.set({ [ACTIVE_NINE1_TAB_GROUP_STORAGE_KEY]: groupId }) + } + } catch { + // Storage is only a coordination convenience for side panel pages. + } +} + +export async function getActiveNine1GroupId(): Promise { + if (activeNine1GroupId !== null && await groupExists(activeNine1GroupId)) { + return activeNine1GroupId + } + + try { + const stored = await chrome.storage.local.get({ [ACTIVE_NINE1_TAB_GROUP_STORAGE_KEY]: -1 }) + const groupId = stored[ACTIVE_NINE1_TAB_GROUP_STORAGE_KEY] + if (typeof groupId === 'number' && groupId >= 0 && await groupExists(groupId)) { + activeNine1GroupId = groupId + return groupId + } + } catch { + // ignore storage failures + } + + await persistActiveGroup(null) + return null +} + +export async function createDedicatedNine1Group(tabId: number, taskLabel?: string): Promise { + try { + const groupId = await chrome.tabs.group({ tabIds: [tabId] }) + await updateGroup(groupId, { collapsed: false, taskLabel }) + await persistActiveGroup(groupId) + return groupId + } catch { + return null + } +} + export async function addTabToNine1Group(tabId: number, taskLabel?: string): Promise { try { + const tab = await getTab(tabId) + if (!tab) return null + + let groupId = await getActiveNine1GroupId() + if (groupId === null) { + return await createDedicatedNine1Group(tabId, taskLabel) + } + + const groupWindowId = await getGroupWindowId(groupId) + if (groupWindowId !== null && groupWindowId !== tab.windowId) { + return await createDedicatedNine1Group(tabId, taskLabel) + } + const existingGroupId = await getGroupIdForTab(tabId) - if (existingGroupId !== null) { - await updateGroup(existingGroupId, { collapsed: false, taskLabel }) - return existingGroupId + if (existingGroupId !== groupId) { + groupId = await chrome.tabs.group({ tabIds: [tabId], groupId }) + await persistActiveGroup(groupId) } - const groupId = await chrome.tabs.group({ tabIds: [tabId] }) await updateGroup(groupId, { collapsed: false, taskLabel }) return groupId } catch { @@ -46,11 +131,26 @@ export async function addTabToNine1Group(tabId: number, taskLabel?: string): Pro } } +export async function isTabInActiveNine1Group(tabId: number): Promise { + const activeGroupId = await getActiveNine1GroupId() + if (activeGroupId === null) return false + return await getGroupIdForTab(tabId) === activeGroupId +} + +export async function getTabsInActiveNine1Group(): Promise { + const groupId = await getActiveNine1GroupId() + if (groupId === null) return [] + try { + return await chrome.tabs.query({ groupId }) + } catch { + return [] + } +} + export async function getTabsInGroupByTab(tabId: number): Promise { try { - const groupId = await getGroupIdForTab(tabId) - if (groupId === null) return [tabId] - const tabs = await chrome.tabs.query({ groupId }) + if (!await isTabInActiveNine1Group(tabId)) return [tabId] + const tabs = await getTabsInActiveNine1Group() const tabIds = tabs .map((tab) => tab.id) .filter((candidate): candidate is number => typeof candidate === 'number') @@ -60,9 +160,21 @@ export async function getTabsInGroupByTab(tabId: number): Promise { } } +export async function getDefaultNine1Tab(windowId?: number): Promise { + const tabs = await getTabsInActiveNine1Group() + if (tabs.length === 0) return null + + const activeInWindow = tabs.find((tab) => tab.active && (windowId === undefined || tab.windowId === windowId)) + if (activeInWindow) return activeInWindow + + const active = tabs.find((tab) => tab.active) + return active ?? tabs[0] ?? null +} + export async function setNine1GroupActive(tabId: number, taskLabel?: string): Promise { try { - const groupId = await addTabToNine1Group(tabId, taskLabel) + if (!await isTabInActiveNine1Group(tabId)) return + const groupId = await getActiveNine1GroupId() if (groupId === null) return await updateGroup(groupId, { collapsed: false, taskLabel }) } catch { @@ -72,7 +184,8 @@ export async function setNine1GroupActive(tabId: number, taskLabel?: string): Pr export async function setNine1GroupIdle(tabId: number): Promise { try { - const groupId = await getGroupIdForTab(tabId) + if (!await isTabInActiveNine1Group(tabId)) return + const groupId = await getActiveNine1GroupId() if (groupId === null) return await updateGroup(groupId, { collapsed: true }) } catch { @@ -80,11 +193,22 @@ export async function setNine1GroupIdle(tabId: number): Promise { } } +async function cleanupMissingActiveGroup(): Promise { + const groupId = await getActiveNine1GroupId() + if (groupId === null) return + const tabs = await getTabsInActiveNine1Group() + if (tabs.length === 0) { + await persistActiveGroup(null) + } +} + export function setupTabGroupCleanup(): void { if (cleanupInstalled) return cleanupInstalled = true chrome.tabs.onRemoved.addListener(() => { - // Tab groups self-heal as tabs close; this keeps setup idempotent. + cleanupMissingActiveGroup().catch(() => { + // Tab groups self-heal as tabs close; this keeps setup idempotent. + }) }) } diff --git a/packages/browser-extension/src/shared/server-config.ts b/packages/browser-extension/src/shared/server-config.ts new file mode 100644 index 00000000..00ab1de5 --- /dev/null +++ b/packages/browser-extension/src/shared/server-config.ts @@ -0,0 +1,118 @@ +export const DEFAULT_SERVER_ORIGIN = 'http://127.0.0.1:4096' +export const DEFAULT_BROWSER_RELAY_ORIGIN = DEFAULT_SERVER_ORIGIN +export const BROWSER_RELAY_ORIGIN_STORAGE_KEY = 'browserRelayOrigin' +export const LEGACY_SERVER_ORIGIN_STORAGE_KEY = 'serverOrigin' +export const SERVER_ORIGIN_STORAGE_KEY = BROWSER_RELAY_ORIGIN_STORAGE_KEY +export const LEGACY_WEB_UI_URL_STORAGE_KEY = 'webUiUrl' +export const LEGACY_RELAY_URL_STORAGE_KEY = 'relayUrl' +export const SIDE_PANEL_OPEN_NONCE_STORAGE_KEY = 'sidePanelOpenNonce' + +export interface StoredServerConfig { + browserRelayOrigin?: unknown + serverOrigin?: unknown + webUiUrl?: unknown + relayUrl?: unknown +} + +export function isAllowedLocalServerOrigin(url: URL): boolean { + if (url.protocol !== 'http:' && url.protocol !== 'https:') return false + return url.hostname === '127.0.0.1' || url.hostname === 'localhost' +} + +export function normalizeServerOrigin(serverOrigin: string, fallback = DEFAULT_SERVER_ORIGIN): string { + try { + const parsed = new URL(serverOrigin.trim()) + if (!isAllowedLocalServerOrigin(parsed)) return fallback + return `${parsed.protocol}//${parsed.host}` + } catch { + return fallback + } +} + +export function serverOriginToRelayUrl(serverOrigin: string): string { + const parsed = new URL(normalizeServerOrigin(serverOrigin)) + const protocol = parsed.protocol === 'https:' ? 'wss:' : 'ws:' + return `${protocol}//${parsed.host}/browser/extension` +} + +export function browserRelayOriginToBootstrapUrl(browserRelayOrigin: string): string { + return `${normalizeServerOrigin(browserRelayOrigin)}/browser/bootstrap` +} + +export function browserRelayOriginToExtensionUrl(browserRelayOrigin: string): string { + return serverOriginToRelayUrl(browserRelayOrigin) +} + +export function relayUrlToServerOrigin(relayUrl: string, fallback = DEFAULT_SERVER_ORIGIN): string { + try { + const parsed = new URL(relayUrl.trim()) + if (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') return fallback + const protocol = parsed.protocol === 'wss:' ? 'https:' : 'http:' + return normalizeServerOrigin(`${protocol}//${parsed.host}`, fallback) + } catch { + return fallback + } +} + +export function resolveStoredServerOrigin(stored: StoredServerConfig): string { + const fromBrowserRelayOrigin = typeof stored.browserRelayOrigin === 'string' && stored.browserRelayOrigin.trim() + ? normalizeServerOrigin(stored.browserRelayOrigin) + : '' + const fromServerOrigin = typeof stored.serverOrigin === 'string' && stored.serverOrigin.trim() + ? normalizeServerOrigin(stored.serverOrigin) + : '' + const fromWebUi = typeof stored.webUiUrl === 'string' && stored.webUiUrl.trim() + ? normalizeServerOrigin(stored.webUiUrl) + : '' + const fromRelay = typeof stored.relayUrl === 'string' && stored.relayUrl.trim() + ? relayUrlToServerOrigin(stored.relayUrl) + : '' + + return fromBrowserRelayOrigin || fromServerOrigin || fromWebUi || fromRelay || DEFAULT_SERVER_ORIGIN +} + +export function buildWebUiUrl(serverOrigin: string, newSessionNonce?: string): string { + const url = new URL(normalizeServerOrigin(serverOrigin)) + url.searchParams.set('client', 'browser-extension') + if (newSessionNonce) { + url.searchParams.set('newSessionNonce', newSessionNonce) + } + return url.toString() +} + +export async function readStoredServerOrigin(): Promise { + const stored = await chrome.storage.sync.get({ + [SERVER_ORIGIN_STORAGE_KEY]: '', + [LEGACY_SERVER_ORIGIN_STORAGE_KEY]: '', + [LEGACY_WEB_UI_URL_STORAGE_KEY]: '', + [LEGACY_RELAY_URL_STORAGE_KEY]: '', + }) as StoredServerConfig + + const serverOrigin = resolveStoredServerOrigin(stored) + if ( + serverOrigin !== stored.browserRelayOrigin || + Boolean(stored.serverOrigin) || + Boolean(stored.webUiUrl) || + Boolean(stored.relayUrl) + ) { + await chrome.storage.sync.set({ [SERVER_ORIGIN_STORAGE_KEY]: serverOrigin }) + await chrome.storage.sync.remove([ + LEGACY_SERVER_ORIGIN_STORAGE_KEY, + LEGACY_WEB_UI_URL_STORAGE_KEY, + LEGACY_RELAY_URL_STORAGE_KEY, + ]) + } + + return serverOrigin +} + +export async function writeStoredServerOrigin(serverOrigin: string): Promise { + const normalized = normalizeServerOrigin(serverOrigin) + await chrome.storage.sync.set({ [SERVER_ORIGIN_STORAGE_KEY]: normalized }) + await chrome.storage.sync.remove([ + LEGACY_SERVER_ORIGIN_STORAGE_KEY, + LEGACY_WEB_UI_URL_STORAGE_KEY, + LEGACY_RELAY_URL_STORAGE_KEY, + ]) + return normalized +} diff --git a/packages/browser-extension/src/shared/tab-group.ts b/packages/browser-extension/src/shared/tab-group.ts new file mode 100644 index 00000000..e45e80f7 --- /dev/null +++ b/packages/browser-extension/src/shared/tab-group.ts @@ -0,0 +1 @@ +export const ACTIVE_NINE1_TAB_GROUP_STORAGE_KEY = 'activeNine1TabGroupId' diff --git a/packages/browser-extension/src/sidepanel/index.ts b/packages/browser-extension/src/sidepanel/index.ts index 477d2c1e..b479ee98 100644 --- a/packages/browser-extension/src/sidepanel/index.ts +++ b/packages/browser-extension/src/sidepanel/index.ts @@ -1,48 +1,87 @@ -const DEFAULT_SERVER_ORIGIN = 'http://127.0.0.1:4096' -let currentFrameOrigin = DEFAULT_SERVER_ORIGIN +import { + SERVER_ORIGIN_STORAGE_KEY, + SIDE_PANEL_OPEN_NONCE_STORAGE_KEY, + browserRelayOriginToBootstrapUrl, + browserRelayOriginToExtensionUrl, + buildWebUiUrl, + isAllowedLocalServerOrigin, + normalizeServerOrigin, + readStoredServerOrigin, + writeStoredServerOrigin, +} from '../shared/server-config' +import { ACTIVE_NINE1_TAB_GROUP_STORAGE_KEY } from '../shared/tab-group' -function normalizeServerOrigin(serverOrigin: string): string { +let currentFrameOrigin = '' +let currentServerOrigin = '' +let currentOpenNonce = '' +let serverReachable = false +let relayConnected = false +let settingsOpen = false +let activeSettingsTab = 'relay' +let healthTimer: ReturnType | null = null +let pageChangeTimer: ReturnType | null = null + +function qs(id: string): T | null { + return document.getElementById(id) as T | null +} + +function createNonce(): string { + return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}` +} + +function parseRelayOriginInput(input: string): { ok: true; origin: string } | { ok: false; message: string } { try { - const parsed = new URL(serverOrigin) - return `${parsed.protocol}//${parsed.host}` + const parsed = new URL(input.trim()) + if (!isAllowedLocalServerOrigin(parsed)) { + return { ok: false, message: '请使用 http(s)://127.0.0.1: 或 http(s)://localhost:。' } + } + return { ok: true, origin: `${parsed.protocol}//${parsed.host}` } } catch { - return DEFAULT_SERVER_ORIGIN + return { ok: false, message: '请输入有效的本地 Browser relay origin,例如 http://127.0.0.1:4096。' } } } -function relayUrlToWebUrl(relayUrl: string): string { +async function readOpenNonce(): Promise { try { - const parsed = new URL(relayUrl) - const scheme = parsed.protocol === 'wss:' ? 'https:' : 'http:' - return `${scheme}//${parsed.host}` + const stored = await chrome.storage.sync.get({ [SIDE_PANEL_OPEN_NONCE_STORAGE_KEY]: '' }) + const nonce = stored[SIDE_PANEL_OPEN_NONCE_STORAGE_KEY] + if (typeof nonce === 'string' && nonce.trim()) return nonce } catch { - return DEFAULT_SERVER_ORIGIN + // ignore storage failures } + + const nonce = createNonce() + await chrome.storage.sync.set({ [SIDE_PANEL_OPEN_NONCE_STORAGE_KEY]: nonce }).catch(() => {}) + return nonce } -async function getWebUiUrl(): Promise { +async function fetchBootstrap(origin: string, timeoutMs = 3000): Promise<{ instanceId?: string; serverOrigin?: string }> { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) try { - const stored = await chrome.storage.sync.get({ - serverOrigin: '', - webUiUrl: '', - relayUrl: '', + const response = await fetch(browserRelayOriginToBootstrapUrl(origin), { + signal: controller.signal, }) + const body = await response.json().catch(() => ({})) + if (!response.ok) { + const message = typeof body?.error === 'string' ? body.error : `HTTP ${response.status}` + throw new Error(message) + } + return body as { instanceId?: string; serverOrigin?: string } + } finally { + clearTimeout(timer) + } +} - const serverOrigin = typeof stored.serverOrigin === 'string' && stored.serverOrigin.trim() - ? normalizeServerOrigin(stored.serverOrigin) - : typeof stored.webUiUrl === 'string' && stored.webUiUrl.trim() - ? normalizeServerOrigin(stored.webUiUrl) - : typeof stored.relayUrl === 'string' && stored.relayUrl.trim() - ? relayUrlToWebUrl(stored.relayUrl) - : DEFAULT_SERVER_ORIGIN - - await chrome.storage.sync.set({ serverOrigin }) - await chrome.storage.sync.remove(['webUiUrl', 'relayUrl']) - return serverOrigin - } catch { - // ignore +async function testRelayOrigin(origin: string): Promise<{ ok: true; message: string; instanceId?: string } | { ok: false; message: string }> { + try { + const body = await fetchBootstrap(origin, 2500) + const instanceId = typeof body?.instanceId === 'string' ? body.instanceId : 'unknown instance' + return { ok: true, message: `已连接到 ${instanceId}。`, instanceId } + } catch (error) { + const aborted = error instanceof Error && error.name === 'AbortError' + return { ok: false, message: aborted ? '连接超时。' : '无法访问该 Browser relay origin。' } } - return DEFAULT_SERVER_ORIGIN } async function checkExtensionHealth(): Promise { @@ -54,72 +93,247 @@ async function checkExtensionHealth(): Promise { } } -function setStatus(connected: boolean): void { - const status = document.getElementById('status') +function relaySettingsPayload(message = '') { + return { + origin: currentServerOrigin, + bootstrapUrl: browserRelayOriginToBootstrapUrl(currentServerOrigin), + extensionUrl: browserRelayOriginToExtensionUrl(currentServerOrigin), + serverReachable, + relayConnected, + message, + } +} + +function postFrameMessage(type: string, payload: Record = {}): void { + const frame = qs('app-frame') + if (!frame?.contentWindow || !currentFrameOrigin || !serverReachable) return + frame.contentWindow.postMessage({ type, ...payload }, currentFrameOrigin) +} + +function broadcastRelayStatus(message = ''): void { + postFrameMessage('nine1bot.relayStatus', { + settings: relaySettingsPayload(message), + }) +} + +function setStatus(): void { + const status = qs('status') if (!status) return - status.textContent = connected ? 'relay connected' : 'relay disconnected' - status.classList.toggle('connected', connected) + + status.classList.toggle('connected', serverReachable && relayConnected) + status.classList.toggle('error', !serverReachable) + + if (!serverReachable) { + status.textContent = '未连接到 Nine1Bot 主进程' + } else if (!relayConnected) { + status.textContent = 'Nine1Bot 可访问,浏览器 relay 正在重连' + } else { + status.textContent = '浏览器 relay 已连接' + } +} + +function setMessage(id: string, message: string, tone: 'neutral' | 'success' | 'error' = 'neutral'): void { + const el = qs(id) + if (!el) return + el.textContent = message + el.className = `message ${tone}` +} + +function setRelayFormValue(origin: string): void { + const input = qs('server-origin') + const bootstrap = qs('bootstrap-url') + const extension = qs('extension-url') + if (input) input.value = origin + if (bootstrap) bootstrap.textContent = browserRelayOriginToBootstrapUrl(origin) + if (extension) extension.textContent = browserRelayOriginToExtensionUrl(origin) +} + +function renderSettingsTabs(): void { + document.querySelectorAll('.tab[data-tab]').forEach((button) => { + const tab = button.dataset.tab || '' + button.classList.toggle('active', tab === activeSettingsTab) + }) + document.querySelectorAll('.settings-section').forEach((section) => { + section.classList.toggle('active', section.id === `section-${activeSettingsTab}`) + }) } -function showFallback(targetUrl: string, visible: boolean): void { - const fallback = document.getElementById('fallback') - const target = document.getElementById('target-url') - const frame = document.getElementById('app-frame') as HTMLIFrameElement | null - if (!fallback || !target || !frame) return +function renderPanels(): void { + const frame = qs('app-frame') + const fallback = qs('fallback-app') + const settingsPanel = qs('settings-panel') + if (!frame || !fallback || !settingsPanel) return - target.textContent = targetUrl - fallback.classList.toggle('visible', visible) - frame.style.display = visible ? 'none' : 'block' + frame.classList.toggle('hidden', !serverReachable) + fallback.classList.toggle('hidden', serverReachable) + settingsPanel.classList.toggle('visible', settingsOpen) + renderSettingsTabs() } async function mountFrame(): Promise { - const targetUrl = await getWebUiUrl() - const frame = document.getElementById('app-frame') as HTMLIFrameElement | null + if (!serverReachable) return + if (!currentOpenNonce) currentOpenNonce = await readOpenNonce() + + const frame = qs('app-frame') if (!frame) return - currentFrameOrigin = normalizeServerOrigin(targetUrl) - showFallback(targetUrl, false) - frame.src = targetUrl + currentFrameOrigin = normalizeServerOrigin(currentServerOrigin) + const targetUrl = buildWebUiUrl(currentServerOrigin, currentOpenNonce) + if (frame.src !== targetUrl) { + frame.src = targetUrl + } else { + broadcastRelayStatus() + } +} + +async function refreshConnection(options: { mount?: boolean } = {}): Promise { + const wasReachable = serverReachable + setRelayFormValue(currentServerOrigin) + const result = await testRelayOrigin(currentServerOrigin) + serverReachable = result.ok + relayConnected = await checkExtensionHealth() + setStatus() + renderPanels() - let loaded = false - const fallbackTimer = setTimeout(() => { - if (!loaded) { - showFallback(targetUrl, true) - } - }, 3000) + if (!serverReachable) { + setMessage('settings-message', result.message, 'error') + } - frame.onload = () => { - loaded = true - clearTimeout(fallbackTimer) - showFallback(targetUrl, false) + if (options.mount || (!wasReachable && serverReachable)) { + await mountFrame() } + broadcastRelayStatus(result.message) +} + +async function saveRelayOrigin( + origin: string, + options: { mount?: boolean } = { mount: true }, +): Promise<{ ok: true; message: string } | { ok: false; message: string }> { + const parsed = parseRelayOriginInput(origin) + if (!parsed.ok) return { ok: false, message: parsed.message } + + currentServerOrigin = await writeStoredServerOrigin(parsed.origin) + setRelayFormValue(currentServerOrigin) + await refreshConnection({ mount: options.mount !== false }) + return { + ok: serverReachable, + message: serverReachable ? '已保存并重新连接。' : '已保存,但暂时无法连接到 Nine1Bot。', + } +} + +async function handleSaveRelaySettings(): Promise { + const input = qs('server-origin') + if (!input) return + + setMessage('settings-message', '正在保存并重连...') + const result = await saveRelayOrigin(input.value) + setMessage('settings-message', result.message, result.ok ? 'success' : 'error') +} + +async function handleTestRelaySettings(): Promise { + const input = qs('server-origin') + if (!input) return + + const parsed = parseRelayOriginInput(input.value) + if (!parsed.ok) { + setMessage('settings-message', parsed.message, 'error') + return + } + + setRelayFormValue(parsed.origin) + setMessage('settings-message', '正在测试连接...') + const result = await testRelayOrigin(parsed.origin) + setMessage('settings-message', result.message, result.ok ? 'success' : 'error') } async function handleFrameMessage(event: MessageEvent): Promise { - const frame = document.getElementById('app-frame') as HTMLIFrameElement | null + const frame = qs('app-frame') if (!frame || event.source !== frame.contentWindow) return if (event.origin !== currentFrameOrigin) return - const message = event.data as { type?: string; requestId?: unknown } | undefined - if (message?.type !== 'nine1bot.requestPageContext' || typeof message.requestId !== 'string') return + const message = event.data as { type?: string; requestId?: unknown; origin?: unknown; sessionID?: unknown } | undefined + if (!message?.type) return - const payload = await collectActiveTabPageContext().catch((error) => { - console.warn('[SidePanel] Failed to collect page context:', error) - return undefined - }) + if (message.type === 'nine1bot.requestPageContext' && typeof message.requestId === 'string') { + const payload = await collectActiveTabPageContext().catch((error) => { + console.warn('[SidePanel] Failed to collect page context:', error) + return undefined + }) + + frame.contentWindow?.postMessage( + { + type: 'nine1bot.pageContext', + requestId: message.requestId, + payload, + }, + currentFrameOrigin, + ) + return + } + + if (message.type === 'nine1bot.getBrowserRelaySettings' && typeof message.requestId === 'string') { + frame.contentWindow?.postMessage( + { + type: 'nine1bot.browserRelaySettings', + requestId: message.requestId, + settings: relaySettingsPayload(), + }, + currentFrameOrigin, + ) + return + } + + if (message.type === 'nine1bot.testBrowserRelayOrigin' && typeof message.requestId === 'string') { + const parsed = typeof message.origin === 'string' ? parseRelayOriginInput(message.origin) : undefined + const result = parsed?.ok ? await testRelayOrigin(parsed.origin) : { ok: false, message: parsed?.message || 'Browser relay origin 无效。' } + frame.contentWindow?.postMessage( + { + type: 'nine1bot.browserRelayTestResult', + requestId: message.requestId, + ...result, + settings: relaySettingsPayload(result.message), + }, + currentFrameOrigin, + ) + return + } - frame.contentWindow?.postMessage( - { - type: 'nine1bot.pageContext', - requestId: message.requestId, - payload, - }, - currentFrameOrigin, - ) + if (message.type === 'nine1bot.saveBrowserRelayOrigin' && typeof message.requestId === 'string') { + const result = typeof message.origin === 'string' + ? await saveRelayOrigin(message.origin, { mount: false }) + : { ok: false, message: 'Browser relay origin 无效。' } + frame.contentWindow?.postMessage( + { + type: 'nine1bot.browserRelaySaveResult', + requestId: message.requestId, + ...result, + settings: relaySettingsPayload(result.message), + }, + currentFrameOrigin, + ) + if (result.ok) { + setTimeout(() => { + mountFrame().catch((error) => { + console.warn('[SidePanel] Failed to reload after browser relay save:', error) + }) + }, 150) + } + return + } + + if (message.type === 'nine1bot.openMainSession' && typeof message.sessionID === 'string') { + await openMainSession(message.sessionID) + } +} + +async function openMainSession(sessionID: string): Promise { + const url = new URL(normalizeServerOrigin(currentServerOrigin)) + url.searchParams.set('session', sessionID) + await chrome.tabs.create({ url: url.toString() }) } async function collectActiveTabPageContext(): Promise { - const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }) + const tab = await getActiveNine1GroupTab() if (!tab?.id) return undefined const response = await chrome.tabs.sendMessage(tab.id, { @@ -132,24 +346,161 @@ async function collectActiveTabPageContext(): Promise { return response.result } +async function getActiveNine1GroupTab(): Promise { + try { + const stored = await chrome.storage.local.get({ [ACTIVE_NINE1_TAB_GROUP_STORAGE_KEY]: -1 }) + const groupId = stored[ACTIVE_NINE1_TAB_GROUP_STORAGE_KEY] + if (typeof groupId !== 'number' || groupId < 0) return undefined + + const tabs = await chrome.tabs.query({ groupId }) + if (tabs.length === 0) return undefined + + const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true }) + if (activeTab?.id && tabs.some((tab) => tab.id === activeTab.id)) return activeTab + + return tabs.find((tab) => tab.active) ?? tabs[0] + } catch { + return undefined + } +} + +async function ensureDedicatedTabGroup(): Promise { + await chrome.runtime.sendMessage({ type: 'nine1bot-sidepanel-ensure-tab-group' }).catch(() => undefined) +} + +function notifyFramePageChanged(): void { + postFrameMessage('nine1bot.activePageChanged') +} + +function scheduleFramePageChanged(): void { + if (pageChangeTimer) { + clearTimeout(pageChangeTimer) + } + pageChangeTimer = setTimeout(() => { + pageChangeTimer = null + notifyFramePageChanged() + }, 180) +} + +function setupActivePageListener(): void { + chrome.tabs.onActivated.addListener(() => { + scheduleFramePageChanged() + }) + + chrome.tabs.onUpdated.addListener((_tabId, changeInfo) => { + if (changeInfo.url || changeInfo.title || changeInfo.status) { + scheduleFramePageChanged() + } + }) + + chrome.storage.onChanged.addListener((changes, areaName) => { + if (areaName === 'local' && changes[ACTIVE_NINE1_TAB_GROUP_STORAGE_KEY]) { + scheduleFramePageChanged() + } + }) +} + +function setupStorageListener(): void { + chrome.storage.onChanged.addListener((changes, areaName) => { + if (areaName !== 'sync') return + + const relayChange = changes[SERVER_ORIGIN_STORAGE_KEY] + if (relayChange?.newValue && typeof relayChange.newValue === 'string') { + currentServerOrigin = normalizeServerOrigin(relayChange.newValue) + refreshConnection({ mount: true }).catch((error) => { + console.warn('[SidePanel] Failed to reload after relay origin change:', error) + }) + } + + const nonceChange = changes[SIDE_PANEL_OPEN_NONCE_STORAGE_KEY] + if (nonceChange?.newValue && typeof nonceChange.newValue === 'string') { + currentOpenNonce = nonceChange.newValue + mountFrame().catch((error) => { + console.warn('[SidePanel] Failed to reload after open nonce change:', error) + }) + } + }) +} + +function setupControls(): void { + qs('settings-toggle')?.addEventListener('click', () => { + settingsOpen = true + activeSettingsTab = 'relay' + renderPanels() + }) + qs('settings-close')?.addEventListener('click', () => { + settingsOpen = false + renderPanels() + }) + qs('settings-panel')?.addEventListener('click', (event) => { + if (event.target === event.currentTarget) { + settingsOpen = false + renderPanels() + } + }) + qs('reload')?.addEventListener('click', () => { + refreshConnection({ mount: true }).catch((error) => { + console.warn('[SidePanel] Failed to refresh connection:', error) + }) + }) + document.addEventListener('click', (event) => { + const target = event.target as HTMLElement | null + if (target?.matches('.tab[data-tab]')) { + activeSettingsTab = target.dataset.tab || 'relay' + renderPanels() + } + if (target?.id === 'save-settings') { + handleSaveRelaySettings().catch((error) => { + console.warn('[SidePanel] Failed to save relay settings:', error) + setMessage('settings-message', '保存失败。', 'error') + }) + } + if (target?.id === 'test-settings') { + handleTestRelaySettings().catch((error) => { + console.warn('[SidePanel] Failed to test relay settings:', error) + setMessage('settings-message', '测试失败。', 'error') + }) + } + }) + qs('app-frame')?.addEventListener('load', () => { + broadcastRelayStatus() + }) +} + async function init(): Promise { - const connected = await checkExtensionHealth() - setStatus(connected) + currentServerOrigin = await readStoredServerOrigin() + currentOpenNonce = await readOpenNonce() + setRelayFormValue(currentServerOrigin) + await ensureDedicatedTabGroup() + setupControls() + setupStorageListener() + setupActivePageListener() window.addEventListener('message', (event) => { handleFrameMessage(event).catch((error) => { console.warn('[SidePanel] Failed to handle frame message:', error) }) }) - await mountFrame() - - const reloadButton = document.getElementById('reload') - reloadButton?.addEventListener('click', () => { - mountFrame().catch((error) => { - console.warn('[SidePanel] Failed to reload frame:', error) - }) - }) + await refreshConnection({ mount: true }) + healthTimer = setInterval(() => { + refreshConnection().catch(() => {}) + }, 5000) } +window.addEventListener('unload', () => { + if (healthTimer) { + clearInterval(healthTimer) + healthTimer = null + } + if (pageChangeTimer) { + clearTimeout(pageChangeTimer) + pageChangeTimer = null + } +}) + init().catch((error) => { console.error('[SidePanel] Failed to initialize:', error) + serverReachable = false + relayConnected = false + setStatus() + renderPanels() }) diff --git a/packages/browser-extension/src/tools/tabs.ts b/packages/browser-extension/src/tools/tabs.ts index 0d00135f..85d9c18a 100644 --- a/packages/browser-extension/src/tools/tabs.ts +++ b/packages/browser-extension/src/tools/tabs.ts @@ -1,5 +1,5 @@ import type { ToolDefinition, ToolResult } from './index' -import { addTabToNine1Group } from '../background/tab-group-manager' +import { addTabToNine1Group, getDefaultNine1Tab, getTabsInActiveNine1Group } from '../background/tab-group-manager' interface TabsContextArgs { createIfEmpty?: boolean @@ -27,20 +27,31 @@ function isAutomatableTabUrl(url?: string): boolean { } } -// Track tabs created by this extension (MCP tab group) -const mcpTabIds = new Set() +function serializeTab(tab: chrome.tabs.Tab) { + return { + id: tab.id, + url: tab.url, + title: tab.title, + active: tab.active, + windowId: tab.windowId, + } +} + +async function listManagedTabs() { + return (await getTabsInActiveNine1Group()).filter((tab) => typeof tab.id === 'number') +} export const tabsContextTool = { definition: { name: 'tabs_context_mcp', description: - 'Get context information about the current MCP tab group. Returns all tab IDs managed by the extension. Use this to know what tabs exist before using other browser automation tools.', + 'Get context information about the active Nine1Bot tab group. Returns only tab IDs managed by the current extension side-panel session.', inputSchema: { type: 'object' as const, properties: { createIfEmpty: { type: 'boolean', - description: 'If true and no tabs exist in the group, create a new tab.', + description: 'If true and no tabs exist in the active Nine1Bot group, create a new tab.', }, url: { type: 'string', @@ -48,7 +59,7 @@ export const tabsContextTool = { }, includeAll: { type: 'boolean', - description: 'If true, include all automatable http/https/file tabs visible to the extension.', + description: 'If true, include automatable tabs from the active Nine1Bot group. It never includes tabs outside the group.', }, }, required: [], @@ -59,68 +70,18 @@ export const tabsContextTool = { const { createIfEmpty = false, url, includeAll = false } = (args as TabsContextArgs) || {} try { - // Clean up closed tabs from our tracking - const allTabs = await chrome.tabs.query({}) - const allTabIds = new Set(allTabs.map((t) => t.id).filter((id): id is number => id !== undefined)) + let managedTabs = await listManagedTabs() - for (const tabId of mcpTabIds) { - if (!allTabIds.has(tabId)) { - mcpTabIds.delete(tabId) - } - } - - // Get info about managed tabs - const managedTabs: Array<{ - id: number - url?: string - title?: string - active: boolean - windowId: number - }> = [] - - for (const tabId of mcpTabIds) { - try { - const tab = await chrome.tabs.get(tabId) - managedTabs.push({ - id: tab.id!, - url: tab.url, - title: tab.title, - active: tab.active, - windowId: tab.windowId, - }) - } catch { - // Tab no longer exists - mcpTabIds.delete(tabId) - } - } - - // Create a new tab if requested and none exist if (createIfEmpty && managedTabs.length === 0) { const newTab = await chrome.tabs.create({ url: normalizeNewTabUrl(url) }) if (newTab.id) { - mcpTabIds.add(newTab.id) await addTabToNine1Group(newTab.id) - managedTabs.push({ - id: newTab.id, - url: newTab.url, - title: newTab.title, - active: newTab.active, - windowId: newTab.windowId, - }) + managedTabs = await listManagedTabs() } } - // Also include the currently active tab if not already tracked - const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true }) - let activeTabInfo = null - if (activeTab?.id) { - activeTabInfo = { - id: activeTab.id, - url: activeTab.url, - title: activeTab.title, - windowId: activeTab.windowId, - } - } + const activeTab = await getDefaultNine1Tab() + const automatableTabs = managedTabs.filter((tab) => isAutomatableTabUrl(tab.url)) return { content: [ @@ -128,19 +89,9 @@ export const tabsContextTool = { type: 'text', text: JSON.stringify( { - mcpTabs: managedTabs, - activeTab: activeTabInfo, - allTabs: includeAll - ? allTabs - .filter((tab) => tab.id && isAutomatableTabUrl(tab.url)) - .map((tab) => ({ - id: tab.id, - url: tab.url, - title: tab.title, - active: tab.active, - windowId: tab.windowId, - })) - : undefined, + mcpTabs: managedTabs.map(serializeTab), + activeTab: activeTab ? serializeTab(activeTab) : null, + allTabs: includeAll ? automatableTabs.map(serializeTab) : undefined, totalMcpTabs: managedTabs.length, }, null, @@ -162,7 +113,7 @@ export const tabsContextTool = { export const tabsCreateTool = { definition: { name: 'tabs_create_mcp', - description: 'Creates a new empty tab in the MCP tab group. Use tabs_context_mcp first to see existing tabs.', + description: 'Creates a new empty tab in the active Nine1Bot tab group. Use tabs_context_mcp first to see existing tabs.', inputSchema: { type: 'object' as const, properties: { @@ -188,7 +139,6 @@ export const tabsCreateTool = { } } - mcpTabIds.add(newTab.id) await addTabToNine1Group(newTab.id) return { @@ -202,7 +152,7 @@ export const tabsCreateTool = { title: newTab.title, windowId: newTab.windowId, requestedUrl: initialUrl, - message: 'New tab created and added to MCP tab group', + message: 'New tab created and added to active Nine1Bot tab group', }, null, 2 @@ -220,17 +170,6 @@ export const tabsCreateTool = { }, } -// Helper to add existing tab to MCP group export function addTabToMcpGroup(tabId: number): void { - mcpTabIds.add(tabId) + addTabToNine1Group(tabId).catch(() => {}) } - -// Helper to remove tab from MCP group -export function removeTabFromMcpGroup(tabId: number): void { - mcpTabIds.delete(tabId) -} - -// Listen for tab close events to clean up -chrome.tabs.onRemoved.addListener((tabId) => { - mcpTabIds.delete(tabId) -}) diff --git a/packages/browser-extension/test/server-config.test.ts b/packages/browser-extension/test/server-config.test.ts new file mode 100644 index 00000000..8f9b4760 --- /dev/null +++ b/packages/browser-extension/test/server-config.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'bun:test' +import { + DEFAULT_SERVER_ORIGIN, + browserRelayOriginToBootstrapUrl, + browserRelayOriginToExtensionUrl, + buildWebUiUrl, + normalizeServerOrigin, + relayUrlToServerOrigin, + resolveStoredServerOrigin, + serverOriginToRelayUrl, +} from '../src/shared/server-config' + +describe('browser extension relay config', () => { + it('uses the default local server origin for empty or invalid input', () => { + expect(normalizeServerOrigin('')).toBe(DEFAULT_SERVER_ORIGIN) + expect(normalizeServerOrigin('not a url')).toBe(DEFAULT_SERVER_ORIGIN) + expect(normalizeServerOrigin('https://example.com:4096')).toBe(DEFAULT_SERVER_ORIGIN) + }) + + it('normalizes localhost and 127.0.0.1 origins with custom ports', () => { + expect(normalizeServerOrigin('http://127.0.0.1:4100/path?q=1')).toBe('http://127.0.0.1:4100') + expect(normalizeServerOrigin('https://localhost:9443/browser/bootstrap')).toBe('https://localhost:9443') + }) + + it('converts browser relay origins to extension WebSocket URLs', () => { + expect(serverOriginToRelayUrl('http://127.0.0.1:4100')).toBe('ws://127.0.0.1:4100/browser/extension') + expect(serverOriginToRelayUrl('https://localhost:9443')).toBe('wss://localhost:9443/browser/extension') + expect(browserRelayOriginToExtensionUrl('http://127.0.0.1:4100')).toBe('ws://127.0.0.1:4100/browser/extension') + expect(browserRelayOriginToBootstrapUrl('http://127.0.0.1:4100')).toBe('http://127.0.0.1:4100/browser/bootstrap') + }) + + it('prefers browserRelayOrigin and migrates legacy serverOrigin/webUiUrl/relayUrl values', () => { + expect(resolveStoredServerOrigin({ + browserRelayOrigin: 'http://127.0.0.1:4103', + serverOrigin: 'http://127.0.0.1:4100', + })).toBe('http://127.0.0.1:4103') + expect(resolveStoredServerOrigin({ serverOrigin: 'http://127.0.0.1:4100' })).toBe('http://127.0.0.1:4100') + expect(resolveStoredServerOrigin({ webUiUrl: 'http://127.0.0.1:4101/app' })).toBe('http://127.0.0.1:4101') + expect(resolveStoredServerOrigin({ relayUrl: 'ws://localhost:4102/browser/extension' })).toBe('http://localhost:4102') + expect(relayUrlToServerOrigin('wss://localhost:9443/browser/extension')).toBe('https://localhost:9443') + }) + + it('builds the embedded web UI URL with extension query params', () => { + expect(buildWebUiUrl('http://127.0.0.1:4100', 'nonce-1')).toBe( + 'http://127.0.0.1:4100/?client=browser-extension&newSessionNonce=nonce-1', + ) + }) +}) diff --git a/packages/nine1bot/src/config/loader.test.ts b/packages/nine1bot/src/config/loader.test.ts index 8b7aae2d..1792cd1d 100644 --- a/packages/nine1bot/src/config/loader.test.ts +++ b/packages/nine1bot/src/config/loader.test.ts @@ -111,6 +111,10 @@ describe('loadConfig browser migration guards', () => { cdpPort: 9222, autoLaunch: true, headless: false, + sidepanel: { + mcpServers: [], + skills: [], + }, }) }) diff --git a/packages/nine1bot/src/config/schema.ts b/packages/nine1bot/src/config/schema.ts index f01ec2d5..fbd650dd 100644 --- a/packages/nine1bot/src/config/schema.ts +++ b/packages/nine1bot/src/config/schema.ts @@ -52,6 +52,17 @@ export const SkillsConfigSchema = z.object({ // 浏览器控制配置 // 浏览器控制已内置到主服务的 /browser/ 路径下,不再需要独立端口 +export const BrowserSidepanelConfigSchema = z.object({ + // 浏览器插件侧边栏专属默认模型,格式 provider/model + model: z.string().optional(), + // 浏览器插件侧边栏专属提示词 + prompt: z.string().optional(), + // 浏览器插件侧边栏默认追加的 MCP 服务器 + mcpServers: z.array(z.string()).default([]), + // 浏览器插件侧边栏默认追加的技能 + skills: z.array(z.string()).default([]), +}) + export const BrowserConfigSchema = z.object({ // 是否启用浏览器控制 enabled: z.boolean().default(false), @@ -61,6 +72,8 @@ export const BrowserConfigSchema = z.object({ autoLaunch: z.boolean().default(true), // 是否使用无头模式 headless: z.boolean().default(false), + // 浏览器插件侧边栏配置 + sidepanel: BrowserSidepanelConfigSchema.default({ mcpServers: [], skills: [] }), }) export const RuntimeFeatureFlagSchema = z.object({ diff --git a/web/src/App.vue b/web/src/App.vue index 54c013e5..120ab02b 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -4,9 +4,11 @@ import { useSession } from './composables/useSession' import { useFiles } from './composables/useFiles' import { useSettings } from './composables/useSettings' import { useAppMode } from './composables/useAppMode' +import { useClientSurface } from './composables/useClientSurface' import { useSessionMode } from './composables/useSessionMode' import { useProjects } from './composables/useProjects' import { useGlobalRecentSessions } from './composables/useGlobalRecentSessions' +import { collectActivePageContext, type RequestPagePayload } from './api/page-context' import { api, type EventStreamSubscription, type GlobalSSEEventEnvelope, type Session } from './api/client' import Header from './components/Header.vue' import Sidebar from './components/Sidebar.vue' @@ -17,6 +19,7 @@ import SearchOverlay from './components/SearchOverlay.vue' import ProjectsPage from './components/ProjectsPage.vue' import AutomationsPage from './components/AutomationsPage.vue' import SettingsPanel from './components/SettingsPanel.vue' +import BrowserExtensionSettingsPanel from './components/BrowserExtensionSettingsPanel.vue' import FileViewer from './components/FileViewer.vue' import TodoList from './components/TodoList.vue' import PlanPanel from './components/PlanPanel.vue' @@ -24,6 +27,8 @@ import McpProjectPanel from './components/McpProjectPanel.vue' import RightPanel from './components/RightPanel.vue' import { useAgentTerminal } from './composables/useAgentTerminal' import { useFilePreview } from './composables/useFilePreview' +import { Globe2, Plus, RefreshCw, Settings, Terminal } from 'lucide-vue-next' +import { getTrustedExtensionParentContext, isTrustedExtensionParentEvent } from './utils/extension-parent' import { MAX_PARALLEL_AGENTS } from './composables/useParallelSessions' @@ -79,7 +84,11 @@ const { } = useSession() // Agent 终端 -const { handleSSEEvent: handleTerminalEvent, setSessionContext: setTerminalSessionContext } = useAgentTerminal() +const { + handleSSEEvent: handleTerminalEvent, + terminals: agentTerminals, + setSessionContext: setTerminalSessionContext, +} = useAgentTerminal() // 文件预览 const { handleSSEEvent: handlePreviewEvent } = useFilePreview() @@ -111,6 +120,8 @@ const { loadConfig, } = useSettings() +const { isBrowserExtension } = useClientSurface() + // App mode (chat / agent) const { mode: appMode, setMode: setAppMode } = useAppMode() @@ -163,6 +174,16 @@ const showAutomationsPage = ref(false) const sidebarCollapsed = ref(false) const projectContextRevision = ref(0) +const extensionPageContext = ref() +const extensionPageLoading = ref(false) +const extensionRelayStatus = ref({ + origin: '', + bootstrapUrl: '', + extensionUrl: '', + serverReachable: true, + relayConnected: true, + message: '', +}) const sidebarSessions = computed(() => { if (appMode.value !== 'agent') { @@ -200,6 +221,35 @@ const isEmptyState = computed(() => !showAutomationsPage.value ) +const extensionPageLabel = computed(() => { + if (extensionPageLoading.value) return 'Checking current page...' + const page = extensionPageContext.value + if (!page) return 'No page context' + return page.title || page.url || page.platform +}) + +const extensionPageDetail = computed(() => { + const page = extensionPageContext.value + if (!page) return 'Open a supported page to include browser context.' + const parts = [page.platform, page.pageType, page.url].filter(Boolean) + return parts.join(' · ') +}) + +const extensionConnectionIssue = computed(() => + isBrowserExtension.value && (!extensionRelayStatus.value.serverReachable || !extensionRelayStatus.value.relayConnected) +) + +const extensionConnectionText = computed(() => { + if (!extensionRelayStatus.value.serverReachable) return '未连接到 Nine1Bot 主进程' + if (!extensionRelayStatus.value.relayConnected) return '浏览器 relay 正在重连,页面对话可继续,浏览器控制暂不可用' + return '' +}) + +const extensionTerminals = computed(() => { + if (!isBrowserExtension.value || !currentSession.value) return [] + return agentTerminals.value.filter((terminal) => terminal.sessionID === currentSession.value?.id) +}) + // Handle model selection from InputBox async function handleSelectModel(providerId: string, modelId: string) { await settingsSelectModel(providerId, modelId) @@ -262,6 +312,87 @@ function subscribeGlobalEvents() { }) } +async function refreshExtensionPageContext() { + if (!isBrowserExtension.value) return + extensionPageLoading.value = true + try { + extensionPageContext.value = await collectActivePageContext(1200) + } catch (error) { + console.error('Failed to collect extension page context:', error) + extensionPageContext.value = undefined + } finally { + extensionPageLoading.value = false + } +} + +function handleExtensionParentMessage(event: MessageEvent) { + if (!isBrowserExtension.value || !isTrustedExtensionParentEvent(event)) return + const message = event.data as { + type?: unknown + settings?: { + origin?: string + bootstrapUrl?: string + extensionUrl?: string + serverReachable?: boolean + relayConnected?: boolean + message?: string + } + } | undefined + if (message?.type === 'nine1bot.activePageChanged') { + void refreshExtensionPageContext() + return + } + if (message?.type === 'nine1bot.relayStatus' && message.settings) { + extensionRelayStatus.value = { + origin: message.settings.origin || '', + bootstrapUrl: message.settings.bootstrapUrl || '', + extensionUrl: message.settings.extensionUrl || '', + serverReachable: message.settings.serverReachable !== false, + relayConnected: message.settings.relayConnected !== false, + message: message.settings.message || '', + } + } +} + +function initialSessionIdFromUrl(): string { + if (typeof window === 'undefined') return '' + const params = new URLSearchParams(window.location.search) + return params.get('session') || params.get('sessionId') || '' +} + +async function selectSessionById(sessionId: string): Promise { + if (!sessionId) return false + let session = + sidebarSessions.value.find((item) => item.id === sessionId) || + sessions.value.find((item) => item.id === sessionId) + + if (!session) { + await loadSessions() + session = + sidebarSessions.value.find((item) => item.id === sessionId) || + sessions.value.find((item) => item.id === sessionId) + } + + if (!session) return false + + showProjectsPage.value = false + showMetricsPage.value = false + showAutomationsPage.value = false + await selectSession(session) + return true +} + +function openCurrentExtensionSessionInMainWeb() { + const sessionId = currentSession.value?.id + if (!sessionId) return + const parentContext = getTrustedExtensionParentContext() + if (!parentContext) return + parentContext.parent.postMessage({ + type: 'nine1bot.openMainSession', + sessionID: sessionId, + }, parentContext.origin) +} + onMounted(async () => { // 先注册事件处理器,确保在 SSE 连接建立时能接收到 server.connected 事件 unregisterTerminalHandler = registerEventHandler(handleTerminalEvent) @@ -284,9 +415,11 @@ onMounted(async () => { // 不传 directory 参数以加载所有会话 await loadSessions() - await loadFiles('.') - await loadProjects() - if (appMode.value === 'agent') { + if (!isBrowserExtension.value) { + await loadFiles('.') + await loadProjects() + } + if (!isBrowserExtension.value && appMode.value === 'agent') { await loadGlobalRecentSessions().catch((error) => { console.error('Failed to load global recent sessions:', error) }) @@ -297,7 +430,15 @@ onMounted(async () => { await loadProviders() await loadConfig() - if (sessions.value.length > 0) { + const requestedSessionId = initialSessionIdFromUrl() + + if (isBrowserExtension.value) { + window.addEventListener('message', handleExtensionParentMessage) + await refreshExtensionPageContext() + createSession('.') + } else if (requestedSessionId && await selectSessionById(requestedSessionId)) { + window.history.replaceState({}, '', window.location.pathname) + } else if (sessions.value.length > 0) { await selectSession(sessions.value[0]) } else { // 进入草稿模式,不实际创建会话 @@ -305,13 +446,18 @@ onMounted(async () => { } // 加载待处理的问题和权限请求 - await loadPendingRequests() + if (!isBrowserExtension.value || currentSession.value) { + await loadPendingRequests() + } - // Cmd+K / Ctrl+K shortcut for search - document.addEventListener('keydown', handleGlobalKeydown) + if (!isBrowserExtension.value) { + // Cmd+K / Ctrl+K shortcut for search + document.addEventListener('keydown', handleGlobalKeydown) + } }) onUnmounted(() => { + window.removeEventListener('message', handleExtensionParentMessage) unsubscribe() if (globalEventSource) { globalEventSource.close() @@ -407,6 +553,7 @@ function handleNewSession() { showProjectsPage.value = false showMetricsPage.value = false showAutomationsPage.value = false + void refreshExtensionPageContext() createSession(currentDirectory.value || '.') } @@ -654,7 +801,128 @@ function handlePromptSelect(prompt: string) {