diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d231a41 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.output +data +node_modules +*.log +*.db +*.db-shm +*.db-wal diff --git a/.env.16688 b/.env.16688 new file mode 100644 index 0000000..25f7794 --- /dev/null +++ b/.env.16688 @@ -0,0 +1,4 @@ +VITE_GENERATION_PROVIDER_ID=16688 +VITE_BANANA_STUDIO_URL=https://banana-studio-cn-20260915.liupingsong89.chatgpt.site +VITE_APP_URL=https://studio.16688.org +VITE_16688_MEDIA_GATEWAY_BASE_URL=http://flow2api-media:8000 diff --git a/.gitignore b/.gitignore index a39e639..d45142b 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,7 @@ yarn-error.log* # local secret files .env .env.* +!.env.16688 # typescript *.tsbuildinfo diff --git a/Dockerfile.16688 b/Dockerfile.16688 new file mode 100644 index 0000000..b007673 --- /dev/null +++ b/Dockerfile.16688 @@ -0,0 +1,27 @@ +FROM node:22-bookworm-slim AS builder + +WORKDIR /app +RUN corepack enable && corepack prepare pnpm@10.32.1 --activate + +COPY . . +RUN corepack pnpm install --frozen-lockfile +RUN mkdir -p data && corepack pnpm db:push --force +RUN corepack pnpm build:16688 + +FROM node:22-bookworm-slim AS runtime + +ENV NODE_ENV=production \ + HOST=0.0.0.0 \ + PORT=3020 + +WORKDIR /app +RUN apt-get update \ + && apt-get install -y --no-install-recommends ffmpeg ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /app/.output ./.output +COPY --from=builder /app/data ./seed-data + +EXPOSE 3020 + +CMD ["sh", "-c", "if [ ! -s /app/data/local.db ] || [ \"$(stat -c %s /app/data/local.db 2>/dev/null || echo 0)\" -le 4096 ]; then cp -a /app/seed-data/. /app/data/; fi; exec node .output/server/index.mjs"] diff --git a/deploy/nginx/studio.16688.org-http.conf b/deploy/nginx/studio.16688.org-http.conf new file mode 100644 index 0000000..084de07 --- /dev/null +++ b/deploy/nginx/studio.16688.org-http.conf @@ -0,0 +1,15 @@ +server { + listen 80; + listen [::]:80; + server_name studio.16688.org; + + location ^~ /.well-known/acme-challenge/ { + root /var/www/letsencrypt; + default_type text/plain; + try_files $uri =404; + } + + location / { + return 301 https://$host$request_uri; + } +} diff --git a/deploy/nginx/studio.16688.org.conf b/deploy/nginx/studio.16688.org.conf new file mode 100644 index 0000000..0d2ae66 --- /dev/null +++ b/deploy/nginx/studio.16688.org.conf @@ -0,0 +1,54 @@ +server { + listen 80; + listen [::]:80; + server_name studio.16688.org; + + location ^~ /.well-known/acme-challenge/ { + root /var/www/letsencrypt; + default_type text/plain; + try_files $uri =404; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name studio.16688.org; + + ssl_certificate /etc/letsencrypt/live/studio.16688.org/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/studio.16688.org/privkey.pem; + ssl_client_certificate /etc/nginx/certs/cloudflare-origin-pull-ca.pem; + ssl_verify_client on; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_session_cache shared:STUDIO_SSL:10m; + ssl_session_timeout 1d; + + auth_basic "16688 Studio Beta"; + auth_basic_user_file /etc/nginx/auth/16688-studio.htpasswd; + + add_header Strict-Transport-Security "max-age=31536000" always; + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options SAMEORIGIN always; + add_header Referrer-Policy same-origin always; + + client_max_body_size 512m; + + location / { + proxy_pass http://127.0.0.1:3020; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_buffering off; + proxy_request_buffering off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } +} diff --git a/docker-compose.16688.yml b/docker-compose.16688.yml new file mode 100644 index 0000000..75b18d3 --- /dev/null +++ b/docker-compose.16688.yml @@ -0,0 +1,36 @@ +services: + studio: + build: + context: . + dockerfile: Dockerfile.16688 + image: 16688-studio:local + container_name: 16688-studio + restart: unless-stopped + environment: + HOST: 0.0.0.0 + PORT: 3020 + BEATDESIGN_ALLOW_REMOTE_SETTINGS: "1" + BEATDESIGN_MEDIA_GATEWAY_HTTP_HOSTS: flow2api-media + ports: + - "127.0.0.1:3020:3020" + volumes: + - ./data:/app/data + networks: + - studio + - flow2api + healthcheck: + test: + - CMD + - node + - -e + - fetch('http://127.0.0.1:3020/zh').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1)) + interval: 30s + timeout: 10s + retries: 5 + start_period: 45s + +networks: + studio: + flow2api: + external: true + name: flow2api-media_default diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index a391516..ba8fc3e 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -35,6 +35,32 @@ An API with a different request or polling contract needs its own adapter. Forks A custom provider definition owns adapter construction, readiness checks, parameter validation, model bindings, upstream model names, and upload paths. Provider credentials must stay server-side. Switching providers does not change Canvas nodes, Editor clips, Asset IDs, or MCP requests; each submitted task also records its provider/model identity so polling does not silently follow a later default-provider change. +## 16688 Studio fork prototype + +The `feat/16688-provider-poc` fork registers `16688 AI` as an opt-in provider. +Use the `16688` Vite mode (`pnpm dev:16688` or `pnpm build:16688`) to make it +active while leaving the upstream BeatAPI development and test mode intact. +The provider has two explicit image transports. GPT Image calls the fixed +`https://api.16688.org/v1/images/generations` endpoint with a user-owned New +API key. Nano Banana calls an independently configured Flow2API-compatible +Gemini media gateway with its own key. Both credentials are encrypted in local +SQLite. The provider binds `nano-banana-2` to +`gemini-3.1-flash-image`, `nano-banana-pro` to +`gemini-3.0-pro-image`, and also exposes `gpt-image-2`, +`gpt-image-2.5-flare`, and `gpt-image-2.5-sunburst`. + +The media gateway URL must use HTTPS. Plain HTTP is accepted only for +`localhost`, `127.0.0.1`, or `::1`, so configuring a remote gateway cannot +silently turn the local server into a clear-text credential forwarder. The +Provider dialog tests `GET /v1/models` before saving a changed channel. + +This stage remains deliberately text-to-image only. Both transports request +inline Base64 output, validate response size and MIME type, and persist results +into the local Project before exposing them as Assets. Reference-image editing, +video, hosted accounts, payments, and shared balances are not claimed by this +prototype. Those capabilities require separate provider contracts and server +work; they must not silently fall back to another model family. + ## Storage Storage is independently configurable from generation: diff --git a/messages/en.json b/messages/en.json index 3ca8c74..b3f559e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -14,7 +14,7 @@ "product.shell.terms": "Terms", "product.shell.privacy": "Privacy", "product.home.eyebrow": "OPEN SOURCE · LOCAL FIRST", - "product.home.metaTitle": "BeatDesign — Open-source AI canvas for image and video creation", + "product.home.metaTitle": "16688 Studio — AI workspace for image, video, and agent workflows", "product.home.metaDescription": "The open-source, local-first AI canvas for image and video creation.", "product.home.titleLine1": "Beat complexity.", "product.home.titleLine2": "Design freely.", @@ -56,8 +56,20 @@ "common.pages.not_found": "Page not found", "AppShell": { "studio": { + "startHere": { + "ideasLabel": "Starting ideas", + "title": "Create Here", + "description": "Imagine the scene. Shape the mood. Bring it to life.", + "selectIdea": "Select {title}", + "ideas": { + "cinematicLandscape": "Cinematic landscape", + "editorialCharacter": "Editorial character", + "materialStudy": "Material study", + "surrealFashionFilm": "Surreal fashion film" + } + }, "intro": { - "title": "Canvas-first BeatDesign workspace", + "title": "Canvas-first 16688 Studio workspace", "description": "Select an image, video, or prompt node, then branch the next generation directly from the canvas." }, "toolbar": { @@ -626,6 +638,8 @@ "projects": "Projects", "projectsLabel": "Open projects", "githubRepository": "Open BeatDesign on GitHub", + "bananaStudio": "Banana AI", + "bananaStudioLabel": "Open Banana AI", "workspaceView": "Workspace view", "create": "Create", "studio": "Studio", @@ -669,24 +683,31 @@ }, "apiConfig": { "triggerLabel": "API configuration", - "triggerConfiguredLabel": "API configuration — BeatAPI connected", - "loadingConnection": "Loading BeatAPI connection", + "triggerConfiguredLabel": "API configuration — provider connected", + "loadingConnection": "Loading API connection", "title": "Connections", - "description": "Connect BeatAPI when you want to use remote generation models.", + "description": "Connect your 16688 AI API key to use remote generation models.", "providerTab": "AI Provider", "storageTab": "Storage", "activeProvider": "Active provider", "defaultBadge": "Default", - "providerDescription": "BeatAPI is the default generation backend for this open-source workspace.", + "providerDescription": "16688 AI is the active generation backend for this workspace.", "endpointLabel": "Endpoint", "mediaLabel": "Media", "mediaValue": "Image + video", "keyLabel": "API Key", - "keyPlaceholder": "Enter your BeatAPI API key", + "keyPlaceholder": "Enter your 16688 AI API key", + "configuredPlaceholder": "Configured — leave blank to keep", + "mediaGatewayTitle": "Gemini media gateway", + "mediaGatewayHint": "Nano Banana 2 / Pro use a separate Flow2API channel instead of posing as text-only Gemini models.", + "mediaGatewayUrlLabel": "Media gateway URL", + "mediaGatewayUrlPlaceholder": "https://media.example.com", + "mediaGatewayKeyLabel": "Media gateway API key", + "mediaGatewayKeyPlaceholder": "Enter the media gateway API key", "save": "Save settings", "saving": "Saving…", "saved": "API key saved", - "getKey": "Get a BeatAPI API key →", + "getKey": "Get a 16688 AI API key →", "storage": { "intro": "Use BeatAPI Files by default, or connect your own public bucket.", "managedTitle": "BeatAPI Files", diff --git a/messages/ja.json b/messages/ja.json index 6a05122..0fc573b 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -14,7 +14,7 @@ "product.shell.terms": "利用規約", "product.shell.privacy": "プライバシー", "product.home.eyebrow": "オープンソース · ローカルファースト", - "product.home.metaTitle": "BeatDesign — 画像・動画制作のためのオープンソースAI Canvas", + "product.home.metaTitle": "16688 Studio — 画像・動画・エージェント制作のAIワークスペース", "product.home.metaDescription": "画像・動画制作のための、オープンソースでローカルファーストなAI Canvas。", "product.home.titleLine1": "複雑さを打ち破り、", "product.home.titleLine2": "自由にデザイン。", @@ -56,8 +56,20 @@ "common.pages.not_found": "ページが見つかりません", "AppShell": { "studio": { + "startHere": { + "ideasLabel": "制作アイデア", + "title": "ここから制作", + "description": "シーンを思い描き、ムードを整え、アイデアを形にしましょう。", + "selectIdea": "{title}を選択", + "ideas": { + "cinematicLandscape": "映画のような風景", + "editorialCharacter": "エディトリアルポートレート", + "materialStudy": "素材のスタディ", + "surrealFashionFilm": "シュールなファッション映像" + } + }, "intro": { - "title": "Canvas中心のBeatDesignワークスペース", + "title": "Canvas中心の16688 Studioワークスペース", "description": "画像、動画、またはプロンプトノードを選択し、Canvasから次の生成へ直接分岐します。" }, "toolbar": { @@ -626,6 +638,8 @@ "projects": "プロジェクト", "projectsLabel": "プロジェクトを開く", "githubRepository": "GitHubでBeatDesignを開く", + "bananaStudio": "Banana AI", + "bananaStudioLabel": "Banana AIを開く", "workspaceView": "ワークスペース表示", "create": "作成", "studio": "Studio", @@ -669,24 +683,31 @@ }, "apiConfig": { "triggerLabel": "API設定", - "triggerConfiguredLabel": "API設定 — BeatAPI接続済み", - "loadingConnection": "BeatAPI接続を読み込み中", + "triggerConfiguredLabel": "API設定 — プロバイダー接続済み", + "loadingConnection": "API接続を読み込み中", "title": "接続", - "description": "リモート生成モデルを使用するときにBeatAPI APIキーを接続します。", + "description": "リモート生成モデルを使用するため、16688 AI APIキーを接続します。", "providerTab": "AIプロバイダー", "storageTab": "ストレージ", "activeProvider": "使用中のプロバイダー", "defaultBadge": "デフォルト", - "providerDescription": "BeatAPIは、このオープンソースワークスペースのデフォルト生成バックエンドです。", + "providerDescription": "16688 AIは、このワークスペースで使用中の生成バックエンドです。", "endpointLabel": "エンドポイント", "mediaLabel": "メディア", "mediaValue": "画像 + 動画", "keyLabel": "API Key", - "keyPlaceholder": "BeatAPI APIキーを入力", + "keyPlaceholder": "16688 AI APIキーを入力", + "configuredPlaceholder": "設定済み — 維持する場合は空欄", + "mediaGatewayTitle": "Geminiメディアゲートウェイ", + "mediaGatewayHint": "Nano Banana 2 / Proは、テキスト専用Geminiモデルではなく独立したFlow2APIチャネルを使用します。", + "mediaGatewayUrlLabel": "メディアゲートウェイURL", + "mediaGatewayUrlPlaceholder": "https://media.example.com", + "mediaGatewayKeyLabel": "メディアゲートウェイAPIキー", + "mediaGatewayKeyPlaceholder": "メディアゲートウェイAPIキーを入力", "save": "設定を保存", "saving": "保存中…", "saved": "APIキーを保存しました", - "getKey": "BeatAPI APIキーを取得 →", + "getKey": "16688 AI APIキーを取得 →", "storage": { "intro": "デフォルトのBeatAPI Filesを使うか、ご自身の公開バケットに接続します。", "managedTitle": "BeatAPI Files", diff --git a/messages/zh.json b/messages/zh.json index 1ae6210..ad1f37f 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -14,7 +14,7 @@ "product.shell.terms": "服务条款", "product.shell.privacy": "隐私政策", "product.home.eyebrow": "开源 · 本地优先", - "product.home.metaTitle": "BeatDesign — 开源 AI 图像与视频创作画布", + "product.home.metaTitle": "16688 Studio — AI 图像、视频与智能体创作工作台", "product.home.metaDescription": "开源、本地优先的 AI 图像与视频创作画布。", "product.home.titleLine1": "化繁为简,", "product.home.titleLine2": "自由创作。", @@ -56,8 +56,20 @@ "common.pages.not_found": "页面不存在", "AppShell": { "studio": { + "startHere": { + "ideasLabel": "创作灵感", + "title": "从这里开始创作", + "description": "想象画面,塑造氛围,让灵感成为作品。", + "selectIdea": "选择{title}", + "ideas": { + "cinematicLandscape": "电影感风景", + "editorialCharacter": "杂志人像", + "materialStudy": "材质探索", + "surrealFashionFilm": "超现实时尚短片" + } + }, "intro": { - "title": "以画布为中心的 BeatDesign 工作区", + "title": "以画布为中心的 16688 Studio 工作区", "description": "选中图片、视频或提示节点后,就能直接从画布继续分支生成。" }, "toolbar": { @@ -626,6 +638,8 @@ "projects": "项目", "projectsLabel": "打开项目列表", "githubRepository": "在 GitHub 打开 BeatDesign", + "bananaStudio": "蕉点 AI", + "bananaStudioLabel": "打开蕉点 AI", "workspaceView": "工作区视图", "create": "创作", "studio": "创作台", @@ -669,24 +683,31 @@ }, "apiConfig": { "triggerLabel": "API 配置", - "triggerConfiguredLabel": "API 配置 — BeatAPI 已连接", - "loadingConnection": "正在读取 BeatAPI 连接", + "triggerConfiguredLabel": "API 配置 — 服务已连接", + "loadingConnection": "正在读取 API 连接", "title": "连接配置", - "description": "需要使用远程生成模型时,连接你的 BeatAPI API Key。", + "description": "需要使用远程生成模型时,连接你的 16688 AI API 密钥。", "providerTab": "AI Provider", "storageTab": "存储", "activeProvider": "当前提供商", "defaultBadge": "默认", - "providerDescription": "BeatAPI 是这套开源工作台默认使用的生成后端。", + "providerDescription": "16688 AI 是当前工作台的默认生成后端。", "endpointLabel": "接口地址", "mediaLabel": "支持类型", "mediaValue": "图片 + 视频", "keyLabel": "API Key", - "keyPlaceholder": "请输入 BeatAPI API Key", + "keyPlaceholder": "请输入 16688 AI API 密钥", + "configuredPlaceholder": "已配置,留空保持不变", + "mediaGatewayTitle": "Gemini 媒体网关", + "mediaGatewayHint": "Nano Banana 2 / Pro 使用独立的 Flow2API 通道,不会冒充文本 Gemini 模型。", + "mediaGatewayUrlLabel": "媒体网关地址", + "mediaGatewayUrlPlaceholder": "https://media.example.com", + "mediaGatewayKeyLabel": "媒体网关 API Key", + "mediaGatewayKeyPlaceholder": "输入媒体网关 API Key", "save": "保存设置", "saving": "保存中…", "saved": "API Key 已保存", - "getKey": "获取 BeatAPI API Key →", + "getKey": "前往 16688 AI 获取 API 密钥 →", "storage": { "intro": "默认使用 BeatAPI Files,也可以接入你自己的公网存储桶。", "managedTitle": "BeatAPI Files", diff --git a/package.json b/package.json index 14d108e..16a7d5b 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "type": "module", "scripts": { "dev": "tsx scripts/prepare-paraglide.ts && vite dev --host 127.0.0.1 --port 3020 --strictPort", + "dev:16688": "tsx scripts/prepare-paraglide.ts && vite dev --mode 16688 --host 127.0.0.1 --port 3020 --strictPort", "dev:agent": "node scripts/dev-with-mcp.mjs", "test": "node scripts/run-tests.mjs", "test:e2e": "playwright test", @@ -35,6 +36,7 @@ "mcp:http": "tsx scripts/mcp-server.ts --http", "prebuild": "node scripts/db-setup.mjs", "build": "tsx scripts/prepare-paraglide.ts && vite build && node scripts/sanitize-output.mjs", + "build:16688": "tsx scripts/prepare-paraglide.ts && vite build --mode 16688 && node scripts/sanitize-output.mjs", "start": "node .output/server/index.mjs", "postinstall": "mkdir -p data && (test -f src/config/db/schema.ts || node scripts/db-setup.mjs)", "db:setup": "node scripts/db-setup.mjs", diff --git a/public/16688-studio-logo.svg b/public/16688-studio-logo.svg new file mode 100644 index 0000000..c52606d --- /dev/null +++ b/public/16688-studio-logo.svg @@ -0,0 +1,11 @@ + + 16688 Studio + Four light routing brackets surrounding a gateway, with a copper signal node. + + + + + + + + diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index 215a24a..6f9c69f 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -4,6 +4,9 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const configuredConcurrency = process.env.BEATDESIGN_TEST_CONCURRENCY?.trim(); +const testConcurrency = configuredConcurrency || + (process.platform === 'win32' ? '1' : undefined); const files = [ ...globSync('src/**/*.test.ts', { cwd: root }), ...globSync('src/**/*.test.tsx', { cwd: root }), @@ -18,7 +21,13 @@ if (files.length === 0) { const result = spawnSync( process.execPath, - ['--import', 'tsx', '--test', ...files], + [ + '--import', + 'tsx', + '--test', + ...(testConcurrency ? [`--test-concurrency=${testConcurrency}`] : []), + ...files, + ], { cwd: root, stdio: 'inherit', diff --git a/src/components/app/product-page-shell.test.ts b/src/components/app/product-page-shell.test.ts index db30ca3..595ecbe 100644 --- a/src/components/app/product-page-shell.test.ts +++ b/src/components/app/product-page-shell.test.ts @@ -34,6 +34,8 @@ test('workspace header keeps mode switching, shared assets, and API configuratio assert.doesNotMatch(shellSource, /ChevronDown/); assert.doesNotMatch(shellSource, /header\.create/); assert.match(shellSource, /GitHubIcon/); + assert.match(shellSource, /VITE_BANANA_STUDIO_URL/); + assert.match(shellSource, /header\.bananaStudio/); assert.match( shellSource, /https:\/\/github\.com\/BeatAPI\/BeatDesign/ @@ -46,15 +48,15 @@ test('workspace header keeps mode switching, shared assets, and API configuratio assert.doesNotMatch(shellSource, /getUserInitial/); }); -test('API configuration presets the BeatAPI endpoint and saves key + host', () => { +test('API configuration follows the active provider and saves storage settings', () => { assert.match(apiConfigSource, /getBeatCanvasProviderPublicConfig/); - assert.match(apiConfigSource, /DEFAULT_BEATAPI_BASE_URL/); - assert.match(apiConfigSource, /\/api\/config\/beatapi/); + assert.match(apiConfigSource, /provider\.configPath/); + assert.match(apiConfigSource, /provider\.baseUrl/); + assert.match(apiConfigSource, /provider\.apiKeyUrl/); assert.match(apiConfigSource, /\/api\/config\/storage/); assert.match(apiConfigSource, /'beatapi' \| 's3'/); assert.match(apiConfigSource, /apiGet\('\/api\/config\/storage'\)/); assert.match(apiConfigSource, /apiPost\('\/api\/config\/storage'/); - assert.match(apiConfigSource, /https:\/\/beatapi\.io\/dashboard\/apikeys/); assert.doesNotMatch(apiConfigSource, /apiJson(?:Get|Post).*\/api\/config\/storage/); }); diff --git a/src/components/app/product-page-shell.tsx b/src/components/app/product-page-shell.tsx index 48f1e11..7bb84f4 100644 --- a/src/components/app/product-page-shell.tsx +++ b/src/components/app/product-page-shell.tsx @@ -12,6 +12,8 @@ import { appConfig } from '@/config'; import { ACTIVE_GENERATION_PROVIDER_ID } from '@/config/generation-providers'; import type { WorkspaceMode } from '@/config/workspace-modes'; +const bananaStudioUrl = import.meta.env?.VITE_BANANA_STUDIO_URL?.trim(); + export function ProductPageShell({ children, workspaceName, @@ -179,6 +181,19 @@ export function ProductPageShell({ ) : null} + {bananaStudioUrl ? ( + + + ) : null} void; + onSaved: (state: ProviderConfigState) => void; }) { const t = useTranslations('AppShell.header.apiConfig'); const [apiKey, setApiKey] = useState(''); const [saving, setSaving] = useState(false); const [editing, setEditing] = useState(false); + const [mediaBaseUrl, setMediaBaseUrl] = useState('http://127.0.0.1:8000'); + const [mediaApiKey, setMediaApiKey] = useState(''); const configured = Boolean(state?.apiKeyConfigured && state.apiKeyPreview); + const mediaGatewayEnabled = provider.id === '16688'; + const mediaConfigured = Boolean( + state?.mediaGateway?.apiKeyConfigured && state.mediaGateway.apiKeyPreview + ); + const mediaBaseUrlChanged = Boolean( + mediaGatewayEnabled && + state?.mediaGateway && + mediaBaseUrl.trim() !== state.mediaGateway.baseUrl + ); + + useEffect(() => { + if (state?.mediaGateway?.baseUrl) { + setMediaBaseUrl(state.mediaGateway.baseUrl); + } + }, [state?.mediaGateway?.baseUrl]); async function save() { const nextKey = apiKey.trim(); - if (!nextKey) return; + const nextMediaKey = mediaApiKey.trim(); + if (!nextKey && !nextMediaKey && !mediaBaseUrlChanged) return; setSaving(true); try { - const result = await apiPost<{ apiKeyPreview?: string }>( - '/api/config/beatapi', - { apiKey: nextKey } + const result = await apiPost<{ + apiKeyPreview?: string; + mediaApiKeyPreview?: string; + }>( + provider.configPath, + { + ...(nextKey ? { apiKey: nextKey } : {}), + ...(mediaGatewayEnabled && (nextMediaKey || mediaBaseUrlChanged) + ? { + mediaBaseUrl: mediaBaseUrl.trim(), + ...(nextMediaKey ? { mediaApiKey: nextMediaKey } : {}), + } + : {}), + } ); toast.success(t('saved')); setApiKey(''); + setMediaApiKey(''); setEditing(false); onSaved({ - baseUrl: DEFAULT_BEATAPI_BASE_URL, - apiKeyConfigured: true, - apiKeyPreview: result?.apiKeyPreview || maskApiKeyPreview(nextKey), + baseUrl: provider.baseUrl, + apiKeyConfigured: configured || Boolean(nextKey), + apiKeyPreview: + result?.apiKeyPreview || + state?.apiKeyPreview || + (nextKey ? maskApiKeyPreview(nextKey) : ''), + ...(mediaGatewayEnabled + ? { + mediaGateway: { + baseUrl: mediaBaseUrl.trim(), + apiKeyConfigured: mediaConfigured || Boolean(nextMediaKey), + apiKeyPreview: + result?.mediaApiKeyPreview || + state?.mediaGateway?.apiKeyPreview || + (nextMediaKey ? maskApiKeyPreview(nextMediaKey) : ''), + }, + } + : {}), }); } catch (error) { toast.error(error instanceof Error ? error.message : String(error)); @@ -124,7 +176,7 @@ function ApiConfigForm({
+ {mediaGatewayEnabled ? ( +
+
+

+ {t('mediaGatewayTitle')} +

+

+ {t('mediaGatewayHint')} +

+
+ setMediaBaseUrl(event.target.value)} + placeholder={t('mediaGatewayUrlPlaceholder')} + aria-label={t('mediaGatewayUrlLabel')} + autoComplete="off" + className={inputClassName} + /> + setMediaApiKey(event.target.value)} + placeholder={ + mediaConfigured + ? t('configuredPlaceholder') + : t('mediaGatewayKeyPlaceholder') + } + aria-label={t('mediaGatewayKeyLabel')} + autoComplete="off" + className={inputClassName} + /> +
+ ) : null} + ('provider'); const [providerState, setProviderState] = - useState(null); + useState(null); const [providerStateLoaded, setProviderStateLoaded] = useState(false); const providerConfigured = Boolean( - providerState?.apiKeyConfigured && providerState.apiKeyPreview + (providerState?.apiKeyConfigured && providerState.apiKeyPreview) || + (providerState?.mediaGateway?.apiKeyConfigured && + providerState.mediaGateway.apiKeyPreview) ); useEffect(() => { let cancelled = false; - void apiGet('/api/config/beatapi') + void apiGet(provider.configPath) .then((config) => { if (!cancelled) setProviderState(config); }) @@ -415,7 +508,7 @@ export function WorkspaceApiConfigDialog({ return () => { cancelled = true; }; - }, []); + }, [provider.configPath]); return ( @@ -479,6 +572,7 @@ export function WorkspaceApiConfigDialog({ { + const restoredDocument = undoCanvas(); + if (!restoredDocument) return; + setAllowEmptyProjectSnapshot(restoredDocument.cards.length === 0); + }, [undoCanvas]); + const handleRedoCanvas = useCallback(() => { + const restoredDocument = redoCanvas(); + if (!restoredDocument) return; + setAllowEmptyProjectSnapshot(restoredDocument.cards.length === 0); + }, [redoCanvas]); const [previewMedia, setPreviewMedia] = useState( null ); @@ -752,8 +762,8 @@ export function BeatCanvasShell({ promptCharacterLimit: generationValidationConstraints.maxPromptChars, canUndoCanvas, canRedoCanvas, - onUndoCanvas: undoCanvas, - onRedoCanvas: redoCanvas, + onUndoCanvas: handleUndoCanvas, + onRedoCanvas: handleRedoCanvas, onCreateImageDraft: () => handleCreatePromptDraft('image'), onUploadImage: () => openUploadPicker({ intent: 'image', mode: 'global' }), @@ -819,8 +829,8 @@ export function BeatCanvasShell({ selectedShapeIds, selectedCanvasCardIds, setActiveComposerCardId, - undoCanvas, - redoCanvas, + handleUndoCanvas, + handleRedoCanvas, videoModels, ] ); @@ -1488,17 +1498,17 @@ export function BeatCanvasShell({ ) { if (key === 'z' && event.shiftKey) { event.preventDefault(); - redoCanvas(); + handleRedoCanvas(); return; } if (key === 'z') { event.preventDefault(); - undoCanvas(); + handleUndoCanvas(); return; } if (key === 'y') { event.preventDefault(); - redoCanvas(); + handleRedoCanvas(); return; } if (key === 'c') { @@ -1567,8 +1577,8 @@ export function BeatCanvasShell({ handleCreatePromptDraft, openUploadPicker, pasteCanvasCards, - redoCanvas, - undoCanvas, + handleRedoCanvas, + handleUndoCanvas, ]); // Register card connector "+" button handler diff --git a/src/components/beatcanvas/use-beatcanvas-react-flow-adapter.ts b/src/components/beatcanvas/use-beatcanvas-react-flow-adapter.ts index 92807ab..fd134b3 100644 --- a/src/components/beatcanvas/use-beatcanvas-react-flow-adapter.ts +++ b/src/components/beatcanvas/use-beatcanvas-react-flow-adapter.ts @@ -1576,7 +1576,7 @@ export function useBeatCanvasReactFlowAdapter({ const undoCanvas = useCallback(() => { const previous = undoStackRef.current.at(-1); - if (!previous) return; + if (!previous) return null; undoStackRef.current = undoStackRef.current.slice(0, -1); redoStackRef.current = [ @@ -1585,11 +1585,12 @@ export function useBeatCanvasReactFlowAdapter({ ]; applyHistoryDocument(previous); syncHistoryState(); + return previous; }, [applyHistoryDocument, captureCanvasDocument, syncHistoryState]); const redoCanvas = useCallback(() => { const next = redoStackRef.current.at(-1); - if (!next) return; + if (!next) return null; redoStackRef.current = redoStackRef.current.slice(0, -1); undoStackRef.current = [ @@ -1598,6 +1599,7 @@ export function useBeatCanvasReactFlowAdapter({ ]; applyHistoryDocument(next); syncHistoryState(); + return next; }, [applyHistoryDocument, captureCanvasDocument, syncHistoryState]); useEffect(() => { diff --git a/src/components/beatcanvas/use-project-snapshot-lifecycle.test.ts b/src/components/beatcanvas/use-project-snapshot-lifecycle.test.ts index e5290cf..0b91e3b 100644 --- a/src/components/beatcanvas/use-project-snapshot-lifecycle.test.ts +++ b/src/components/beatcanvas/use-project-snapshot-lifecycle.test.ts @@ -92,6 +92,32 @@ test('snapshot autosave retries a version conflict without permanently stopping assert.match(source, /mergeProjectSnapshotsAfterConflict/); assert.match(source, /result = await sendSaveRequest\(latest\.version\)/); assert.match(source, /onProjectSnapshotConflict\?\.\(\)/); + assert.match(source, /result\.code === 'REVISION_CONFLICT'/); + assert.match(source, /error\.message\.startsWith\('Canvas '\)/); + assert.doesNotMatch( + source, + /catch \(error\) \{ onProjectSnapshotConflict\?\.\(\); throw error; \}/ + ); +}); + +test('canvas undo and redo authorize persisting an explicitly restored empty document', () => { + const shellSource = readFileSync( + new URL('./beatcanvas-shell.tsx', import.meta.url), + 'utf8' + ); + const adapterSource = readFileSync( + new URL('./use-beatcanvas-react-flow-adapter.ts', import.meta.url), + 'utf8' + ); + + assert.match(adapterSource, /return previous;/); + assert.match(adapterSource, /return next;/); + assert.match(shellSource, /const restoredDocument = undoCanvas\(\)/); + assert.match(shellSource, /const restoredDocument = redoCanvas\(\)/); + assert.match( + shellSource, + /setAllowEmptyProjectSnapshot\(restoredDocument\.cards\.length === 0\)/ + ); }); test('snapshot conflict recovery preserves independent local and Agent edits', () => { diff --git a/src/components/beatcanvas/use-project-snapshot-lifecycle.ts b/src/components/beatcanvas/use-project-snapshot-lifecycle.ts index 39b160d..22ea417 100644 --- a/src/components/beatcanvas/use-project-snapshot-lifecycle.ts +++ b/src/components/beatcanvas/use-project-snapshot-lifecycle.ts @@ -257,7 +257,9 @@ export function useProjectSnapshotLifecycle({ recoveredFromConflict = true; } if (result && !result.ok) { - onProjectSnapshotConflict?.(); + if (result.code === 'REVISION_CONFLICT') { + onProjectSnapshotConflict?.(); + } throw new Error(result.message); } if (result?.ok) { @@ -299,9 +301,16 @@ export function useProjectSnapshotLifecycle({ const guardedSave = async () => { saveInFlightRef.current = true; - try { await runSave(); } - catch (error) { onProjectSnapshotConflict?.(); throw error; } - finally { saveInFlightRef.current = false; } + try { + await runSave(); + } catch (error) { + if (error instanceof Error && error.message.startsWith('Canvas ')) { + onProjectSnapshotConflict?.(); + } + throw error; + } finally { + saveInFlightRef.current = false; + } }; const queuedSave = saveQueueRef.current.then(guardedSave, guardedSave); saveQueueRef.current = queuedSave.catch(() => {}); diff --git a/src/components/marketing/beatapi-product-home.test.ts b/src/components/marketing/beatapi-product-home.test.ts index 3ffacbe..e07ad91 100644 --- a/src/components/marketing/beatapi-product-home.test.ts +++ b/src/components/marketing/beatapi-product-home.test.ts @@ -10,6 +10,10 @@ const routeSource = readFileSync( new URL('../../routes/index.tsx', import.meta.url), 'utf8' ); +const globalStyles = readFileSync( + new URL('../../styles/globals.css', import.meta.url), + 'utf8' +); test('root route renders the Home surface instead of redirecting to projects', () => { assert.match(routeSource, /BeatApiProductHome/); @@ -18,3 +22,14 @@ test('root route renders the Home surface instead of redirecting to projects', ( assert.match(homeSource, /href="\/projects"/); assert.match(homeSource, /action="\/studio"/); }); + +test('hero glow stays within the responsive page gutters', () => { + const heroGlowStyles = globalStyles.match( + /\.beat-product-hero::before\s*\{[^}]+\}/g + ); + + assert.ok(heroGlowStyles); + assert.match(heroGlowStyles.join('\n'), /calc\(100% \+ 2\.5rem\)/); + assert.match(heroGlowStyles.join('\n'), /calc\(100% \+ 3rem\)/); + assert.doesNotMatch(heroGlowStyles.join('\n'), /100vw/); +}); diff --git a/src/components/studio/beat-studio-workspace.test.ts b/src/components/studio/beat-studio-workspace.test.ts index bca4883..52ac6ea 100644 --- a/src/components/studio/beat-studio-workspace.test.ts +++ b/src/components/studio/beat-studio-workspace.test.ts @@ -42,7 +42,10 @@ test('studio keeps the original wide composer and a project generation feed', () assert.match(feedSource, /object-contain/); assert.doesNotMatch(feedSource, /item\.prompt/); assert.match(studioSource, /justify-end/); - assert.match(startHereSource, /Create Here/); + assert.match(startHereSource, /useTranslations\('AppShell\.studio\.startHere'\)/); + assert.match(startHereSource, /t\('title'\)/); + assert.match(startHereSource, /t\('selectIdea', \{ title \}\)/); + assert.doesNotMatch(startHereSource, />\s*Create Here\s*(null); return (
@@ -58,13 +60,14 @@ export function StudioStartHere() { {STUDIO_START_HERE_IDEAS.map((idea) => { const isSelected = selectedSrc === idea.src; const hasSelection = selectedSrc !== null; + const title = t(idea.titleKey); return (

- Create Here + {t('title')}

- Imagine the scene. Shape the mood. Bring it to life. + {t('description')}

); diff --git a/src/config/data-root.test.ts b/src/config/data-root.test.ts index 5dda215..f2417a1 100644 --- a/src/config/data-root.test.ts +++ b/src/config/data-root.test.ts @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { resolve } from 'node:path'; import test from 'node:test'; import { @@ -7,23 +8,25 @@ import { } from './data-root'; test('uses the repository data directory by default', () => { + const repositoryRoot = resolve('fixtures/beatdesign-repository'); assert.equal( - getBeatDesignDataRoot('/tmp/beatdesign-repository', undefined), - '/tmp/beatdesign-repository/data' + getBeatDesignDataRoot(repositoryRoot, undefined), + resolve(repositoryRoot, 'data') ); assert.equal( - getBeatDesignDatabaseUrl('/tmp/beatdesign-repository', undefined), + getBeatDesignDatabaseUrl(repositoryRoot, undefined), 'file:data/local.db' ); }); test('uses an explicit application-data directory for packaged runtimes', () => { + const configuredRoot = resolve('fixtures/beatdesign-user-data'); assert.equal( - getBeatDesignDataRoot('/tmp/ignored', '/tmp/beatdesign-user-data'), - '/tmp/beatdesign-user-data' + getBeatDesignDataRoot(resolve('fixtures/ignored'), configuredRoot), + configuredRoot ); assert.equal( - getBeatDesignDatabaseUrl('/tmp/ignored', '/tmp/beatdesign-user-data'), - 'file:/tmp/beatdesign-user-data/local.db' + getBeatDesignDatabaseUrl(resolve('fixtures/ignored'), configuredRoot), + `file:${resolve(configuredRoot, 'local.db')}` ); }); diff --git a/src/config/generation-providers.ts b/src/config/generation-providers.ts index dfdc51f..19bc761 100644 --- a/src/config/generation-providers.ts +++ b/src/config/generation-providers.ts @@ -1,4 +1,5 @@ import type { GenerationProviderRegistrar } from '@/core/generation-providers/contracts'; +import { newApi16688GenerationProvider } from '@/core/generation-providers/newapi-16688-provider'; /** * Source-level extension point for open-source forks. @@ -7,8 +8,14 @@ import type { GenerationProviderRegistrar } from '@/core/generation-providers/co * provider implementation here and change ACTIVE_GENERATION_PROVIDER_ID. * Keep credentials inside that provider's server-only implementation. */ -export const ACTIVE_GENERATION_PROVIDER_ID = 'beatapi'; +const requestedProviderId = import.meta.env?.VITE_GENERATION_PROVIDER_ID?.trim(); +const is16688BuildMode = import.meta.env?.MODE === '16688'; + +export const ACTIVE_GENERATION_PROVIDER_ID = + requestedProviderId === '16688' || is16688BuildMode ? '16688' : 'beatapi'; export function registerProjectGenerationProviders( - _register: GenerationProviderRegistrar -) {} + register: GenerationProviderRegistrar +) { + register(newApi16688GenerationProvider); +} diff --git a/src/config/index.ts b/src/config/index.ts index c462bcb..ba9f8c9 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,9 +1,10 @@ -/** Fixed product configuration for the local BeatDesign workbench. */ +/** Fixed product configuration for the local 16688 Studio workbench. */ export const appConfig = { - app_url: 'http://127.0.0.1:3020', - app_name: 'BeatDesign', + app_url: + import.meta.env?.VITE_APP_URL?.trim() || 'http://127.0.0.1:3020', + app_name: '16688 Studio', app_description: - 'The open-source, local-first AI canvas for image and video creation.', - app_logo: '/logo.png', + 'A local-first AI canvas for image, video, MCP, and agent workflows.', + app_logo: '/16688-studio-logo.svg', database_url: 'file:data/local.db', } as const; diff --git a/src/config/paraglide.test.ts b/src/config/paraglide.test.ts index f94641b..765d27a 100644 --- a/src/config/paraglide.test.ts +++ b/src/config/paraglide.test.ts @@ -14,7 +14,11 @@ test('the shared Paraglide config can prepare server imports before Vite starts' const outdir = join(outputRoot, 'paraglide'); try { - await symlink(resolve('node_modules'), join(outputRoot, 'node_modules')); + await symlink( + resolve('node_modules'), + join(outputRoot, 'node_modules'), + process.platform === 'win32' ? 'junction' : 'dir' + ); await compile({ ...paraglideCompilerOptions, outdir }); await Promise.all([ diff --git a/src/core/adapters/media-gateway-url.test.ts b/src/core/adapters/media-gateway-url.test.ts new file mode 100644 index 0000000..979363c --- /dev/null +++ b/src/core/adapters/media-gateway-url.test.ts @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { normalizeMediaGatewayBaseUrl } from './media-gateway-url'; + +test('accepts HTTPS gateways and loopback HTTP development URLs', () => { + assert.equal( + normalizeMediaGatewayBaseUrl('https://media.example.com/gateway/'), + 'https://media.example.com/gateway' + ); + assert.equal( + normalizeMediaGatewayBaseUrl('http://127.0.0.1:8000/'), + 'http://127.0.0.1:8000' + ); + assert.equal( + normalizeMediaGatewayBaseUrl('http://flow2api-media:8000/', { + allowedHttpHosts: ['flow2api-media'], + }), + 'http://flow2api-media:8000' + ); +}); + +test('rejects clear-text remote, credentialed, and query-bearing gateways', () => { + for (const url of [ + 'http://media.example.com', + 'http://flow2api-media:8000', + 'https://user:pass@media.example.com', + 'https://media.example.com?key=secret', + 'file:///tmp/gateway', + ]) { + assert.throws(() => normalizeMediaGatewayBaseUrl(url)); + } +}); diff --git a/src/core/adapters/media-gateway-url.ts b/src/core/adapters/media-gateway-url.ts new file mode 100644 index 0000000..f13b778 --- /dev/null +++ b/src/core/adapters/media-gateway-url.ts @@ -0,0 +1,26 @@ +export const normalizeMediaGatewayBaseUrl = ( + value: string, + { allowedHttpHosts = [] }: { allowedHttpHosts?: string[] } = {} +) => { + const candidate = new URL(value); + const hostname = candidate.hostname.toLowerCase(); + const isAllowedHttpHost = [ + '127.0.0.1', + 'localhost', + '::1', + ...allowedHttpHosts.map((host) => host.trim().toLowerCase()), + ].includes(hostname); + if ( + candidate.username || + candidate.password || + candidate.search || + candidate.hash || + (candidate.protocol !== 'https:' && + !(candidate.protocol === 'http:' && isAllowedHttpHost)) + ) { + throw new Error( + 'Media gateway must use HTTPS, or an explicitly allowed private HTTP host.' + ); + } + return candidate.href.replace(/\/+$/, ''); +}; diff --git a/src/core/adapters/newapi-16688-adapter.test.ts b/src/core/adapters/newapi-16688-adapter.test.ts new file mode 100644 index 0000000..78cc9ff --- /dev/null +++ b/src/core/adapters/newapi-16688-adapter.test.ts @@ -0,0 +1,161 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + build16688GeminiImageRequest, + build16688ImageRequest, + normalize16688GeminiImageResponse, + normalize16688ImageResponse, + validate16688ImageInput, +} from './newapi-16688-adapter'; + +test('maps wide and portrait canvas requests to OpenAI image sizes', () => { + assert.deepEqual( + build16688ImageRequest({ + model: 'gpt-image-2.5-flare', + input: { prompt: ' Neon city at night ', aspect_ratio: '16:9' }, + }), + { + model: 'gpt-image-2.5-flare', + prompt: 'Neon city at night', + n: 1, + size: '1536x1024', + response_format: 'b64_json', + } + ); + assert.equal( + build16688ImageRequest({ + model: 'gpt-image-2', + input: { prompt: 'Poster', aspect_ratio: '9:16' }, + }).size, + '1024x1536' + ); +}); + +test('maps Gemini image requests to the native generateContent shape', () => { + assert.deepEqual( + build16688GeminiImageRequest({ + input: { + prompt: ' Product photo on a marble table ', + aspect_ratio: '4:3', + wmOutputQuality: '2k', + }, + }), + { + systemInstruction: { parts: [{ text: 'Return an image only.' }] }, + contents: [ + { + role: 'user', + parts: [{ text: 'Product photo on a marble table' }], + }, + ], + generationConfig: { + responseModalities: ['IMAGE'], + imageConfig: { aspectRatio: '4:3', imageSize: '2K' }, + }, + } + ); +}); + +test('normalizes only inline images from 16688 AI', () => { + assert.deepEqual( + normalize16688ImageResponse({ + data: [{ b64_json: 'aGVsbG8=' }], + usage: { total_tokens: 10 }, + }), + { + status: 'succeeded', + output: { + provider: '16688', + result_url: 'data:image/png;base64,aGVsbG8=', + resultUrls: ['data:image/png;base64,aGVsbG8='], + image_urls: ['data:image/png;base64,aGVsbG8='], + usage: { total_tokens: 10 }, + }, + } + ); + assert.equal( + normalize16688ImageResponse({ data: [{ url: 'https://untrusted.test/a.png' }] }) + .status, + 'failed' + ); +}); + +test('normalizes only bounded inline Gemini image media', () => { + assert.deepEqual( + normalize16688GeminiImageResponse({ + candidates: [ + { + content: { + parts: [ + { + inlineData: { + mimeType: 'image/webp', + data: 'aGVsbG8=', + }, + }, + ], + }, + }, + ], + usageMetadata: { totalTokenCount: 12 }, + }), + { + status: 'succeeded', + output: { + provider: '16688', + result_url: 'data:image/webp;base64,aGVsbG8=', + resultUrls: ['data:image/webp;base64,aGVsbG8='], + image_urls: ['data:image/webp;base64,aGVsbG8='], + usage: { totalTokenCount: 12 }, + }, + } + ); + assert.equal( + normalize16688GeminiImageResponse({ + candidates: [ + { + content: { + parts: [ + { + inlineData: { + mimeType: 'application/octet-stream', + data: 'aGVsbG8=', + }, + }, + ], + }, + }, + ], + }).status, + 'failed' + ); +}); + +test('prototype rejects references and non-image effects before submission', () => { + assert.throws( + () => + build16688ImageRequest({ + model: 'gpt-image-2', + input: { + prompt: 'Restyle', + image_urls: ['https://example.com/input.png'], + }, + }), + /text-to-image only/ + ); + assert.throws( + () => + build16688GeminiImageRequest({ + input: { + prompt: 'Restyle', + image_urls: ['https://example.com/input.png'], + }, + }), + /Gemini text-to-image only/ + ); + assert.throws( + () => validate16688ImageInput({ effectType: 1, input: { prompt: 'Video' } }), + /image generation only/ + ); +}); diff --git a/src/core/adapters/newapi-16688-adapter.ts b/src/core/adapters/newapi-16688-adapter.ts new file mode 100644 index 0000000..2269d82 --- /dev/null +++ b/src/core/adapters/newapi-16688-adapter.ts @@ -0,0 +1,305 @@ +import { z } from 'zod'; + +import { getConfig } from '@/modules/config/service'; +import { BaseAdapter, type GenerationResult } from './base-adapter'; + +export const DEFAULT_16688_API_BASE_URL = 'https://api.16688.org'; +export const DEFAULT_16688_MEDIA_GATEWAY_BASE_URL = + import.meta.env?.VITE_16688_MEDIA_GATEWAY_BASE_URL?.trim() || + 'http://127.0.0.1:8000'; + +const GEMINI_IMAGE_MODELS = new Set([ + 'gemini-3.1-flash-image', + 'gemini-3.0-pro-image', +]); + +const REQUEST_TIMEOUT_MS = 180_000; +const MAX_RESPONSE_BYTES = 40 * 1024 * 1024; + +const inputSchema = z.object({ + prompt: z.string().min(1), + aspect_ratio: z.string().optional(), + wmOutputQuality: z.string().optional(), + image_urls: z.array(z.string().url()).optional(), + image_url: z.string().url().optional(), +}); + +const asRecord = (value: unknown): Record | null => + value && typeof value === 'object' + ? (value as Record) + : null; + +const readString = (value: unknown) => + typeof value === 'string' && value.length > 0 ? value : null; + +const mapAspectRatioToOpenAiSize = (aspectRatio?: string) => { + if (!aspectRatio || aspectRatio === 'auto') return 'auto'; + const [width, height] = aspectRatio.split(':').map(Number); + if (!Number.isFinite(width) || !Number.isFinite(height) || height === 0) { + return 'auto'; + } + const ratio = width / height; + if (ratio > 1.15) return '1536x1024'; + if (ratio < 0.87) return '1024x1536'; + return '1024x1024'; +}; + +export const build16688ImageRequest = ({ + model, + input, +}: { + model: string; + input: z.infer; +}) => { + const references = input.image_urls?.length + ? input.image_urls + : input.image_url + ? [input.image_url] + : []; + if (references.length > 0) { + throw new Error( + '16688 Studio prototype currently supports text-to-image only. Remove image references and try again.' + ); + } + + return { + model, + prompt: input.prompt.trim(), + n: 1, + size: mapAspectRatioToOpenAiSize(input.aspect_ratio), + response_format: 'b64_json', + }; +}; + +const mapAspectRatioToGemini = (aspectRatio?: string) => { + const supported = new Set(['1:1', '4:3', '3:4', '16:9', '9:16']); + return supported.has(aspectRatio || '') ? aspectRatio : '1:1'; +}; + +const mapGeminiImageSize = (quality?: string) => { + const normalized = quality?.trim().toUpperCase(); + return normalized === '2K' || normalized === '4K' ? normalized : '1K'; +}; + +export const build16688GeminiImageRequest = ({ + input, +}: { + input: z.infer; +}) => { + const references = input.image_urls?.length + ? input.image_urls + : input.image_url + ? [input.image_url] + : []; + if (references.length > 0) { + throw new Error( + '16688 Studio currently supports Gemini text-to-image only. Remove image references and try again.' + ); + } + + return { + systemInstruction: { parts: [{ text: 'Return an image only.' }] }, + contents: [{ role: 'user', parts: [{ text: input.prompt.trim() }] }], + generationConfig: { + responseModalities: ['IMAGE'], + imageConfig: { + aspectRatio: mapAspectRatioToGemini(input.aspect_ratio), + imageSize: mapGeminiImageSize(input.wmOutputQuality), + }, + }, + }; +}; + +export const normalize16688ImageResponse = ( + payload: unknown +): GenerationResult => { + const root = asRecord(payload); + const items = Array.isArray(root?.data) ? root.data : []; + const images = items.flatMap((item) => { + const record = asRecord(item); + const base64 = readString(record?.b64_json); + return base64 ? [`data:image/png;base64,${base64}`] : []; + }); + if (images.length === 0) { + return { + status: 'failed', + error: '16688 AI did not return an inline image.', + }; + } + + return { + status: 'succeeded', + output: { + provider: '16688', + result_url: images[0], + resultUrls: images, + image_urls: images, + ...(root?.usage !== undefined ? { usage: root.usage } : {}), + }, + }; +}; + +export const normalize16688GeminiImageResponse = ( + payload: unknown +): GenerationResult => { + const root = asRecord(payload); + const candidates = Array.isArray(root?.candidates) ? root.candidates : []; + const images = candidates.flatMap((candidate) => { + const content = asRecord(asRecord(candidate)?.content); + const parts = Array.isArray(content?.parts) ? content.parts : []; + return parts.flatMap((part) => { + const inline = asRecord(asRecord(part)?.inlineData); + const base64 = readString(inline?.data); + if (!base64) return []; + const mimeType = readString(inline?.mimeType) ?? 'image/png'; + if (!['image/png', 'image/jpeg', 'image/webp'].includes(mimeType)) { + return []; + } + return [`data:${mimeType};base64,${base64}`]; + }); + }); + if (images.length === 0) { + return { + status: 'failed', + error: '16688 Gemini media gateway did not return an inline image.', + }; + } + + return { + status: 'succeeded', + output: { + provider: '16688', + result_url: images[0], + resultUrls: images, + image_urls: images, + ...(root?.usageMetadata !== undefined + ? { usage: root.usageMetadata } + : {}), + }, + }; +}; + +const readBoundedJson = async (response: Response) => { + const declaredLength = Number(response.headers.get('content-length')); + if (Number.isFinite(declaredLength) && declaredLength > MAX_RESPONSE_BYTES) { + throw new Error('16688 AI response exceeded the 40 MB limit.'); + } + if (!response.body) return null; + + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > MAX_RESPONSE_BYTES) { + await reader.cancel(); + throw new Error('16688 AI response exceeded the 40 MB limit.'); + } + chunks.push(value); + } + + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + const text = new TextDecoder().decode(bytes); + if (!text) return null; + try { + return JSON.parse(text) as unknown; + } catch { + return null; + } +}; + +export const validate16688ImageInput = ({ + effectType, + input, +}: { + effectType: number; + input: Record; +}) => { + if (effectType !== 2) { + throw new Error('The 16688 Studio prototype supports image generation only.'); + } + const unknownKeys = Object.keys(input).filter( + (key) => !(key in inputSchema.shape) + ); + if (unknownKeys.length > 0) { + throw new Error(`Unsupported model parameters: ${unknownKeys.join(', ')}`); + } + return inputSchema.parse(input); +}; + +export class NewApi16688Adapter extends BaseAdapter { + async createGeneration(input: unknown): Promise { + const parsed = inputSchema.safeParse(input); + if (!parsed.success) { + return { + status: 'failed', + error: parsed.error.issues[0]?.message || 'Invalid input', + }; + } + + try { + const isGemini = GEMINI_IMAGE_MODELS.has(this.effect.model); + const apiKey = await getConfig( + isGemini ? 'MEDIA_GATEWAY_API_KEY' : 'NEWAPI_API_KEY' + ); + if (!apiKey) { + throw new Error( + isGemini + ? 'Connect the Gemini media gateway before generating.' + : 'Connect a 16688 AI API key before generating.' + ); + } + const mediaBaseUrl = + (await getConfig('MEDIA_GATEWAY_BASE_URL')) || + DEFAULT_16688_MEDIA_GATEWAY_BASE_URL; + const url = isGemini + ? `${mediaBaseUrl.replace(/\/+$/, '')}/models/${encodeURIComponent(this.effect.model)}:generateContent` + : `${DEFAULT_16688_API_BASE_URL}/v1/images/generations`; + const body = isGemini + ? build16688GeminiImageRequest({ input: parsed.data }) + : build16688ImageRequest({ + model: this.effect.model, + input: parsed.data, + }); + const response = await fetch(url, { + method: 'POST', + headers: isGemini + ? { + 'x-goog-api-key': apiKey, + 'Content-Type': 'application/json', + } + : { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + const payload = await readBoundedJson(response); + if (!response.ok) { + const root = asRecord(payload); + const error = asRecord(root?.error); + throw new Error( + readString(error?.message) ?? + readString(root?.message) ?? + `16688 AI request failed with status ${response.status}` + ); + } + return isGemini + ? normalize16688GeminiImageResponse(payload) + : normalize16688ImageResponse(payload); + } catch (cause) { + return { + status: 'failed', + error: cause instanceof Error ? cause.message : '16688 AI generation failed', + }; + } + } +} diff --git a/src/core/beatcanvas/providers/provider-config.test.ts b/src/core/beatcanvas/providers/provider-config.test.ts index 90fbc40..41b8015 100644 --- a/src/core/beatcanvas/providers/provider-config.test.ts +++ b/src/core/beatcanvas/providers/provider-config.test.ts @@ -3,20 +3,25 @@ import test from 'node:test'; import { DEFAULT_BEATAPI_BASE_URL, + DEFAULT_16688_BASE_URL, getBeatCanvasProviderPublicConfig, getBeatCanvasProviderServerConfig, isOfficialBeatApiBaseUrl, resolveBeatCanvasProviderId, } from './provider-config'; -test('BeatAPI is the default BeatCanvas generation provider', () => { +test('BeatAPI remains the upstream default while the fork exposes 16688 AI', () => { assert.equal(resolveBeatCanvasProviderId(undefined), 'beatapi'); assert.deepEqual(getBeatCanvasProviderPublicConfig(undefined), { id: 'beatapi', label: 'BeatAPI', isDefault: true, supports: ['image', 'video', 'analysis'], + baseUrl: DEFAULT_BEATAPI_BASE_URL, + configPath: '/api/config/beatapi', + apiKeyUrl: 'https://beatapi.io/dashboard/apikeys', }); + assert.equal(getBeatCanvasProviderPublicConfig('16688').baseUrl, DEFAULT_16688_BASE_URL); }); test('default server config points to the production BeatAPI endpoint', () => { @@ -31,6 +36,10 @@ test('default server config points to the production BeatAPI endpoint', () => { }).baseUrl, DEFAULT_BEATAPI_BASE_URL ); + assert.equal( + getBeatCanvasProviderServerConfig({ providerId: 'beatapi' }).baseUrl, + DEFAULT_BEATAPI_BASE_URL + ); }); test('managed storage eligibility is limited to the official BeatAPI billing endpoint', () => { diff --git a/src/core/beatcanvas/providers/provider-config.ts b/src/core/beatcanvas/providers/provider-config.ts index 1591a71..1ac946b 100644 --- a/src/core/beatcanvas/providers/provider-config.ts +++ b/src/core/beatcanvas/providers/provider-config.ts @@ -1,4 +1,4 @@ -export const BEATCANVAS_PROVIDER_IDS = ['beatapi'] as const; +export const BEATCANVAS_PROVIDER_IDS = ['beatapi', '16688'] as const; export type BeatCanvasProviderId = (typeof BEATCANVAS_PROVIDER_IDS)[number]; @@ -7,7 +7,10 @@ export type BeatCanvasProviderPublicConfig = { id: BeatCanvasProviderId; label: string; isDefault: boolean; - supports: readonly ['image', 'video', 'analysis']; + supports: readonly ('image' | 'video' | 'analysis')[]; + baseUrl: string; + configPath: '/api/config/beatapi' | '/api/config/16688'; + apiKeyUrl: string; }; export type BeatCanvasProviderServerConfig = @@ -17,6 +20,7 @@ export type BeatCanvasProviderServerConfig = }; export const DEFAULT_BEATAPI_BASE_URL = 'https://api.beatapi.io'; +export const DEFAULT_16688_BASE_URL = 'https://api.16688.org'; export const isOfficialBeatApiBaseUrl = ( baseUrl: string | null | undefined @@ -42,20 +46,33 @@ export const isOfficialBeatApiBaseUrl = ( }; export const resolveBeatCanvasProviderId = ( - _value: string | null | undefined -): BeatCanvasProviderId => 'beatapi'; + value: string | null | undefined +): BeatCanvasProviderId => + value === 'beatapi' || value === '16688' ? value : 'beatapi'; export const getBeatCanvasProviderPublicConfig = ( providerId: string | null | undefined ): BeatCanvasProviderPublicConfig => { const id = resolveBeatCanvasProviderId(providerId); - - return { - id, - label: 'BeatAPI', - isDefault: true, - supports: ['image', 'video', 'analysis'], - }; + return id === '16688' + ? { + id, + label: '16688 AI', + isDefault: true, + supports: ['image'], + baseUrl: DEFAULT_16688_BASE_URL, + configPath: '/api/config/16688', + apiKeyUrl: 'https://api.16688.org/token', + } + : { + id, + label: 'BeatAPI', + isDefault: true, + supports: ['image', 'video', 'analysis'], + baseUrl: DEFAULT_BEATAPI_BASE_URL, + configPath: '/api/config/beatapi', + apiKeyUrl: 'https://beatapi.io/dashboard/apikeys', + }; }; export const getBeatCanvasProviderServerConfig = ({ @@ -71,7 +88,7 @@ export const getBeatCanvasProviderServerConfig = ({ return { ...publicConfig, - baseUrl: DEFAULT_BEATAPI_BASE_URL, + baseUrl: publicConfig.baseUrl, apiKey: apiKey || '', }; }; diff --git a/src/core/db/sqlite.test.ts b/src/core/db/sqlite.test.ts index 732036a..9dfcd5a 100644 --- a/src/core/db/sqlite.test.ts +++ b/src/core/db/sqlite.test.ts @@ -20,6 +20,11 @@ test('local SQLite uses WAL and a busy timeout on each connection', async () => assert.equal((await clients[1].execute('SELECT id FROM test')).rows[0].id, 1); } finally { clients.forEach((client) => client.close()); - await rm(directory, { recursive: true, force: true }); + await rm(directory, { + recursive: true, + force: true, + maxRetries: 20, + retryDelay: 100, + }); } }); diff --git a/src/core/db/sqlite.ts b/src/core/db/sqlite.ts index 5025f71..13d51c0 100644 --- a/src/core/db/sqlite.ts +++ b/src/core/db/sqlite.ts @@ -5,6 +5,7 @@ import type { DbConfig } from './types'; // SQLite/libsql singleton let sqliteDbInstance: Promise> | null = null; +let sqliteClientInstance: ReturnType | null = null; export async function configureLocalSqlite(client: ReturnType) { await client.execute('PRAGMA busy_timeout = 5000'); @@ -19,10 +20,22 @@ export async function createSqliteDb(config: DbConfig) { if (sqliteDbInstance) return sqliteDbInstance; const client = createClient({ url: databaseUrl }); + sqliteClientInstance = client; sqliteDbInstance = configureLocalSqlite(client).then(() => drizzle({ client })).catch((error) => { client.close(); sqliteDbInstance = null; + sqliteClientInstance = null; throw error; }); return sqliteDbInstance; } + +export async function closeSqliteDb() { + const pendingDb = sqliteDbInstance; + const client = sqliteClientInstance; + sqliteDbInstance = null; + sqliteClientInstance = null; + + await pendingDb?.catch(() => undefined); + client?.close(); +} diff --git a/src/core/effects/output-storage.test.ts b/src/core/effects/output-storage.test.ts index 4385741..c0c448f 100644 --- a/src/core/effects/output-storage.test.ts +++ b/src/core/effects/output-storage.test.ts @@ -3,6 +3,7 @@ import test from 'node:test'; import { buildOutputStoragePlan, + decodeInlineImageDataUrl, shouldRetryOutputStorageSync, } from './output-storage'; @@ -53,3 +54,15 @@ test('stores video covers as thumbnails without replacing the main output asset' ] ); }); + +test('accepts bounded inline images for local provider persistence', () => { + const decoded = decodeInlineImageDataUrl( + 'data:image/png;base64,aGVsbG8=' + ); + assert.equal(decoded?.mimeType, 'image/png'); + assert.equal(decoded?.bytes.toString(), 'hello'); + assert.equal( + decodeInlineImageDataUrl('data:text/html;base64,aGVsbG8='), + null + ); +}); diff --git a/src/core/effects/output-storage.ts b/src/core/effects/output-storage.ts index 0d06d16..56247a6 100644 --- a/src/core/effects/output-storage.ts +++ b/src/core/effects/output-storage.ts @@ -1,4 +1,5 @@ import { createHash } from 'node:crypto'; +import { Buffer } from 'node:buffer'; import { resolveOutputMedia } from './output-media'; import { isOfficialBeatApiMediaUrl } from './beatapi-media-url'; @@ -26,6 +27,29 @@ export const LOCAL_MEDIA_DOWNLOAD_TIMEOUT_MS = 180_000; const isLocalProjectAssetUrl = (url: string) => url.startsWith('/api/app/projects/') && url.includes('/assets/'); +const INLINE_IMAGE_DATA_URL = + /^data:(image\/(?:png|jpeg|webp));base64,([A-Za-z0-9+/]+={0,2})$/; + +export const decodeInlineImageDataUrl = (url: string) => { + const match = INLINE_IMAGE_DATA_URL.exec(url); + if (!match) return null; + const mimeType = match[1]; + const encoded = match[2]; + if (encoded.length > Math.ceil((MAX_LOCAL_IMAGE_ASSET_BYTES * 4) / 3) + 4) { + throw new Error('Generated media is too large to save locally'); + } + const bytes = Buffer.from(encoded, 'base64'); + if (bytes.byteLength === 0 || bytes.byteLength > MAX_LOCAL_IMAGE_ASSET_BYTES) { + throw new Error('Generated media is too large to save locally'); + } + return { bytes, mimeType }; +}; + +const outputProviderId = (output: unknown) => + output && typeof output === 'object' + ? (output as Record).provider + : null; + export const createLocalProviderAssetId = ( projectId: string, providerUrl: string @@ -135,33 +159,44 @@ export async function persistEffectOutputIfNeeded({ const assetIds: string[] = []; const localUrlByProviderUrl = new Map(); + const providerId = outputProviderId(output); for (const { url, type, role } of providerEntries) { - if (!isOfficialBeatApiMediaUrl(url)) { - throw new Error('Generated media URL is not an approved BeatAPI asset'); - } - const response = await fetch(url, { - signal: AbortSignal.timeout(LOCAL_MEDIA_DOWNLOAD_TIMEOUT_MS), - }); - if (!response.ok) { - throw new Error(`Failed to save generated media locally (${response.status})`); - } - const maxBytes = type === 'video' ? MAX_LOCAL_VIDEO_ASSET_BYTES : MAX_LOCAL_IMAGE_ASSET_BYTES; - const declaredLength = Number(response.headers.get('content-length')); - if (Number.isFinite(declaredLength) && declaredLength > maxBytes) { - throw new Error('Generated media is too large to save locally'); + const inlineImage = type === 'image' ? decodeInlineImageDataUrl(url) : null; + let bytes: Uint8Array; + let mimeType: string; + if (inlineImage) { + if (providerId !== '16688') { + throw new Error('Inline generated media is not from an approved provider'); + } + bytes = inlineImage.bytes; + mimeType = inlineImage.mimeType; + } else { + if (!isOfficialBeatApiMediaUrl(url)) { + throw new Error('Generated media URL is not an approved provider asset'); + } + const response = await fetch(url, { + signal: AbortSignal.timeout(LOCAL_MEDIA_DOWNLOAD_TIMEOUT_MS), + }); + if (!response.ok) { + throw new Error(`Failed to save generated media locally (${response.status})`); + } + const declaredLength = Number(response.headers.get('content-length')); + if (Number.isFinite(declaredLength) && declaredLength > maxBytes) { + throw new Error('Generated media is too large to save locally'); + } + bytes = await readResponseBodyWithLimit(response, maxBytes); + const fallbackMimeType = type === 'video' ? 'video/mp4' : 'image/png'; + const responseMimeType = + response.headers.get('content-type')?.split(';')[0]?.trim() || ''; + mimeType = responseMimeType.startsWith(`${type}/`) + ? responseMimeType + : fallbackMimeType; } - const bytes = await readResponseBodyWithLimit(response, maxBytes); - const fallbackMimeType = type === 'video' ? 'video/mp4' : 'image/png'; - const responseMimeType = - response.headers.get('content-type')?.split(';')[0]?.trim() || ''; - const mimeType = responseMimeType.startsWith(`${type}/`) - ? responseMimeType - : fallbackMimeType; const persisted = await persistLocalProjectAsset({ projectId: generation.projectId, assetId: createLocalProviderAssetId(generation.projectId, url), @@ -185,7 +220,8 @@ export async function persistEffectOutputIfNeeded({ metadata: { effectId, generationId: wmTaskId, - providerUrl: url, + provider: providerId ?? 'beatapi', + ...(inlineImage ? { inline: true } : { providerUrl: url }), }, }); await linkGenerationAsset({ generationId: wmTaskId, assetId, role }); @@ -201,16 +237,19 @@ export async function persistEffectOutputIfNeeded({ localUrlByProviderUrl ) as Record; const providerResultUrl = media.resultUrl; + const remoteProviderEntries = providerEntries.filter( + ({ url }) => !url.startsWith('data:') + ); return { ...localizedOutput, - ...(providerResultUrl + ...(providerResultUrl && !providerResultUrl.startsWith('data:') ? { provider_result_url: providerResultUrl, stored_result_url: localUrlByProviderUrl.get(providerResultUrl) ?? providerResultUrl, } : {}), - provider_result_urls: providerEntries.map(({ url }) => url), + provider_result_urls: remoteProviderEntries.map(({ url }) => url), assetIds, storage_sync_failed: false, }; diff --git a/src/core/generation-providers/contracts.ts b/src/core/generation-providers/contracts.ts index 40c22a5..25465e7 100644 --- a/src/core/generation-providers/contracts.ts +++ b/src/core/generation-providers/contracts.ts @@ -20,7 +20,9 @@ export type GenerationProviderDefinition = { supports: readonly GenerationMediaCapability[]; modelBindings: readonly GenerationProviderModelBinding[]; createAdapter: (effect: EffectRecord) => BaseAdapter; - assertConfigured?: () => Promise; + assertConfigured?: ( + binding?: GenerationProviderModelBinding + ) => Promise; validateInput?: (effect: EffectRecord, input: Record) => void; }; diff --git a/src/core/generation-providers/newapi-16688-provider.ts b/src/core/generation-providers/newapi-16688-provider.ts new file mode 100644 index 0000000..1954bd3 --- /dev/null +++ b/src/core/generation-providers/newapi-16688-provider.ts @@ -0,0 +1,45 @@ +import { + NewApi16688Adapter, + validate16688ImageInput, +} from '@/core/adapters/newapi-16688-adapter'; +import { getConfig } from '@/modules/config/service'; + +import type { GenerationProviderDefinition } from './contracts'; + +export const NEWAPI_16688_PROVIDER_ID = '16688'; + +export const NEWAPI_16688_MODEL_BINDINGS = [ + ['nano-banana-2', 25, 'gemini-3.1-flash-image'], + ['nano-banana-pro', 6, 'gemini-3.0-pro-image'], + ['gpt-image-2', 12, 'gpt-image-2'], + ['gpt-image-2.5-flare', 27, 'gpt-image-2.5-flare'], + ['gpt-image-2.5-sunburst', 28, 'gpt-image-2.5-sunburst'], +] as const; + +export const newApi16688GenerationProvider: GenerationProviderDefinition = { + id: NEWAPI_16688_PROVIDER_ID, + label: '16688 AI', + supports: ['image'], + modelBindings: NEWAPI_16688_MODEL_BINDINGS.map(([modelId, effectId, upstreamModelId]) => ({ + modelId, + effectId, + upstreamModelId, + uploadPath: '', + imageBucketName: 'local', + })), + createAdapter: (effect) => new NewApi16688Adapter(effect), + assertConfigured: async (binding) => { + const isGemini = binding?.upstreamModelId.startsWith('gemini-') === true; + const configKey = isGemini ? 'MEDIA_GATEWAY_API_KEY' : 'NEWAPI_API_KEY'; + if (!(await getConfig(configKey))) { + throw new Error( + isGemini + ? 'Connect the Gemini media gateway before generating.' + : 'Connect a 16688 AI API key before generating.' + ); + } + }, + validateInput: (effect, input) => { + validate16688ImageInput({ effectType: effect.type, input }); + }, +}; diff --git a/src/core/generation-providers/registry.test.ts b/src/core/generation-providers/registry.test.ts index ededd8c..4914beb 100644 --- a/src/core/generation-providers/registry.test.ts +++ b/src/core/generation-providers/registry.test.ts @@ -4,12 +4,13 @@ import test from 'node:test'; import { getActiveGenerationProvider, + getGenerationProvider, getGenerationModelBinding, getGenerationModelDescriptor, listGenerationModelDescriptors, } from './index'; -test('BeatAPI is the default provider behind stable logical model ids', () => { +test('BeatAPI remains the test default behind stable logical model ids', () => { const provider = getActiveGenerationProvider(); const binding = getGenerationModelBinding({ modelId: 'seedance-2' }); @@ -18,6 +19,22 @@ test('BeatAPI is the default provider behind stable logical model ids', () => { assert.equal(binding?.upstreamModelId, 'seedance-2'); }); +test('the fork registers the restricted 16688 AI image provider', () => { + const provider = getGenerationProvider('16688'); + assert.equal(provider?.label, '16688 AI'); + assert.deepEqual(provider?.supports, ['image']); + assert.deepEqual( + provider?.modelBindings.map((binding) => binding.modelId), + [ + 'nano-banana-2', + 'nano-banana-pro', + 'gpt-image-2', + 'gpt-image-2.5-flare', + 'gpt-image-2.5-sunburst', + ] + ); +}); + test('model discovery exposes parameters and references without upstream URLs', () => { const models = listGenerationModelDescriptors(); const seedance = getGenerationModelDescriptor('seedance-2'); diff --git a/src/core/generation-providers/submit.integration.test.ts b/src/core/generation-providers/submit.integration.test.ts index bd75372..633a9b1 100644 --- a/src/core/generation-providers/submit.integration.test.ts +++ b/src/core/generation-providers/submit.integration.test.ts @@ -2,13 +2,26 @@ import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; import { after, test } from 'node:test'; const directory = await mkdtemp(join(tmpdir(), 'beatdesign-generation-test-')); process.env.BEATDESIGN_DATA_DIR = directory; -execFileSync('pnpm', ['exec', 'drizzle-kit', 'push', '--force'], { env: process.env, stdio: 'pipe' }); -after(async () => { await rm(directory, { recursive: true, force: true }); }); +execFileSync( + process.execPath, + [resolve('node_modules/drizzle-kit/bin.cjs'), 'push', '--force'], + { env: process.env, stdio: 'pipe' } +); +const { closeSqliteDb } = await import('@/core/db/sqlite'); +after(async () => { + await closeSqliteDb(); + await rm(directory, { + recursive: true, + force: true, + maxRetries: 20, + retryDelay: 100, + }); +}); const { createProject } = await import('@/core/projects/projects'); const { createAssetFirstGenerationIntent, preflightAssetFirstGeneration, submitAssetFirstGeneration } = await import('./submit'); diff --git a/src/core/generation-providers/submit.ts b/src/core/generation-providers/submit.ts index eb997ff..4fc7741 100644 --- a/src/core/generation-providers/submit.ts +++ b/src/core/generation-providers/submit.ts @@ -209,7 +209,7 @@ export async function preflightAssetFirstGeneration(source: unknown) { }; const input = await compileAssetFirstGenerationInput({ generation: validationRequest, generationIntentId: '', authorizedDeliveryUrls: validationUrls }); validateGenerationModelInput({ modelId: generation.modelId, input }); - await provider.assertConfigured?.(); + await provider.assertConfigured?.(binding); return { generation, binding, expectedUploadCount }; } diff --git a/src/core/projects/local-project-assets.test.ts b/src/core/projects/local-project-assets.test.ts index 756040f..35ff683 100644 --- a/src/core/projects/local-project-assets.test.ts +++ b/src/core/projects/local-project-assets.test.ts @@ -63,7 +63,11 @@ test('rejects symlinked project asset directories', async () => { const root = await mkdtemp(join(tmpdir(), 'beatapi-project-assets-root-')); const outside = await mkdtemp(join(tmpdir(), 'beatapi-project-assets-outside-')); try { - await symlink(outside, join(root, 'project-1')); + await symlink( + outside, + join(root, 'project-1'), + process.platform === 'win32' ? 'junction' : 'dir' + ); assert.throws( () => resolveLocalProjectAssetPath({ diff --git a/src/lib/trusted-local-request.test.ts b/src/lib/trusted-local-request.test.ts index 0e4f9a1..b2af067 100644 --- a/src/lib/trusted-local-request.test.ts +++ b/src/lib/trusted-local-request.test.ts @@ -69,6 +69,10 @@ test('accepts same-origin workspace mutations on an access-controlled deployment ok: true, }); assert.equal(validateTrustedLocalJsonMutation(hostedRequest).ok, false); + assert.deepEqual( + validateTrustedLocalJsonMutation(hostedRequest, { allowRemote: true }), + { ok: true } + ); }); test('accepts trusted same-origin multipart workspace mutations without weakening JSON routes', () => { diff --git a/src/lib/trusted-local-request.ts b/src/lib/trusted-local-request.ts index fd07b26..6c8d2f8 100644 --- a/src/lib/trusted-local-request.ts +++ b/src/lib/trusted-local-request.ts @@ -60,10 +60,11 @@ export function validateTrustedWorkspaceJsonMutation( } export function validateTrustedLocalJsonMutation( - request: Request + request: Request, + { allowRemote = false }: { allowRemote?: boolean } = {} ): TrustedLocalRequestResult { const requestUrl = new URL(request.url); - if (!LOCAL_SETTINGS_HOSTS.has(requestUrl.hostname)) { + if (!allowRemote && !LOCAL_SETTINGS_HOSTS.has(requestUrl.hostname)) { return { ok: false, status: 403, diff --git a/src/mcp/cwd-independence.test.ts b/src/mcp/cwd-independence.test.ts index 8f2f0cf..7cbb596 100644 --- a/src/mcp/cwd-independence.test.ts +++ b/src/mcp/cwd-independence.test.ts @@ -49,8 +49,12 @@ test('MCP server starts and answers when spawned from a foreign working director const env = { ...process.env }; delete env.TSX_TSCONFIG_PATH; const child = spawn( - resolve(repositoryRoot, 'node_modules/.bin/tsx'), - [resolve(repositoryRoot, 'scripts/mcp-server.ts')], + process.execPath, + [ + '--import', + import.meta.resolve('tsx'), + resolve(repositoryRoot, 'scripts/mcp-server.ts'), + ], { cwd: foreignRoot, env, @@ -104,8 +108,13 @@ test('MCP server starts and answers when spawned from a foreign working director }); child.kill('SIGTERM'); const exitResult = await exited; - assert.equal(exitResult.signal, null); - assert.equal(exitResult.code, 143); + if (process.platform === 'win32') { + assert.equal(exitResult.signal, 'SIGTERM'); + assert.equal(exitResult.code, null); + } else { + assert.equal(exitResult.signal, null); + assert.equal(exitResult.code, 143); + } } finally { if (!child.killed) child.kill(); rmSync(foreignRoot, { recursive: true, force: true }); diff --git a/src/mcp/stdio-probe.test.ts b/src/mcp/stdio-probe.test.ts index b68d8c4..8aba4af 100644 --- a/src/mcp/stdio-probe.test.ts +++ b/src/mcp/stdio-probe.test.ts @@ -35,8 +35,12 @@ const readJsonLine = async ( test('MCP stdio handshake lists the catalogued tools and can list projects', async () => { const child = spawn( - resolve('node_modules/.bin/tsx'), - ['scripts/mcp-server.ts'], + process.execPath, + [ + '--import', + import.meta.resolve('tsx'), + resolve('scripts/mcp-server.ts'), + ], { cwd: resolve('.'), env: { ...process.env, NODE_ENV: 'development' }, diff --git a/src/mcp/workbuddy-package.ts b/src/mcp/workbuddy-package.ts index d7ca5b5..cc8b287 100644 --- a/src/mcp/workbuddy-package.ts +++ b/src/mcp/workbuddy-package.ts @@ -143,10 +143,11 @@ export const validateWorkBuddyConnector = async ( resolve(connectorDirectory, 'skills/beatdesign-workspace/SKILL.md'), 'utf8' ); - assert.match(skill, /^---\n[\s\S]*?\n---\n/); - assert.match(skill, /\nname: beatdesign-workspace\n/); + const normalizedSkill = skill.replace(/\r\n?/g, '\n'); + assert.match(normalizedSkill, /^---\n[\s\S]*?\n---\n/); + assert.match(normalizedSkill, /\nname: beatdesign-workspace\n/); assert.doesNotMatch( - skill, + normalizedSkill, /\[(?:TODO|PLACEHOLDER)(?::|\])/i, 'WorkBuddy Skill must not contain unfinished placeholders' ); diff --git a/src/modules/config/service.ts b/src/modules/config/service.ts index c2c58d3..26244d4 100644 --- a/src/modules/config/service.ts +++ b/src/modules/config/service.ts @@ -62,6 +62,10 @@ export async function getAllConfigs(): Promise { const WRITABLE_CONFIG_KEYS: ReadonlySet = new Set([ 'BEATAPI_API_BASE_URL', 'BEATAPI_API_KEY', + 'NEWAPI_API_BASE_URL', + 'NEWAPI_API_KEY', + 'MEDIA_GATEWAY_BASE_URL', + 'MEDIA_GATEWAY_API_KEY', 'WORKSPACE_STORAGE_MODE', 'R2_REGION', 'R2_ENDPOINT', diff --git a/src/routes/api/config/16688.ts b/src/routes/api/config/16688.ts new file mode 100644 index 0000000..dadc554 --- /dev/null +++ b/src/routes/api/config/16688.ts @@ -0,0 +1,133 @@ +import { createFileRoute } from '@tanstack/react-router'; + +import { + DEFAULT_16688_API_BASE_URL, + DEFAULT_16688_MEDIA_GATEWAY_BASE_URL, +} from '@/core/adapters/newapi-16688-adapter'; +import { normalizeMediaGatewayBaseUrl } from '@/core/adapters/media-gateway-url'; +import { maskApiKeyPreview } from '@/lib/mask-api-key'; +import { readRequestJsonWithLimit, RequestBodyTooLargeError, MAX_WORKSPACE_JSON_REQUEST_BYTES } from '@/lib/request-body-limit'; +import { respData, respErr } from '@/lib/resp'; +import { validateTrustedLocalJsonMutation } from '@/lib/trusted-local-request'; +import { getConfig, saveConfigs } from '@/modules/config/service'; + +async function GET() { + try { + const apiKey = (await getConfig('NEWAPI_API_KEY')) || ''; + const mediaApiKey = (await getConfig('MEDIA_GATEWAY_API_KEY')) || ''; + const mediaBaseUrl = + (await getConfig('MEDIA_GATEWAY_BASE_URL')) || + DEFAULT_16688_MEDIA_GATEWAY_BASE_URL; + return respData({ + baseUrl: DEFAULT_16688_API_BASE_URL, + apiKeyConfigured: Boolean(apiKey), + apiKeyPreview: apiKey ? maskApiKeyPreview(apiKey) : '', + mediaGateway: { + baseUrl: mediaBaseUrl, + apiKeyConfigured: Boolean(mediaApiKey), + apiKeyPreview: mediaApiKey ? maskApiKeyPreview(mediaApiKey) : '', + }, + }); + } catch { + return respErr('Internal error', 500); + } +} + +async function POST({ request }: { request: Request }) { + try { + const trust = validateTrustedLocalJsonMutation(request, { + allowRemote: process.env.BEATDESIGN_ALLOW_REMOTE_SETTINGS === '1', + }); + if (!trust.ok) return respErr(trust.message, trust.status); + + const body = await readRequestJsonWithLimit<{ + apiKey?: unknown; + mediaBaseUrl?: unknown; + mediaApiKey?: unknown; + }>( + request, + MAX_WORKSPACE_JSON_REQUEST_BYTES + ).catch((error) => { + if (error instanceof RequestBodyTooLargeError) throw error; + return null; + }); + if (!body || typeof body !== 'object') return respErr('Invalid body'); + + const submittedKey = + typeof body.apiKey === 'string' ? body.apiKey.trim() : ''; + const apiKey = submittedKey || (await getConfig('NEWAPI_API_KEY')) || ''; + const submittedMediaKey = + typeof body.mediaApiKey === 'string' ? body.mediaApiKey.trim() : ''; + const mediaApiKey = + submittedMediaKey || (await getConfig('MEDIA_GATEWAY_API_KEY')) || ''; + const submittedMediaBaseUrl = + typeof body.mediaBaseUrl === 'string' ? body.mediaBaseUrl.trim() : ''; + let mediaBaseUrl = + submittedMediaBaseUrl || + (await getConfig('MEDIA_GATEWAY_BASE_URL')) || + DEFAULT_16688_MEDIA_GATEWAY_BASE_URL; + try { + mediaBaseUrl = normalizeMediaGatewayBaseUrl(mediaBaseUrl, { + allowedHttpHosts: ( + process.env.BEATDESIGN_MEDIA_GATEWAY_HTTP_HOSTS || '' + ) + .split(',') + .filter(Boolean), + }); + } catch (error) { + return respErr( + error instanceof Error ? error.message : 'Invalid media gateway URL', + 400 + ); + } + if (!apiKey && !mediaApiKey) { + return respErr('Connect at least one 16688 generation channel', 400); + } + + if (submittedKey) { + const response = await fetch(`${DEFAULT_16688_API_BASE_URL}/v1/models`, { + headers: { Authorization: `Bearer ${apiKey}` }, + signal: AbortSignal.timeout(15_000), + }); + if (!response.ok) return respErr('16688 AI connection test failed', 400); + } + if (submittedMediaKey || submittedMediaBaseUrl) { + if (!mediaApiKey) return respErr('Gemini media gateway key is required', 400); + const response = await fetch(`${mediaBaseUrl}/v1/models`, { + headers: { Authorization: `Bearer ${mediaApiKey}` }, + signal: AbortSignal.timeout(15_000), + }); + if (!response.ok) { + return respErr('Gemini media gateway connection test failed', 400); + } + } + + await saveConfigs({ + ...(submittedKey ? { NEWAPI_API_KEY: submittedKey } : {}), + NEWAPI_API_BASE_URL: DEFAULT_16688_API_BASE_URL, + ...(submittedMediaKey + ? { MEDIA_GATEWAY_API_KEY: submittedMediaKey } + : {}), + ...(submittedMediaBaseUrl + ? { MEDIA_GATEWAY_BASE_URL: mediaBaseUrl } + : {}), + }); + return respData({ + ok: true, + connected: true, + apiKeyPreview: apiKey ? maskApiKeyPreview(apiKey) : '', + mediaApiKeyPreview: mediaApiKey + ? maskApiKeyPreview(mediaApiKey) + : '', + }); + } catch (error) { + if (error instanceof RequestBodyTooLargeError) { + return respErr('Request body is too large', 413); + } + return respErr('Internal error', 500); + } +} + +export const Route = createFileRoute('/api/config/16688')({ + server: { handlers: { GET, POST } }, +}); diff --git a/src/routes/api/config/beatapi.ts b/src/routes/api/config/beatapi.ts index 6b477b0..77ee47c 100644 --- a/src/routes/api/config/beatapi.ts +++ b/src/routes/api/config/beatapi.ts @@ -30,7 +30,9 @@ async function GET({ request }: { request: Request }) { async function POST({ request }: { request: Request }) { try { - const trust = validateTrustedLocalJsonMutation(request); + const trust = validateTrustedLocalJsonMutation(request, { + allowRemote: process.env.BEATDESIGN_ALLOW_REMOTE_SETTINGS === '1', + }); if (!trust.ok) { return respErr(trust.message, trust.status); } diff --git a/src/routes/api/config/storage.ts b/src/routes/api/config/storage.ts index 033a40c..dfdf41a 100644 --- a/src/routes/api/config/storage.ts +++ b/src/routes/api/config/storage.ts @@ -58,7 +58,9 @@ async function GET() { async function POST({ request }: { request: Request }) { try { - const trust = validateTrustedLocalJsonMutation(request); + const trust = validateTrustedLocalJsonMutation(request, { + allowRemote: process.env.BEATDESIGN_ALLOW_REMOTE_SETTINGS === '1', + }); if (!trust.ok) { return respErr(trust.message, trust.status); } diff --git a/src/styles/globals.css b/src/styles/globals.css index 0a33e6e..bfdaab9 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -110,7 +110,7 @@ button[aria-disabled="true"] { z-index: -1; top: -5rem; left: 50%; - width: min(64rem, 100vw); + width: min(64rem, calc(100% + 2.5rem)); height: 34rem; content: ""; pointer-events: none; @@ -124,6 +124,12 @@ button[aria-disabled="true"] { opacity: 0.72; } +@media (min-width: 640px) { + .beat-product-hero::before { + width: min(64rem, calc(100% + 3rem)); + } +} + @media (max-width: 640px) { .beat-product-hero::before { top: -2rem;