Skip to content
Open
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
2 changes: 1 addition & 1 deletion docs/deployment/docker-image.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ Managed platforms usually inject `PORT`. Do not hard-code a different listen por

Managed HTTPS platforms often terminate TLS before forwarding HTTP to the container, so the container sees plain HTTP. Set `PUBLIC_ORIGIN` to the site's public origin for those deployments so the CSRF origin check compares against the real public origin instead of the container-local request URL. Render and Railway are auto-detected (`RENDER_EXTERNAL_URL` / `RAILWAY_PUBLIC_DOMAIN`), so a one-click deploy needs no manual value; set `PUBLIC_ORIGIN` explicitly when you add a custom domain (append it as a second comma-separated entry).

`INSTATIC_SECRET_KEY` is the stable AES master key for reversible server secrets, including Anthropic, OpenAI, and OpenRouter credentials and TOTP MFA seeds. If it is missing in production, adding a credential or enabling TOTP MFA fails. If it is rotated or lost, existing stored credentials must be re-entered and TOTP MFA must be re-enrolled.
`INSTATIC_SECRET_KEY` is the stable AES master key for reversible server secrets, including Anthropic, OpenAI, OpenRouter, and OpenCode credentials and TOTP MFA seeds. If it is missing in production, adding a credential or enabling TOTP MFA fails. If it is rotated or lost, existing stored credentials must be re-entered and TOTP MFA must be re-enrolled.

## Health Check

Expand Down
5 changes: 3 additions & 2 deletions docs/features/agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

The AI Agent is a model-powered assistant integrated into the visual editor. The user types a request in the Agent Panel; the agent reads the current page snapshot, plans a sequence of edits, and executes them by calling tools. Structure is written as semantic HTML (`insertHtml` / `replaceNodeHtml`); styling is written as CSS — a `<style>` block and/or `class=` attributes inside the insert, or the dedicated `applyCss` tool for authoring/editing any CSS on its own. There is one CSS path and it accepts every selector; `assignClass` / `removeClass` attach existing classes to nodes.

The agent runs on a provider-agnostic AI runtime (`server/ai/`) that can drive any supported model (Anthropic Claude, OpenAI, OpenRouter, Ollama). Every driver talks directly to its provider's REST API over HTTP/SSE — no provider SDKs. All four share one multi-turn tool loop (`drivers/http/toolLoop.ts`); each supplies only a small `ProviderAdapter` of pure mapping functions. The plain `@anthropic-ai/sdk` (and any provider SDK) is banned repo-wide. Gated by `ai-driver-isolation.test.ts`.
The agent runs on a provider-agnostic AI runtime (`server/ai/`) that can drive any supported model (Anthropic Claude, OpenAI, OpenRouter, OpenCode, Ollama). Every driver talks directly to its provider's REST API over HTTP/SSE — no provider SDKs. All five share one multi-turn tool loop (`drivers/http/toolLoop.ts`); each supplies only a small `ProviderAdapter` of pure mapping functions. The plain `@anthropic-ai/sdk` (and any provider SDK) is banned repo-wide. Gated by `ai-driver-isolation.test.ts`.

---

Expand All @@ -12,7 +12,7 @@ The agent runs on a provider-agnostic AI runtime (`server/ai/`) that can drive a
- **Styling via CSS.** The agent emits CSS the same way a human pastes it: a `<style>` block and/or `class=` attributes inside the `insertHtml`/`replaceNodeHtml` payload, or the standalone `applyCss` tool. The importer (`cssToStyleRules`) classifies every selector — a bare `.foo {}` rule becomes a reusable Selectors-panel class bound to `class="foo"`; any other selector (`.hero a`, `a:hover`, `nav > li`) becomes an ambient rule; `style=` attributes land on the node's inline styles. There is no structured `classes` parameter — the agent never hand-builds classes node-by-node at insert time. `applyCss` is the single tool for authoring/editing CSS on its own; it **upserts**, so re-applying a selector edits the existing rule (the way descendant/pseudo rules get restyled).
- **35 tools total.** 6 server-side catalog read tools (resolved server-side from the posted snapshot / DB) + 29 browser-bridged tools.
- **Two-endpoint bridge.** `POST /admin/api/ai/chat/site` opens an NDJSON stream. When the model calls a browser-bridged tool, the server emits `toolRequest`; the browser executor reads or mutates the editor store and POSTs the `AiToolOutput` result to `POST /admin/api/ai/tool-result`.
- **Provider-agnostic.** The runtime selects a driver (Anthropic, OpenAI, OpenRouter, Ollama) from the conversation's configured credential.
- **Provider-agnostic.** The runtime selects a driver (Anthropic, OpenAI, OpenRouter, OpenCode, Ollama) from the conversation's configured credential.
- **Tool input schemas are a single source of truth** in `@core/ai` (`src/core/ai/toolSchemas.ts`). The server tool registry (`server/ai/tools/site/writeTools.ts`) and the browser executor (`executor.ts` + `tokenRunners.ts`) import the exact same schema objects — a constraint added once is enforced on both sides at build time. Gated by `ai-tool-schema-ssot.test.ts` and `ai-tools-typebox-only.test.ts`.
- **Capabilities.** `ai.chat` required to stream; `ai.tools.write` required for write tools. Gated by `ai-handlers-capability-gated.test.ts`.

Expand Down Expand Up @@ -65,6 +65,7 @@ server/ai/
│ ├── anthropic.ts — Anthropic driver: direct POST /v1/messages (no SDK)
│ ├── openai.ts — OpenAI driver: direct POST /v1/responses (no SDK)
│ ├── openrouter.ts — OpenRouter driver: direct POST /v1/responses (shared Responses path; live /models; native cost)
│ ├── opencode.ts — OpenCode Zen driver: direct POST /zen/v1/chat/completions (no SDK; live /models)
│ └── ollama.ts — Ollama driver: direct POST /v1/chat/completions (no SDK)
└── runtime/
├── runner.ts — runChat(): drives a driver, emits stream events
Expand Down
2 changes: 2 additions & 0 deletions server/ai/drivers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@ import { anthropicDriver } from './anthropic'
import { openaiDriver } from './openai'
import { ollamaDriver } from './ollama'
import { openrouterDriver } from './openrouter'
import { opencodeDriver } from './opencode'

const DRIVERS: Record<AiProviderId, AiProvider> = {
anthropic: anthropicDriver,
openai: openaiDriver,
ollama: ollamaDriver,
openrouter: openrouterDriver,
opencode: opencodeDriver,
}

/** Returns the driver for a provider id, or throws if unknown. */
Expand Down
189 changes: 189 additions & 0 deletions server/ai/drivers/opencode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
/**
* OpenCode driver — direct HTTP against OpenCode Zen.
*
* Talks to `POST https://opencode.ai/zen/v1/chat/completions` with no SDK.
* OpenCode Zen exposes an OpenAI-compatible chat/completions API, so this
* driver reuses the same chat-completions mapping + SSE translator as Ollama.
*
* Tools are sent with their canonical TypeBox `inputSchema` as JSON Schema
* parameters directly — no Zod bridge.
*/

import { Type, parseValue } from '@core/utils/typeboxHelpers'
import type {
AiAuthMode,
AiProviderId,
AiStreamEvent,
AiToolOutput,
} from '../runtime/types'
import type {
AiProvider,
AiProviderCapabilities,
AiProviderModel,
AiResolvedCredential,
AiStreamRequest,
} from './types'
import { runToolLoop, type ProviderAdapter } from './http/toolLoop'
import {
ChatCompletionsTurnTranslator,
mapChatHistory,
type ChatMessage,
} from './ollama'

const SUPPORTED_AUTH_MODES: AiAuthMode[] = ['apiKey']

const OPENCODE_BASE_URL = 'https://opencode.ai/zen/v1'
const OPENCODE_CHAT_ENDPOINT = `${OPENCODE_BASE_URL}/chat/completions`
const OPENCODE_MODELS_ENDPOINT = `${OPENCODE_BASE_URL}/models`

const DEFAULT_CAPABILITIES: AiProviderCapabilities = {
toolCalling: true,
visionInput: false,
promptCache: false,
streaming: true,
}

const OpenCodeModelSchema = Type.Object(
{
id: Type.String(),
name: Type.Optional(Type.String()),
capabilities: Type.Optional(
Type.Object(
{
toolcall: Type.Optional(Type.Boolean()),
input: Type.Optional(
Type.Object({ image: Type.Optional(Type.Boolean()) }, { additionalProperties: true }),
),
},
{ additionalProperties: true },
),
),
limit: Type.Optional(
Type.Object({ context: Type.Optional(Type.Number()) }, { additionalProperties: true }),
),
cost: Type.Optional(
Type.Object(
{ input: Type.Optional(Type.Number()), output: Type.Optional(Type.Number()) },
{ additionalProperties: true },
),
),
},
{ additionalProperties: true },
)

const OpenCodeModelsResponseSchema = Type.Object({
data: Type.Optional(Type.Array(OpenCodeModelSchema)),
})

const opencodeAdapter: ProviderAdapter<ChatMessage[]> = {
label: 'OpenCode',
endpoint: OPENCODE_CHAT_ENDPOINT,

buildHeaders(req) {
return {
Authorization: `Bearer ${req.credentials.apiKey!}`,
'content-type': 'application/json',
}
},

mapHistory(req) {
return mapChatHistory(req.systemPrompt, req.messages)
},

buildRequestBody(messages, req) {
const body: Record<string, unknown> = {
model: req.modelId,
messages: messages.flat(),
stream: true,
stream_options: { include_usage: true },
}
if (req.tools.length > 0) {
body.tools = req.tools.map((t) => ({
type: 'function',
function: {
name: t.name,
description: t.description,
parameters: t.inputSchema,
},
}))
}
return body
},

buildToolResultMessage(results) {
return results.map((r) => ({
role: 'tool' as const,
tool_call_id: r.id,
content: toolOutputToString(r.output),
}))
},

createTurnTranslator() {
return new ChatCompletionsTurnTranslator()
},
}

export const opencodeDriver: AiProvider = {
id: 'opencode' as AiProviderId,
label: 'OpenCode',
supportedAuthModes: SUPPORTED_AUTH_MODES,

capabilities(_modelId: string) {
return DEFAULT_CAPABILITIES
},

async listModels(creds: AiResolvedCredential) {
if (creds.authMode !== 'apiKey' || !creds.apiKey) return []
return fetchOpenCodeModels(creds)
},

async *stream(req: AiStreamRequest): AsyncIterable<AiStreamEvent> {
if (req.credentials.authMode !== 'apiKey' || !req.credentials.apiKey) {
yield {
type: 'error',
message:
'OpenCode requires an API key. Add an API-key credential in /admin/ai/providers and pick it for the site default.',
}
return
}
yield* runToolLoop(opencodeAdapter, req)
},
}

async function fetchOpenCodeModels(creds: AiResolvedCredential): Promise<AiProviderModel[]> {
const res = await fetch(OPENCODE_MODELS_ENDPOINT, {
headers: { Authorization: `Bearer ${creds.apiKey!}` },
})
if (!res.ok) {
throw new Error(`[ai/opencode] models request failed: ${res.status} ${res.statusText}`)
}

const parsed = parseValue(OpenCodeModelsResponseSchema, await res.json())
return (parsed.data ?? []).map((model) => {
const caps = model.capabilities
return {
id: model.id,
label: model.name ?? model.id,
catalogueSource: 'live' as const,
capabilities: {
toolCalling: caps?.toolcall ?? true,
visionInput: caps?.input?.image ?? false,
promptCache: false,
streaming: true,
},
...(typeof model.cost?.input === 'number' && typeof model.cost?.output === 'number'
? { pricing: { inputPerMTok: model.cost.input, outputPerMTok: model.cost.output } }
: {}),
...(typeof model.limit?.context === 'number' ? { contextWindow: model.limit.context } : {}),
}
})
}

function toolOutputToString(output: AiToolOutput): string {
if (!output.ok) return output.error ?? 'Tool call failed.'
const text = JSON.stringify(output.data ?? { ok: true })
if (output.images && output.images.length > 0) {
return `${text}\n\n[${output.images.length} screenshot(s) omitted: this provider delivers tool results as text only.]`
}
return text
}
3 changes: 2 additions & 1 deletion server/ai/drivers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,8 @@ export interface AiProvider {
* anthropic → ['apiKey']
* openai → ['apiKey']
* openrouter → ['apiKey']
* ollama → ['baseUrl']
* opencode → ['apiKey']
* ollama → ['baseUrl']
*/
readonly supportedAuthModes: readonly AiAuthMode[]

Expand Down
1 change: 1 addition & 0 deletions server/ai/handlers/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const ProviderId = Type.Union([
Type.Literal('openai'),
Type.Literal('ollama'),
Type.Literal('openrouter'),
Type.Literal('opencode'),
])

const CreateBodySchema = Type.Union([
Expand Down
2 changes: 1 addition & 1 deletion server/ai/handlers/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { getModelCatalogue, pricingKey } from '../pricing'
import type { AiProviderModel } from '../drivers/types'
import type { AiProviderId } from '../runtime/types'

const VALID_PROVIDERS: AiProviderId[] = ['anthropic', 'openai', 'ollama', 'openrouter']
const VALID_PROVIDERS: AiProviderId[] = ['anthropic', 'openai', 'ollama', 'openrouter', 'opencode']

export function tryHandleAiModels(
req: Request,
Expand Down
6 changes: 3 additions & 3 deletions server/ai/runtime/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,12 @@ export type { AiContentBlock, AiToolImage, AiToolOutput } from '@core/ai'
// Provider identity + auth modes
// ---------------------------------------------------------------------------

export type AiProviderId = 'anthropic' | 'openai' | 'ollama' | 'openrouter'
export type AiProviderId = 'anthropic' | 'openai' | 'ollama' | 'openrouter' | 'opencode'
/**
* Credential auth modes.
*
* - `apiKey` — encrypted user-supplied key (Anthropic, OpenAI, OpenRouter).
* - `apiKey` — encrypted user-supplied key (Anthropic, OpenAI, OpenRouter,
* OpenCode Zen).
* - `baseUrl` — OpenAI-compatible local endpoint (Ollama). Optional
* bearer token may be stored alongside the URL.
*/
Expand Down Expand Up @@ -206,4 +207,3 @@ export interface AiBrowserBridge {
// Aggregated usage — drivers report token counts so the handler can persist
// per-message + per-conversation totals and compute cost from pricing.ts.
// ---------------------------------------------------------------------------

Loading