1 Commits

Author SHA1 Message Date
pftom 32f5f857d2 feat(providers): support OpenAI-compatible / Azure / Google Gemini endpoints (closes #16)
The hosted-API BYOK fallback was hard-wired to api.anthropic.com. Issue
#16 asks for Azure (Microsoft OpenAI hosting Anthropic-style models) but
the same plumbing unlocks every common BYOK target — OpenRouter,
LiteLLM, DeepSeek, Groq, Together, Mistral, plus Google Gemini direct.

Provider model:
- New `provider: 'anthropic' | 'openai' | 'azure' | 'google'` discriminator
  on AppConfig (defaults to 'anthropic' so existing localStorage
  configs migrate seamlessly).
- src/providers/model.ts routes to one of four streaming clients:
  anthropic.ts (existing SDK path), openai.ts (new SSE pump shared with
  azure.ts), azure.ts (deployment URL + api-key header), google.ts
  (Generative Language streamGenerateContent).
- src/providers/presets.ts ships per-provider defaults (baseUrl, model
  suggestions, api-key placeholder, Azure api-version flag) so the UI
  can stay declarative.

UI:
- SettingsDialog now shows a provider picker on the Hosted-API tab and
  surfaces an Azure-only api-version field. Provider switches preserve
  any non-empty user values.
- EntryView / AvatarMenu env meta line shows the active provider label.
- en + zh-CN locales updated; README + README.zh-CN document every
  provider, with explicit guidance to reach AWS Bedrock / GCP Vertex
  Anthropic models via a server-side LiteLLM proxy (signing belongs on
  the server, not the browser).

Why an OpenAI-compatible adapter rather than a native Bedrock/Vertex
client: AWS SigV4 and GCP service-account JWTs aren't safe to do from a
browser holding long-lived BYOK credentials. LiteLLM (or any
Anthropic/OpenAI-compatible proxy) sidesteps that and is the same path
lobe-chat uses for Bedrock/Vertex.
2026-04-29 07:44:00 +08:00
22 changed files with 665 additions and 739 deletions
+29 -7
View File
@@ -39,7 +39,7 @@ OD stands on four open-source shoulders:
| | What you get | | | What you get |
|---|---| |---|---|
| **Coding agents supported** | Claude Code · Codex CLI · Cursor Agent · Gemini CLI · OpenCode · Qwen Code · Anthropic API (BYOK fallback) | | **Coding agents supported** | Claude Code · Codex CLI · Cursor Agent · Gemini CLI · OpenCode · Qwen Code · Hosted-API BYOK fallback (Anthropic · OpenAI-compatible · Azure · Google Gemini, plus AWS Bedrock & GCP Vertex via proxy) |
| **Design systems built-in** | **71** — 2 hand-authored starters + 69 product systems (Linear, Stripe, Vercel, Airbnb, Tesla, Notion, Anthropic, Apple, Cursor, Supabase, Figma, …) imported from [`awesome-design-md`][acd2] | | **Design systems built-in** | **71** — 2 hand-authored starters + 69 product systems (Linear, Stripe, Vercel, Airbnb, Tesla, Notion, Anthropic, Apple, Cursor, Supabase, Figma, …) imported from [`awesome-design-md`][acd2] |
| **Skills built-in** | **19** — prototype, deck, mobile, dashboard, pricing, docs, blog, SaaS landing, plus 10 document/work-product templates (PM spec, weekly update, OKRs, runbook, kanban, …) | | **Skills built-in** | **19** — prototype, deck, mobile, dashboard, pricing, docs, blog, SaaS landing, plus 10 document/work-product templates (PM spec, weekly update, OKRs, runbook, kanban, …) |
| **Visual directions** | 5 curated schools (Editorial Monocle · Modern Minimal · Tech Utility · Brutalist · Soft Warm) — each ships a deterministic OKLch palette + font stack | | **Visual directions** | 5 curated schools (Editorial Monocle · Modern Minimal · Tech Utility · Brutalist · Soft Warm) — each ships a deterministic OKLch palette + font stack |
@@ -180,7 +180,7 @@ Adding a skill takes one folder. Read [`docs/skills-protocol.md`](docs/skills-pr
### 1 · We don't ship an agent. Yours is good enough. ### 1 · We don't ship an agent. Yours is good enough.
The daemon scans your `PATH` for [`claude`](https://docs.anthropic.com/en/docs/claude-code), [`codex`](https://github.com/openai/codex), [`cursor-agent`](https://www.cursor.com/cli), [`gemini`](https://github.com/google-gemini/gemini-cli), [`opencode`](https://opencode.ai/), and [`qwen`](https://github.com/QwenLM/qwen-code) on startup. Whichever it finds becomes the design engine — driven via stdio, with one adapter per CLI. Inspired by [`multica`](https://github.com/multica-ai/multica) and [`cc-switch`](https://github.com/farion1231/cc-switch). No CLI? `Anthropic API · BYOK` is the same pipeline minus the spawn. The daemon scans your `PATH` for [`claude`](https://docs.anthropic.com/en/docs/claude-code), [`codex`](https://github.com/openai/codex), [`cursor-agent`](https://www.cursor.com/cli), [`gemini`](https://github.com/google-gemini/gemini-cli), [`opencode`](https://opencode.ai/), and [`qwen`](https://github.com/QwenLM/qwen-code) on startup. Whichever it finds becomes the design engine — driven via stdio, with one adapter per CLI. Inspired by [`multica`](https://github.com/multica-ai/multica) and [`cc-switch`](https://github.com/farion1231/cc-switch). No CLI? The `Hosted API · BYOK` fallback streams directly from the browser to **Anthropic**, any **OpenAI-compatible** endpoint (OpenRouter / LiteLLM / DeepSeek / Groq / Together / Mistral …), **Azure OpenAI**, or **Google Gemini** — pick the provider in Settings, paste a key, go. AWS Bedrock and GCP Vertex Anthropic models work the same way through a server-side LiteLLM (or equivalent) proxy pointed at the `Anthropic` provider, since SigV4 / GCP JWT signing belongs on the server, not the browser.
### 2 · Skills are files, not plugins. ### 2 · Skills are files, not plugins.
@@ -227,8 +227,10 @@ Every layer is composable. Every layer is a file you can edit. Read [`src/prompt
│ /api/* (proxied in dev) │ direct (BYOK) │ /api/* (proxied in dev) │ direct (BYOK)
▼ ▼ ▼ ▼
┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐
│ Local daemon │ │ Anthropic SDK │ Local daemon │ │ Hosted-API router
│ (Express + SQLite) │ │ (browser fallback) │ (Express + SQLite) │ │ Anthropic · OpenAI-
│ │ │ compatible · Azure │
│ │ │ · Google Gemini │
│ │ └──────────────────────┘ │ │ └──────────────────────┘
│ /api/agents │ │ /api/agents │
│ /api/skills │ │ /api/skills │
@@ -268,7 +270,7 @@ The first load:
1. Detects which agent CLIs you have on `PATH` and picks one automatically. 1. Detects which agent CLIs you have on `PATH` and picks one automatically.
2. Loads 19 skills + 71 design systems. 2. Loads 19 skills + 71 design systems.
3. Pops the welcome dialog so you can paste an Anthropic key (only needed for the BYOK fallback path). 3. Pops the welcome dialog so you can pick a hosted-API provider — **Anthropic**, **OpenAI-compatible** (OpenRouter / LiteLLM / DeepSeek / Groq / Together / Mistral / OpenAI), **Azure OpenAI**, or **Google Gemini** — and paste the matching key (only needed for the BYOK fallback path; for AWS Bedrock or GCP Vertex Anthropic models, run a server-side LiteLLM proxy and point the `Anthropic` provider at it).
4. **Auto-creates `./.od/`** — the local runtime folder for the SQLite project DB, per-project artifacts, and saved renders. There is no `od init` step; the daemon `mkdir`s everything it needs on boot. 4. **Auto-creates `./.od/`** — the local runtime folder for the SQLite project DB, per-project artifacts, and saved renders. There is no `od init` step; the daemon `mkdir`s everything it needs on boot.
Type a prompt, hit **Send**, watch the question form arrive, fill it, watch the todo card stream, watch the artifact render. Click **Save to disk** or download as a project ZIP. Type a prompt, hit **Send**, watch the question form arrive, fill it, watch the todo card stream, watch the artifact render. Click **Save to disk** or download as a project ZIP.
@@ -334,7 +336,12 @@ open-design/
│ │ └── zip.ts ← project archive │ │ └── zip.ts ← project archive
│ ├── providers/ │ ├── providers/
│ │ ├── daemon.ts ← /api/chat SSE stream consumer │ │ ├── daemon.ts ← /api/chat SSE stream consumer
│ │ ├── anthropic.ts ← BYOK Anthropic SDK path │ │ ├── model.ts ← BYOK provider router (anthropic / openai / azure / google)
│ │ ├── anthropic.ts ← Anthropic SDK path (also covers any Anthropic-compatible proxy)
│ │ ├── openai.ts ← OpenAI-compatible SSE (OpenRouter / LiteLLM / DeepSeek / Groq / Together)
│ │ ├── azure.ts ← Azure OpenAI deployment URLs + api-key header
│ │ ├── google.ts ← Google Generative Language streamGenerateContent
│ │ ├── presets.ts ← per-provider defaults shown in Settings
│ │ └── registry.ts ← /api/agents, /api/skills, /api/design-systems │ │ └── registry.ts ← /api/agents, /api/skills, /api/design-systems
│ └── state/ ← config + projects (localStorage + daemon-backed) │ └── state/ ← config + projects (localStorage + daemon-backed)
@@ -499,10 +506,25 @@ Auto-detected from `PATH` on daemon boot. No config required.
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | line-buffered | `gemini -p` | | [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | line-buffered | `gemini -p` |
| [OpenCode](https://opencode.ai/) | `opencode` | line-buffered | `opencode run` | | [OpenCode](https://opencode.ai/) | `opencode` | line-buffered | `opencode run` |
| [Qwen Code](https://github.com/QwenLM/qwen-code) | `qwen` | line-buffered | `qwen -p` | | [Qwen Code](https://github.com/QwenLM/qwen-code) | `qwen` | line-buffered | `qwen -p` |
| Anthropic API · BYOK | n/a | SSE direct | Browser fallback when no CLI is on PATH | | Hosted API · BYOK | n/a | SSE direct | Browser fallback when no CLI is on PATH — pick any of the providers below |
Adding a new CLI is one entry in [`daemon/agents.js`](daemon/agents.js). Streaming format is one of `claude-stream-json` (typed events) or `plain` (raw text). Adding a new CLI is one entry in [`daemon/agents.js`](daemon/agents.js). Streaming format is one of `claude-stream-json` (typed events) or `plain` (raw text).
### Hosted-API providers (BYOK fallback)
When no CLI is detected, OD streams directly from the browser to a hosted endpoint. Pick one in **Settings → Hosted API**, paste a key, optionally tweak the base URL.
| Provider | Wire format | What it covers |
|---|---|---|
| **Anthropic** | `@anthropic-ai/sdk` | `api.anthropic.com`, plus any Anthropic-compatible proxy (LiteLLM, custom gateways, **AWS Bedrock** & **GCP Vertex** via a server-side proxy) |
| **OpenAI-compatible** | `/chat/completions` SSE | OpenAI proper, [OpenRouter](https://openrouter.ai), [LiteLLM proxy](https://docs.litellm.ai/), [DeepSeek](https://platform.deepseek.com/), [Groq](https://groq.com/), [Together](https://together.ai/), [Mistral](https://mistral.ai/), and any other OpenAI-shaped endpoint |
| **Azure OpenAI** | `/openai/deployments/<deployment>/chat/completions` SSE + `api-key` header | Azure-hosted OpenAI deployments. Base URL is the resource endpoint, Model is the deployment name, plus the Azure `api-version` |
| **Google Gemini** | `:streamGenerateContent?alt=sse` | Google Generative Language API direct (Gemini family) |
**On AWS Bedrock & GCP Vertex with Anthropic models:** Both require credential signing (SigV4 / GCP service-account JWT) which is unsafe to do from a browser with long-lived BYOK credentials. The recommended path is to run a server-side proxy ([LiteLLM](https://docs.litellm.ai/) works well — it speaks Anthropic-compatible *and* OpenAI-compatible) and point either the `Anthropic` or `OpenAI-compatible` provider at the proxy URL. The signing stays on the server where it belongs.
Adding a fifth wire format is mechanical: a row in `ModelProvider`, an entry in [`src/providers/presets.ts`](src/providers/presets.ts), a `stream<X>` function alongside [`anthropic.ts` / `openai.ts` / `azure.ts` / `google.ts`](src/providers/), one more `case` in [`src/providers/model.ts`](src/providers/model.ts).
## References & lineage ## References & lineage
Every external project this repo borrows from. Each link goes to the source so you can verify the provenance. Every external project this repo borrows from. Each link goes to the source so you can verify the provenance.
+29 -7
View File
@@ -39,7 +39,7 @@ OD 站在四个开源项目的肩膀上:
| | 你拿到的 | | | 你拿到的 |
|---|---| |---|---|
| **支持的 coding agent** | Claude Code · Codex CLI · Cursor Agent · Gemini CLI · OpenCode · Qwen Code · Anthropic APIBYOK 兜底 | | **支持的 coding agent** | Claude Code · Codex CLI · Cursor Agent · Gemini CLI · OpenCode · Qwen Code · 托管 API · BYOK 兜底(Anthropic · OpenAI 兼容 · Azure · Google GeminiAWS Bedrock 与 GCP Vertex 通过代理接入 |
| **内置 design system** | **71 套** —— 2 套手写起手 + 69 套从 [`awesome-design-md`][acd2] 导入的产品系统(Linear、Stripe、Vercel、Airbnb、Tesla、Notion、Anthropic、Apple、Cursor、Supabase、Figma…) | | **内置 design system** | **71 套** —— 2 套手写起手 + 69 套从 [`awesome-design-md`][acd2] 导入的产品系统(Linear、Stripe、Vercel、Airbnb、Tesla、Notion、Anthropic、Apple、Cursor、Supabase、Figma…) |
| **内置 skill** | **19 个** —— 原型 / deck / 移动端 / dashboard / pricing / docs / blog / SaaS landing,外加 10 个文档与办公产物模板(PM 规范、周报、OKR、runbook、看板…) | | **内置 skill** | **19 个** —— 原型 / deck / 移动端 / dashboard / pricing / docs / blog / SaaS landing,外加 10 个文档与办公产物模板(PM 规范、周报、OKR、runbook、看板…) |
| **视觉方向** | 5 套精选流派(Editorial Monocle · Modern Minimal · Tech Utility · Brutalist · Soft Warm),每一套自带 OKLch 色板 + 字体栈 | | **视觉方向** | 5 套精选流派(Editorial Monocle · Modern Minimal · Tech Utility · Brutalist · Soft Warm),每一套自带 OKLch 色板 + 字体栈 |
@@ -180,7 +180,7 @@ OD 站在四个开源项目的肩膀上:
### 1 · 我们不带 agent,你的就够好 ### 1 · 我们不带 agent,你的就够好
Daemon 启动时扫 `PATH`,找 [`claude`](https://docs.anthropic.com/en/docs/claude-code)、[`codex`](https://github.com/openai/codex)、[`cursor-agent`](https://www.cursor.com/cli)、[`gemini`](https://github.com/google-gemini/gemini-cli)、[`opencode`](https://opencode.ai/)、[`qwen`](https://github.com/QwenLM/qwen-code)。哪个在就用哪个 —— 通过 stdio 驱动,每个 CLI 一个 adapter。灵感来自 [`multica`](https://github.com/multica-ai/multica) 和 [`cc-switch`](https://github.com/farion1231/cc-switch)。一个 CLI 都没有?`Anthropic API · BYOK` 就是同一条管线减去 spawn。 Daemon 启动时扫 `PATH`,找 [`claude`](https://docs.anthropic.com/en/docs/claude-code)、[`codex`](https://github.com/openai/codex)、[`cursor-agent`](https://www.cursor.com/cli)、[`gemini`](https://github.com/google-gemini/gemini-cli)、[`opencode`](https://opencode.ai/)、[`qwen`](https://github.com/QwenLM/qwen-code)。哪个在就用哪个 —— 通过 stdio 驱动,每个 CLI 一个 adapter。灵感来自 [`multica`](https://github.com/multica-ai/multica) 和 [`cc-switch`](https://github.com/farion1231/cc-switch)。一个 CLI 都没有?`托管 API · BYOK` 就是同一条管线减去 spawn —— 浏览器直连 **Anthropic**、任意 **OpenAI 兼容**端点(OpenRouter / LiteLLM / DeepSeek / Groq / Together / Mistral …)、**Azure OpenAI**、或 **Google Gemini**。在 Settings 里选渠道、贴 Key、走起。AWS Bedrock 和 GCP Vertex 上的 Anthropic 模型按同样的方式接入:服务端跑一个 LiteLLM(或同类)代理,再把 `Anthropic` 渠道指向它 —— SigV4 / GCP JWT 签名应留在服务器,不放进浏览器
### 2 · Skill 是文件,不是插件 ### 2 · Skill 是文件,不是插件
@@ -227,8 +227,10 @@ DISCOVERY 指令 turn-1 表单、turn-2 品牌分支、TodoWrite、
│ /api/* dev 走代理) │ direct (BYOK) │ /api/* dev 走代理) │ direct (BYOK)
▼ ▼ ▼ ▼
┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐
│ 本地 daemon │ │ Anthropic SDK │ 本地 daemon │ │ 托管 API 路由器
Express + SQLite)│ │ (浏览器兜底) Express + SQLite)│ │ Anthropic · OpenAI
│ │ │ 兼容 · Azure · Google│
│ │ │ Gemini │
│ │ └──────────────────────┘ │ │ └──────────────────────┘
│ /api/agents │ │ /api/agents │
│ /api/skills │ │ /api/skills │
@@ -268,7 +270,7 @@ open http://localhost:5173
1. 检测你 `PATH` 上有哪些 agent CLI,自动选一个。 1. 检测你 `PATH` 上有哪些 agent CLI,自动选一个。
2. 加载 19 个 skill + 71 套 design system。 2. 加载 19 个 skill + 71 套 design system。
3. 弹欢迎对话框,让你贴 Anthropic key(仅 BYOK 兜底路径需要)。 3. 弹欢迎对话框,让你挑一个托管 API 渠道 —— **Anthropic**、**OpenAI 兼容**OpenRouter / LiteLLM / DeepSeek / Groq / Together / Mistral / OpenAI)、**Azure OpenAI** 或 **Google Gemini** —— 并贴上对应的 Key(仅 BYOK 兜底路径需要;要在浏览器里调 AWS Bedrock / GCP Vertex 上的 Anthropic 模型,建议在服务器跑一个 LiteLLM 代理,再把 `Anthropic` 渠道指向它)。
4. **自动创建 `./.od/`** —— 本地运行时目录,存放 SQLite 项目库、各项目工作区、保存下来的 artifact。**没有** `od init` 这一步,daemon 启动时会自己 `mkdir` 4. **自动创建 `./.od/`** —— 本地运行时目录,存放 SQLite 项目库、各项目工作区、保存下来的 artifact。**没有** `od init` 这一步,daemon 启动时会自己 `mkdir`
输入需求,回车,看 question form 跳出来,填,看 todo 卡片流动,看 artifact 渲染。点 **Save to disk** 或导出整个项目 ZIP。 输入需求,回车,看 question form 跳出来,填,看 todo 卡片流动,看 artifact 渲染。点 **Save to disk** 或导出整个项目 ZIP。
@@ -334,7 +336,12 @@ open-design/
│ │ └── zip.ts ← 项目打包 │ │ └── zip.ts ← 项目打包
│ ├── providers/ │ ├── providers/
│ │ ├── daemon.ts ← /api/chat SSE 流消费者 │ │ ├── daemon.ts ← /api/chat SSE 流消费者
│ │ ├── anthropic.ts ← BYOK Anthropic SDK 路径 │ │ ├── model.ts ← BYOK 渠道路由(anthropic / openai / azure / google
│ │ ├── anthropic.ts ← Anthropic SDK 路径(也涵盖任意 Anthropic 兼容代理)
│ │ ├── openai.ts ← OpenAI 兼容 SSEOpenRouter / LiteLLM / DeepSeek / Groq / Together
│ │ ├── azure.ts ← Azure OpenAI 部署 URL + api-key 头
│ │ ├── google.ts ← Google Generative Language streamGenerateContent
│ │ ├── presets.ts ← Settings 中各渠道的默认值
│ │ └── registry.ts ← /api/agents、/api/skills、/api/design-systems │ │ └── registry.ts ← /api/agents、/api/skills、/api/design-systems
│ └── state/ ← config + projectslocalStorage + daemon 持久化) │ └── state/ ← config + projectslocalStorage + daemon 持久化)
@@ -499,10 +506,25 @@ Daemon 启动时从 `PATH` 自动检测,无需配置。
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | line-buffered | `gemini -p` | | [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | line-buffered | `gemini -p` |
| [OpenCode](https://opencode.ai/) | `opencode` | line-buffered | `opencode run` | | [OpenCode](https://opencode.ai/) | `opencode` | line-buffered | `opencode run` |
| [Qwen Code](https://github.com/QwenLM/qwen-code) | `qwen` | line-buffered | `qwen -p` | | [Qwen Code](https://github.com/QwenLM/qwen-code) | `qwen` | line-buffered | `qwen -p` |
| Anthropic API · BYOK | n/a | SSE 直连 | 没装任何 CLI 时的浏览器兜底 | | 托管 API · BYOK | n/a | SSE 直连 | 没装任何 CLI 时的浏览器兜底 —— 在下方任意一个渠道里挑一个 |
加一个新 CLI = 在 [`daemon/agents.js`](daemon/agents.js) 里加一项。流式格式从 `claude-stream-json`(类型化事件)和 `plain`(原始文本)两种里选一个。 加一个新 CLI = 在 [`daemon/agents.js`](daemon/agents.js) 里加一项。流式格式从 `claude-stream-json`(类型化事件)和 `plain`(原始文本)两种里选一个。
### 托管 API 渠道(BYOK 兜底)
没检测到 CLI 时,OD 会从浏览器直连一个托管端点。在 **Settings → 托管 API** 里挑一个渠道,贴 Key,按需改 Base URL。
| 渠道 | 报文格式 | 覆盖范围 |
|---|---|---|
| **Anthropic** | `@anthropic-ai/sdk` | `api.anthropic.com`,以及任意 Anthropic 兼容代理(LiteLLM、自建网关、**AWS Bedrock** 与 **GCP Vertex** 通过服务端代理接入) |
| **OpenAI 兼容** | `/chat/completions` SSE | OpenAI 官方、[OpenRouter](https://openrouter.ai)、[LiteLLM 代理](https://docs.litellm.ai/)、[DeepSeek](https://platform.deepseek.com/)、[Groq](https://groq.com/)、[Together](https://together.ai/)、[Mistral](https://mistral.ai/),以及任意 OpenAI 形态的端点 |
| **Azure OpenAI** | `/openai/deployments/<deployment>/chat/completions` SSE + `api-key` 头 | Azure 托管的 OpenAI 部署。Base URL 是资源终结点,Model 是部署名,再加 Azure 的 `api-version` |
| **Google Gemini** | `:streamGenerateContent?alt=sse` | Google Generative Language API 直连(Gemini 系列) |
**关于 AWS Bedrock 与 GCP Vertex 上的 Anthropic 模型:**两者都需要凭证签名(SigV4 / GCP service-account JWT),用浏览器里长期存放的 BYOK 凭证去签是不安全的。推荐做法:在服务器端跑一个代理([LiteLLM](https://docs.litellm.ai/) 同时支持 Anthropic 兼容和 OpenAI 兼容),把 `Anthropic``OpenAI 兼容` 渠道的 Base URL 指向代理,签名留在服务器端。
加第五种报文格式很机械:`ModelProvider` 里加一行、[`src/providers/presets.ts`](src/providers/presets.ts) 里加一项、和 [`anthropic.ts` / `openai.ts` / `azure.ts` / `google.ts`](src/providers/) 一起放一个 `stream<X>` 函数、[`src/providers/model.ts`](src/providers/model.ts) 里再加一个 `case`
## 引用与师承 ## 引用与师承
每一个被借鉴的开源项目都列在这里。点链接可以验证师承。 每一个被借鉴的开源项目都列在这里。点链接可以验证师承。
+14 -255
View File
@@ -6,82 +6,25 @@ import path from 'node:path';
const execFileP = promisify(execFile); const execFileP = promisify(execFile);
// Per-agent model picker. // Each entry defines how to invoke the agent in non-interactive "one-shot" mode.
// // `buildArgs(prompt, imagePaths, extraAllowedDirs)` returns argv for the child
// - `listModels` : optional spec for fetching the model list from // process. `extraAllowedDirs` is a list of absolute directories the agent must
// the CLI itself ({ args, parse, timeoutMs }). // be permitted to read files from (skill seeds, design-system specs) that live
// When defined we run it during agent detection
// (best-effort, with a timeout) and use the
// result. If the listing fails we fall back to
// `fallbackModels` so the UI still has something
// to show.
// - `fallbackModels` : static hint list. Used as the source of truth
// for CLIs that don't expose a listing command
// (Claude Code, Codex, Gemini CLI, Qwen Code)
// and as the fallback for the others.
// - `reasoningOptions` : optional reasoning-effort presets (currently
// only Codex exposes this knob).
// - `buildArgs(prompt, imagePaths, extraAllowedDirs, options)` returns
// argv for the child process. `options = { model, reasoning }` carries
// whatever the user picked in the model menu — agents that don't take a
// model flag ignore them.
//
// Every model list is prefixed with a synthetic `'default'` entry meaning
// "let the CLI pick" — the agent runs with no `--model` flag, so the
// user's local CLI config wins.
//
// `extraAllowedDirs` is a list of absolute directories the agent must be
// permitted to read files from (skill seeds, design-system specs) that live
// outside the project cwd. Currently only Claude Code wires this through // outside the project cwd. Currently only Claude Code wires this through
// (`--add-dir`); other agents either inherit broader access or run with cwd // (`--add-dir`); other agents either inherit broader access or run with cwd
// boundaries we can't widen via flags. // boundaries we can't widen via flags.
//
// `streamFormat` hints to the daemon how to interpret stdout: // `streamFormat` hints to the daemon how to interpret stdout:
// - 'claude-stream-json' : line-delimited JSON emitted by Claude Code's // - 'claude-stream-json' : line-delimited JSON emitted by Claude Code's
// `--output-format stream-json`. Daemon parses it into typed events // `--output-format stream-json`. Daemon parses it into typed events
// (text / thinking / tool_use / tool_result / status) for the UI. // (text / thinking / tool_use / tool_result / status) for the UI.
// - 'plain' (default) : raw text, forwarded chunk-by-chunk. // - 'plain' (default) : raw text, forwarded chunk-by-chunk.
const DEFAULT_MODEL_OPTION = { id: 'default', label: 'Default (CLI config)' };
// Parse one-id-per-line stdout from `<cli> models` and prepend the synthetic
// default option. Used by opencode / cursor-agent.
function parseLineSeparatedModels(stdout) {
const ids = String(stdout || '')
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith('#'));
// De-dupe while preserving order — some CLIs print near-duplicates.
const seen = new Set();
const out = [DEFAULT_MODEL_OPTION];
for (const id of ids) {
if (seen.has(id)) continue;
seen.add(id);
out.push({ id, label: id });
}
return out;
}
export const AGENT_DEFS = [ export const AGENT_DEFS = [
{ {
id: 'claude', id: 'claude',
name: 'Claude Code', name: 'Claude Code',
bin: 'claude', bin: 'claude',
versionArgs: ['--version'], versionArgs: ['--version'],
// `claude` has no list-models subcommand; the CLI accepts both short buildArgs: (prompt, _imagePaths, extraAllowedDirs = []) => {
// aliases (sonnet/opus/haiku) and the full ids, so we ship both as
// hints. Users who want a non-shipped model can paste it via the
// Settings dialog's custom-model input.
fallbackModels: [
DEFAULT_MODEL_OPTION,
{ id: 'sonnet', label: 'Sonnet (alias)' },
{ id: 'opus', label: 'Opus (alias)' },
{ id: 'haiku', label: 'Haiku (alias)' },
{ id: 'claude-opus-4-5', label: 'claude-opus-4-5' },
{ id: 'claude-sonnet-4-5', label: 'claude-sonnet-4-5' },
{ id: 'claude-haiku-4-5', label: 'claude-haiku-4-5' },
],
buildArgs: (prompt, _imagePaths, extraAllowedDirs = [], options = {}) => {
const args = [ const args = [
'-p', '-p',
prompt, prompt,
@@ -90,9 +33,6 @@ export const AGENT_DEFS = [
'--verbose', '--verbose',
'--include-partial-messages', '--include-partial-messages',
]; ];
if (options.model && options.model !== 'default') {
args.push('--model', options.model);
}
const dirs = (extraAllowedDirs || []).filter( const dirs = (extraAllowedDirs || []).filter(
(d) => typeof d === 'string' && d.length > 0, (d) => typeof d === 'string' && d.length > 0,
); );
@@ -108,35 +48,7 @@ export const AGENT_DEFS = [
name: 'Codex CLI', name: 'Codex CLI',
bin: 'codex', bin: 'codex',
versionArgs: ['--version'], versionArgs: ['--version'],
// Codex doesn't have a `models` subcommand; ship the most common ids buildArgs: (prompt) => ['exec', prompt],
// as a hint. Users can supply other ids via the custom-model input.
fallbackModels: [
DEFAULT_MODEL_OPTION,
{ id: 'gpt-5-codex', label: 'gpt-5-codex' },
{ id: 'gpt-5', label: 'gpt-5' },
{ id: 'o3', label: 'o3' },
{ id: 'o4-mini', label: 'o4-mini' },
],
reasoningOptions: [
{ id: 'default', label: 'Default' },
{ id: 'minimal', label: 'Minimal' },
{ id: 'low', label: 'Low' },
{ id: 'medium', label: 'Medium' },
{ id: 'high', label: 'High' },
],
buildArgs: (prompt, _imagePaths, _extra, options = {}) => {
const args = ['exec'];
if (options.model && options.model !== 'default') {
args.push('--model', options.model);
}
if (options.reasoning && options.reasoning !== 'default') {
// Codex accepts `-c key=value` config overrides; reasoning effort
// is exposed as `model_reasoning_effort`.
args.push('-c', `model_reasoning_effort="${options.reasoning}"`);
}
args.push(prompt);
return args;
},
streamFormat: 'plain', streamFormat: 'plain',
}, },
{ {
@@ -144,19 +56,7 @@ export const AGENT_DEFS = [
name: 'Gemini CLI', name: 'Gemini CLI',
bin: 'gemini', bin: 'gemini',
versionArgs: ['--version'], versionArgs: ['--version'],
fallbackModels: [ buildArgs: (prompt) => ['-p', prompt],
DEFAULT_MODEL_OPTION,
{ id: 'gemini-2.5-pro', label: 'gemini-2.5-pro' },
{ id: 'gemini-2.5-flash', label: 'gemini-2.5-flash' },
],
buildArgs: (prompt, _imagePaths, _extra, options = {}) => {
const args = [];
if (options.model && options.model !== 'default') {
args.push('--model', options.model);
}
args.push('-p', prompt);
return args;
},
streamFormat: 'plain', streamFormat: 'plain',
}, },
{ {
@@ -164,26 +64,7 @@ export const AGENT_DEFS = [
name: 'OpenCode', name: 'OpenCode',
bin: 'opencode', bin: 'opencode',
versionArgs: ['--version'], versionArgs: ['--version'],
// `opencode models` prints `provider/model` per line. buildArgs: (prompt) => ['run', prompt],
listModels: {
args: ['models'],
parse: parseLineSeparatedModels,
timeoutMs: 8000,
},
fallbackModels: [
DEFAULT_MODEL_OPTION,
{ id: 'anthropic/claude-sonnet-4-5', label: 'anthropic/claude-sonnet-4-5' },
{ id: 'openai/gpt-5', label: 'openai/gpt-5' },
{ id: 'google/gemini-2.5-pro', label: 'google/gemini-2.5-pro' },
],
buildArgs: (prompt, _imagePaths, _extra, options = {}) => {
const args = ['run'];
if (options.model && options.model !== 'default') {
args.push('--model', options.model);
}
args.push(prompt);
return args;
},
streamFormat: 'plain', streamFormat: 'plain',
}, },
{ {
@@ -191,33 +72,7 @@ export const AGENT_DEFS = [
name: 'Cursor Agent', name: 'Cursor Agent',
bin: 'cursor-agent', bin: 'cursor-agent',
versionArgs: ['--version'], versionArgs: ['--version'],
// `cursor-agent models` prints account-bound model ids per line. When buildArgs: (prompt) => ['-p', prompt],
// the user isn't authed it prints "No models available for this
// account." — that's not a model list, so we detect it and fall back.
listModels: {
args: ['models'],
timeoutMs: 5000,
parse: (stdout) => {
const trimmed = String(stdout || '').trim();
if (!trimmed || /no models available/i.test(trimmed)) return null;
return parseLineSeparatedModels(trimmed);
},
},
fallbackModels: [
DEFAULT_MODEL_OPTION,
{ id: 'auto', label: 'auto' },
{ id: 'sonnet-4', label: 'sonnet-4' },
{ id: 'sonnet-4-thinking', label: 'sonnet-4-thinking' },
{ id: 'gpt-5', label: 'gpt-5' },
],
buildArgs: (prompt, _imagePaths, _extra, options = {}) => {
const args = [];
if (options.model && options.model !== 'default') {
args.push('--model', options.model);
}
args.push('-p', prompt);
return args;
},
streamFormat: 'plain', streamFormat: 'plain',
}, },
{ {
@@ -225,19 +80,7 @@ export const AGENT_DEFS = [
name: 'Qwen Code', name: 'Qwen Code',
bin: 'qwen', bin: 'qwen',
versionArgs: ['--version'], versionArgs: ['--version'],
fallbackModels: [ buildArgs: (prompt) => ['-p', prompt],
DEFAULT_MODEL_OPTION,
{ id: 'qwen3-coder-plus', label: 'qwen3-coder-plus' },
{ id: 'qwen3-coder-flash', label: 'qwen3-coder-flash' },
],
buildArgs: (prompt, _imagePaths, _extra, options = {}) => {
const args = [];
if (options.model && options.model !== 'default') {
args.push('--model', options.model);
}
args.push('-p', prompt);
return args;
},
streamFormat: 'plain', streamFormat: 'plain',
}, },
]; ];
@@ -257,36 +100,9 @@ function resolveOnPath(bin) {
return null; return null;
} }
async function fetchModels(def, resolvedBin) {
if (!def.listModels) return def.fallbackModels;
try {
const { stdout } = await execFileP(resolvedBin, def.listModels.args, {
timeout: def.listModels.timeoutMs ?? 5000,
// Models lists from popular CLIs (e.g. opencode) easily exceed the
// default 1MB buffer once you include every openrouter model. Bump
// it so we don't truncate the listing.
maxBuffer: 8 * 1024 * 1024,
});
const parsed = def.listModels.parse(stdout);
// Empty / null parse result means the CLI didn't actually return a
// usable list (e.g. cursor-agent's "No models available"); fall back
// to the static hint so the picker isn't stuck on Default-only.
if (!parsed || parsed.length === 0) return def.fallbackModels;
return parsed;
} catch {
return def.fallbackModels;
}
}
async function probe(def) { async function probe(def) {
const resolved = resolveOnPath(def.bin); const resolved = resolveOnPath(def.bin);
if (!resolved) { if (!resolved) return { ...stripFns(def), available: false };
return {
...stripFns(def),
models: def.fallbackModels ?? [DEFAULT_MODEL_OPTION],
available: false,
};
}
let version = null; let version = null;
try { try {
const { stdout } = await execFileP(resolved, def.versionArgs, { timeout: 3000 }); const { stdout } = await execFileP(resolved, def.versionArgs, { timeout: 3000 });
@@ -294,75 +110,18 @@ async function probe(def) {
} catch { } catch {
// binary exists but --version failed; still mark available // binary exists but --version failed; still mark available
} }
const models = await fetchModels(def, resolved); return { ...stripFns(def), available: true, path: resolved, version };
return {
...stripFns(def),
models,
available: true,
path: resolved,
version,
};
} }
function stripFns(def) { function stripFns(def) {
// Drop the buildArgs / listModels closures but keep declarative metadata const { buildArgs, ...rest } = def;
// (reasoningOptions, streamFormat, name, bin, etc.). `models` is
// populated separately by `fetchModels`, so we strip the static
// `fallbackModels` slot here too.
const { buildArgs, listModels, fallbackModels, ...rest } = def;
return rest; return rest;
} }
export async function detectAgents() { export async function detectAgents() {
const results = await Promise.all(AGENT_DEFS.map(probe)); return Promise.all(AGENT_DEFS.map(probe));
// Refresh the validation cache from whatever we just surfaced to the UI
// so /api/chat can accept any model the user could have just picked,
// including ones that only showed up after a CLI re-auth.
for (const agent of results) {
rememberLiveModels(agent.id, agent.models);
}
return results;
} }
export function getAgentDef(id) { export function getAgentDef(id) {
return AGENT_DEFS.find((a) => a.id === id) || null; return AGENT_DEFS.find((a) => a.id === id) || null;
} }
// Daemon's /api/chat needs to validate the user's model pick against the
// list we last surfaced to the UI. We keep a per-agent cache of the most
// recent live list (refreshed every detectAgents() call) and additionally
// trust any value present in the static fallback. A model that's neither
// gets rejected so a stale or hostile value can't smuggle arbitrary flags.
const liveModelCache = new Map();
export function rememberLiveModels(agentId, models) {
if (!Array.isArray(models)) return;
liveModelCache.set(
agentId,
new Set(models.map((m) => m && m.id).filter((id) => typeof id === 'string')),
);
}
export function isKnownModel(def, modelId) {
if (!modelId) return false;
const live = liveModelCache.get(def.id);
if (live && live.has(modelId)) return true;
if (Array.isArray(def.fallbackModels)) {
return def.fallbackModels.some((m) => m.id === modelId);
}
return false;
}
// Permit user-typed model ids that didn't appear in either the live
// listing or the static fallback (e.g. the user is on a brand-new model
// the CLI's `models` command hasn't surfaced yet). The CLI gets the value
// as a child-process arg — not a shell string — so injection isn't a
// concern, but we still reject anything that could be misread as a flag
// by a downstream CLI or that contains whitespace / control chars.
export function sanitizeCustomModel(id) {
if (typeof id !== 'string') return null;
const trimmed = id.trim();
if (trimmed.length === 0 || trimmed.length > 200) return null;
if (!/^[A-Za-z0-9][A-Za-z0-9._/:@-]*$/.test(trimmed)) return null;
return trimmed;
}
+2 -27
View File
@@ -6,12 +6,7 @@ import { fileURLToPath } from 'node:url';
import path from 'node:path'; import path from 'node:path';
import fs from 'node:fs'; import fs from 'node:fs';
import os from 'node:os'; import os from 'node:os';
import { import { detectAgents, getAgentDef } from './agents.js';
detectAgents,
getAgentDef,
isKnownModel,
sanitizeCustomModel,
} from './agents.js';
import { listSkills } from './skills.js'; import { listSkills } from './skills.js';
import { listDesignSystems, readDesignSystem } from './design-systems.js'; import { listDesignSystems, readDesignSystem } from './design-systems.js';
import { createClaudeStreamHandler } from './claude-stream.js'; import { createClaudeStreamHandler } from './claude-stream.js';
@@ -695,8 +690,6 @@ export async function startServer({ port = 7456 } = {}) {
imagePaths = [], imagePaths = [],
projectId, projectId,
attachments = [], attachments = [],
model,
reasoning,
} = req.body || {}; } = req.body || {};
const def = getAgentDef(agentId); const def = getAgentDef(agentId);
if (!def) return res.status(400).json({ error: `unknown agent: ${agentId}` }); if (!def) return res.status(400).json({ error: `unknown agent: ${agentId}` });
@@ -786,23 +779,7 @@ export async function startServer({ port = 7456 } = {}) {
const extraAllowedDirs = [SKILLS_DIR, DESIGN_SYSTEMS_DIR].filter( const extraAllowedDirs = [SKILLS_DIR, DESIGN_SYSTEMS_DIR].filter(
(d) => fs.existsSync(d), (d) => fs.existsSync(d),
); );
// Per-agent model + reasoning the user picked in the model menu. const args = def.buildArgs(composed, safeImages, extraAllowedDirs);
// Trust the value when it matches the most recent /api/agents listing
// (live or fallback). Otherwise allow it through if it passes a
// permissive sanitizer — that's the path for user-typed custom model
// ids the CLI's listing didn't surface yet.
const safeModel =
typeof model === 'string'
? isKnownModel(def, model)
? model
: sanitizeCustomModel(model)
: null;
const safeReasoning =
typeof reasoning === 'string' && Array.isArray(def.reasoningOptions)
? def.reasoningOptions.find((r) => r.id === reasoning)?.id ?? null
: null;
const agentOptions = { model: safeModel, reasoning: safeReasoning };
const args = def.buildArgs(composed, safeImages, extraAllowedDirs, agentOptions);
res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache, no-transform'); res.setHeader('Cache-Control', 'no-cache, no-transform');
@@ -821,8 +798,6 @@ export async function startServer({ port = 7456 } = {}) {
streamFormat: def.streamFormat ?? 'plain', streamFormat: def.streamFormat ?? 'plain',
projectId: typeof projectId === 'string' ? projectId : null, projectId: typeof projectId === 'string' ? projectId : null,
cwd, cwd,
model: safeModel,
reasoning: safeReasoning,
}); });
let child; let child;
-13
View File
@@ -137,18 +137,6 @@ export function App() {
[config], [config],
); );
const handleAgentModelChange = useCallback(
(agentId: string, choice: { model?: string; reasoning?: string }) => {
const prev = config.agentModels?.[agentId] ?? {};
const merged = { ...prev, ...choice };
const nextAgentModels = { ...(config.agentModels ?? {}), [agentId]: merged };
const next = { ...config, agentModels: nextAgentModels };
saveConfig(next);
setConfig(next);
},
[config],
);
const handleChangeDefaultDesignSystem = useCallback( const handleChangeDefaultDesignSystem = useCallback(
(designSystemId: string) => { (designSystemId: string) => {
const next = { ...config, designSystemId }; const next = { ...config, designSystemId };
@@ -284,7 +272,6 @@ export function App() {
daemonLive={daemonLive} daemonLive={daemonLive}
onModeChange={handleModeChange} onModeChange={handleModeChange}
onAgentChange={handleAgentChange} onAgentChange={handleAgentChange}
onAgentModelChange={handleAgentModelChange}
onRefreshAgents={refreshAgents} onRefreshAgents={refreshAgents}
onOpenSettings={openSettings} onOpenSettings={openSettings}
onBack={handleBack} onBack={handleBack}
+17 -90
View File
@@ -1,8 +1,8 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useT } from '../i18n'; import { useT } from '../i18n';
import { providerLabel } from '../providers/presets';
import { AgentIcon } from './AgentIcon'; import { AgentIcon } from './AgentIcon';
import { Icon } from './Icon'; import { Icon } from './Icon';
import { renderModelOptions } from './modelOptions';
import type { AgentInfo, AppConfig, ExecMode } from '../types'; import type { AgentInfo, AppConfig, ExecMode } from '../types';
interface Props { interface Props {
@@ -11,10 +11,6 @@ interface Props {
daemonLive: boolean; daemonLive: boolean;
onModeChange: (mode: ExecMode) => void; onModeChange: (mode: ExecMode) => void;
onAgentChange: (id: string) => void; onAgentChange: (id: string) => void;
onAgentModelChange: (
id: string,
choice: { model?: string; reasoning?: string },
) => void;
onOpenSettings: () => void; onOpenSettings: () => void;
onRefreshAgents: () => void; onRefreshAgents: () => void;
onBack?: () => void; onBack?: () => void;
@@ -31,7 +27,6 @@ export function AvatarMenu({
daemonLive, daemonLive,
onModeChange, onModeChange,
onAgentChange, onAgentChange,
onAgentModelChange,
onOpenSettings, onOpenSettings,
onRefreshAgents, onRefreshAgents,
onBack, onBack,
@@ -64,19 +59,6 @@ export function AvatarMenu({
const installedAgents = agents.filter((a) => a.available); const installedAgents = agents.filter((a) => a.available);
// Resolve the user's model + reasoning pick for the active agent. Falls
// back to the agent's first declared option (`'default'`) when the user
// hasn't touched the picker yet so the labels don't read as empty.
const currentChoice =
(config.agentId && config.agentModels?.[config.agentId]) || {};
const currentModelId =
currentChoice.model ?? currentAgent?.models?.[0]?.id ?? null;
const currentReasoningId =
currentChoice.reasoning ?? currentAgent?.reasoningOptions?.[0]?.id ?? null;
const currentModelLabel = currentAgent?.models?.find(
(m) => m.id === currentModelId,
)?.label;
return ( return (
<div className="avatar-menu" ref={wrapRef}> <div className="avatar-menu" ref={wrapRef}>
<button <button
@@ -101,13 +83,13 @@ export function AvatarMenu({
<span className="who"> <span className="who">
{config.mode === 'daemon' {config.mode === 'daemon'
? t('avatar.localCli') ? t('avatar.localCli')
: t('avatar.anthropicApi')} : providerLabel(config.provider)}
</span> </span>
<span className="where"> <span className="where">
{config.mode === 'api' {config.mode === 'api'
? safeHost(config.baseUrl) ? `${config.model}${config.baseUrl ? ` · ${safeHost(config.baseUrl)}` : ''}`
: currentAgent : currentAgent
? `${currentAgent.name}${currentAgent.version ? ` · ${currentAgent.version}` : ''}${currentModelLabel && currentModelId !== 'default' ? ` · ${currentModelLabel}` : ''}` ? `${currentAgent.name}${currentAgent.version ? ` · ${currentAgent.version}` : ''}`
: t('avatar.noAgentSelected')} : t('avatar.noAgentSelected')}
</span> </span>
</div> </div>
@@ -152,7 +134,18 @@ export function AvatarMenu({
{config.mode === 'daemon' && installedAgents.length > 0 ? ( {config.mode === 'daemon' && installedAgents.length > 0 ? (
<> <>
<div className="avatar-section-label">{t('avatar.codeAgent')}</div> <div
style={{
fontSize: 10.5,
textTransform: 'uppercase',
letterSpacing: '0.06em',
color: 'var(--text-faint)',
fontWeight: 600,
padding: '8px 10px 4px',
}}
>
{t('avatar.codeAgent')}
</div>
{installedAgents.map((a) => ( {installedAgents.map((a) => (
<button <button
type="button" type="button"
@@ -160,8 +153,7 @@ export function AvatarMenu({
className="avatar-item" className="avatar-item"
onClick={() => { onClick={() => {
onAgentChange(a.id); onAgentChange(a.id);
// Keep the popover open so the user can immediately setOpen(false);
// pick a model for the agent they just chose.
}} }}
> >
<AgentIcon id={a.id} size={18} /> <AgentIcon id={a.id} size={18} />
@@ -175,71 +167,6 @@ export function AvatarMenu({
) : null} ) : null}
</button> </button>
))} ))}
{currentAgent &&
currentAgent.available &&
((currentAgent.models && currentAgent.models.length > 0) ||
(currentAgent.reasoningOptions &&
currentAgent.reasoningOptions.length > 0)) ? (
<div className="avatar-model-section">
<div className="avatar-section-label">
{t('avatar.modelSection')}
</div>
{currentAgent.models && currentAgent.models.length > 0 ? (
<label className="avatar-select-row">
<span className="avatar-select-label">
{t('avatar.modelLabel')}
</span>
<select
className="avatar-select"
value={currentModelId ?? ''}
onChange={(e) =>
onAgentModelChange(currentAgent.id, {
model: e.target.value,
})
}
>
{renderModelOptions(currentAgent.models)}
{/* When the user has typed a custom id in
Settings, surface it here too so the dropdown
actually shows the active selection rather
than collapsing to "Default". */}
{currentModelId &&
!currentAgent.models.some(
(m) => m.id === currentModelId,
) ? (
<option value={currentModelId}>
{currentModelId}{' '}
{t('avatar.customSuffix')}
</option>
) : null}
</select>
</label>
) : null}
{currentAgent.reasoningOptions &&
currentAgent.reasoningOptions.length > 0 ? (
<label className="avatar-select-row">
<span className="avatar-select-label">
{t('avatar.reasoningLabel')}
</span>
<select
className="avatar-select"
value={currentReasoningId ?? ''}
onChange={(e) =>
onAgentModelChange(currentAgent.id, {
reasoning: e.target.value,
})
}
>
{currentAgent.reasoningOptions.map((r) => (
<option key={r.id} value={r.id}>
{r.label}
</option>
))}
</select>
</label>
) : null}
</div>
) : null}
<button <button
type="button" type="button"
className="avatar-item" className="avatar-item"
+5 -3
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useT } from '../i18n'; import { useT } from '../i18n';
import { providerLabel } from '../providers/presets';
import type { import type {
AgentInfo, AgentInfo,
AppConfig, AppConfig,
@@ -82,16 +83,17 @@ export function EntryView({
const envMetaLine = useMemo(() => { const envMetaLine = useMemo(() => {
if (config.mode === 'api') { if (config.mode === 'api') {
const provider = providerLabel(config.provider);
try { try {
return `${config.model} · ${new URL(config.baseUrl).host}`; return `${provider} · ${config.model} · ${new URL(config.baseUrl).host}`;
} catch { } catch {
return config.model; return `${provider} · ${config.model}`;
} }
} }
return currentAgent return currentAgent
? `${currentAgent.name}${currentAgent.version ? ` · ${currentAgent.version}` : ''}` ? `${currentAgent.name}${currentAgent.version ? ` · ${currentAgent.version}` : ''}`
: t('settings.noAgentSelected'); : t('settings.noAgentSelected');
}, [config.mode, config.model, config.baseUrl, currentAgent, t]); }, [config.mode, config.model, config.baseUrl, config.provider, currentAgent, t]);
// 'Use this prompt' on an example card is a fast path — skip the form and // 'Use this prompt' on an example card is a fast path — skip the form and
// create the project immediately with sane defaults derived from the skill, // create the project immediately with sane defaults derived from the skill,
+2 -11
View File
@@ -1,8 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createArtifactParser } from '../artifacts/parser'; import { createArtifactParser } from '../artifacts/parser';
import { useT } from '../i18n'; import { useT } from '../i18n';
import { streamMessage } from '../providers/anthropic';
import { streamViaDaemon } from '../providers/daemon'; import { streamViaDaemon } from '../providers/daemon';
import { streamModel } from '../providers/model';
import { import {
fetchDesignSystem, fetchDesignSystem,
fetchProjectFiles, fetchProjectFiles,
@@ -53,10 +53,6 @@ interface Props {
daemonLive: boolean; daemonLive: boolean;
onModeChange: (mode: AppConfig['mode']) => void; onModeChange: (mode: AppConfig['mode']) => void;
onAgentChange: (id: string) => void; onAgentChange: (id: string) => void;
onAgentModelChange: (
id: string,
choice: { model?: string; reasoning?: string },
) => void;
onRefreshAgents: () => void; onRefreshAgents: () => void;
onOpenSettings: () => void; onOpenSettings: () => void;
onBack: () => void; onBack: () => void;
@@ -76,7 +72,6 @@ export function ProjectView({
daemonLive, daemonLive,
onModeChange, onModeChange,
onAgentChange, onAgentChange,
onAgentModelChange,
onRefreshAgents, onRefreshAgents,
onOpenSettings, onOpenSettings,
onBack, onBack,
@@ -495,7 +490,6 @@ export function ProjectView({
handlers.onError(new Error('Pick a local agent first (top bar).')); handlers.onError(new Error('Pick a local agent first (top bar).'));
return; return;
} }
const choice = config.agentModels?.[config.agentId];
void streamViaDaemon({ void streamViaDaemon({
agentId: config.agentId, agentId: config.agentId,
history: nextHistory, history: nextHistory,
@@ -504,12 +498,10 @@ export function ProjectView({
handlers, handlers,
projectId: project.id, projectId: project.id,
attachments: attachments.map((a) => a.path), attachments: attachments.map((a) => a.path),
model: choice?.model ?? null,
reasoning: choice?.reasoning ?? null,
}); });
} else { } else {
pushEvent({ kind: 'status', label: 'requesting', detail: config.model }); pushEvent({ kind: 'status', label: 'requesting', detail: config.model });
void streamMessage(config, systemPrompt, nextHistory, controller.signal, { void streamModel(config, systemPrompt, nextHistory, controller.signal, {
onDelta: (delta) => { onDelta: (delta) => {
handlers.onDelta(delta); handlers.onDelta(delta);
handlers.onAgentEvent({ kind: 'text', text: delta }); handlers.onAgentEvent({ kind: 'text', text: delta });
@@ -736,7 +728,6 @@ export function ProjectView({
daemonLive={daemonLive} daemonLive={daemonLive}
onModeChange={onModeChange} onModeChange={onModeChange}
onAgentChange={onAgentChange} onAgentChange={onAgentChange}
onAgentModelChange={onAgentModelChange}
onOpenSettings={onOpenSettings} onOpenSettings={onOpenSettings}
onRefreshAgents={onRefreshAgents} onRefreshAgents={onRefreshAgents}
onBack={onBack} onBack={onBack}
+78 -118
View File
@@ -1,13 +1,9 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { LOCALE_LABEL, LOCALES, useI18n } from '../i18n'; import { LOCALE_LABEL, LOCALES, useI18n } from '../i18n';
import type { Locale } from '../i18n'; import type { Locale } from '../i18n';
import { PROVIDER_ORDER, PROVIDER_PRESETS } from '../providers/presets';
import { AgentIcon } from './AgentIcon'; import { AgentIcon } from './AgentIcon';
import { import type { AgentInfo, AppConfig, ExecMode, ModelProvider } from '../types';
CUSTOM_MODEL_SENTINEL,
isCustomModel,
renderModelOptions,
} from './modelOptions';
import type { AgentInfo, AppConfig, ExecMode } from '../types';
interface Props { interface Props {
initial: AppConfig; initial: AppConfig;
@@ -19,12 +15,6 @@ interface Props {
onRefreshAgents: () => void; onRefreshAgents: () => void;
} }
const SUGGESTED_MODELS = [
'claude-opus-4-5',
'claude-sonnet-4-5',
'claude-haiku-4-5',
];
export function SettingsDialog({ export function SettingsDialog({
initial, initial,
agents, agents,
@@ -53,10 +43,38 @@ export function SettingsDialog({
const setMode = (mode: ExecMode) => setCfg((c) => ({ ...c, mode })); const setMode = (mode: ExecMode) => setCfg((c) => ({ ...c, mode }));
// Switching providers swaps in that provider's defaults, but preserves
// any non-empty values the user already typed — they may have a custom
// baseUrl (e.g. an OpenRouter URL while staying on the openai provider)
// they don't want clobbered. Empty fields fall back to the preset.
const setProvider = (provider: ModelProvider) => {
setCfg((c) => {
if (c.provider === provider) return c;
const preset = PROVIDER_PRESETS[provider];
return {
...c,
provider,
baseUrl: c.baseUrl?.trim() ? c.baseUrl : preset.baseUrl,
model: c.model?.trim() ? c.model : preset.defaultModel,
};
});
};
const activePreset = PROVIDER_PRESETS[cfg.provider];
const canSave = const canSave =
cfg.mode === 'daemon' cfg.mode === 'daemon'
? Boolean(cfg.agentId && agents.find((a) => a.id === cfg.agentId)?.available) ? Boolean(cfg.agentId && agents.find((a) => a.id === cfg.agentId)?.available)
: Boolean(cfg.apiKey.trim() && cfg.model.trim() && cfg.baseUrl.trim()); : Boolean(
cfg.apiKey.trim() &&
cfg.model.trim() &&
// Azure has no global default base URL — require the user to
// paste their resource endpoint. Other providers ship a usable
// default so a blank field falls back to the preset.
(cfg.provider === 'azure'
? cfg.baseUrl.trim().length > 0
: true),
);
return ( return (
<div className="modal-backdrop" onClick={onClose}> <div className="modal-backdrop" onClick={onClose}>
@@ -188,120 +206,45 @@ export function SettingsDialog({
})} })}
</div> </div>
)} )}
{(() => {
const selected = agents.find(
(a) => a.id === cfg.agentId && a.available,
);
if (!selected) return null;
const hasModels =
Array.isArray(selected.models) && selected.models.length > 0;
const hasReasoning =
Array.isArray(selected.reasoningOptions) &&
selected.reasoningOptions.length > 0;
if (!hasModels && !hasReasoning) return null;
const choice = cfg.agentModels?.[selected.id] ?? {};
const setChoice = (
next: { model?: string; reasoning?: string },
) => {
setCfg((c) => {
const prev = c.agentModels?.[selected.id] ?? {};
return {
...c,
agentModels: {
...(c.agentModels ?? {}),
[selected.id]: { ...prev, ...next },
},
};
});
};
const modelValue =
choice.model ?? selected.models?.[0]?.id ?? '';
const reasoningValue =
choice.reasoning ??
selected.reasoningOptions?.[0]?.id ?? '';
const customActive =
hasModels && isCustomModel(modelValue, selected.models!);
const selectValue = customActive
? CUSTOM_MODEL_SENTINEL
: modelValue;
return (
<div className="agent-model-row">
{hasModels ? (
<label className="field">
<span className="field-label">
{t('settings.modelPicker')}
</span>
<select
value={selectValue}
onChange={(e) => {
if (e.target.value === CUSTOM_MODEL_SENTINEL) {
// Switching to "Custom…" should clear the
// value so the input below opens empty for
// typing — keeping the previous live id
// would defeat the point.
setChoice({ model: '' });
} else {
setChoice({ model: e.target.value });
}
}}
>
{renderModelOptions(selected.models!)}
<option value={CUSTOM_MODEL_SENTINEL}>
{t('settings.modelCustom')}
</option>
</select>
</label>
) : null}
{customActive ? (
<label className="field">
<span className="field-label">
{t('settings.modelCustomLabel')}
</span>
<input
type="text"
value={modelValue}
placeholder={t('settings.modelCustomPlaceholder')}
onChange={(e) =>
setChoice({ model: e.target.value.trim() })
}
/>
</label>
) : null}
{hasReasoning ? (
<label className="field">
<span className="field-label">
{t('settings.reasoningPicker')}
</span>
<select
value={reasoningValue}
onChange={(e) =>
setChoice({ reasoning: e.target.value })
}
>
{selected.reasoningOptions!.map((r) => (
<option key={r.id} value={r.id}>
{r.label}
</option>
))}
</select>
</label>
) : null}
<p className="hint">{t('settings.modelPickerHint')}</p>
</div>
);
})()}
</section> </section>
) : ( ) : (
<section className="settings-section"> <section className="settings-section">
<div className="section-head"> <div className="section-head">
<h3>{t('settings.apiSection')}</h3> <div>
<h3>{t('settings.apiSection')}</h3>
<p className="hint">{t('settings.providerHint')}</p>
</div>
</div>
<div
className="seg-control"
role="tablist"
aria-label={t('settings.providerLabel')}
>
{PROVIDER_ORDER.map((id) => {
const preset = PROVIDER_PRESETS[id];
const active = cfg.provider === id;
return (
<button
key={id}
type="button"
role="tab"
aria-selected={active}
className={'seg-btn' + (active ? ' active' : '')}
onClick={() => setProvider(id)}
title={preset.blurb}
>
<span className="seg-title">{preset.label}</span>
<span className="seg-meta">{preset.blurb}</span>
</button>
);
})}
</div> </div>
<label className="field"> <label className="field">
<span className="field-label">{t('settings.apiKey')}</span> <span className="field-label">{t('settings.apiKey')}</span>
<div className="field-row"> <div className="field-row">
<input <input
type={showApiKey ? 'text' : 'password'} type={showApiKey ? 'text' : 'password'}
placeholder="sk-ant-..." placeholder={activePreset.apiKeyPlaceholder}
value={cfg.apiKey} value={cfg.apiKey}
onChange={(e) => setCfg({ ...cfg, apiKey: e.target.value })} onChange={(e) => setCfg({ ...cfg, apiKey: e.target.value })}
autoFocus autoFocus
@@ -324,10 +267,11 @@ export function SettingsDialog({
type="text" type="text"
value={cfg.model} value={cfg.model}
list="suggested-models" list="suggested-models"
placeholder={activePreset.defaultModel}
onChange={(e) => setCfg({ ...cfg, model: e.target.value })} onChange={(e) => setCfg({ ...cfg, model: e.target.value })}
/> />
<datalist id="suggested-models"> <datalist id="suggested-models">
{SUGGESTED_MODELS.map((m) => ( {activePreset.modelSuggestions.map((m) => (
<option value={m} key={m} /> <option value={m} key={m} />
))} ))}
</datalist> </datalist>
@@ -337,10 +281,26 @@ export function SettingsDialog({
<input <input
type="text" type="text"
value={cfg.baseUrl} value={cfg.baseUrl}
placeholder={activePreset.baseUrl || 'https://...'}
onChange={(e) => setCfg({ ...cfg, baseUrl: e.target.value })} onChange={(e) => setCfg({ ...cfg, baseUrl: e.target.value })}
/> />
</label> </label>
{activePreset.needsApiVersion ? (
<label className="field">
<span className="field-label">{t('settings.apiVersion')}</span>
<input
type="text"
value={cfg.apiVersion ?? ''}
placeholder="2024-08-01-preview"
onChange={(e) =>
setCfg({ ...cfg, apiVersion: e.target.value })
}
/>
<span className="hint">{t('settings.apiVersionHint')}</span>
</label>
) : null}
<p className="hint">{t('settings.apiHint')}</p> <p className="hint">{t('settings.apiHint')}</p>
<p className="hint">{t('settings.proxyHint')}</p>
</section> </section>
)} )}
-71
View File
@@ -1,71 +0,0 @@
import type { AgentModelOption } from '../types';
// Render the `<option>` children for a model `<select>`. When the list
// contains `provider/model` ids (opencode's listing has hundreds), we
// group them under `<optgroup>` so the dropdown is navigable. Flat lists
// (Claude, Codex, Gemini, Qwen) are emitted as plain options.
//
// `'default'` is always pinned first (no group), so the user can return
// to "let the CLI decide" with one click.
export function renderModelOptions(models: AgentModelOption[]) {
const groups = new Map<string, AgentModelOption[]>();
const flat: AgentModelOption[] = [];
for (const m of models) {
const slash = m.id.indexOf('/');
if (m.id === 'default' || slash <= 0) {
flat.push(m);
continue;
}
const provider = m.id.slice(0, slash);
const arr = groups.get(provider) ?? [];
arr.push(m);
groups.set(provider, arr);
}
if (groups.size === 0) {
return (
<>
{flat.map((m) => (
<option key={m.id} value={m.id}>
{m.label}
</option>
))}
</>
);
}
return (
<>
{flat.map((m) => (
<option key={m.id} value={m.id}>
{m.label}
</option>
))}
{Array.from(groups.entries()).map(([provider, items]) => (
<optgroup key={provider} label={provider}>
{items.map((m) => (
<option key={m.id} value={m.id}>
{/* Strip the redundant `provider/` prefix from the label
inside its own optgroup; keep it in the value so the
CLI sees the fully-qualified id. */}
{m.label.startsWith(`${provider}/`)
? m.label.slice(provider.length + 1)
: m.label}
</option>
))}
</optgroup>
))}
</>
);
}
// True when the picked model id isn't one of the listed options — i.e.
// the user has typed a custom id and we should keep the custom input
// visible / the dropdown showing "Custom…".
export function isCustomModel(
modelId: string | null | undefined,
models: AgentModelOption[],
): boolean {
if (!modelId) return false;
return !models.some((m) => m.id === modelId);
}
export const CUSTOM_MODEL_SENTINEL = '__custom__';
+16 -19
View File
@@ -49,15 +49,15 @@ export const en: Dict = {
'settings.kicker': 'Settings', 'settings.kicker': 'Settings',
'settings.title': 'Execution & model', 'settings.title': 'Execution & model',
'settings.subtitle': 'settings.subtitle':
'Choose between a local code-agent CLI and the Anthropic API (BYOK). Your API key is stored only in this browser.', 'Choose between a local code-agent CLI and a hosted model provider (BYOK). Anthropic, OpenAI-compatible (OpenRouter / LiteLLM / DeepSeek / Groq …), Azure OpenAI, and Google Gemini are supported. Your API key is stored only in this browser.',
'settings.modeAria': 'Execution mode', 'settings.modeAria': 'Execution mode',
'settings.modeDaemon': 'Local CLI', 'settings.modeDaemon': 'Local CLI',
'settings.modeDaemonHelp': 'Run via a code-agent CLI on your machine', 'settings.modeDaemonHelp': 'Run via a code-agent CLI on your machine',
'settings.modeDaemonOffline': 'Daemon is not running', 'settings.modeDaemonOffline': 'Daemon is not running',
'settings.modeDaemonOfflineMeta': 'daemon offline', 'settings.modeDaemonOfflineMeta': 'daemon offline',
'settings.modeDaemonInstalledMeta': '{count} installed', 'settings.modeDaemonInstalledMeta': '{count} installed',
'settings.modeApi': 'Anthropic API', 'settings.modeApi': 'Hosted API',
'settings.modeApiMeta': 'BYOK', 'settings.modeApiMeta': 'BYOK · multi-provider',
'settings.codeAgent': 'Code agent', 'settings.codeAgent': 'Code agent',
'settings.codeAgentHint': 'settings.codeAgentHint':
'Detected by scanning your PATH. Pick the CLI you want generations to flow through.', 'Detected by scanning your PATH. Pick the CLI you want generations to flow through.',
@@ -65,7 +65,7 @@ export const en: Dict = {
'settings.rescanTitle': 'Re-scan PATH', 'settings.rescanTitle': 'Re-scan PATH',
'settings.noAgentsDetected': 'settings.noAgentsDetected':
'No agents detected yet. Install one of Claude Code, Codex, Gemini CLI, OpenCode, Cursor Agent, or Qwen, then click Rescan.', 'No agents detected yet. Install one of Claude Code, Codex, Gemini CLI, OpenCode, Cursor Agent, or Qwen, then click Rescan.',
'settings.apiSection': 'Anthropic API', 'settings.apiSection': 'Model endpoint',
'settings.apiKey': 'API key', 'settings.apiKey': 'API key',
'settings.showKey': 'Show key', 'settings.showKey': 'Show key',
'settings.hideKey': 'Hide key', 'settings.hideKey': 'Hide key',
@@ -75,21 +75,22 @@ export const en: Dict = {
'settings.baseUrl': 'Base URL', 'settings.baseUrl': 'Base URL',
'settings.apiHint': 'settings.apiHint':
'Calls go directly from this browser to the base URL you set. No proxy. The key never leaves localStorage.', 'Calls go directly from this browser to the base URL you set. No proxy. The key never leaves localStorage.',
'settings.providerLabel': 'Provider',
'settings.providerHint':
'Pick the wire format. Anthropic also covers any Anthropic-compatible proxy. OpenAI-compatible covers OpenRouter, LiteLLM, DeepSeek, Groq, Together, etc.',
'settings.apiVersion': 'API version',
'settings.apiVersionHint':
'Azure REST api-version (e.g. 2024-08-01-preview). Leave blank to use the default.',
'settings.proxyHint':
'Tip: For AWS Bedrock or Google Vertex with Anthropic models, run a server-side proxy (LiteLLM works well) and point the Anthropic provider at it — credential signing belongs on the server, not the browser.',
'settings.skipForNow': 'Skip for now', 'settings.skipForNow': 'Skip for now',
'settings.getStarted': 'Get started', 'settings.getStarted': 'Get started',
'settings.envConfigure': 'Configure execution mode', 'settings.envConfigure': 'Configure execution mode',
'settings.localCli': 'Local CLI', 'settings.localCli': 'Local CLI',
'settings.anthropicApi': 'Anthropic API', 'settings.anthropicApi': 'Hosted API',
'settings.noAgentSelected': 'no agent selected', 'settings.noAgentSelected': 'no agent selected',
'settings.language': 'Language', 'settings.language': 'Language',
'settings.languageHint': 'Switch the interface language. Saved to this browser.', 'settings.languageHint': 'Switch the interface language. Saved to this browser.',
'settings.modelPicker': 'Model',
'settings.reasoningPicker': 'Reasoning effort',
'settings.modelPickerHint':
'Fetched from the CLI when it exposes a `models` command. "Default" leaves the choice to the CLIs own config; "Custom…" lets you type any model id the CLI accepts.',
'settings.modelCustom': 'Custom (type below)…',
'settings.modelCustomLabel': 'Custom model id',
'settings.modelCustomPlaceholder': 'e.g. anthropic/claude-sonnet-4-6',
'entry.tabDesigns': 'Designs', 'entry.tabDesigns': 'Designs',
'entry.tabExamples': 'Examples', 'entry.tabExamples': 'Examples',
@@ -207,9 +208,9 @@ export const en: Dict = {
'avatar.title': 'Account & settings', 'avatar.title': 'Account & settings',
'avatar.localCli': 'Local CLI', 'avatar.localCli': 'Local CLI',
'avatar.anthropicApi': 'Anthropic API', 'avatar.anthropicApi': 'Hosted API',
'avatar.useLocal': 'Use Local CLI', 'avatar.useLocal': 'Use Local CLI',
'avatar.useApi': 'Use Anthropic API', 'avatar.useApi': 'Use hosted API',
'avatar.codeAgent': 'Code agent', 'avatar.codeAgent': 'Code agent',
'avatar.rescan': 'Rescan PATH', 'avatar.rescan': 'Rescan PATH',
'avatar.settings': 'Settings', 'avatar.settings': 'Settings',
@@ -218,10 +219,6 @@ export const en: Dict = {
'avatar.metaOffline': 'offline', 'avatar.metaOffline': 'offline',
'avatar.metaSelected': 'selected', 'avatar.metaSelected': 'selected',
'avatar.noAgentSelected': 'no agent selected', 'avatar.noAgentSelected': 'no agent selected',
'avatar.modelSection': 'Model',
'avatar.modelLabel': 'Model',
'avatar.reasoningLabel': 'Reasoning',
'avatar.customSuffix': '(custom)',
'project.backToProjects': 'Back to projects', 'project.backToProjects': 'Back to projects',
'project.metaFreeform': 'freeform', 'project.metaFreeform': 'freeform',
@@ -422,7 +419,7 @@ export const en: Dict = {
'agentPicker.modeChoose': 'Choose execution mode', 'agentPicker.modeChoose': 'Choose execution mode',
'agentPicker.localCli': 'Local CLI', 'agentPicker.localCli': 'Local CLI',
'agentPicker.daemonOff': 'daemon off', 'agentPicker.daemonOff': 'daemon off',
'agentPicker.byok': 'Anthropic API · BYOK', 'agentPicker.byok': 'Hosted API · BYOK',
'agentPicker.selectAgent': 'Select a detected code-agent CLI', 'agentPicker.selectAgent': 'Select a detected code-agent CLI',
'agentPicker.noAgents': 'no agents on PATH', 'agentPicker.noAgents': 'no agents on PATH',
'agentPicker.notInstalled': 'not installed', 'agentPicker.notInstalled': 'not installed',
+16 -19
View File
@@ -49,22 +49,22 @@ export const zhCN: Dict = {
'settings.kicker': '设置', 'settings.kicker': '设置',
'settings.title': '执行模式与模型', 'settings.title': '执行模式与模型',
'settings.subtitle': 'settings.subtitle':
'在本机的代码代理 CLI 与 Anthropic API(自带 Key)之间切换。API Key 只保存在当前浏览器中。', '在本机的代码代理 CLI 与托管模型 API(自带 Key)之间切换。支持 Anthropic、OpenAI 兼容(OpenRouter / LiteLLM / DeepSeek / Groq 等)、Azure OpenAI 与 Google Gemini。API Key 只保存在当前浏览器中。',
'settings.modeAria': '执行模式', 'settings.modeAria': '执行模式',
'settings.modeDaemon': '本机 CLI', 'settings.modeDaemon': '本机 CLI',
'settings.modeDaemonHelp': '通过本机的代码代理 CLI 执行', 'settings.modeDaemonHelp': '通过本机的代码代理 CLI 执行',
'settings.modeDaemonOffline': '后台守护进程未运行', 'settings.modeDaemonOffline': '后台守护进程未运行',
'settings.modeDaemonOfflineMeta': '守护进程未运行', 'settings.modeDaemonOfflineMeta': '守护进程未运行',
'settings.modeDaemonInstalledMeta': '已安装 {count} 个', 'settings.modeDaemonInstalledMeta': '已安装 {count} 个',
'settings.modeApi': 'Anthropic API', 'settings.modeApi': '托管 API',
'settings.modeApiMeta': '自带 Key', 'settings.modeApiMeta': '自带 Key · 多渠道',
'settings.codeAgent': '代码代理', 'settings.codeAgent': '代码代理',
'settings.codeAgentHint': '通过扫描 PATH 自动检测,选择你希望使用的 CLI。', 'settings.codeAgentHint': '通过扫描 PATH 自动检测,选择你希望使用的 CLI。',
'settings.rescan': '↻ 重新扫描', 'settings.rescan': '↻ 重新扫描',
'settings.rescanTitle': '重新扫描 PATH', 'settings.rescanTitle': '重新扫描 PATH',
'settings.noAgentsDetected': 'settings.noAgentsDetected':
'尚未检测到任何代理。请安装 Claude Code、Codex、Gemini CLI、OpenCode、Cursor Agent 或 Qwen 中的一个,然后点击「重新扫描」。', '尚未检测到任何代理。请安装 Claude Code、Codex、Gemini CLI、OpenCode、Cursor Agent 或 Qwen 中的一个,然后点击「重新扫描」。',
'settings.apiSection': 'Anthropic API', 'settings.apiSection': '模型端点',
'settings.apiKey': 'API Key', 'settings.apiKey': 'API Key',
'settings.showKey': '显示 Key', 'settings.showKey': '显示 Key',
'settings.hideKey': '隐藏 Key', 'settings.hideKey': '隐藏 Key',
@@ -74,21 +74,22 @@ export const zhCN: Dict = {
'settings.baseUrl': 'Base URL', 'settings.baseUrl': 'Base URL',
'settings.apiHint': 'settings.apiHint':
'请求会从当前浏览器直连你设置的 Base URL,无中转代理。Key 只存放在 localStorage。', '请求会从当前浏览器直连你设置的 Base URL,无中转代理。Key 只存放在 localStorage。',
'settings.providerLabel': '渠道',
'settings.providerHint':
'选择请求格式。Anthropic 也涵盖任意 Anthropic 兼容代理;OpenAI 兼容涵盖 OpenRouter、LiteLLM、DeepSeek、Groq、Together 等。',
'settings.apiVersion': 'API version',
'settings.apiVersionHint':
'Azure 的 api-version 查询参数(如 2024-08-01-preview)。留空则使用默认值。',
'settings.proxyHint':
'提示:若要在浏览器里使用 AWS Bedrock 或 Google Vertex 上的 Anthropic 模型,建议在服务器端跑一个 LiteLLM 代理,再把 Anthropic 渠道指向它 —— 凭证签名应留在服务器,不放进浏览器。',
'settings.skipForNow': '暂时跳过', 'settings.skipForNow': '暂时跳过',
'settings.getStarted': '开始使用', 'settings.getStarted': '开始使用',
'settings.envConfigure': '配置执行模式', 'settings.envConfigure': '配置执行模式',
'settings.localCli': '本机 CLI', 'settings.localCli': '本机 CLI',
'settings.anthropicApi': 'Anthropic API', 'settings.anthropicApi': '托管 API',
'settings.noAgentSelected': '尚未选择代理', 'settings.noAgentSelected': '尚未选择代理',
'settings.language': '界面语言', 'settings.language': '界面语言',
'settings.languageHint': '切换界面语言,设置仅保存在当前浏览器。', 'settings.languageHint': '切换界面语言,设置仅保存在当前浏览器。',
'settings.modelPicker': '模型',
'settings.reasoningPicker': '推理强度',
'settings.modelPickerHint':
'当 CLI 提供 `models` 命令时会自动拉取。选择「默认」则沿用 CLI 自身的配置;选择「自定义」可手动输入任何 CLI 支持的模型 id。',
'settings.modelCustom': '自定义(在下方填写)…',
'settings.modelCustomLabel': '自定义模型 id',
'settings.modelCustomPlaceholder': '例如 anthropic/claude-sonnet-4-6',
'entry.tabDesigns': '我的设计', 'entry.tabDesigns': '我的设计',
'entry.tabExamples': '示例', 'entry.tabExamples': '示例',
@@ -204,9 +205,9 @@ export const zhCN: Dict = {
'avatar.title': '账户与设置', 'avatar.title': '账户与设置',
'avatar.localCli': '本机 CLI', 'avatar.localCli': '本机 CLI',
'avatar.anthropicApi': 'Anthropic API', 'avatar.anthropicApi': '托管 API',
'avatar.useLocal': '使用本机 CLI', 'avatar.useLocal': '使用本机 CLI',
'avatar.useApi': '使用 Anthropic API', 'avatar.useApi': '使用托管 API',
'avatar.codeAgent': '代码代理', 'avatar.codeAgent': '代码代理',
'avatar.rescan': '重新扫描 PATH', 'avatar.rescan': '重新扫描 PATH',
'avatar.settings': '设置', 'avatar.settings': '设置',
@@ -215,10 +216,6 @@ export const zhCN: Dict = {
'avatar.metaOffline': '未运行', 'avatar.metaOffline': '未运行',
'avatar.metaSelected': '已选', 'avatar.metaSelected': '已选',
'avatar.noAgentSelected': '尚未选择代理', 'avatar.noAgentSelected': '尚未选择代理',
'avatar.modelSection': '模型',
'avatar.modelLabel': '模型',
'avatar.reasoningLabel': '推理',
'avatar.customSuffix': '(自定义)',
'project.backToProjects': '返回项目列表', 'project.backToProjects': '返回项目列表',
'project.metaFreeform': '自由设计', 'project.metaFreeform': '自由设计',
@@ -411,7 +408,7 @@ export const zhCN: Dict = {
'agentPicker.modeChoose': '选择执行模式', 'agentPicker.modeChoose': '选择执行模式',
'agentPicker.localCli': '本机 CLI', 'agentPicker.localCli': '本机 CLI',
'agentPicker.daemonOff': '守护进程未运行', 'agentPicker.daemonOff': '守护进程未运行',
'agentPicker.byok': 'Anthropic API · 自带 Key', 'agentPicker.byok': '托管 API · 自带 Key',
'agentPicker.selectAgent': '选择已检测到的代码代理 CLI', 'agentPicker.selectAgent': '选择已检测到的代码代理 CLI',
'agentPicker.noAgents': 'PATH 中未发现代理', 'agentPicker.noAgents': 'PATH 中未发现代理',
'agentPicker.notInstalled': '未安装', 'agentPicker.notInstalled': '未安装',
+5 -10
View File
@@ -85,6 +85,11 @@ export interface Dict {
'settings.model': string; 'settings.model': string;
'settings.baseUrl': string; 'settings.baseUrl': string;
'settings.apiHint': string; 'settings.apiHint': string;
'settings.providerLabel': string;
'settings.providerHint': string;
'settings.apiVersion': string;
'settings.apiVersionHint': string;
'settings.proxyHint': string;
'settings.skipForNow': string; 'settings.skipForNow': string;
'settings.getStarted': string; 'settings.getStarted': string;
'settings.envConfigure': string; 'settings.envConfigure': string;
@@ -93,12 +98,6 @@ export interface Dict {
'settings.noAgentSelected': string; 'settings.noAgentSelected': string;
'settings.language': string; 'settings.language': string;
'settings.languageHint': string; 'settings.languageHint': string;
'settings.modelPicker': string;
'settings.reasoningPicker': string;
'settings.modelPickerHint': string;
'settings.modelCustom': string;
'settings.modelCustomLabel': string;
'settings.modelCustomPlaceholder': string;
// Entry view / tabs // Entry view / tabs
'entry.tabDesigns': string; 'entry.tabDesigns': string;
@@ -230,10 +229,6 @@ export interface Dict {
'avatar.metaOffline': string; 'avatar.metaOffline': string;
'avatar.metaSelected': string; 'avatar.metaSelected': string;
'avatar.noAgentSelected': string; 'avatar.noAgentSelected': string;
'avatar.modelSection': string;
'avatar.modelLabel': string;
'avatar.reasoningLabel': string;
'avatar.customSuffix': string;
// Project view / chat pane / composer // Project view / chat pane / composer
'project.backToProjects': string; 'project.backToProjects': string;
-56
View File
@@ -293,45 +293,6 @@ code {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
white-space: nowrap; white-space: nowrap;
} }
.avatar-section-label {
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-faint);
font-weight: 600;
padding: 8px 10px 4px;
}
.avatar-model-section {
padding: 2px 10px 6px;
display: flex;
flex-direction: column;
gap: 6px;
border-top: 1px dashed var(--border-soft);
margin-top: 4px;
}
.avatar-select-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text-muted);
}
.avatar-select-label {
flex-shrink: 0;
min-width: 64px;
}
.avatar-select {
flex: 1;
min-width: 0;
font-size: 12px;
padding: 4px 6px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-panel);
color: var(--text);
cursor: pointer;
}
.avatar-select:focus { outline: 2px solid var(--accent-soft, var(--border-strong)); }
/* Environment pill — only used in entry view header now */ /* Environment pill — only used in entry view header now */
.env-pill { .env-pill {
@@ -866,23 +827,6 @@ code {
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
} }
.agent-card-meta .muted { color: var(--text-soft); font-style: italic; } .agent-card-meta .muted { color: var(--text-soft); font-style: italic; }
.agent-model-row {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
border: 1px solid var(--border-soft);
border-radius: var(--radius-sm);
background: var(--bg-subtle);
}
.agent-model-row .field { gap: 4px; }
.agent-model-row .field-label {
font-size: 11.5px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
}
.agent-model-row .hint { margin: 0; font-size: 11.5px; }
.status-dot { .status-dot {
width: 8px; height: 8px; width: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
+56
View File
@@ -0,0 +1,56 @@
/**
* Azure OpenAI streaming client. Wire format is OpenAI's (chat.completions
* SSE), but the URL embeds the deployment name and an api-version query
* string, and auth uses the `api-key` header rather than `Authorization:
* Bearer`. We reuse streamChatCompletions() from openai.ts for the SSE
* pump and only diverge on URL + headers.
*/
import type { AppConfig, ChatMessage } from '../types';
import type { StreamHandlers } from './anthropic';
import { streamChatCompletions } from './openai';
const DEFAULT_API_VERSION = '2024-08-01-preview';
export async function streamAzure(
cfg: AppConfig,
system: string,
history: ChatMessage[],
signal: AbortSignal,
handlers: StreamHandlers,
): Promise<void> {
if (!cfg.apiKey) {
handlers.onError(new Error('Missing Azure key — open Settings and paste one in.'));
return;
}
if (!cfg.baseUrl) {
handlers.onError(
new Error('Missing Azure endpoint — set Base URL to https://<resource>.openai.azure.com.'),
);
return;
}
if (!cfg.model) {
handlers.onError(
new Error('Missing Azure deployment — set Model to your deployment name.'),
);
return;
}
const apiVersion = (cfg.apiVersion?.trim() || DEFAULT_API_VERSION);
const url = buildAzureUrl(cfg.baseUrl, cfg.model, apiVersion);
const body = {
stream: true,
max_tokens: 8192,
messages: [
...(system ? [{ role: 'system', content: system }] : []),
...history.map((m) => ({ role: m.role, content: m.content })),
],
};
await streamChatCompletions(url, cfg.apiKey, body, signal, handlers, 'azure');
}
function buildAzureUrl(baseUrl: string, deployment: string, apiVersion: string): string {
const base = baseUrl.replace(/\/+$/, '');
return `${base}/openai/deployments/${encodeURIComponent(deployment)}/chat/completions?api-version=${encodeURIComponent(apiVersion)}`;
}
-9
View File
@@ -30,11 +30,6 @@ export interface DaemonStreamOptions {
// daemon resolves them inside the project folder, validates they // daemon resolves them inside the project folder, validates they
// exist, and stitches them into the user message as `@<path>` hints. // exist, and stitches them into the user message as `@<path>` hints.
attachments?: string[]; attachments?: string[];
// Per-CLI model + reasoning the user picked in the model menu. Both are
// optional; the daemon validates them against the agent's declared
// options and falls back to the CLI default when missing.
model?: string | null;
reasoning?: string | null;
} }
export async function streamViaDaemon({ export async function streamViaDaemon({
@@ -45,8 +40,6 @@ export async function streamViaDaemon({
handlers, handlers,
projectId, projectId,
attachments, attachments,
model,
reasoning,
}: DaemonStreamOptions): Promise<void> { }: DaemonStreamOptions): Promise<void> {
// Local CLIs are single-turn print-mode programs, so we collapse the whole // Local CLIs are single-turn print-mode programs, so we collapse the whole
// chat into one string. If this becomes too noisy for long histories, the // chat into one string. If this becomes too noisy for long histories, the
@@ -60,8 +53,6 @@ export async function streamViaDaemon({
message: transcript, message: transcript,
projectId: projectId ?? null, projectId: projectId ?? null,
attachments: attachments ?? [], attachments: attachments ?? [],
model: model ?? null,
reasoning: reasoning ?? null,
}); });
let acc = ''; let acc = '';
+105
View File
@@ -0,0 +1,105 @@
/**
* Google Generative Language API streaming client (Gemini direct). The
* REST surface is at generativelanguage.googleapis.com and accepts an
* api key in the query string. We hit `:streamGenerateContent` with
* `alt=sse` so the response arrives as a server-sent event stream we
* can pump like the OpenAI one.
*/
import type { AppConfig, ChatMessage } from '../types';
import type { StreamHandlers } from './anthropic';
export async function streamGoogle(
cfg: AppConfig,
system: string,
history: ChatMessage[],
signal: AbortSignal,
handlers: StreamHandlers,
): Promise<void> {
if (!cfg.apiKey) {
handlers.onError(new Error('Missing API key — open Settings and paste one in.'));
return;
}
if (!cfg.model) {
handlers.onError(new Error('Missing model — set one in Settings.'));
return;
}
const base = (cfg.baseUrl || 'https://generativelanguage.googleapis.com').replace(/\/+$/, '');
const url = `${base}/v1beta/models/${encodeURIComponent(cfg.model)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(cfg.apiKey)}`;
const contents = history.map((m) => ({
role: m.role === 'assistant' ? 'model' : 'user',
parts: [{ text: m.content }],
}));
const body: Record<string, unknown> = { contents };
if (system) {
body.systemInstruction = { role: 'system', parts: [{ text: system }] };
}
let acc = '';
try {
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal,
});
if (!resp.ok || !resp.body) {
const text = await resp.text().catch(() => '');
handlers.onError(new Error(`upstream ${resp.status}: ${text || 'no body'}`));
return;
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
let idx: number;
while ((idx = buf.indexOf('\n\n')) !== -1) {
const frame = buf.slice(0, idx).trim();
buf = buf.slice(idx + 2);
if (!frame) continue;
for (const line of frame.split('\n')) {
if (!line.startsWith('data:')) continue;
const payload = line.slice(5).trim();
if (!payload) continue;
let parsed: unknown;
try {
parsed = JSON.parse(payload);
} catch {
continue;
}
const delta = extractGeminiText(parsed);
if (delta) {
acc += delta;
handlers.onDelta(delta);
}
}
}
}
handlers.onDone(acc);
} catch (err) {
if ((err as Error).name === 'AbortError') return;
handlers.onError(err instanceof Error ? err : new Error(String(err)));
}
}
function extractGeminiText(payload: unknown): string {
if (!payload || typeof payload !== 'object') return '';
const candidates = (payload as { candidates?: unknown }).candidates;
if (!Array.isArray(candidates) || candidates.length === 0) return '';
const first = candidates[0] as { content?: { parts?: Array<{ text?: unknown }> } };
const parts = first?.content?.parts;
if (!Array.isArray(parts)) return '';
let out = '';
for (const p of parts) {
if (typeof p?.text === 'string') out += p.text;
}
return out;
}
+32
View File
@@ -0,0 +1,32 @@
/**
* BYOK model router. Picks a streaming client based on cfg.provider so
* the rest of the app can stay provider-agnostic. Adding a fifth provider
* later means: add an entry to ModelProvider, add a presets row, add a
* `stream<X>` function, and one more `case` here.
*/
import type { AppConfig, ChatMessage } from '../types';
import type { StreamHandlers } from './anthropic';
import { streamMessage as streamAnthropic } from './anthropic';
import { streamAzure } from './azure';
import { streamGoogle } from './google';
import { streamOpenAI } from './openai';
export async function streamModel(
cfg: AppConfig,
system: string,
history: ChatMessage[],
signal: AbortSignal,
handlers: StreamHandlers,
): Promise<void> {
switch (cfg.provider) {
case 'openai':
return streamOpenAI(cfg, system, history, signal, handlers);
case 'azure':
return streamAzure(cfg, system, history, signal, handlers);
case 'google':
return streamGoogle(cfg, system, history, signal, handlers);
case 'anthropic':
default:
return streamAnthropic(cfg, system, history, signal, handlers);
}
}
+135
View File
@@ -0,0 +1,135 @@
/**
* OpenAI-compatible streaming client. Covers any endpoint that speaks the
* `/chat/completions` SSE wire format OpenAI proper, OpenRouter,
* LiteLLM proxy, DeepSeek, Groq, Together, Mistral. Azure has its own
* URL shape and lives in azure.ts.
*
* Browser fetch is fine here for the same BYOK reason streamMessage()
* uses dangerouslyAllowBrowser: this is a local-first tool, the key is
* the user's, it never leaves their machine. Move to a server proxy if
* you ever ship a hosted build.
*/
import type { AppConfig, ChatMessage } from '../types';
import type { StreamHandlers } from './anthropic';
export async function streamOpenAI(
cfg: AppConfig,
system: string,
history: ChatMessage[],
signal: AbortSignal,
handlers: StreamHandlers,
): Promise<void> {
if (!cfg.apiKey) {
handlers.onError(new Error('Missing API key — open Settings and paste one in.'));
return;
}
if (!cfg.baseUrl) {
handlers.onError(new Error('Missing base URL — open Settings and set one.'));
return;
}
const url = joinUrl(cfg.baseUrl, '/chat/completions');
const body = {
model: cfg.model,
stream: true,
max_tokens: 8192,
messages: [
...(system ? [{ role: 'system', content: system }] : []),
...history.map((m) => ({ role: m.role, content: m.content })),
],
};
await streamChatCompletions(url, cfg.apiKey, body, signal, handlers, 'bearer');
}
// Shared SSE pump between the OpenAI and Azure clients — they only differ
// in URL shape and auth header.
export async function streamChatCompletions(
url: string,
apiKey: string,
body: Record<string, unknown>,
signal: AbortSignal,
handlers: StreamHandlers,
auth: 'bearer' | 'azure',
): Promise<void> {
let acc = '';
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (auth === 'bearer') headers['Authorization'] = `Bearer ${apiKey}`;
else headers['api-key'] = apiKey;
const resp = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal,
});
if (!resp.ok || !resp.body) {
const text = await resp.text().catch(() => '');
handlers.onError(new Error(`upstream ${resp.status}: ${text || 'no body'}`));
return;
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
// Frames are separated by a blank line. Split on \n\n; the trailing
// partial frame stays in buf for the next iteration.
let idx: number;
while ((idx = buf.indexOf('\n\n')) !== -1) {
const frame = buf.slice(0, idx).trim();
buf = buf.slice(idx + 2);
if (!frame) continue;
// Each frame is one or more `data: ...` lines plus optional
// `event:` / comments. We only care about `data:` payloads.
for (const line of frame.split('\n')) {
if (!line.startsWith('data:')) continue;
const payload = line.slice(5).trim();
if (!payload || payload === '[DONE]') continue;
let parsed: unknown;
try {
parsed = JSON.parse(payload);
} catch {
continue;
}
const delta = extractDelta(parsed);
if (delta) {
acc += delta;
handlers.onDelta(delta);
}
}
}
}
handlers.onDone(acc);
} catch (err) {
if ((err as Error).name === 'AbortError') return;
handlers.onError(err instanceof Error ? err : new Error(String(err)));
}
}
function extractDelta(payload: unknown): string {
if (!payload || typeof payload !== 'object') return '';
const choices = (payload as { choices?: unknown }).choices;
if (!Array.isArray(choices) || choices.length === 0) return '';
const first = choices[0] as { delta?: { content?: unknown }; text?: unknown };
if (first?.delta && typeof first.delta.content === 'string') {
return first.delta.content;
}
// Some legacy / completion-style proxies emit `text` instead of delta.
if (typeof first?.text === 'string') return first.text;
return '';
}
function joinUrl(base: string, path: string): string {
const b = base.replace(/\/+$/, '');
const p = path.startsWith('/') ? path : `/${path}`;
return `${b}${p}`;
}
+108
View File
@@ -0,0 +1,108 @@
/**
* Provider presets the BYOK side of the app supports four wire formats
* (Anthropic-native, OpenAI-compatible, Azure OpenAI, Google Generative
* Language). Each one ships with a default base URL and a short list of
* suggested model ids so the SettingsDialog datalist gives the user a
* head-start. The presets stay deliberately conservative: a user pointing
* an `openai` provider at LiteLLM / OpenRouter / DeepSeek just types a
* different baseUrl + model, no code change required.
*
* AWS Bedrock and Google Vertex aren't first-class providers here. Both
* require credential signing (SigV4 for AWS, GCP service-account JWT for
* Vertex) which is unsafe to do from the browser with long-lived BYOK
* credentials. The recommended path is to run LiteLLM (or a similar
* proxy) server-side and point the `anthropic` or `openai` provider at
* that proxy's URL the provider chooser surfaces this guidance.
*/
import type { ModelProvider } from '../types';
export interface ProviderPreset {
id: ModelProvider;
// Display name shown in the chooser and the env meta line.
label: string;
// Short marketing-style line shown under the provider card.
blurb: string;
// Default base URL preloaded into the form when the user picks this
// provider for the first time. Empty string means "the user must fill
// it in" (Azure has no global default).
baseUrl: string;
// Suggested model id (datalist anchor). The user can type anything.
defaultModel: string;
// Suggestions surfaced in the model field's <datalist>.
modelSuggestions: string[];
// Placeholder hint for the api key field.
apiKeyPlaceholder: string;
// Whether the provider requires the Azure-specific apiVersion field.
needsApiVersion?: boolean;
}
export const PROVIDER_PRESETS: Record<ModelProvider, ProviderPreset> = {
anthropic: {
id: 'anthropic',
label: 'Anthropic',
blurb: 'Direct to api.anthropic.com or any Anthropic-compatible proxy (LiteLLM, AWS Bedrock / GCP Vertex via proxy).',
baseUrl: 'https://api.anthropic.com',
defaultModel: 'claude-sonnet-4-5',
modelSuggestions: [
'claude-opus-4-5',
'claude-sonnet-4-5',
'claude-haiku-4-5',
'claude-3-5-sonnet-latest',
],
apiKeyPlaceholder: 'sk-ant-...',
},
openai: {
id: 'openai',
label: 'OpenAI-compatible',
blurb: 'Any OpenAI /chat/completions endpoint — OpenAI, OpenRouter, LiteLLM proxy, DeepSeek, Groq, Together, Mistral.',
baseUrl: 'https://api.openai.com/v1',
defaultModel: 'gpt-4o-mini',
modelSuggestions: [
'gpt-4o',
'gpt-4o-mini',
'anthropic/claude-3.5-sonnet',
'google/gemini-2.0-flash',
'deepseek/deepseek-chat',
'meta-llama/llama-3.3-70b-instruct',
],
apiKeyPlaceholder: 'sk-...',
},
azure: {
id: 'azure',
label: 'Azure OpenAI',
blurb: 'Azure-hosted deployments. Base URL is your resource endpoint; Model is the deployment name.',
baseUrl: '',
defaultModel: '',
modelSuggestions: [],
apiKeyPlaceholder: 'azure key',
needsApiVersion: true,
},
google: {
id: 'google',
label: 'Google Gemini',
blurb: 'Google Generative Language API — Gemini family, key from aistudio.google.com.',
baseUrl: 'https://generativelanguage.googleapis.com',
defaultModel: 'gemini-2.0-flash',
modelSuggestions: [
'gemini-2.0-flash',
'gemini-2.0-flash-lite',
'gemini-1.5-pro',
'gemini-1.5-flash',
],
apiKeyPlaceholder: 'AIza...',
},
};
export const PROVIDER_ORDER: ModelProvider[] = [
'anthropic',
'openai',
'azure',
'google',
];
// True when the provider's wire format expects a deployment-specific URL
// rather than a generic baseUrl + path. Today only Azure qualifies — kept
// as a helper so callers don't have to memorize that.
export function providerLabel(provider: ModelProvider): string {
return PROVIDER_PRESETS[provider]?.label ?? provider;
}
+2 -1
View File
@@ -4,14 +4,15 @@ const STORAGE_KEY = 'open-design:config';
export const DEFAULT_CONFIG: AppConfig = { export const DEFAULT_CONFIG: AppConfig = {
mode: 'daemon', mode: 'daemon',
provider: 'anthropic',
apiKey: '', apiKey: '',
baseUrl: 'https://api.anthropic.com', baseUrl: 'https://api.anthropic.com',
model: 'claude-sonnet-4-5', model: 'claude-sonnet-4-5',
apiVersion: '',
agentId: null, agentId: null,
skillId: null, skillId: null,
designSystemId: null, designSystemId: null,
onboardingCompleted: false, onboardingCompleted: false,
agentModels: {},
}; };
export function loadConfig(): AppConfig { export function loadConfig(): AppConfig {
+14 -23
View File
@@ -1,19 +1,25 @@
export type ExecMode = 'daemon' | 'api'; export type ExecMode = 'daemon' | 'api';
// Per-CLI model + reasoning the user picked in the model menu. Each agent // Which BYOK model endpoint to talk to in `mode === 'api'`. Each provider
// keeps its own slot so flipping between Codex and Gemini doesn't reset the // has its own request shape — see src/providers/{anthropic,openai,azure,
// other one's choice. Missing entries fall back to the agent's first // google}.ts for the wire details. AWS Bedrock and Google Vertex are
// declared model (`'default'` — let the CLI pick). // reached via the `anthropic` provider pointed at an Anthropic-compatible
export interface AgentModelChoice { // proxy (e.g. LiteLLM), which keeps signing on the server where the
model?: string; // long-lived AWS / GCP credentials belong.
reasoning?: string; export type ModelProvider = 'anthropic' | 'openai' | 'azure' | 'google';
}
export interface AppConfig { export interface AppConfig {
mode: ExecMode; mode: ExecMode;
// Active provider when `mode === 'api'`. Older configs that predate the
// multi-provider rework default to 'anthropic' on load.
provider: ModelProvider;
apiKey: string; apiKey: string;
baseUrl: string; baseUrl: string;
model: string; model: string;
// Azure OpenAI only — the api-version query string the Azure REST
// surface requires (e.g. '2024-08-01-preview'). Ignored by every other
// provider so the same config can round-trip through localStorage.
apiVersion?: string;
agentId: string | null; agentId: string | null;
skillId: string | null; skillId: string | null;
designSystemId: string | null; designSystemId: string | null;
@@ -21,10 +27,6 @@ export interface AppConfig {
// least once (saved or skipped). Bootstrap skips the auto-popup when // least once (saved or skipped). Bootstrap skips the auto-popup when
// this is set so refreshing the page doesn't re-prompt. // this is set so refreshing the page doesn't re-prompt.
onboardingCompleted?: boolean; onboardingCompleted?: boolean;
// Per-CLI model picker state, keyed by agent id (e.g. `gemini`, `codex`).
// Pre-existing configs without this field fall through to the agent's
// declared default.
agentModels?: Record<string, AgentModelChoice>;
} }
export type AgentEvent = export type AgentEvent =
@@ -76,11 +78,6 @@ export interface ExamplePreview {
html: string; html: string;
} }
export interface AgentModelOption {
id: string;
label: string;
}
export interface AgentInfo { export interface AgentInfo {
id: string; id: string;
name: string; name: string;
@@ -88,12 +85,6 @@ export interface AgentInfo {
available: boolean; available: boolean;
path?: string; path?: string;
version?: string | null; version?: string | null;
// Models surfaced in the model picker for this CLI. The first entry is
// treated as the default (typically the synthetic `'default'` option,
// meaning "let the CLI use whatever's in its own config").
models?: AgentModelOption[];
// Reasoning-effort presets — currently only Codex exposes this.
reasoningOptions?: AgentModelOption[];
} }
export interface SkillSummary { export interface SkillSummary {