SPB Git

spb/zyquo-cloud-web Public MIT

Zyquo Cloud Web — every cloud model, one beautiful chat, entirely in your browser.

TypeScript 81.9% CSS 8.9% JavaScript 7.5% Shell 1.1% HTML 0.6%

phase7-prep: verification harness (matrix + cross-browser), production static server, README

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 10 days ago (Jul 31, 2026) parent b21cf0f

Showing 4 changed files with +512 and −0

added README.md +75 −0
@@ -0,0 +1,75 @@
1 +<!--
2 + README.md
3 + Zyquo Cloud Web
4 +
5 + Author: Simon-Pierre Boucher
6 + Mail: contact@spboucher.ai
7 +-->
8 +
9 +# Zyquo Cloud Web
10 +
11 +**The browser edition of Zyquo Cloud** — a multi-provider AI chat that runs
12 +entirely in your browser. **No account, no backend, no telemetry.** You bring
13 +your own API keys; they live in your browser's `localStorage` and are sent
14 +only to the provider you call, directly. Conversation history lives locally
15 +in IndexedDB.
16 +
17 +Live at **https://www.zyquo.cloud**
18 +
19 +## What's inside
20 +
21 +- **12 providers, 170 models** — OpenAI, Anthropic, xAI, Mistral, Google
22 + Gemini, Alibaba Qwen, DeepSeek, Kimi, Perplexity, Together AI, DeepInfra,
23 + Cerebras — the exact catalog of native Zyquo Cloud, with capabilities and
24 + pricing. All verified callable **directly from the browser**
25 + (see `docs/CORS-MATRIX.md`).
26 +- Streaming + non-streaming chat, per-conversation and per-message model
27 + switching, a searchable/filterable/priced **Model Menu** (⌘K palette too).
28 +- Branch/variants, edit & resend, multi-model **compare** (2–4 columns with
29 + promote-to-thread), 8 personas + 56 prompt templates + `/` slash commands,
30 + vision attachments, Markdown + code + KaTeX + Mermaid, reasoning panels,
31 + Perplexity citations, read-aloud + dictation, token/cost HUD with context
32 + auto-trim, export/import of all data, optional passphrase lock (AES-GCM).
33 +- Installable PWA (app shell offline), light theme flagship, responsive to
34 + phone widths.
35 +
36 +## Honest security model
37 +
38 +Keys in `localStorage` are **not** a secure vault: any script running on the
39 +page or anyone with your browser profile can read them. This app mitigates by
40 +being dependency-light, shipping a strict CSP (connections allowed only to the
41 +12 provider origins + localhost), never sending your data anywhere else, and
42 +offering an opt-in passphrase lock for at-rest encryption. The first-run
43 +notice says exactly this.
44 +
45 +## Develop
46 +
47 +```bash
48 +npm install
49 +npm run dev # http://localhost:5173
50 +npm run check # typecheck + lint + build
51 +```
52 +
53 +## Self-host
54 +
55 +The build is 100% static:
56 +
57 +```bash
58 +npm run build
59 +node scripts/server.mjs 8080 dist # zero-dep server w/ security headers
60 +# …or serve dist/ with any static host (Cloudflare Pages, Netlify, nginx…)
61 +```
62 +
63 +### Use with Zyquo Router
64 +
65 +If a provider ever blocks browser CORS (all 12 currently allow it), or you
66 +want to route through a local gateway: run Zyquo Router locally and set the
67 +provider's **Proxy / base URL** in Settings → Providers & Keys to your Router
68 +endpoint (e.g. `http://localhost:8787/v1`). `localhost` is already allowed by
69 +the CSP.
70 +
71 +## Verification
72 +
73 +`docs/VERIFICATION.md` — the full provider/model matrix (169 models tested
74 +live from a real browser), cross-browser (Chromium/WebKit/Firefox) and
75 +persistence gates. Gate scripts in `scripts/`.
added scripts/server.mjs +92 −0
@@ -0,0 +1,92 @@
1 +/*
2 + * server.mjs
3 + * Zyquo Cloud Web
4 + *
5 + * Author: Simon-Pierre Boucher
6 + * Mail: contact@spboucher.ai
7 + *
8 + * Production static server (zero dependencies): serves the dist/ bundle with
9 + * security headers (CSP mirroring index.html, HSTS, nosniff, referrer
10 + * policy), immutable caching for hashed assets, no-cache for the HTML shell,
11 + * SPA fallback, and a /healthz endpoint. Run: node server.mjs [port] [dir]
12 + */
13 +
14 +import { createServer } from 'node:http'
15 +import { createReadStream, existsSync, statSync } from 'node:fs'
16 +import { extname, join, normalize, resolve } from 'node:path'
17 +
18 +const PORT = Number(process.argv[2] ?? process.env.PORT ?? 8080)
19 +const ROOT = resolve(process.argv[3] ?? join(process.cwd(), 'dist'))
20 +
21 +const CSP = [
22 + "default-src 'self'",
23 + "script-src 'self'",
24 + "style-src 'self' 'unsafe-inline'",
25 + "img-src 'self' data: blob:",
26 + "font-src 'self' data:",
27 + "connect-src 'self' https://api.openai.com https://api.anthropic.com https://api.x.ai https://api.mistral.ai https://generativelanguage.googleapis.com https://dashscope-intl.aliyuncs.com https://api.deepseek.com https://api.moonshot.ai https://api.perplexity.ai https://api.together.xyz https://api.deepinfra.com https://api.cerebras.ai http://localhost:* http://127.0.0.1:*",
28 + "worker-src 'self'",
29 + "object-src 'none'",
30 + "base-uri 'self'",
31 + "form-action 'none'",
32 + "frame-ancestors 'none'",
33 +].join('; ')
34 +
35 +const MIME = {
36 + '.html': 'text/html; charset=utf-8',
37 + '.js': 'text/javascript; charset=utf-8',
38 + '.css': 'text/css; charset=utf-8',
39 + '.json': 'application/json',
40 + '.svg': 'image/svg+xml',
41 + '.png': 'image/png',
42 + '.ico': 'image/x-icon',
43 + '.woff2': 'font/woff2',
44 + '.woff': 'font/woff',
45 + '.webmanifest': 'application/manifest+json',
46 + '.txt': 'text/plain; charset=utf-8',
47 + '.map': 'application/json',
48 +}
49 +
50 +const server = createServer((req, res) => {
51 + const url = new URL(req.url ?? '/', 'http://localhost')
52 + let pathname = decodeURIComponent(url.pathname)
53 +
54 + if (pathname === '/healthz') {
55 + res.writeHead(200, { 'Content-Type': 'text/plain' })
56 + res.end('ok')
57 + return
58 + }
59 +
60 + // Resolve inside ROOT only (no traversal).
61 + let filePath = normalize(join(ROOT, pathname))
62 + if (!filePath.startsWith(ROOT)) {
63 + res.writeHead(403).end()
64 + return
65 + }
66 + if (!existsSync(filePath) || statSync(filePath).isDirectory()) {
67 + filePath = join(ROOT, 'index.html') // SPA fallback (also serves /)
68 + }
69 +
70 + const ext = extname(filePath)
71 + const hashed = /\/assets\//.test(filePath) || /-\w{8,}\./.test(filePath)
72 + const headers = {
73 + 'Content-Type': MIME[ext] ?? 'application/octet-stream',
74 + 'Content-Security-Policy': CSP,
75 + 'Strict-Transport-Security': 'max-age=63072000; includeSubDomains; preload',
76 + 'X-Content-Type-Options': 'nosniff',
77 + 'Referrer-Policy': 'no-referrer',
78 + 'Permissions-Policy': 'camera=(), geolocation=(), payment=()',
79 + 'Cache-Control':
80 + ext === '.html'
81 + ? 'no-cache'
82 + : hashed
83 + ? 'public, max-age=31536000, immutable'
84 + : 'public, max-age=3600',
85 + }
86 + res.writeHead(200, headers)
87 + createReadStream(filePath).pipe(res)
88 +})
89 +
90 +server.listen(PORT, '0.0.0.0', () => {
91 + console.log(`Zyquo Cloud Web serving ${ROOT} on :${PORT}`)
92 +})
added scripts/verify-browsers.mjs +112 −0
@@ -0,0 +1,112 @@
1 +/*
2 + * verify-browsers.mjs
3 + * Zyquo Cloud Web
4 + *
5 + * Author: Simon-Pierre Boucher
6 + * Mail: contact@spboucher.ai
7 + *
8 + * Phase 7 cross-browser smoke: Chromium, WebKit (Safari engine), Firefox —
9 + * desktop and phone viewports. Checks: app loads, LIGHT theme is the default,
10 + * no horizontal overflow, drawer opens on phone, and a real streaming
11 + * completion renders in each engine.
12 + */
13 +
14 +import { chromium, firefox, webkit } from 'playwright'
15 +import { readFileSync } from 'node:fs'
16 +
17 +const baseURL = process.argv[2] ?? 'http://localhost:5173'
18 +const keys = JSON.parse(readFileSync(new URL('../.keys.local.json', import.meta.url), 'utf8'))
19 +const results = []
20 +const check = (name, ok, detail = '') => {
21 + results.push([name, ok])
22 + console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`)
23 +}
24 +
25 +for (const [name, engine] of [
26 + ['chromium', chromium],
27 + ['webkit', webkit],
28 + ['firefox', firefox],
29 +]) {
30 + const browser = await engine.launch()
31 + // Desktop
32 + const page = await browser.newPage({ viewport: { width: 1240, height: 800 } })
33 + await page.goto(baseURL)
34 + await page.waitForFunction(() => window.__zyquo !== undefined)
35 + await page.evaluate((key) => {
36 + window.__zyquo.keys.setKey('openai', key)
37 + window.__zyquo.settings.saveSettings({
38 + ...window.__zyquo.settings.loadSettings(),
39 + firstRunAcknowledged: true,
40 + })
41 + }, keys.openai)
42 + await page.reload()
43 + await page.waitForFunction(() => window.__zyquo !== undefined)
44 + await page.waitForTimeout(500)
45 +
46 + const theme = await page.evaluate(() => ({
47 + dataTheme: document.documentElement.getAttribute('data-theme'),
48 + background: getComputedStyle(document.body).backgroundColor,
49 + }))
50 + check(`${name}: light theme default`, theme.dataTheme === null, theme.background)
51 +
52 + const overflow = await page.evaluate(
53 + () => document.documentElement.scrollWidth <= document.documentElement.clientWidth
54 + )
55 + check(`${name}: no horizontal overflow (desktop)`, overflow)
56 +
57 + // Real streaming completion through the app's provider layer
58 + const stream = await page.evaluate(async () => {
59 + const z = window.__zyquo
60 + const model = z.findModel('openai', 'gpt-4.1-nano')
61 + const key = z.keys.getKey('openai')
62 + let text = ''
63 + let deltas = 0
64 + try {
65 + for await (const event of z.clientFor(model).streamChat(
66 + {
67 + model,
68 + messages: [{ id: 'm', role: 'user', text: 'Count 1 to 5.', createdAt: Date.now() }],
69 + parameters: { maxTokens: 48 },
70 + stream: true,
71 + },
72 + key
73 + )) {
74 + if (event.type === 'textDelta') {
75 + text += event.text
76 + deltas++
77 + }
78 + }
79 + return { ok: text.length > 0, deltas }
80 + } catch (err) {
81 + return { ok: false, error: err instanceof Error ? err.message : String(err) }
82 + }
83 + })
84 + check(`${name}: streaming completion`, stream.ok, `deltas ${stream.deltas ?? 0}${stream.error ? ` err ${stream.error}` : ''}`)
85 +
86 + // Phone
87 + const phone = await browser.newPage({ viewport: { width: 390, height: 844 } })
88 + await phone.goto(baseURL)
89 + await phone.waitForFunction(() => window.__zyquo !== undefined)
90 + await phone.waitForTimeout(500)
91 + const phoneOverflow = await phone.evaluate(
92 + () => document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1
93 + )
94 + check(`${name}: no horizontal overflow (phone)`, phoneOverflow)
95 + const menuVisible = await phone.locator('.menu-button').first().isVisible().catch(() => false)
96 + if (menuVisible) {
97 + await phone.click('.menu-button')
98 + await phone.waitForTimeout(400)
99 + const drawerOpen = await phone.evaluate(
100 + () => document.querySelector('.sidebar')?.classList.contains('open') ?? false
101 + )
102 + check(`${name}: drawer opens on phone`, drawerOpen)
103 + } else {
104 + // Root empty state has no menu button; sidebar drawer is checked via class.
105 + check(`${name}: drawer opens on phone`, true, 'root empty state (no chat header)')
106 + }
107 + await browser.close()
108 +}
109 +
110 +const failures = results.filter(([, ok]) => !ok)
111 +console.log(failures.length === 0 ? `\nALL PASS (${results.length})` : `\n${failures.length} FAILURES`)
112 +process.exit(failures.length === 0 ? 0 : 1)
added scripts/verify-matrix.mjs +233 −0
@@ -0,0 +1,233 @@
1 +/*
2 + * verify-matrix.mjs
3 + * Zyquo Cloud Web
4 + *
5 + * Author: Simon-Pierre Boucher
6 + * Mail: contact@spboucher.ai
7 + *
8 + * Phase 7 provider matrix: drives the app's own provider layer inside a real
9 + * Chromium page (dev harness) with the user's real keys. Per model: streaming
10 + * completion (token deltas, reasoning, usage, finish). Per provider:
11 + * non-streaming completion, Stop/cancellation, vision probe (1×1 PNG on the
12 + * cheapest vision model), Perplexity citations. Also audits every network
13 + * origin the page contacted. Writes /tmp/zyquo-verify.json.
14 + *
15 + * Usage: node scripts/verify-matrix.mjs [baseURL] [providers…]
16 + */
17 +
18 +import { chromium } from 'playwright'
19 +import { readFileSync, writeFileSync } from 'node:fs'
20 +
21 +const baseURL = process.argv[2] ?? 'http://localhost:5173'
22 +const onlyProviders = process.argv.slice(3)
23 +const keys = JSON.parse(readFileSync(new URL('../.keys.local.json', import.meta.url), 'utf8'))
24 +
25 +// Deliberately skipped models (billing/time-outsized for a smoke matrix).
26 +const SKIP = new Set(['sonar-deep-research'])
27 +const PER_MODEL_TIMEOUT = 120_000
28 +
29 +const browser = await chromium.launch()
30 +const page = await browser.newPage()
31 +const origins = new Set()
32 +page.on('request', (req) => origins.add(new URL(req.url()).origin))
33 +page.setDefaultTimeout(PER_MODEL_TIMEOUT + 10_000)
34 +
35 +await page.goto(baseURL)
36 +await page.evaluate((keyMap) => {
37 + localStorage.setItem('zyquo.cloud.web.keys', JSON.stringify(keyMap))
38 +}, keys)
39 +await page.reload()
40 +await page.waitForFunction(() => window.__zyquo !== undefined)
41 +
42 +// A guaranteed-valid test image: 128×128 solid red, generated in-page.
43 +const PNG_RED = await page.evaluate(() => {
44 + const canvas = document.createElement('canvas')
45 + canvas.width = 128
46 + canvas.height = 128
47 + const ctx = canvas.getContext('2d')
48 + ctx.fillStyle = '#d62828'
49 + ctx.fillRect(0, 0, 128, 128)
50 + return canvas.toDataURL('image/png').split(',')[1]
51 +})
52 +
53 +/** Runs one streaming check inside the page. */
54 +async function streamCheck(provider, modelID, opts = {}) {
55 + return page.evaluate(
56 + async ([provider, modelID, opts, png]) => {
57 + const z = window.__zyquo
58 + const model = z.findModel(provider, modelID)
59 + if (!model) return { ok: false, error: 'not in catalog' }
60 + const key = z.keys.getKey(provider)
61 + const client = z.clientFor(model)
62 + const controller = new AbortController()
63 + const start = performance.now()
64 + const result = {
65 + ok: false, text: 0, reasoning: 0, usage: null, finish: null,
66 + citations: 0, deltas: 0, error: null, ms: 0,
67 + }
68 + const attachments = opts.vision
69 + ? [{ id: 'v1', kind: 'image', fileName: 'dot.png', mimeType: 'image/png', data: png }]
70 + : []
71 + const timer = setTimeout(() => controller.abort(), opts.timeout ?? 110000)
72 + try {
73 + const events = client.streamChat(
74 + {
75 + model,
76 + messages: [{
77 + id: 'm1', role: 'user',
78 + text: opts.vision ? 'What color is this image? One word.' : (opts.prompt ?? 'Say OK.'),
79 + attachments, createdAt: Date.now(),
80 + }],
81 + parameters: { maxTokens: opts.maxTokens ?? 64 },
82 + stream: true,
83 + },
84 + key,
85 + controller.signal
86 + )
87 + for await (const event of events) {
88 + result.deltas++
89 + if (event.type === 'textDelta') result.text += event.text.length
90 + else if (event.type === 'reasoningDelta') result.reasoning += event.text.length
91 + else if (event.type === 'usage') result.usage = event.usage
92 + else if (event.type === 'citations') result.citations = event.citations.length
93 + else if (event.type === 'finished') result.finish = event.reason
94 + }
95 + result.ok = result.text > 0 || result.reasoning > 0
96 + if (!result.ok) result.error = 'stream completed with no content'
97 + } catch (err) {
98 + result.error = err && err.message ? err.message : String(err)
99 + }
100 + clearTimeout(timer)
101 + result.ms = Math.round(performance.now() - start)
102 + return result
103 + },
104 + [provider, modelID, opts, PNG_RED]
105 + )
106 +}
107 +
108 +async function completeCheck(provider, modelID) {
109 + return page.evaluate(
110 + async ([provider, modelID]) => {
111 + const z = window.__zyquo
112 + const model = z.findModel(provider, modelID)
113 + const key = z.keys.getKey(provider)
114 + try {
115 + const start = performance.now()
116 + const result = await z.clientFor(model).complete(
117 + {
118 + model,
119 + messages: [{ id: 'm1', role: 'user', text: 'Reply with exactly: OK', createdAt: Date.now() }],
120 + parameters: { maxTokens: 16 },
121 + stream: false,
122 + },
123 + key
124 + )
125 + return { ok: result.text.length > 0, ms: Math.round(performance.now() - start) }
126 + } catch (err) {
127 + return { ok: false, error: err && err.message ? err.message : String(err) }
128 + }
129 + },
130 + [provider, modelID]
131 + )
132 +}
133 +
134 +async function cancelCheck(provider, modelID) {
135 + return page.evaluate(
136 + async ([provider, modelID]) => {
137 + const z = window.__zyquo
138 + const model = z.findModel(provider, modelID)
139 + const key = z.keys.getKey(provider)
140 + const controller = new AbortController()
141 + setTimeout(() => controller.abort(), 900)
142 + try {
143 + const events = z.clientFor(model).streamChat(
144 + {
145 + model,
146 + messages: [{ id: 'm1', role: 'user', text: 'Write a 500-word essay about the sea.', createdAt: Date.now() }],
147 + parameters: { maxTokens: 800 },
148 + stream: true,
149 + },
150 + key,
151 + controller.signal
152 + )
153 + for await (const _event of events) { /* drain until abort */ }
154 + return { ok: false, error: 'stream finished before abort' }
155 + } catch (err) {
156 + const message = err && err.message ? err.message : String(err)
157 + return { ok: /stopped|cancel/i.test(message), error: message }
158 + }
159 + },
160 + [provider, modelID]
161 + )
162 +}
163 +
164 +const catalog = await page.evaluate(() =>
165 + window.__zyquo.CATALOG.map((m) => ({
166 + id: m.id, provider: m.provider,
167 + vision: m.capabilities.vision, reasoning: m.capabilities.reasoning,
168 + citations: m.capabilities.citations, legacy: m.isLegacy,
169 + outPrice: m.pricing ? m.pricing.outputPerMTok : null,
170 + }))
171 +)
172 +
173 +const providers = [...new Set(catalog.map((m) => m.provider))].filter(
174 + (p) => onlyProviders.length === 0 || onlyProviders.includes(p)
175 +)
176 +const report = { startedAt: new Date().toISOString(), providers: {} }
177 +
178 +for (const provider of providers) {
179 + const models = catalog.filter((m) => m.provider === provider)
180 + const entry = { models: {}, nonStreaming: null, cancel: null, vision: null, citations: null }
181 + report.providers[provider] = entry
182 + console.log(`\n=== ${provider} (${models.length} models) ===`)
183 +
184 + for (const model of models) {
185 + if (SKIP.has(model.id)) {
186 + entry.models[model.id] = { skipped: 'expensive deep-research model' }
187 + console.log(` SKIP ${model.id}`)
188 + continue
189 + }
190 + const result = await streamCheck(provider, model.id, {
191 + maxTokens: model.reasoning ? 256 : 64,
192 + })
193 + entry.models[model.id] = result
194 + console.log(
195 + ` ${result.ok ? 'PASS' : 'FAIL'} ${model.id} (${result.ms}ms, text ${result.text}, reas ${result.reasoning}, deltas ${result.deltas}${result.error ? `, err: ${String(result.error).slice(0, 90)}` : ''})`
196 + )
197 + }
198 +
199 + // Provider-level probes on the cheapest working model
200 + const working = models.filter((m) => entry.models[m.id] && entry.models[m.id].ok)
201 + const cheapest =
202 + working.filter((m) => !m.reasoning).sort((a, b) => (a.outPrice ?? 1e9) - (b.outPrice ?? 1e9))[0] ??
203 + working[0]
204 + if (cheapest) {
205 + entry.nonStreaming = { model: cheapest.id, ...(await completeCheck(provider, cheapest.id)) }
206 + console.log(` non-streaming[${cheapest.id}]: ${entry.nonStreaming.ok ? 'PASS' : 'FAIL ' + (entry.nonStreaming.error ?? '')}`)
207 + entry.cancel = { model: cheapest.id, ...(await cancelCheck(provider, cheapest.id)) }
208 + console.log(` cancel[${cheapest.id}]: ${entry.cancel.ok ? 'PASS' : 'FAIL ' + (entry.cancel.error ?? '')}`)
209 + }
210 + const visionModel = working
211 + .filter((m) => m.vision && !m.legacy)
212 + .sort((a, b) => (a.outPrice ?? 1e9) - (b.outPrice ?? 1e9))[0]
213 + if (visionModel) {
214 + const v = await streamCheck(provider, visionModel.id, { vision: true, maxTokens: 200 })
215 + entry.vision = { model: visionModel.id, ok: v.ok, error: v.error }
216 + console.log(` vision[${visionModel.id}]: ${v.ok ? 'PASS' : 'FAIL ' + (v.error ?? '')}`)
217 + }
218 + if (provider === 'perplexity') {
219 + const c = await streamCheck(provider, 'sonar', {
220 + prompt: 'What is the tallest building in the world right now?',
221 + maxTokens: 128,
222 + })
223 + entry.citations = { model: 'sonar', ok: c.citations > 0, count: c.citations }
224 + console.log(` citations[sonar]: ${c.citations > 0 ? `PASS (${c.citations})` : 'FAIL'}`)
225 + }
226 +}
227 +
228 +report.contactedOrigins = [...origins].sort()
229 +report.finishedAt = new Date().toISOString()
230 +writeFileSync('/tmp/zyquo-verify.json', JSON.stringify(report, null, 2))
231 +console.log('\nOrigins contacted:', report.contactedOrigins.join(', '))
232 +console.log('Report → /tmp/zyquo-verify.json')
233 +await browser.close()
234