Skip to content

Commit 852e32c

Browse files
committed
feat(desktop): custom base URL (proxy/relay) support + handoff notes
Onboarding now exposes an "Advanced — custom base URL" input under the API key field, so users on third-party gateways or self-hosted relays can validate against and route generation through their own endpoint. - shared/config: add `BaseUrlRef` + `Config.baseUrls` + `OnboardingState.baseUrl` - main/onboarding-ipc: persist baseUrl per provider in config.toml; expose `getBaseUrlForProvider()` for the generate IPC - main/index: thread stored baseUrl into `core.generate()` (renderer-supplied baseUrl in the IPC payload still takes precedence if present) - preload: add `baseUrl` to saveKey signature - renderer/onboarding/PasteKey: collapsible details with URL input, validation re-runs on baseUrl change, plumbed through `onValidated` - renderer/onboarding/index: forward baseUrl to saveKey Plus: docs/HANDOFF.md — kickoff guide for the second contributor (read order, open PRs, suggested next 5 things, gotchas, demo verification checklist). All checks green. Signed-off-by: hqhq1025 <1506751656@qq.com>
1 parent 0eb375d commit 852e32c

7 files changed

Lines changed: 242 additions & 10 deletions

File tree

apps/desktop/src/main/index.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@ import { BRAND, CodesignError, GeneratePayload } from '@open-codesign/shared';
66
import { BrowserWindow, app, ipcMain, shell } from 'electron';
77
import { autoUpdater } from 'electron-updater';
88
import { registerExporterIpc } from './exporter-ipc';
9-
import { getApiKeyForProvider, loadConfigOnBoot, registerOnboardingIpc } from './onboarding-ipc';
9+
import {
10+
getApiKeyForProvider,
11+
getBaseUrlForProvider,
12+
loadConfigOnBoot,
13+
registerOnboardingIpc,
14+
} from './onboarding-ipc';
1015

1116
const __filename = fileURLToPath(import.meta.url);
1217
const __dirname = dirname(__filename);
@@ -55,12 +60,14 @@ function registerIpcHandlers(): void {
5560
ipcMain.handle('codesign:generate', async (_e, raw: unknown) => {
5661
const payload = GeneratePayload.parse(raw);
5762
const apiKey = getApiKeyForProvider(payload.model.provider);
63+
const storedBaseUrl = getBaseUrlForProvider(payload.model.provider);
64+
const baseUrl = payload.baseUrl ?? storedBaseUrl;
5865
return generate({
5966
prompt: payload.prompt,
6067
history: payload.history,
6168
model: payload.model,
6269
apiKey,
63-
...(payload.baseUrl !== undefined ? { baseUrl: payload.baseUrl } : {}),
70+
...(baseUrl !== undefined ? { baseUrl } : {}),
6471
});
6572
});
6673
}

apps/desktop/src/main/onboarding-ipc.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ interface SaveKeyInput {
1515
apiKey: string;
1616
modelPrimary: string;
1717
modelFast: string;
18+
baseUrl?: string;
1819
}
1920

2021
interface ValidateKeyInput {
@@ -53,22 +54,36 @@ export function getApiKeyForProvider(provider: string): string {
5354
return decryptSecret(ref.ciphertext);
5455
}
5556

57+
export function getBaseUrlForProvider(provider: string): string | undefined {
58+
const cfg = getCachedConfig();
59+
if (cfg === null) return undefined;
60+
const ref = cfg.baseUrls?.[provider as keyof typeof cfg.baseUrls];
61+
return ref?.baseUrl;
62+
}
63+
5664
function toState(cfg: Config | null): OnboardingState {
5765
if (cfg === null) {
58-
return { hasKey: false, provider: null, modelPrimary: null, modelFast: null };
66+
return { hasKey: false, provider: null, modelPrimary: null, modelFast: null, baseUrl: null };
5967
}
6068
if (!isSupportedOnboardingProvider(cfg.provider)) {
61-
return { hasKey: false, provider: null, modelPrimary: null, modelFast: null };
69+
return { hasKey: false, provider: null, modelPrimary: null, modelFast: null, baseUrl: null };
6270
}
6371
const ref = cfg.secrets[cfg.provider];
6472
if (ref === undefined) {
65-
return { hasKey: false, provider: cfg.provider, modelPrimary: null, modelFast: null };
73+
return {
74+
hasKey: false,
75+
provider: cfg.provider,
76+
modelPrimary: null,
77+
modelFast: null,
78+
baseUrl: null,
79+
};
6680
}
6781
return {
6882
hasKey: true,
6983
provider: cfg.provider,
7084
modelPrimary: cfg.modelPrimary,
7185
modelFast: cfg.modelFast,
86+
baseUrl: cfg.baseUrls?.[cfg.provider]?.baseUrl ?? null,
7287
};
7388
}
7489

@@ -81,6 +96,7 @@ function parseSaveKey(raw: unknown): SaveKeyInput {
8196
const apiKey = r['apiKey'];
8297
const modelPrimary = r['modelPrimary'];
8398
const modelFast = r['modelFast'];
99+
const baseUrl = r['baseUrl'];
84100
if (typeof provider !== 'string' || !isSupportedOnboardingProvider(provider)) {
85101
throw new CodesignError(
86102
`Provider "${String(provider)}" is not supported in v0.1.`,
@@ -96,7 +112,16 @@ function parseSaveKey(raw: unknown): SaveKeyInput {
96112
if (typeof modelFast !== 'string' || modelFast.trim().length === 0) {
97113
throw new CodesignError('modelFast must be a non-empty string', 'IPC_BAD_INPUT');
98114
}
99-
return { provider, apiKey, modelPrimary, modelFast };
115+
const out: SaveKeyInput = { provider, apiKey, modelPrimary, modelFast };
116+
if (typeof baseUrl === 'string' && baseUrl.trim().length > 0) {
117+
try {
118+
new URL(baseUrl);
119+
} catch {
120+
throw new CodesignError(`baseUrl "${baseUrl}" is not a valid URL`, 'IPC_BAD_INPUT');
121+
}
122+
out.baseUrl = baseUrl.trim();
123+
}
124+
return out;
100125
}
101126

102127
function parseValidateKey(raw: unknown): ValidateKeyInput {
@@ -135,6 +160,12 @@ export function registerOnboardingIpc(): void {
135160
ipcMain.handle('onboarding:save-key', async (_e, raw: unknown): Promise<OnboardingState> => {
136161
const input = parseSaveKey(raw);
137162
const ciphertext = encryptSecret(input.apiKey);
163+
const nextBaseUrls = { ...(cachedConfig?.baseUrls ?? {}) };
164+
if (input.baseUrl !== undefined) {
165+
nextBaseUrls[input.provider] = { baseUrl: input.baseUrl };
166+
} else {
167+
delete nextBaseUrls[input.provider];
168+
}
138169
const next: Config = {
139170
version: 1,
140171
provider: input.provider,
@@ -144,6 +175,7 @@ export function registerOnboardingIpc(): void {
144175
...(cachedConfig?.secrets ?? {}),
145176
[input.provider]: { ciphertext },
146177
},
178+
baseUrls: nextBaseUrls,
147179
};
148180
await writeConfig(next);
149181
cachedConfig = next;

apps/desktop/src/preload/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ const api = {
5757
apiKey: string;
5858
modelPrimary: string;
5959
modelFast: string;
60+
baseUrl?: string;
6061
}) => ipcRenderer.invoke('onboarding:save-key', input) as Promise<OnboardingState>,
6162
skip: () => ipcRenderer.invoke('onboarding:skip') as Promise<OnboardingState>,
6263
},

apps/desktop/src/renderer/src/onboarding/PasteKey.tsx

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,18 @@ type ValidationState =
1818
| { kind: 'error'; code: ValidateKeyError['code'] | 'unsupported'; message: string };
1919

2020
interface PasteKeyProps {
21-
onValidated: (provider: SupportedOnboardingProvider, apiKey: string) => void;
21+
onValidated: (
22+
provider: SupportedOnboardingProvider,
23+
apiKey: string,
24+
baseUrl: string | null,
25+
) => void;
2226
onBack: () => void;
2327
}
2428

2529
export function PasteKey({ onValidated, onBack }: PasteKeyProps) {
2630
const [apiKey, setApiKey] = useState('');
31+
const [baseUrl, setBaseUrl] = useState('');
32+
const [advancedOpen, setAdvancedOpen] = useState(false);
2733
const [provider, setProvider] = useState<SupportedOnboardingProvider | null>(null);
2834
const [state, setState] = useState<ValidationState>({ kind: 'idle' });
2935
const reqIdRef = useRef(0);
@@ -34,6 +40,7 @@ export function PasteKey({ onValidated, onBack }: PasteKeyProps) {
3440
}, []);
3541

3642
const trimmed = apiKey.trim();
43+
const trimmedBaseUrl = baseUrl.trim();
3744

3845
useEffect(() => {
3946
if (trimmed.length === 0) {
@@ -95,6 +102,7 @@ export function PasteKey({ onValidated, onBack }: PasteKeyProps) {
95102
result = await window.codesign.onboarding.validateKey({
96103
provider: detected,
97104
apiKey: trimmed,
105+
...(trimmedBaseUrl.length > 0 ? { baseUrl: trimmedBaseUrl } : {}),
98106
});
99107
} catch (err) {
100108
if (reqId !== reqIdRef.current) return;
@@ -115,7 +123,7 @@ export function PasteKey({ onValidated, onBack }: PasteKeyProps) {
115123
}, VALIDATE_DEBOUNCE_MS);
116124

117125
return () => window.clearTimeout(handle);
118-
}, [trimmed]);
126+
}, [trimmed, trimmedBaseUrl]);
119127

120128
const helpUrl = useMemo(() => {
121129
if (provider === null) return null;
@@ -124,7 +132,7 @@ export function PasteKey({ onValidated, onBack }: PasteKeyProps) {
124132

125133
function handleContinue() {
126134
if (state.kind !== 'ok' || provider === null) return;
127-
onValidated(provider, trimmed);
135+
onValidated(provider, trimmed, trimmedBaseUrl.length > 0 ? trimmedBaseUrl : null);
128136
}
129137

130138
return (
@@ -162,6 +170,41 @@ export function PasteKey({ onValidated, onBack }: PasteKeyProps) {
162170

163171
<StatusLine provider={provider} state={state} helpUrl={helpUrl} />
164172

173+
<details
174+
open={advancedOpen}
175+
onToggle={(e) => setAdvancedOpen((e.currentTarget as HTMLDetailsElement).open)}
176+
className="text-[13px] text-[var(--color-text-secondary)]"
177+
>
178+
<summary
179+
className="cursor-pointer select-none text-[12px] text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)] transition-colors"
180+
style={{ fontFamily: 'var(--font-mono)' }}
181+
>
182+
Advanced — custom base URL (proxy / relay)
183+
</summary>
184+
<label className="flex flex-col gap-2 mt-3">
185+
<span
186+
className="text-[10px] uppercase tracking-[0.08em] text-[var(--color-text-muted)] font-medium"
187+
style={{ fontFamily: 'var(--font-mono)' }}
188+
>
189+
Base URL
190+
</span>
191+
<input
192+
type="url"
193+
value={baseUrl}
194+
onChange={(e) => setBaseUrl(e.target.value)}
195+
placeholder="https://your-proxy.example.com/v1"
196+
spellCheck={false}
197+
style={{ fontFamily: 'var(--font-mono)' }}
198+
className="w-full h-[40px] px-3 rounded-[var(--radius-md)] bg-[var(--color-surface)] border border-[var(--color-border)] text-[13px] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-muted)] focus:outline-none focus:border-[var(--color-accent)] focus:shadow-[0_0_0_3px_var(--color-focus-ring)] transition-[box-shadow,border-color] duration-150 ease-[cubic-bezier(0.16,1,0.3,1)]"
199+
/>
200+
<span className="text-[12px] text-[var(--color-text-muted)] leading-[1.5]">
201+
Override the default endpoint for your provider. Useful for relay services
202+
(e.g. third-party AI gateways) and self-hosted proxies. Leave empty for the
203+
official endpoint.
204+
</span>
205+
</label>
206+
</details>
207+
165208
<div className="flex justify-between gap-2 pt-2">
166209
<Button type="button" variant="ghost" onClick={onBack}>
167210
Back

apps/desktop/src/renderer/src/onboarding/index.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@ export function Onboarding() {
1313
const [step, setStep] = useState<Step>('welcome');
1414
const [provider, setProvider] = useState<SupportedOnboardingProvider | null>(null);
1515
const [apiKey, setApiKey] = useState('');
16+
const [baseUrl, setBaseUrl] = useState<string | null>(null);
1617
const [saving, setSaving] = useState(false);
1718
const [errorMessage, setErrorMessage] = useState<string | null>(null);
1819

19-
function handleValidated(p: SupportedOnboardingProvider, key: string) {
20+
function handleValidated(p: SupportedOnboardingProvider, key: string, url: string | null) {
2021
setProvider(p);
2122
setApiKey(key);
23+
setBaseUrl(url);
2224
setStep('model');
2325
}
2426

@@ -36,6 +38,7 @@ export function Onboarding() {
3638
apiKey,
3739
modelPrimary,
3840
modelFast,
41+
...(baseUrl !== null ? { baseUrl } : {}),
3942
});
4043
completeOnboarding(next);
4144
} catch (err) {

docs/HANDOFF.md

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
# Handoff Notes — open-codesign
2+
3+
For the second contributor joining the project. Goal: pick up where main was left, ship the remaining v0.1 quality bar, then move to v0.2 features.
4+
5+
Last updated: 2026-04-18 by hqhq1025.
6+
7+
---
8+
9+
## 0. First 30 minutes
10+
11+
```bash
12+
git clone git@github.com:OpenCoworkAI/open-codesign.git
13+
cd open-codesign
14+
pnpm install
15+
pnpm --filter @open-codesign/desktop dev
16+
```
17+
18+
Open Settings on the welcome screen, paste a real API key (Anthropic / OpenAI / OpenRouter), pick models, then try a starter prompt. If you see a generated HTML mockup in the right pane and can export it via the toolbar, you have a working baseline.
19+
20+
If you use a proxy / relay (中转站), expand **Advanced — custom base URL** under the API key field and paste the relay endpoint (must be a full URL including `/v1`).
21+
22+
---
23+
24+
## 1. Read first
25+
26+
Read these in order before changing anything substantial:
27+
28+
1. [`docs/CONSENSUS.md`](./docs/CONSENSUS.md) — single source of truth for decisions, current state, gotchas
29+
2. [`CLAUDE.md`](./CLAUDE.md) — repo conventions
30+
3. [`docs/PRINCIPLES.md`](./docs/PRINCIPLES.md) — CI-enforced engineering rules (especially §5b)
31+
4. [`docs/VISION.md`](./docs/VISION.md) — what the product is and isn't
32+
5. [`docs/COLLABORATION.md`](./docs/COLLABORATION.md) — workflow (worktrees, PRs, squash-merge cadence)
33+
6. [`docs/RESEARCH_QUEUE.md`](./docs/RESEARCH_QUEUE.md) — the 9 research reports under `docs/research/` and what each one decided
34+
35+
---
36+
37+
## 2. State of the union
38+
39+
**On `main` and working today:**
40+
- Onboarding wizard (3 steps) with `safeStorage`-encrypted keychain
41+
- Custom base URL (proxy / relay) input under "Advanced" in the paste-key step
42+
- 4 starter demo prompts (`packages/templates/src/index.ts`)
43+
- HTML generation via `@mariozechner/pi-ai` → streaming artifact parser → iframe preview
44+
- HTML export via `dialog.showSaveDialog`
45+
- Marketing site (VitePress) at `website/`
46+
- Tokenized design system in `packages/ui/src/tokens.css` (Wordmark, EmptyMark, full text/leading/tracking/space/motion scales)
47+
48+
**Open PRs that need conflict resolution before they merge:**
49+
50+
| # | Branch | What it adds | Conflicts on |
51+
|---|---|---|---|
52+
| 2 | `wt/i18n` | `packages/i18n` (en + zh-CN), locale IPC, per-locale demo prompts, system-locale auto-detect | `App.tsx`, `store.ts`, `main/index.ts`, `preload/index.ts` |
53+
| 3 | `wt/preview-ux` | Settings overlay (4 tabs), command palette (Cmd+K), Toast, Sidebar/PreviewPane/TopBar extraction, theme toggle | `App.tsx`, `store.ts` |
54+
| 6 | `wt/reliability` | Error boundaries, AbortController cancellation, `completeWithRetry` with 429 handling, iframe error reporting | `App.tsx`, `store.ts`, `core/index.ts`, `providers/index.ts` |
55+
| 7 | `wt/exporters` | Real PDF (puppeteer-core + system Chrome), real PPTX (pptxgenjs + CJK fix), real ZIP (zip-lib) | `package.json`, `PreviewToolbar.tsx` |
56+
57+
These branches are valuable but the App.tsx/store.ts conflicts are 4-way (we have onboarding + first-demo + UIUX iteration on main now). Resolution strategy options:
58+
59+
- **Option A** (recommended): cherry-pick the new files (those are conflict-free) and rewrite the App.tsx/store.ts integration by hand on top of current main.
60+
- **Option B**: rebase each branch carefully and resolve the merge markers manually.
61+
- **Option C**: close the PRs and reimplement the missing features as fresh small PRs against the current main.
62+
63+
I tried option B for #2 and hit four-way merges in store.ts. Option A is what I would do next.
64+
65+
---
66+
67+
## 3. Suggested next 5 things to ship (in order)
68+
69+
1. **Resolve the 4 open PRs** (or reimplement their features). Highest-value pieces, in priority order:
70+
- `wt/exporters` real PDF + ZIP (PPTX is tier 2 and can wait)
71+
- `wt/reliability` error boundaries + AbortController + retry
72+
- `wt/i18n` zh-CN end-to-end (most users will want this)
73+
- `wt/preview-ux` settings overlay + command palette + theme toggle
74+
2. **Streaming generation** (Tier 2 of `packages/core`) — currently `core.generate()` blocks until the full response arrives. Switch to `streamArtifacts()` so tokens appear as they arrive and the UI can show ≤200 ms feedback. See `docs/research/05-pi-ai-boundary.md` and `docs/research/07-first-5-minutes.md`.
75+
3. **Inline comment loop** — click an element in the preview, write a comment, AI rewrites that region. Mechanism is decided in `docs/research/02-inline-comment-and-sliders.md`. Estimated 3-5 days.
76+
4. **AI-generated custom sliders** — model emits `design_params` JSON; frontend renders sliders bound to CSS variables; drag mutates CSS without re-running the model. Same research report.
77+
5. **UIUX iteration v2** — push 7.5/10 → 9/10. Focus areas: chat history list, generation-in-progress states (skeleton, streaming token highlight), settings drawer. The first iteration is in commits `49985a9` ... `a11f416`.
78+
79+
---
80+
81+
## 4. Workflow expectations
82+
83+
- **Branch naming**: `feat/<slug>` for features, `fix/<slug>` for bugfixes, `wt/<slug>` for worktree-isolated agent work.
84+
- **DCO sign-off** required (`git commit -s -m "..."`). CI's `DCO check` step blocks otherwise.
85+
- **Conventional Commits** (commitlint enforces this).
86+
- **Squash-merge** to main as soon as CI is green. Pre-alpha solo workflow does not require external review; switch back to PR review when we go public.
87+
- **No new prod deps** without justification (size + license + alternatives) in the PR body.
88+
- **Run before push**: `pnpm install && pnpm -r typecheck && pnpm lint && pnpm -r test`
89+
- **All visible UI** uses `var(--color-*)` / `var(--text-*)` / `var(--space-*)` tokens. Never hardcode hex / px / fonts.
90+
91+
---
92+
93+
## 5. Things I would have done if I had another hour
94+
95+
- Properly resolve the 4 open PRs into main (the file boundaries are fine, the merge effort is real).
96+
- Add `OPENAI_API_KEY` + `OPENAI_BASE_URL` repo secrets and set `vars.CODEX_BOT_ENABLED=true` so the Codex bot starts reviewing PRs.
97+
- Wire the bundle-size CI gate (`size-limit` step in `.github/workflows/ci.yml`) before any release.
98+
- Write integration tests for the onboarding flow (Playwright against the built app).
99+
- Acquire a real exported HTML sample from Claude Design Pro and reverse-engineer it — the artifact schema in `packages/shared` is still tentative.
100+
101+
---
102+
103+
## 6. Things to be careful about
104+
105+
These have bitten me already:
106+
107+
- **Preload path** is `out/preload/index.mjs` (NOT `.js`). Electron 33+ supports it natively but the main process must reference `.mjs`. Already fixed in `46e8c8d`.
108+
- **electron-vite + lockfile drift**: after a fresh `pnpm install`, you sometimes need `pnpm install` AGAIN if you switched between worktrees. The Re-optimizing dependencies message in the dev log is the signal.
109+
- **Squash-merge can leave conflict markers** if a rebase was partially completed. Always grep for `<<<<<<<` after a squash-merge: `grep -rn "<<<<<<<" apps/ packages/`.
110+
- **Tailwind v4 length tokens**: `text-[var(--text-xl)]` is silently treated as ambiguous. Use `text-[length:var(--text-xl)]` or register tokens via `@theme`.
111+
- **Biome `useLiteralKeys` is OFF** because it conflicts with TS `noPropertyAccessFromIndexSignature`. Use bracket notation for env / record / config access (`process.env['FOO']`, not `process.env.FOO`).
112+
- **pi-ai is single-maintainer** (`badlogic/pi-mono`, ~36k stars, bus-factor 1). Pin the version. Don't fork. Wrap missing features in `packages/providers`.
113+
- **Electron 41.x is excluded** in `renovate.json` due to a cross-origin isolation regression. Don't bypass.
114+
115+
---
116+
117+
## 7. Pilot demo verification checklist
118+
119+
After any non-trivial change, walk this list to confirm nothing regressed:
120+
121+
- [ ] `pnpm install && pnpm -r typecheck && pnpm lint && pnpm -r test` all green
122+
- [ ] `pnpm --filter @open-codesign/desktop dev` opens an Electron window without errors in `/tmp/codesign-dev.log`
123+
- [ ] First launch shows the Welcome step of the onboarding wizard
124+
- [ ] Pasting a real `sk-ant-...` key auto-detects Anthropic and validates within 500 ms
125+
- [ ] Expanding "Advanced — custom base URL" and pasting a proxy URL also validates against the proxy
126+
- [ ] After completing onboarding, the chat shell with 4 starter chips appears
127+
- [ ] Clicking any chip + Send produces an HTML artifact in the right pane within ~30 s
128+
- [ ] Click Export ▾ → HTML → save → opens correctly in the browser
129+
- [ ] PDF / PPTX / ZIP show "Coming in Phase 2" until #7 lands
130+
- [ ] Restart the app — onboarding is skipped and the chat shell loads with the saved provider
131+
132+
---
133+
134+
## 8. How to reach me
135+
136+
- GitHub @hqhq1025 — open an Issue, tag me on a PR, or comment on a Discussion
137+
- For architecture / direction questions, open a GitHub Discussion first; don't change the locked decisions in `docs/VISION.md` without that
138+
139+
Happy hacking. Build the simplest thing that works, then iterate. Do not skip the research-first step — every locked decision in this repo has a report under `docs/research/` explaining why.

0 commit comments

Comments
 (0)