@@ -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;