Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<typeof BrowserExtensionModel>

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<typeof BrowserExtensionConfigPatch>

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<Record<string, any>> {
const text = await readFile(pathname, "utf-8")
return (parseJsonc(text) || {}) as Record<string, any>
}

async function writeNine1botConfig(pathname: string, nextConfig: Record<string, any>) {
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<string, any>): 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<BrowserExtensionConfig> {
const pathname = configPath()
if (!pathname) return {}
const config = (await readNine1botConfig(pathname).catch(() => ({}))) as Record<string, any>
return normalizeBrowserExtensionConfig(config)
}

export async function patchBrowserExtensionConfig(patch: BrowserExtensionConfigPatch): Promise<BrowserExtensionConfig> {
const pathname = configPath()
if (!pathname) {
throw new Error("No config path")
}

const existing = (await readNine1botConfig(pathname).catch(() => ({}))) as Record<string, any>
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)
}
26 changes: 26 additions & 0 deletions opencode/packages/opencode/src/server/routes/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" })

Expand Down Expand Up @@ -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) {
Expand Down
85 changes: 81 additions & 4 deletions opencode/packages/opencode/src/server/routes/nine1bot-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" })

Expand Down Expand Up @@ -196,20 +201,68 @@ 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<RuntimeControllerProtocol.ResourceSelection> = {
...(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),
})
const compiledProfile = await SessionProfileCompiler.compile({
directory: input?.directory ?? Instance.directory,
permission,
source: "new-session",
agentName: input?.sessionChoice?.agent,
agentName: sessionChoice?.agent,
profileTemplate: template.profileTemplate,
})
const session = await Session.createNext({
Expand All @@ -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 {
Expand All @@ -235,6 +289,28 @@ export async function createControllerSession(input?: RuntimeControllerProtocol.
}
}

async function isBrowserExtensionSession(sessionID: string, entry?: RuntimeControllerProtocol.Entry) {
if (isBrowserExtensionEntry(entry)) return true
Comment thread
1923213756 marked this conversation as resolved.
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<RuntimeControllerProtocol.MessageSendRequest> {
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
Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions opencode/packages/opencode/src/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof Client>

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({
Expand Down Expand Up @@ -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),
Expand All @@ -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 ??
Expand Down
Loading
Loading