6 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
Tom Huang 94941f59a9 feat(dev): auto-switch ports on dev:all when defaults are busy (#9)
* Refactor project name from "Open Claude Design" to "Open Design"

- Updated project name in package.json, package-lock.json, and README files.
- Changed CLI commands and references from "ocd" to "od".
- Adjusted file structure references in documentation and code to reflect new naming conventions.
- Enhanced .gitignore to include new runtime data files.
- Updated metadata in LICENSE file to match new project name.

* Add contributing guidelines in English and Chinese

- Introduced CONTRIBUTING.md and CONTRIBUTING.zh-CN.md to provide clear instructions for contributors.
- Outlined contribution types, local setup instructions, and merging criteria for skills and design systems.
- Enhanced README files to reference the new contributing guidelines.

* Update README and documentation for deck framework directives

- Clarified DECK_FRAMEWORK_DIRECTIVE description in both English and Chinese README files to specify conditions for deck kind without a skill seed.
- Added detailed workflow instructions in deck-framework.ts to emphasize the importance of copying the framework before adding content.
- Enhanced discovery.ts to reinforce the framework-first approach for deck projects.
- Updated system.ts to ensure proper handling of deck projects with and without bound skills, preventing re-authorship of scaling and navigation logic.

* Update README and documentation for deck framework directives

- Clarified DECK_FRAMEWORK_DIRECTIVE description in both English and Chinese README files to specify conditions for deck kind without a skill seed.
- Added detailed workflow instructions in deck-framework.ts to emphasize the importance of copying the framework before adding content.
- Enhanced discovery.ts to reinforce the framework-first approach for deck projects.
- Updated system.ts to ensure proper handling of deck projects with and without bound skills, preventing re-authorship of scaling and navigation logic.

* Enhance README and add star promotion assets

- Added a "Star us" section in both English and Chinese README files to encourage users to star the project on GitHub.
- Included a new image asset for the star promotion.
- Introduced a new HTML file for a dedicated star promotion page.
- Updated .gitignore to exclude new cursor-related files.

* feat(dev): auto-switch ports on dev:all when defaults are busy

Adds a small launcher (scripts/dev-all.mjs) that probes free ports
for the daemon (OD_PORT, default 7456) and Vite (VITE_PORT, default
5173) before invoking concurrently, so a stray process holding
either port no longer breaks the boot. The resolved ports are
exported into the child env; vite.config.ts now reads VITE_PORT to
keep its dev server and /api proxy aligned with the daemon's actual
port.

Made-with: Cursor
2026-04-28 22:25:45 +08:00
Tom Huang d243b37d74 fix: allow Claude Code to read skill seeds and design-system specs (#6) (#7)
* Allow Claude Code to read skill seeds and design-system specs (#6)

The skill body's preamble points the agent at absolute paths like
`<repo>/skills/guizang-ppt/assets/template.html`, but the agent's cwd
is `.od/projects/<id>/`. Without an explicit allowlist Claude Code
blocks Read on those paths and the user sees a permission error
mid-conversation.

Pass `SKILLS_DIR` and `DESIGN_SYSTEMS_DIR` through `buildArgs` and emit
them as `--add-dir` for Claude so the seed template, references, and
design-system DESIGN.md are all readable. Other agents ignore the
extra dirs (no equivalent flag).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: add verification screenshot for issue #6 fix

Captures the agent successfully Read-ing skills/guizang-ppt/ side files
through the new --add-dir allowlist, confirming the permission error
from issue #6 is gone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:25:32 +08:00
Tom Huang 1c942e6cb7 Feat/support star us (#5)
* Refactor project name from "Open Claude Design" to "Open Design"

- Updated project name in package.json, package-lock.json, and README files.
- Changed CLI commands and references from "ocd" to "od".
- Adjusted file structure references in documentation and code to reflect new naming conventions.
- Enhanced .gitignore to include new runtime data files.
- Updated metadata in LICENSE file to match new project name.

* Add contributing guidelines in English and Chinese

- Introduced CONTRIBUTING.md and CONTRIBUTING.zh-CN.md to provide clear instructions for contributors.
- Outlined contribution types, local setup instructions, and merging criteria for skills and design systems.
- Enhanced README files to reference the new contributing guidelines.

* Update README and documentation for deck framework directives

- Clarified DECK_FRAMEWORK_DIRECTIVE description in both English and Chinese README files to specify conditions for deck kind without a skill seed.
- Added detailed workflow instructions in deck-framework.ts to emphasize the importance of copying the framework before adding content.
- Enhanced discovery.ts to reinforce the framework-first approach for deck projects.
- Updated system.ts to ensure proper handling of deck projects with and without bound skills, preventing re-authorship of scaling and navigation logic.

* Update README and documentation for deck framework directives

- Clarified DECK_FRAMEWORK_DIRECTIVE description in both English and Chinese README files to specify conditions for deck kind without a skill seed.
- Added detailed workflow instructions in deck-framework.ts to emphasize the importance of copying the framework before adding content.
- Enhanced discovery.ts to reinforce the framework-first approach for deck projects.
- Updated system.ts to ensure proper handling of deck projects with and without bound skills, preventing re-authorship of scaling and navigation logic.

* Enhance README and add star promotion assets

- Added a "Star us" section in both English and Chinese README files to encourage users to star the project on GitHub.
- Included a new image asset for the star promotion.
- Introduced a new HTML file for a dedicated star promotion page.
- Updated .gitignore to exclude new cursor-related files.
2026-04-28 21:00:33 +08:00
lakatos d1c3230642 fix: welcome dialog Save overwriting user's agent pick (#4)
The Save button fired both onSave and onClose. onClose's closed-over
`config` still held the bootstrap default (agentId: 'claude'), so it
re-ran setConfig with the stale value right after onSave wrote the
user's choice — leaving the user with Claude Code even when they had
clicked Codex on first run.

Split the responsibilities: Save now owns close (handleConfigSave
calls setSettingsOpen(false)); onClose stays scoped to dismiss flows
(Skip / backdrop) where the stale-config write is harmless.
2026-04-28 20:41:27 +08:00
Tom Huang bc2198103a Feat/optimize naming (#2)
* Refactor project name from "Open Claude Design" to "Open Design"

- Updated project name in package.json, package-lock.json, and README files.
- Changed CLI commands and references from "ocd" to "od".
- Adjusted file structure references in documentation and code to reflect new naming conventions.
- Enhanced .gitignore to include new runtime data files.
- Updated metadata in LICENSE file to match new project name.

* Add contributing guidelines in English and Chinese

- Introduced CONTRIBUTING.md and CONTRIBUTING.zh-CN.md to provide clear instructions for contributors.
- Outlined contribution types, local setup instructions, and merging criteria for skills and design systems.
- Enhanced README files to reference the new contributing guidelines.

* Update README and documentation for deck framework directives

- Clarified DECK_FRAMEWORK_DIRECTIVE description in both English and Chinese README files to specify conditions for deck kind without a skill seed.
- Added detailed workflow instructions in deck-framework.ts to emphasize the importance of copying the framework before adding content.
- Enhanced discovery.ts to reinforce the framework-first approach for deck projects.
- Updated system.ts to ensure proper handling of deck projects with and without bound skills, preventing re-authorship of scaling and navigation logic.

* Update README and documentation for deck framework directives

- Clarified DECK_FRAMEWORK_DIRECTIVE description in both English and Chinese README files to specify conditions for deck kind without a skill seed.
- Added detailed workflow instructions in deck-framework.ts to emphasize the importance of copying the framework before adding content.
- Enhanced discovery.ts to reinforce the framework-first approach for deck projects.
- Updated system.ts to ensure proper handling of deck projects with and without bound skills, preventing re-authorship of scaling and navigation logic.
2026-04-28 16:28:19 +08:00
33 changed files with 1656 additions and 145 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

+2
View File
@@ -16,3 +16,5 @@ dist
tsconfig.tsbuildinfo tsconfig.tsbuildinfo
.claude-sessions/* .claude-sessions/*
.cursor/*
+38 -8
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.
@@ -211,7 +211,7 @@ DISCOVERY directives (turn-1 form, turn-2 brand branch, TodoWrite, 5-dim critiq
+ active SKILL.md (19 skills available) + active SKILL.md (19 skills available)
+ project metadata (kind, fidelity, speakerNotes, animations, inspiration ids) + project metadata (kind, fidelity, speakerNotes, animations, inspiration ids)
+ skill side files (auto-injected pre-flight: read assets/template.html + references/*.md) + skill side files (auto-injected pre-flight: read assets/template.html + references/*.md)
+ (deck mode only) DECK_FRAMEWORK_DIRECTIVE (nav / counter / scroll / print) + (deck kind, no skill seed) DECK_FRAMEWORK_DIRECTIVE (nav / counter / scroll / print)
``` ```
Every layer is composable. Every layer is a file you can edit. Read [`src/prompts/system.ts`](src/prompts/system.ts) and [`src/prompts/discovery.ts`](src/prompts/discovery.ts) to see the actual contract. Every layer is composable. Every layer is a file you can edit. Read [`src/prompts/system.ts`](src/prompts/system.ts) and [`src/prompts/discovery.ts`](src/prompts/discovery.ts) to see the actual contract.
@@ -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.
@@ -538,6 +560,14 @@ Phased delivery → [`docs/roadmap.md`](docs/roadmap.md).
This is an early implementation — the closed loop (detect → pick skill + design system → chat → parse `<artifact>` → preview → save) runs end-to-end. The prompt stack and skill library are where most of the value lives, and they're stable. The component-level UI is shipping daily. This is an early implementation — the closed loop (detect → pick skill + design system → chat → parse `<artifact>` → preview → save) runs end-to-end. The prompt stack and skill library are where most of the value lives, and they're stable. The component-level UI is shipping daily.
## Star us
<p align="center">
<a href="https://github.com/nexu-io/open-design"><img src="docs/assets/star-us.png" alt="Star Open Design on GitHub — github.com/nexu-io/open-design" width="100%" /></a>
</p>
If this saved you thirty minutes — give it a ★. Stars don't pay rent, but they tell the next designer, agent, and contributor that this experiment is worth their attention. One click, three seconds, real signal: [github.com/nexu-io/open-design](https://github.com/nexu-io/open-design).
## Contributing ## Contributing
Issues, PRs, new skills, and new design systems are all welcome. The highest-leverage contributions are usually one folder, one Markdown file, or one PR-sized adapter: Issues, PRs, new skills, and new design systems are all welcome. The highest-leverage contributions are usually one folder, one Markdown file, or one PR-sized adapter:
+38 -8
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 是文件,不是插件
@@ -211,7 +211,7 @@ DISCOVERY 指令 turn-1 表单、turn-2 品牌分支、TodoWrite、
+ 激活的 SKILL.md 19 套备选) + 激活的 SKILL.md 19 套备选)
+ 项目元数据 kind、fidelity、speakerNotes、animations、灵感 system id + 项目元数据 kind、fidelity、speakerNotes、animations、灵感 system id
+ Skill 副文件 (自动注入 pre-flight:先读 assets/template.html + references/*.md + Skill 副文件 (自动注入 pre-flight:先读 assets/template.html + references/*.md
+ deck 模式) DECK_FRAMEWORK_DIRECTIVE nav / counter / scroll / print + deck kind 且无 skill 种子时) DECK_FRAMEWORK_DIRECTIVE nav / counter / scroll / print
``` ```
每一层都可组合。每一层都是一个你能改的文件。看 [`src/prompts/system.ts`](src/prompts/system.ts) 和 [`src/prompts/discovery.ts`](src/prompts/discovery.ts) 就知道真实契约长什么样。 每一层都可组合。每一层都是一个你能改的文件。看 [`src/prompts/system.ts`](src/prompts/system.ts) 和 [`src/prompts/discovery.ts`](src/prompts/discovery.ts) 就知道真实契约长什么样。
@@ -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`
## 引用与师承 ## 引用与师承
每一个被借鉴的开源项目都列在这里。点链接可以验证师承。 每一个被借鉴的开源项目都列在这里。点链接可以验证师承。
@@ -538,6 +560,14 @@ Daemon 启动时从 `PATH` 自动检测,无需配置。
这是一个早期实现 —— 闭环(检测 → 选 skill + design system → 对话 → 解析 `<artifact>` → 预览 → 保存)已经端到端跑通。提示词栈和 skill 库是价值最重的部分,目前已稳定。组件级 UI 仍在每天迭代。 这是一个早期实现 —— 闭环(检测 → 选 skill + design system → 对话 → 解析 `<artifact>` → 预览 → 保存)已经端到端跑通。提示词栈和 skill 库是价值最重的部分,目前已稳定。组件级 UI 仍在每天迭代。
## 给我们点个 Star
<p align="center">
<a href="https://github.com/nexu-io/open-design"><img src="docs/assets/star-us.png" alt="给 Open Design 点个 Star —— github.com/nexu-io/open-design" width="100%" /></a>
</p>
如果这套东西帮你省了半小时,给它一个 ★。Star 不付房租,但它告诉下一个设计师、Agent 和贡献者:这个实验值得他们的注意力。一次点击、三秒钟、真实信号:[github.com/nexu-io/open-design](https://github.com/nexu-io/open-design)。
## 贡献 ## 贡献
欢迎 issue、PR、新 skill、新 design system。收益最高的贡献往往就是一个文件夹、一份 Markdown,或者一个 PR 大小的 adapter 欢迎 issue、PR、新 skill、新 design system。收益最高的贡献往往就是一个文件夹、一份 Markdown,或者一个 PR 大小的 adapter
+23 -9
View File
@@ -7,7 +7,12 @@ import path from 'node:path';
const execFileP = promisify(execFile); const execFileP = promisify(execFile);
// Each entry defines how to invoke the agent in non-interactive "one-shot" mode. // Each entry defines how to invoke the agent in non-interactive "one-shot" mode.
// `buildArgs(prompt, imagePaths)` returns argv for the child process. // `buildArgs(prompt, imagePaths, extraAllowedDirs)` returns argv for the child
// process. `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
// (`--add-dir`); other agents either inherit broader access or run with cwd
// 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
@@ -19,14 +24,23 @@ export const AGENT_DEFS = [
name: 'Claude Code', name: 'Claude Code',
bin: 'claude', bin: 'claude',
versionArgs: ['--version'], versionArgs: ['--version'],
buildArgs: (prompt) => [ buildArgs: (prompt, _imagePaths, extraAllowedDirs = []) => {
'-p', const args = [
prompt, '-p',
'--output-format', prompt,
'stream-json', '--output-format',
'--verbose', 'stream-json',
'--include-partial-messages', '--verbose',
], '--include-partial-messages',
];
const dirs = (extraAllowedDirs || []).filter(
(d) => typeof d === 'string' && d.length > 0,
);
if (dirs.length > 0) {
args.push('--add-dir', ...dirs);
}
return args;
},
streamFormat: 'claude-stream-json', streamFormat: 'claude-stream-json',
}, },
{ {
+11 -1
View File
@@ -769,7 +769,17 @@ export async function startServer({ port = 7456 } = {}) {
safeImages.length ? `\n\n${safeImages.map((p) => `@${p}`).join(' ')}` : '', safeImages.length ? `\n\n${safeImages.map((p) => `@${p}`).join(' ')}` : '',
].join(''); ].join('');
const args = def.buildArgs(composed, safeImages); // Skill seeds (`skills/<id>/assets/template.html`) and design-system
// specs (`design-systems/<id>/DESIGN.md`) live outside the project cwd.
// The composed system prompt asks the agent to Read them via absolute
// paths in the skill-root preamble — without an explicit allowlist,
// Claude Code blocks those reads (issue #6: "no permission to read
// skills template"). We surface both roots so any agent that honours
// `--add-dir` can resolve those side files.
const extraAllowedDirs = [SKILLS_DIR, DESIGN_SYSTEMS_DIR].filter(
(d) => fs.existsSync(d),
);
const args = def.buildArgs(composed, safeImages, extraAllowedDirs);
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');
+76 -64
View File
@@ -2,9 +2,9 @@
// front-matter, returns listing. No watching in this MVP — re-scans on every // front-matter, returns listing. No watching in this MVP — re-scans on every
// GET /api/skills, which is fine for dozens of skills. // GET /api/skills, which is fine for dozens of skills.
import { readdir, readFile, stat } from 'node:fs/promises'; import { readdir, readFile, stat } from "node:fs/promises";
import path from 'node:path'; import path from "node:path";
import { parseFrontmatter } from './frontmatter.js'; import { parseFrontmatter } from "./frontmatter.js";
export async function listSkills(skillsRoot) { export async function listSkills(skillsRoot) {
const out = []; const out = [];
@@ -17,26 +17,32 @@ export async function listSkills(skillsRoot) {
for (const entry of entries) { for (const entry of entries) {
if (!entry.isDirectory()) continue; if (!entry.isDirectory()) continue;
const dir = path.join(skillsRoot, entry.name); const dir = path.join(skillsRoot, entry.name);
const skillPath = path.join(dir, 'SKILL.md'); const skillPath = path.join(dir, "SKILL.md");
try { try {
const stats = await stat(skillPath); const stats = await stat(skillPath);
if (!stats.isFile()) continue; if (!stats.isFile()) continue;
const raw = await readFile(skillPath, 'utf8'); const raw = await readFile(skillPath, "utf8");
const { data, body } = parseFrontmatter(raw); const { data, body } = parseFrontmatter(raw);
const hasAttachments = await dirHasAttachments(dir); const hasAttachments = await dirHasAttachments(dir);
const mode = data.od?.mode || inferMode(body, data.description); const mode = data.od?.mode || inferMode(body, data.description);
out.push({ out.push({
id: data.name || entry.name, id: data.name || entry.name,
name: data.name || entry.name, name: data.name || entry.name,
description: data.description || '', description: data.description || "",
triggers: Array.isArray(data.triggers) ? data.triggers : [], triggers: Array.isArray(data.triggers) ? data.triggers : [],
mode, mode,
platform: normalizePlatform(data.od?.platform, mode, body, data.description), platform: normalizePlatform(
data.od?.platform,
mode,
body,
data.description
),
scenario: normalizeScenario(data.od?.scenario, body, data.description), scenario: normalizeScenario(data.od?.scenario, body, data.description),
previewType: data.od?.preview?.type || 'html', previewType: data.od?.preview?.type || "html",
designSystemRequired: data.od?.design_system?.requires ?? true, designSystemRequired: data.od?.design_system?.requires ?? true,
defaultFor: normalizeDefaultFor(data.od?.default_for), defaultFor: normalizeDefaultFor(data.od?.default_for),
upstream: typeof data.od?.upstream === 'string' ? data.od.upstream : null, upstream:
typeof data.od?.upstream === "string" ? data.od.upstream : null,
featured: normalizeFeatured(data.od?.featured), featured: normalizeFeatured(data.od?.featured),
// Optional metadata hints used by 'Use this prompt' fast-create so // Optional metadata hints used by 'Use this prompt' fast-create so
// the resulting project mirrors the shipped example.html. Each hint // the resulting project mirrors the shipped example.html. Each hint
@@ -63,15 +69,15 @@ export async function listSkills(skillsRoot) {
// open those files via absolute paths. // open those files via absolute paths.
function withSkillRootPreamble(body, dir) { function withSkillRootPreamble(body, dir) {
const preamble = [ const preamble = [
'> **Skill root (absolute):** `' + dir + '`', "> **Skill root (absolute):** `" + dir + "`",
'>', ">",
'> This skill ships side files alongside `SKILL.md`. When the workflow', "> This skill ships side files alongside `SKILL.md`. When the workflow",
'> below references relative paths such as `assets/template.html` or', "> below references relative paths such as `assets/template.html` or",
'> `references/layouts.md`, resolve them against the skill root above and', "> `references/layouts.md`, resolve them against the skill root above and",
'> open them via their full absolute path.', "> open them via their full absolute path.",
'', "",
'', "",
].join('\n'); ].join("\n");
return preamble + body; return preamble + body;
} }
@@ -79,7 +85,9 @@ async function dirHasAttachments(dir) {
try { try {
const entries = await readdir(dir, { withFileTypes: true }); const entries = await readdir(dir, { withFileTypes: true });
return entries.some( return entries.some(
(e) => e.name !== 'SKILL.md' && (e.isDirectory() || /\.(md|html|css|js|json|txt)$/i.test(e.name)), (e) =>
e.name !== "SKILL.md" &&
(e.isDirectory() || /\.(md|html|css|js|json|txt)$/i.test(e.name))
); );
} catch { } catch {
return false; return false;
@@ -96,7 +104,7 @@ function normalizeDefaultFor(value) {
// 'high-fidelity' are meaningful — anything else collapses to null so the // 'high-fidelity' are meaningful — anything else collapses to null so the
// caller falls back to the form default ('high-fidelity'). // caller falls back to the form default ('high-fidelity').
function normalizeFidelity(value) { function normalizeFidelity(value) {
if (value === 'wireframe' || value === 'high-fidelity') return value; if (value === "wireframe" || value === "high-fidelity") return value;
return null; return null;
} }
@@ -104,11 +112,11 @@ function normalizeFidelity(value) {
// to a real boolean. Returns null for anything we can't interpret so the // to a real boolean. Returns null for anything we can't interpret so the
// caller knows to fall back to the form default. // caller knows to fall back to the form default.
function normalizeBoolHint(value) { function normalizeBoolHint(value) {
if (typeof value === 'boolean') return value; if (typeof value === "boolean") return value;
if (typeof value === 'string') { if (typeof value === "string") {
const v = value.trim().toLowerCase(); const v = value.trim().toLowerCase();
if (v === 'true' || v === 'yes' || v === '1') return true; if (v === "true" || v === "yes" || v === "1") return true;
if (v === 'false' || v === 'no' || v === '0') return false; if (v === "false" || v === "no" || v === "0") return false;
} }
return null; return null;
} }
@@ -119,8 +127,8 @@ function normalizeBoolHint(value) {
// natural alphabetical order. // natural alphabetical order.
function normalizeFeatured(value) { function normalizeFeatured(value) {
if (value === true) return 1; if (value === true) return 1;
if (typeof value === 'number' && Number.isFinite(value)) return value; if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === 'string' && value.trim()) { if (typeof value === "string" && value.trim()) {
const n = Number(value); const n = Number(value);
if (Number.isFinite(n)) return n; if (Number.isFinite(n)) return n;
} }
@@ -133,66 +141,70 @@ function normalizeFeatured(value) {
// serves as a passable starter prompt. // serves as a passable starter prompt.
function derivePrompt(data) { function derivePrompt(data) {
const explicit = data.od?.example_prompt; const explicit = data.od?.example_prompt;
if (typeof explicit === 'string' && explicit.trim()) return explicit.trim(); if (typeof explicit === "string" && explicit.trim()) return explicit.trim();
const desc = typeof data.description === 'string' ? data.description.trim() : ''; const desc =
if (!desc) return ''; typeof data.description === "string" ? data.description.trim() : "";
const collapsed = desc.replace(/\s+/g, ' ').trim(); if (!desc) return "";
const collapsed = desc.replace(/\s+/g, " ").trim();
const firstSentence = collapsed.match(/^.+?[.!?。!?](?:\s|$)/)?.[0]?.trim(); const firstSentence = collapsed.match(/^.+?[.!?。!?](?:\s|$)/)?.[0]?.trim();
return (firstSentence || collapsed).slice(0, 320); return (firstSentence || collapsed).slice(0, 320);
} }
function inferMode(body, description) { function inferMode(body, description) {
const hay = `${description ?? ''}\n${body ?? ''}`.toLowerCase(); const hay = `${description ?? ""}\n${body ?? ""}`.toLowerCase();
if (/\bppt|deck|slide|presentation|幻灯|投影/.test(hay)) return 'deck'; if (/\bppt|deck|slide|presentation|幻灯|投影/.test(hay)) return "deck";
if (/\bdesign[- ]system|\bdesign\.md|\bdesign tokens/.test(hay)) return 'design-system'; if (/\bdesign[- ]system|\bdesign\.md|\bdesign tokens/.test(hay))
if (/\btemplate\b/.test(hay)) return 'template'; return "design-system";
return 'prototype'; if (/\btemplate\b/.test(hay)) return "template";
return "prototype";
} }
// Validate platform tag — only desktop / mobile are meaningful for the // Validate platform tag — only desktop / mobile are meaningful for the
// Examples gallery. Falls back to autodetecting "mobile" from descriptions // Examples gallery. Falls back to autodetecting "mobile" from descriptions
// so legacy skills sort under the right pill without authoring changes. // so legacy skills sort under the right pill without authoring changes.
function normalizePlatform(value, mode, body, description) { function normalizePlatform(value, mode, body, description) {
if (value === 'desktop' || value === 'mobile') return value; if (value === "desktop" || value === "mobile") return value;
if (mode !== 'prototype') return null; if (mode !== "prototype") return null;
const hay = `${description ?? ''}\n${body ?? ''}`.toLowerCase(); const hay = `${description ?? ""}\n${body ?? ""}`.toLowerCase();
if (/mobile|phone|ios|android|手机|移动端/.test(hay)) return 'mobile'; if (/mobile|phone|ios|android|手机|移动端/.test(hay)) return "mobile";
return 'desktop'; return "desktop";
} }
// Normalise a scenario tag to a small fixed vocabulary so the filter pills // Normalise a scenario tag to a small fixed vocabulary so the filter pills
// stay tidy. Unknown values pass through verbatim so authors can experiment; // stay tidy. Unknown values pass through verbatim so authors can experiment;
// missing values default to "general". // missing values default to "general".
const KNOWN_SCENARIOS = new Set([ const KNOWN_SCENARIOS = new Set([
'general', "general",
'engineering', "engineering",
'product', "product",
'design', "design",
'marketing', "marketing",
'sales', "sales",
'finance', "finance",
'hr', "hr",
'operations', "operations",
'support', "support",
'legal', "legal",
'education', "education",
'personal', "personal",
]); ]);
function normalizeScenario(value, body, description) { function normalizeScenario(value, body, description) {
if (typeof value === 'string') { if (typeof value === "string") {
const v = value.trim().toLowerCase(); const v = value.trim().toLowerCase();
if (v) return v; if (v) return v;
} }
const hay = `${description ?? ''}\n${body ?? ''}`.toLowerCase(); const hay = `${description ?? ""}\n${body ?? ""}`.toLowerCase();
if (/finance|invoice|expense|budget|p&l|revenue/.test(hay)) return 'finance'; if (/finance|invoice|expense|budget|p&l|revenue/.test(hay)) return "finance";
if (/\bhr\b|onboarding|payroll|employee|人事/.test(hay)) return 'hr'; if (/\bhr\b|onboarding|payroll|employee|人事/.test(hay)) return "hr";
if (/marketing|campaign|brand|landing/.test(hay)) return 'marketing'; if (/marketing|campaign|brand|landing/.test(hay)) return "marketing";
if (/runbook|incident|deploy|engineering|sre|api/.test(hay)) return 'engineering'; if (/runbook|incident|deploy|engineering|sre|api/.test(hay))
if (/spec|prd|roadmap|product manager|product team/.test(hay)) return 'product'; return "engineering";
if (/design system|moodboard|mockup|ui kit/.test(hay)) return 'design'; if (/spec|prd|roadmap|product manager|product team/.test(hay))
if (/sales|quote|proposal|lead/.test(hay)) return 'sales'; return "product";
if (/operations|ops|logistics|inventory/.test(hay)) return 'operations'; if (/design system|moodboard|mockup|ui kit/.test(hay)) return "design";
return 'general'; if (/sales|quote|proposal|lead/.test(hay)) return "sales";
if (/operations|ops|logistics|inventory/.test(hay)) return "operations";
return "general";
} }
// Surface the vocabulary so callers (frontend filter UI) could mirror it // Surface the vocabulary so callers (frontend filter UI) could mirror it
// later if they want to. Not exported today, kept here for documentation. // later if they want to. Not exported today, kept here for documentation.
+743
View File
@@ -0,0 +1,743 @@
<!doctype html>
<html lang='en'>
<head>
<meta charset='utf-8' />
<title>Open Design — Star us on GitHub</title>
<link rel='preconnect' href='https://fonts.googleapis.com'>
<link rel='preconnect' href='https://fonts.gstatic.com' crossorigin>
<link href='https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300;0,9..144,500;0,9..144,700;1,9..144,500;1,9..144,700&family=JetBrains+Mono:wght@400;500&family=Inter:wght@400;500;600&display=swap' rel='stylesheet'>
<style>
:root {
--bg: #0d0a06;
--bg-2: #14100a;
--ink: #f3ead8;
--muted: #9b8f78;
--rule: #4a3f2c;
--accent: #f0833f;
--accent-2: #e85a2c;
--gold: #ffc83d;
--gold-2: #ffb000;
--paper: #f6efdd;
--gh-bg: #0d1117;
--gh-fg: #e6edf3;
--gh-muted: #8b949e;
--gh-border: #30363d;
--gh-border-2: #21262d;
--gh-btn: #21262d;
--gh-btn-hover: #30363d;
--gh-pill: #1f2328;
--gh-link: #4493f8;
--gh-tag-bg: #15295a;
--gh-tag-fg: #79c0ff;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
width: 1920px;
height: 1080px;
background: var(--bg);
color: var(--ink);
font-family: 'Inter', -apple-system, system-ui, sans-serif;
overflow: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
background:
radial-gradient(1200px 760px at 80% 18%, rgba(255,200,61,0.10), transparent 70%),
radial-gradient(900px 600px at 12% 80%, rgba(240,131,63,0.06), transparent 70%),
linear-gradient(180deg, #0e0b07 0%, #0a0805 100%);
padding: 56px 72px;
display: flex;
flex-direction: column;
}
.mono {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 12px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--muted);
}
header {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
padding-bottom: 22px;
border-bottom: 1px solid var(--rule);
}
header .l { text-align: left; }
header .r { text-align: right; }
header .center {
font-family: 'Fraunces', serif;
font-style: italic;
font-weight: 500;
font-size: 22px;
color: var(--ink);
letter-spacing: 0.01em;
}
header .center .star {
color: var(--gold);
margin-right: 6px;
font-size: 20px;
vertical-align: 1px;
}
main {
flex: 1;
display: grid;
grid-template-columns: 1.05fr 1fr;
column-gap: 64px;
padding-top: 56px;
align-items: stretch;
}
/* --------- LEFT: editorial copy --------- */
.left {
display: flex;
flex-direction: column;
justify-content: space-between;
}
.eyebrow {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 28px;
}
.pill {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
letter-spacing: 0.22em;
color: #f6e9d4;
background: rgba(240,131,63,0.16);
border: 1px solid rgba(240,131,63,0.45);
padding: 6px 12px;
border-radius: 999px;
text-transform: uppercase;
}
.pill.alt {
color: var(--muted);
background: transparent;
border-color: var(--rule);
}
.pill.gold {
color: #2a1a05;
background: linear-gradient(180deg, var(--gold), var(--gold-2));
border: 1px solid rgba(255,200,61,0.85);
}
h1 {
font-family: 'Fraunces', serif;
font-weight: 500;
font-size: 100px;
line-height: 0.94;
letter-spacing: -0.025em;
color: var(--ink);
margin: 0 0 32px 0;
}
h1 em {
font-style: italic;
color: var(--accent);
font-weight: 500;
}
h1 .star-glyph {
color: var(--gold);
font-style: normal;
font-weight: 500;
display: inline-block;
transform: translateY(8px);
text-shadow:
0 0 24px rgba(255,200,61,0.55),
0 0 60px rgba(255,176,0,0.35);
}
h1 .underline-accent {
position: relative;
display: inline-block;
}
h1 .underline-accent::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: -6px;
height: 6px;
background: var(--accent);
border-radius: 4px;
opacity: 0.85;
}
.lede {
font-family: 'Fraunces', serif;
font-weight: 300;
font-size: 22px;
line-height: 1.5;
color: #d8cdb6;
max-width: 640px;
margin: 0 0 28px 0;
}
.lede b { font-weight: 500; color: #f3ead8; }
.url-card {
display: inline-flex;
align-items: center;
gap: 18px;
padding: 16px 22px;
border: 1px solid var(--rule);
border-radius: 14px;
background: rgba(255,255,255,0.02);
margin-bottom: 36px;
box-shadow: 0 14px 30px rgba(0,0,0,0.35);
}
.url-card .arrow {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
letter-spacing: 0.22em;
color: var(--muted);
text-transform: uppercase;
}
.url-card .url {
font-family: 'JetBrains Mono', monospace;
font-size: 22px;
color: var(--ink);
letter-spacing: 0.01em;
}
.url-card .url b {
color: var(--accent);
font-weight: 500;
}
.url-card .copybtn {
margin-left: 8px;
border: 1px solid var(--rule);
color: var(--muted);
padding: 4px 10px;
border-radius: 6px;
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
letter-spacing: 0.22em;
text-transform: uppercase;
}
.meta {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0;
border-top: 1px solid var(--rule);
padding-top: 22px;
}
.meta .cell {
border-right: 1px solid var(--rule);
padding-right: 18px;
}
.meta .cell:last-child { border-right: none; }
.meta .num {
font-family: 'Fraunces', serif;
font-weight: 500;
font-size: 42px;
line-height: 1;
color: var(--ink);
letter-spacing: -0.01em;
}
.meta .num em {
font-style: italic;
color: var(--accent);
font-weight: 500;
}
.meta .num .gold {
color: var(--gold);
text-shadow: 0 0 18px rgba(255,200,61,0.45);
}
.meta .lbl {
margin-top: 10px;
font-family: 'JetBrains Mono', monospace;
font-size: 10.5px;
letter-spacing: 0.18em;
color: var(--muted);
text-transform: uppercase;
line-height: 1.4;
}
/* --------- RIGHT: GitHub mock --------- */
.right {
position: relative;
}
.stage {
position: absolute;
inset: 0;
}
.window {
position: absolute;
top: 130px;
left: 0;
right: 0;
background: var(--gh-bg);
color: var(--gh-fg);
border-radius: 16px;
overflow: hidden;
border: 1px solid var(--gh-border);
box-shadow:
0 1px 0 rgba(255,255,255,0.04) inset,
0 24px 60px rgba(0,0,0,0.55),
0 6px 18px rgba(0,0,0,0.45);
transform: rotate(-1deg);
}
.winbar {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-bottom: 1px solid var(--gh-border);
background: linear-gradient(180deg, #15191e, #0f1216);
}
.winbar .dots { display: flex; gap: 6px; margin-right: 8px; }
.winbar .dot { width: 12px; height: 12px; border-radius: 50%; background: #3a3a3a; }
.winbar .dot.r { background: #ff5f57; }
.winbar .dot.y { background: #febc2e; }
.winbar .dot.g { background: #28c840; }
.winbar .urlbar {
flex: 1;
background: #161b22;
border: 1px solid var(--gh-border);
border-radius: 8px;
padding: 6px 12px;
color: var(--gh-muted);
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.winbar .urlbar .lock { color: #6e7681; font-size: 10px; }
.winbar .urlbar b { color: var(--gh-fg); font-weight: 500; }
.ghnav {
display: flex;
align-items: center;
gap: 18px;
padding: 10px 22px;
border-bottom: 1px solid var(--gh-border);
background: var(--gh-bg);
font-size: 13px;
color: var(--gh-fg);
}
.ghnav .logo { width: 22px; height: 22px; }
.ghnav .crumbs { color: var(--gh-fg); font-weight: 500; }
.ghnav .crumbs .slash { color: var(--gh-muted); margin: 0 6px; font-weight: 300; }
.ghnav .crumbs .repo { color: var(--gh-link); }
.ghnav .private {
margin-left: 8px;
font-size: 11px;
color: var(--gh-muted);
border: 1px solid var(--gh-border);
border-radius: 999px;
padding: 2px 10px;
}
.ghhead {
padding: 22px 26px 6px;
}
.ghhead .row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: nowrap;
}
.ghhead .row > .repotitle { min-width: 0; flex-shrink: 1; }
.repotitle {
display: flex;
align-items: center;
gap: 8px;
font-size: 18px;
font-weight: 500;
white-space: nowrap;
}
.repotitle .icon { color: var(--gh-muted); }
.repotitle .org { color: var(--gh-link); }
.repotitle .name { color: var(--gh-link); font-weight: 600; }
.repotitle .sep { color: var(--gh-muted); font-weight: 300; }
.repotitle .badge {
margin-left: 10px;
border: 1px solid var(--gh-border);
color: var(--gh-muted);
border-radius: 999px;
padding: 2px 10px;
font-size: 11px;
}
.actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.ghbtn {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--gh-btn);
border: 1px solid var(--gh-border);
color: var(--gh-fg);
padding: 5px 10px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
line-height: 1.2;
}
.ghbtn .icon { color: var(--gh-muted); width: 14px; height: 14px; }
.ghbtn .count {
margin-left: 4px;
padding: 1px 6px;
background: var(--gh-pill);
border-radius: 999px;
color: var(--gh-fg);
font-weight: 500;
font-size: 11px;
}
.ghbtn .caret {
width: 0; height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 5px solid var(--gh-muted);
margin-left: 6px;
}
.star-wrap {
position: relative;
padding: 6px;
margin: -6px;
border: 2px dashed var(--accent-2);
border-radius: 12px;
box-shadow:
0 0 0 4px rgba(232,90,44,0.10),
0 0 36px rgba(255,200,61,0.18);
background: rgba(232,90,44,0.04);
}
.ghbtn.star {
background: linear-gradient(180deg, #1c1f25, #14171c);
border-color: var(--gh-border);
}
.ghbtn.star .icon { color: var(--gold); }
.ghbtn.star .label { color: var(--gh-fg); font-weight: 600; }
/* CTA arrow & note pointing at the star */
.point {
position: absolute;
z-index: 10;
pointer-events: none;
}
.point .note {
font-family: 'Fraunces', serif;
font-style: italic;
font-weight: 500;
font-size: 28px;
color: var(--accent);
line-height: 1.15;
text-shadow: 0 6px 18px rgba(0,0,0,0.55);
}
.point .note .gold { color: var(--gold); }
.point .sub {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
letter-spacing: 0.22em;
color: var(--muted);
text-transform: uppercase;
margin-top: 8px;
}
.point.tap {
top: -20px;
right: 60px;
text-align: right;
}
.arrow-svg {
position: absolute;
overflow: visible;
z-index: 9;
pointer-events: none;
}
/* floating sparkle */
.sparkle {
position: absolute;
color: var(--gold);
text-shadow: 0 0 16px rgba(255,200,61,0.6);
font-size: 22px;
}
.stamp {
position: absolute;
bottom: 30px;
right: 0;
width: 110px;
height: 110px;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #ffe27a, var(--gold-2) 80%);
color: #2a1a05;
font-family: 'Fraunces', serif;
font-style: italic;
font-weight: 700;
font-size: 14px;
line-height: 1.15;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
transform: rotate(8deg);
box-shadow: 0 14px 30px rgba(255,176,0,0.45);
z-index: 5;
}
.stamp .big { font-size: 28px; line-height: 1; display: block; margin-bottom: 2px; }
/* secondary stat strip below window */
.ghbody {
display: grid;
grid-template-columns: 1fr 220px;
gap: 0;
padding: 18px 26px 22px;
border-top: 1px solid var(--gh-border);
background: var(--gh-bg);
}
.ghbody .desc {
color: var(--gh-fg);
font-size: 13px;
line-height: 1.55;
}
.ghbody .desc .em { color: var(--gh-link); }
.ghbody .topics {
margin-top: 10px;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.topic {
font-size: 11px;
color: var(--gh-tag-fg);
background: var(--gh-tag-bg);
padding: 2px 10px;
border-radius: 999px;
}
.ghbody .stats {
font-size: 12px;
color: var(--gh-muted);
text-align: right;
}
.ghbody .stats .row {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
margin-bottom: 6px;
}
.ghbody .stats .row .icon { color: var(--gh-muted); }
.ghbody .stats .row b { color: var(--gh-fg); font-weight: 500; }
footer {
margin-top: 48px;
padding-top: 22px;
border-top: 1px solid var(--rule);
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
}
footer .l { text-align: left; }
footer .r { text-align: right; }
footer .c {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
letter-spacing: 0.28em;
color: var(--muted);
text-transform: uppercase;
}
</style>
</head>
<body>
<header>
<div class='mono l'>Open Design · Community · 2026 Edition</div>
<div class='center'><span class='star'></span>open.design</div>
<div class='mono r'>Cover · 03 / 08 · Star Us</div>
</header>
<main>
<section class='left'>
<div>
<div class='eyebrow'>
<span class='pill gold'>★ STAR US</span>
<span class='pill alt'>Open Source · Apache 2.0</span>
</div>
<h1>
If this saved<br/>
you <em>thirty</em><br/>
minutes,<br/>
give it a <span class='star-glyph'></span>.
</h1>
<p class='lede'>
Open Design is built and maintained in the open. <b>Stars don't pay rent —</b>
but they tell the next designer, agent, and contributor that this experiment
is worth their attention. One click, three seconds, real signal.
</p>
<div class='url-card'>
<span class='arrow'>· DROP BY →</span>
<span class='url'>github.com/<b>nexu-io/open-design</b></span>
<span class='copybtn'>· COPY</span>
</div>
</div>
<div class='meta'>
<div class='cell'>
<div class='num'>71</div>
<div class='lbl'>Design<br/>Systems</div>
</div>
<div class='cell'>
<div class='num'>19</div>
<div class='lbl'>Composable<br/>Skills</div>
</div>
<div class='cell'>
<div class='num'>06</div>
<div class='lbl'>Coding<br/>Agents</div>
</div>
<div class='cell'>
<div class='num'><span class='gold'></span></div>
<div class='lbl'>One click =<br/>one signal</div>
</div>
</div>
</section>
<!-- RIGHT: stylized GitHub repo header -->
<section class='right'>
<div class='stage'>
<!-- Pointing note above the star -->
<div class='point tap'>
<div class='note'>Tap the <span class='gold'></span> Star<br/>top-right.</div>
<div class='sub'>· three seconds · one click ·</div>
</div>
<!-- arc arrow from the note down to the Star button -->
<svg class='arrow-svg' style='top:30px; right:30px; width:200px; height:200px;' viewBox='0 0 200 200' fill='none'>
<path d='M 30 60 C 60 90, 120 120, 168 168' stroke='#f0833f' stroke-width='2.5' stroke-linecap='round' fill='none' stroke-dasharray='2 8'/>
<path d='M 152 156 L 168 170 L 162 150' stroke='#f0833f' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/>
</svg>
<!-- sparkles -->
<span class='sparkle' style='top:200px; right:30px; font-size:18px;'></span>
<span class='sparkle' style='top:260px; right:120px; font-size:14px; opacity:.7;'></span>
<span class='sparkle' style='top:150px; right:240px; font-size:12px; opacity:.55;'></span>
<div class='window'>
<div class='winbar'>
<div class='dots'>
<span class='dot r'></span><span class='dot y'></span><span class='dot g'></span>
</div>
<div class='urlbar'>
<span class='lock'>🔒</span>
<span>github.com/<b>nexu-io/open-design</b></span>
</div>
</div>
<div class='ghnav'>
<svg class='logo' viewBox='0 0 16 16' fill='currentColor'>
<path d='M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z'/>
</svg>
<div class='crumbs'>
<span class='org'>nexu-io</span>
<span class='slash'>/</span>
<span class='repo'>open-design</span>
</div>
<span class='private'>Public</span>
</div>
<div class='ghhead'>
<div class='row'>
<div class='repotitle'>
<svg class='icon' width='18' height='18' viewBox='0 0 16 16' fill='currentColor'>
<path fill-rule='evenodd' d='M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.692 1.72.75.75 0 0 1-1.034 1.084A2.5 2.5 0 0 1 2 11.5v-9zm10.5-1V9h-8c-.356 0-.694.074-1 .208V2.5a1 1 0 0 1 1-1h8zM5 12.25v3.25a.25.25 0 0 0 .4.2l1.45-1.087a.25.25 0 0 1 .3 0L8.6 15.7a.25.25 0 0 0 .4-.2v-3.25a.25.25 0 0 0-.25-.25h-3.5a.25.25 0 0 0-.25.25z'/>
</svg>
<span class='org'>nexu-io</span>
<span class='sep'>/</span>
<span class='name'>open-design</span>
<span class='badge'>Public</span>
</div>
<div class='actions'>
<span class='ghbtn'>
<svg class='icon' viewBox='0 0 16 16' fill='currentColor'><path d='M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.756l8.61-8.61Z'/></svg>
<span>Edit Pins</span>
<span class='caret'></span>
</span>
<span class='ghbtn'>
<svg class='icon' viewBox='0 0 16 16' fill='currentColor'><path d='M8 2c1.981 0 3.671.992 4.933 2.078 1.27 1.091 2.187 2.345 2.637 3.023a1.62 1.62 0 0 1 0 1.798c-.45.678-1.367 1.932-2.637 3.023C11.67 13.008 9.981 14 8 14c-1.981 0-3.671-.992-4.933-2.078C1.797 10.83.88 9.576.43 8.898a1.62 1.62 0 0 1 0-1.798c.45-.678 1.367-1.932 2.637-3.023C4.33 2.992 6.019 2 8 2ZM1.679 7.932a.12.12 0 0 0 0 .136c.411.622 1.241 1.75 2.366 2.717C5.176 11.758 6.527 12.5 8 12.5c1.473 0 2.825-.742 3.955-1.715 1.124-.967 1.954-2.096 2.366-2.717a.12.12 0 0 0 0-.136c-.412-.621-1.242-1.75-2.366-2.717C10.824 4.242 9.473 3.5 8 3.5c-1.473 0-2.825.742-3.955 1.715-1.124.967-1.954 2.096-2.366 2.717ZM8 10a2 2 0 1 1-.001-3.999A2 2 0 0 1 8 10Z'/></svg>
<span>Watch</span>
<span class='count'>0</span>
<span class='caret'></span>
</span>
<span class='ghbtn'>
<svg class='icon' viewBox='0 0 16 16' fill='currentColor'><path d='M5 5.372v.878c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.878a2.25 2.25 0 1 1 1.5 0v.878a2.25 2.25 0 0 1-2.25 2.25h-1.5v2.128a2.251 2.251 0 1 1-1.5 0V8.5h-1.5A2.25 2.25 0 0 1 3.5 6.25v-.878a2.25 2.25 0 1 1 1.5 0ZM5 3.25a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0Zm6.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm-3 8.75a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0Z'/></svg>
<span>Fork</span>
<span class='count'>0</span>
<span class='caret'></span>
</span>
<span class='star-wrap'>
<span class='ghbtn star'>
<svg class='icon' viewBox='0 0 16 16' fill='currentColor'><path d='M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.751.751 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Z'/></svg>
<span class='label'>Star</span>
<span class='count'>1</span>
<span class='caret'></span>
</span>
</span>
</div>
</div>
</div>
<div class='ghbody'>
<div>
<div class='desc'>
<span class='em'></span> Local-first open replica of Anthropic's <b>Claude Design</b>.
⚡ 19 Skills · ✶ 71 brand-grade Design Systems · ⛁ sandboxed preview ·
⇩ HTML / PDF / PPTX export. Runs on Claude Code · Codex · Cursor · Gemini CLI · OpenCode · Qwen.
</div>
<div class='topics'>
<span class='topic'>react</span>
<span class='topic'>design</span>
<span class='topic'>design-systems</span>
<span class='topic'>typescript</span>
<span class='topic'>skills</span>
<span class='topic'>cursor</span>
<span class='topic'>local-first</span>
<span class='topic'>byok</span>
<span class='topic'>claude</span>
<span class='topic'>ai-agents</span>
</div>
</div>
<div class='stats'>
<div class='row'>
<span>Apache 2.0</span>
<svg class='icon' width='14' height='14' viewBox='0 0 16 16' fill='currentColor'><path d='M8.75.75V2h.985c.304 0 .603.08.867.231l1.29.736c.038.022.08.033.124.033h2.234a.75.75 0 0 1 0 1.5h-.427l2.111 4.692a.75.75 0 0 1-.154.838l-.53-.53.529.531-.001.002-.002.002-.006.006-.006.005-.01.01-.045.04c-.21.176-.441.327-.686.45C14.556 10.78 13.88 11 13 11a4.498 4.498 0 0 1-2.023-.454 3.544 3.544 0 0 1-.686-.45l-.045-.04-.016-.015-.006-.006-.004-.004v-.001a.75.75 0 0 1-.154-.838L12.178 4.5h-.162c-.305 0-.604-.079-.868-.231l-1.29-.736a.245.245 0 0 0-.124-.033H8.75V13h2.5a.75.75 0 0 1 0 1.5h-6.5a.75.75 0 0 1 0-1.5h2.5V3.5h-.984a.245.245 0 0 0-.124.033l-1.289.737c-.265.15-.564.23-.869.23h-.162l2.112 4.692a.75.75 0 0 1-.154.838l-.53-.53.529.531-.001.002-.002.002-.006.006-.016.015-.045.04c-.21.176-.441.327-.686.45C4.556 10.78 3.88 11 3 11a4.498 4.498 0 0 1-2.023-.454 3.544 3.544 0 0 1-.686-.45l-.045-.04-.016-.015-.006-.006-.004-.004v-.001a.75.75 0 0 1-.154-.838L2.178 4.5H1.75a.75.75 0 0 1 0-1.5h2.234a.249.249 0 0 0 .125-.033l1.288-.737c.265-.15.564-.23.869-.23h.984V.75a.75.75 0 0 1 1.5 0Z'/></svg>
</div>
<div class='row'><b>3</b> Commits</div>
<div class='row'><b>2</b> Branches</div>
<div class='row'><b>0</b> Tags</div>
<div class='row'><b>0</b> Issues</div>
<div class='row' style='color:var(--gold);'><b style='color:var(--gold);'>1</b> Star · be the next</div>
</div>
</div>
</div>
<div class='stamp'>
<span><span class='big'></span>STAR<br/>US!</span>
</div>
</div>
</section>
</main>
<footer>
<div class='mono l'>Local-first · BYOK · Apache 2.0</div>
<div class='c'>· git clone · pnpm install · pnpm dev ·</div>
<div class='mono r'>github.com/nexu-io/open-design</div>
</footer>
</body>
</html>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 787 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

+1 -1
View File
@@ -11,7 +11,7 @@
"scripts": { "scripts": {
"daemon": "node daemon/cli.js --no-open", "daemon": "node daemon/cli.js --no-open",
"dev": "vite", "dev": "vite",
"dev:all": "concurrently -k -n daemon,web -c cyan,magenta \"npm:daemon\" \"npm:dev\"", "dev:all": "node scripts/dev-all.mjs",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "tsc -b --noEmit", "typecheck": "tsc -b --noEmit",
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env node
// Launcher for `npm run dev:all`.
//
// Probes for free ports for the daemon (OD_PORT, default 7456) and the Vite
// dev server (VITE_PORT, default 5173) before spawning `concurrently`, so a
// stray process holding either port doesn't kill the whole boot. The
// resolved ports are exported into the child env, which means:
// * the daemon's cli.js sees the new OD_PORT and binds to it
// * vite.config.ts reads the same OD_PORT and points its /api proxy at
// the daemon's actual port
// * Vite itself binds to VITE_PORT
//
// If a port is busy we walk forward up to PORT_SEARCH_RANGE steps and log
// the switch so the user notices.
import { spawn } from 'node:child_process';
import net from 'node:net';
const HOST = '127.0.0.1';
const PORT_SEARCH_RANGE = 50;
function isPortFree(port, host = HOST) {
return new Promise((resolve) => {
const server = net.createServer();
server.unref();
server.once('error', () => resolve(false));
server.listen({ port, host, exclusive: true }, () => {
server.close(() => resolve(true));
});
});
}
async function findFreePort(start, label) {
for (let port = start; port < start + PORT_SEARCH_RANGE; port++) {
if (await isPortFree(port)) return port;
}
throw new Error(
`[dev:all] could not find a free ${label} port near ${start} (tried ${PORT_SEARCH_RANGE})`,
);
}
const desiredDaemon = Number(process.env.OD_PORT) || 7456;
const desiredVite = Number(process.env.VITE_PORT) || 5173;
const daemonPort = await findFreePort(desiredDaemon, 'daemon');
const vitePort = await findFreePort(desiredVite, 'vite');
if (daemonPort !== desiredDaemon) {
console.log(
`[dev:all] daemon port ${desiredDaemon} is busy, switching to ${daemonPort}`,
);
}
if (vitePort !== desiredVite) {
console.log(
`[dev:all] vite port ${desiredVite} is busy, switching to ${vitePort}`,
);
}
const env = {
...process.env,
OD_PORT: String(daemonPort),
VITE_PORT: String(vitePort),
};
// We spawn the local `concurrently` bin via shell so Windows .cmd shims
// resolve correctly. The `npm:daemon` / `npm:dev` shorthand runs the
// matching package.json scripts, so any future tweak to those scripts is
// picked up automatically.
const child = spawn(
'concurrently',
['-k', '-n', 'daemon,web', '-c', 'cyan,magenta', 'npm:daemon', 'npm:dev'],
{ env, stdio: 'inherit', shell: true },
);
child.on('exit', (code, signal) => {
if (signal) process.kill(process.pid, signal);
else process.exit(code ?? 0);
});
for (const sig of ['SIGINT', 'SIGTERM']) {
process.on(sig, () => {
if (!child.killed) child.kill(sig);
});
}
+1
View File
@@ -116,6 +116,7 @@ export function App() {
const withOnboarding: AppConfig = { ...next, onboardingCompleted: true }; const withOnboarding: AppConfig = { ...next, onboardingCompleted: true };
saveConfig(withOnboarding); saveConfig(withOnboarding);
setConfig(withOnboarding); setConfig(withOnboarding);
setSettingsOpen(false);
}, []); }, []);
const handleModeChange = useCallback( const handleModeChange = useCallback(
+3 -2
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 { AgentIcon } from './AgentIcon'; import { AgentIcon } from './AgentIcon';
import { Icon } from './Icon'; import { Icon } from './Icon';
import type { AgentInfo, AppConfig, ExecMode } from '../types'; import type { AgentInfo, AppConfig, ExecMode } from '../types';
@@ -82,11 +83,11 @@ 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}` : ''}` ? `${currentAgent.name}${currentAgent.version ? ` · ${currentAgent.version}` : ''}`
: t('avatar.noAgentSelected')} : t('avatar.noAgentSelected')}
+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 -2
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,
@@ -501,7 +501,7 @@ export function ProjectView({
}); });
} 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 });
+79 -15
View File
@@ -1,8 +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 type { AgentInfo, AppConfig, ExecMode } from '../types'; import type { AgentInfo, AppConfig, ExecMode, ModelProvider } from '../types';
interface Props { interface Props {
initial: AppConfig; initial: AppConfig;
@@ -14,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,
@@ -48,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}>
@@ -187,14 +210,41 @@ export function SettingsDialog({
) : ( ) : (
<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
@@ -217,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>
@@ -230,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>
)} )}
@@ -276,10 +343,7 @@ export function SettingsDialog({
type="button" type="button"
className="primary" className="primary"
disabled={!canSave} disabled={!canSave}
onClick={() => { onClick={() => onSave(cfg)}
onSave(cfg);
onClose();
}}
> >
{welcome ? t('settings.getStarted') : t('common.save')} {welcome ? t('settings.getStarted') : t('common.save')}
</button> </button>
+16 -8
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,11 +75,19 @@ 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.',
@@ -200,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',
@@ -411,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 -8
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,11 +74,19 @@ 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': '切换界面语言,设置仅保存在当前浏览器。',
@@ -197,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': '设置',
@@ -400,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
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;
+16
View File
@@ -313,6 +313,22 @@ Decks regress when each turn re-authors the scale-to-fit logic, the keyboard han
**You do not write any of that. You do not modify any of that.** Your job is to fill content slots only. **You do not write any of that. You do not modify any of that.** Your job is to fill content slots only.
## Workflow copy framework first, then fill content
When the user asks for slides, your TodoWrite plan **must** start with "copy the deck framework verbatim" before any content step. The intended order is:
\`\`\`
1. Bind the active direction's palette + fonts to :root in the framework
2. Copy the canonical skeleton below as index.html (nothing else first)
3. Plan the slide arc and theme rhythm (state aloud before writing)
4. Add per-deck classes inside the second <style> block
5. Replace each <section class="slide"> SLOT with real content
6. Self-check (no rewriting framework chrome / @media print / nav script)
7. Emit single <artifact>
\`\`\`
If you find yourself writing \`<style>\` rules for \`.deck-shell\`, \`.deck-stage\`, \`.slide\`, \`.canvas\`, \`fit()\`, \`@media print\`, or a keyboard handler — STOP. The framework already has them. Re-read this directive, then keep going from "fill SLOT content".
## The contract ## The contract
When you start a new deck, your output is a single HTML file built from the canonical skeleton below. **Copy the skeleton verbatim**, including its first \`<style>\` block, the \`.deck-shell\` / \`.deck-stage\` / \`.deck-counter\` / \`.deck-hint\` chrome, and the entire trailing \`<script>\`. When you start a new deck, your output is a single HTML file built from the canonical skeleton below. **Copy the skeleton verbatim**, including its first \`<style>\` block, the \`.deck-shell\` / \`.deck-stage\` / \`.deck-counter\` / \`.deck-hint\` chrome, and the entire trailing \`<script>\`.
+2
View File
@@ -142,6 +142,8 @@ The standard plan template (adapt the middle steps to the brief):
- 9. Emit single <artifact> - 9. Emit single <artifact>
\`\`\` \`\`\`
**Decks especially framework first, content second.** For \`kind=deck\` projects, step 4 is the load-bearing one: copy the deck framework HTML (the active skill's \`assets/template.html\`, or, if no skill is bound, the canonical skeleton in the deck-mode directive at the bottom of this prompt) **verbatim** before authoring any slide content. Do NOT write your own scale-to-fit logic, keyboard handler, slide visibility toggle, counter, or print stylesheet — every freeform attempt at this re-introduces the same iframe positioning / scaling bugs we have already fixed in the framework. Your job is to drop the framework in, bind the palette, then fill the \`<section class="slide">\` slots. That's it.
After TodoWrite, immediately update **mark step 1 \`in_progress\` before starting it, \`completed\` the moment it's done, mark step 2 \`in_progress\`**, etc. Do not batch updates at the end of the turn; the live progress is the point. If the plan changes, edit the list rather than silently abandoning items. After TodoWrite, immediately update **mark step 1 \`in_progress\` before starting it, \`completed\` the moment it's done, mark step 2 \`in_progress\`**, etc. Do not batch updates at the end of the turn; the live progress is the point. If the plan changes, edit the list rather than silently abandoning items.
Step 7 (checklist) and step 8 (critique) are non-negotiable. Step 7 (checklist) and step 8 (critique) are non-negotiable.
+28 -6
View File
@@ -14,11 +14,17 @@
* (`assets/template.html`) and references (`references/layouts.md`, * (`assets/template.html`) and references (`references/layouts.md`,
* `references/checklist.md`), we inject a hard pre-flight rule above * `references/checklist.md`), we inject a hard pre-flight rule above
* the skill body so the agent reads them BEFORE writing any code. * the skill body so the agent reads them BEFORE writing any code.
* 4. For decks (skillMode === 'deck'), the deck framework directive * 4. For decks (skillMode === 'deck' OR metadata.kind === 'deck'), the
* (./deck-framework.ts) is pinned LAST so it overrides any softer * deck framework directive (./deck-framework.ts) is pinned LAST so it
* slide-handling wording earlier in the stack this is the * overrides any softer slide-handling wording earlier in the stack
* load-bearing nav / counter / scroll JS / print stylesheet contract * this is the load-bearing nav / counter / scroll JS / print
* that PDF stitching depends on. * stylesheet contract that PDF stitching depends on. We also fire on
* the metadata path so deck-kind projects without a bound skill
* (skill_id null) still get a framework, instead of having the agent
* re-author scaling / nav / print logic from scratch each turn. When
* the active skill ships its own seed (skill body references
* `assets/template.html`), we defer to that seed and skip the generic
* skeleton the skill's framework wins to avoid double-injection.
* *
* The composed string is what the daemon sees as `systemPrompt` and what * The composed string is what the daemon sees as `systemPrompt` and what
* the Anthropic path sends as `system`. * the Anthropic path sends as `system`.
@@ -85,7 +91,23 @@ export function composeSystemPrompt({
// Decks have a load-bearing framework (nav, counter, scroll JS, print // Decks have a load-bearing framework (nav, counter, scroll JS, print
// stylesheet for PDF stitching). Pin it last so it overrides any softer // stylesheet for PDF stitching). Pin it last so it overrides any softer
// wording earlier in the stack ("write a script that handles arrows…"). // wording earlier in the stack ("write a script that handles arrows…").
if (skillMode === 'deck') { //
// We fire on either (a) the active skill is a deck skill OR (b) the
// project metadata declares kind=deck. Case (b) catches projects created
// without a skill (skill_id null) — without this, a deck-kind project
// with no bound skill gets neither a skill seed nor the framework
// skeleton, and the agent writes scaling / nav / print logic from scratch
// with the same buggy `place-items: center` + transform pattern we keep
// having to fix at runtime. Skill seeds (when present) win — they
// already define their own opinionated framework (simple-deck's
// scroll-snap, guizang-ppt's magazine layout) and re-pinning the generic
// skeleton would conflict. The skill-seed path takes over via
// `derivePreflight` above, so we only fire the generic skeleton when no
// skill seed is on offer.
const isDeckProject = skillMode === 'deck' || metadata?.kind === 'deck';
const hasSkillSeed =
!!skillBody && /assets\/template\.html/.test(skillBody);
if (isDeckProject && !hasSkillSeed) {
parts.push(`\n\n---\n\n${DECK_FRAMEWORK_DIRECTIVE}`); parts.push(`\n\n---\n\n${DECK_FRAMEWORK_DIRECTIVE}`);
} }
+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)}`;
}
+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;
}
+11 -8
View File
@@ -16,10 +16,10 @@
*/ */
export function buildSrcdoc( export function buildSrcdoc(
html: string, html: string,
options: { deck?: boolean } = {}, options: { deck?: boolean } = {}
): string { ): string {
const head = html.trimStart().slice(0, 64).toLowerCase(); const head = html.trimStart().slice(0, 64).toLowerCase();
const isFullDoc = head.startsWith('<!doctype') || head.startsWith('<html'); const isFullDoc = head.startsWith("<!doctype") || head.startsWith("<html");
const wrapped = isFullDoc const wrapped = isFullDoc
? html ? html
: `<!doctype html> : `<!doctype html>
@@ -68,8 +68,10 @@ function injectSandboxShim(doc: string): string {
tryShim('localStorage'); tryShim('localStorage');
tryShim('sessionStorage'); tryShim('sessionStorage');
})();</script>`; })();</script>`;
if (/<head[^>]*>/i.test(doc)) return doc.replace(/<head[^>]*>/i, (m) => `${m}${shim}`); if (/<head[^>]*>/i.test(doc))
if (/<body[^>]*>/i.test(doc)) return doc.replace(/<body[^>]*>/i, (m) => `${m}${shim}`); return doc.replace(/<head[^>]*>/i, (m) => `${m}${shim}`);
if (/<body[^>]*>/i.test(doc))
return doc.replace(/<body[^>]*>/i, (m) => `${m}${shim}`);
return shim + doc; return shim + doc;
} }
@@ -101,10 +103,10 @@ function injectDeckBridge(doc: string): string {
.stage, .deck-stage, .deck-shell { place-content: center !important; } .stage, .deck-stage, .deck-shell { place-content: center !important; }
</style>`; </style>`;
const docWithStyle = /<\/head>/i.test(doc) const docWithStyle = /<\/head>/i.test(doc)
? doc.replace(/<\/head>/i, styleFix + '</head>') ? doc.replace(/<\/head>/i, styleFix + "</head>")
: /<head[^>]*>/i.test(doc) : /<head[^>]*>/i.test(doc)
? doc.replace(/<head[^>]*>/i, (m) => m + styleFix) ? doc.replace(/<head[^>]*>/i, (m) => m + styleFix)
: styleFix + doc; : styleFix + doc;
doc = docWithStyle; doc = docWithStyle;
const script = `<script>(function(){ const script = `<script>(function(){
function slides(){ return document.querySelectorAll('.slide'); } function slides(){ return document.querySelectorAll('.slide'); }
@@ -265,6 +267,7 @@ function injectDeckBridge(doc: string): string {
} }
observeSlides(); observeSlides();
})();</script>`; })();</script>`;
if (/<\/body>/i.test(doc)) return doc.replace(/<\/body>/i, `${script}</body>`); if (/<\/body>/i.test(doc))
return doc.replace(/<\/body>/i, `${script}</body>`);
return doc + script; return doc + script;
} }
+2
View File
@@ -4,9 +4,11 @@ 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,
+15
View File
@@ -1,10 +1,25 @@
export type ExecMode = 'daemon' | 'api'; export type ExecMode = 'daemon' | 'api';
// Which BYOK model endpoint to talk to in `mode === 'api'`. Each provider
// has its own request shape — see src/providers/{anthropic,openai,azure,
// google}.ts for the wire details. AWS Bedrock and Google Vertex are
// reached via the `anthropic` provider pointed at an Anthropic-compatible
// proxy (e.g. LiteLLM), which keeps signing on the server where the
// long-lived AWS / GCP credentials belong.
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;
+2 -1
View File
@@ -2,11 +2,12 @@ import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
const DAEMON_PORT = Number(process.env.OD_PORT) || 7456; const DAEMON_PORT = Number(process.env.OD_PORT) || 7456;
const VITE_PORT = Number(process.env.VITE_PORT) || 5173;
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { server: {
port: 5173, port: VITE_PORT,
proxy: { proxy: {
'/api': { '/api': {
target: `http://127.0.0.1:${DAEMON_PORT}`, target: `http://127.0.0.1:${DAEMON_PORT}`,