merge: sync remote main and preserve local integrations
This commit is contained in:
374
electron/api/routes/image-prompt-museum.ts
Normal file
374
electron/api/routes/image-prompt-museum.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { sendJson } from '../route-utils';
|
||||
import { WORKS_SQUARE_CONFIG } from '../works-config';
|
||||
import { getValidWorksSquareAccessToken } from '../../services/works-square-session';
|
||||
import { proxyAwareFetch } from '../../utils/proxy-fetch';
|
||||
|
||||
const LOCAL_ROOT = '/api/works/image-prompt-museum';
|
||||
const UPSTREAM_ROOT = '/api/image-prompt-museum';
|
||||
const LIST_QUERY_KEYS = ['q', 'use_case', 'style', 'subject', 'language', 'model', 'cursor', 'limit'] as const;
|
||||
const MAX_QUERY_VALUE_LENGTH = 256;
|
||||
const MAX_LIST_ITEMS = 48;
|
||||
const MAX_FACET_ITEMS = 256;
|
||||
const MAX_CATEGORIES = 32;
|
||||
const MAX_IMAGES = 16;
|
||||
const MAX_VARIABLES = 64;
|
||||
|
||||
type PromptMuseumRouteDependencies = {
|
||||
fetchImpl?: typeof fetch;
|
||||
getAccessToken?: typeof getValidWorksSquareAccessToken;
|
||||
apiBaseUrl?: string;
|
||||
};
|
||||
|
||||
function isMuseumPath(pathname: string): boolean {
|
||||
return pathname === LOCAL_ROOT || /^\/api\/works\/image-prompt-museum\/[^/]+$/.test(pathname);
|
||||
}
|
||||
|
||||
function upstreamPath(pathname: string): string | null {
|
||||
if (pathname === LOCAL_ROOT) return UPSTREAM_ROOT;
|
||||
const entryId = pathname.slice(`${LOCAL_ROOT}/`.length);
|
||||
if (!entryId) return null;
|
||||
return `${UPSTREAM_ROOT}/${encodeURIComponent(decodeURIComponent(entryId))}`;
|
||||
}
|
||||
|
||||
function copyAllowedQuery(source: URL): string {
|
||||
const query = new URLSearchParams();
|
||||
for (const key of LIST_QUERY_KEYS) {
|
||||
const value = source.searchParams.get(key)?.trim();
|
||||
if (!value || value.length > MAX_QUERY_VALUE_LENGTH) continue;
|
||||
query.set(key, value);
|
||||
}
|
||||
const encoded = query.toString();
|
||||
return encoded ? `?${encoded}` : '';
|
||||
}
|
||||
|
||||
async function readPayload(response: Response): Promise<unknown> {
|
||||
try {
|
||||
return await response.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
throw new Error('invalid Prompt Museum object');
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function boundedString(value: unknown, maxLength = 4096): string {
|
||||
if (typeof value !== 'string') throw new Error('invalid Prompt Museum string');
|
||||
const normalized = value.trim();
|
||||
if (!normalized || normalized.length > maxLength) throw new Error('invalid Prompt Museum string');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function nullableString(value: unknown, maxLength = 4096): string | null | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (value === null) return null;
|
||||
return boundedString(value, maxLength);
|
||||
}
|
||||
|
||||
function httpsUrl(value: unknown): string {
|
||||
const raw = boundedString(value, 2048);
|
||||
const parsed = new URL(raw);
|
||||
if (parsed.protocol !== 'https:' || parsed.username || parsed.password) {
|
||||
throw new Error('invalid Prompt Museum URL');
|
||||
}
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
function nullableHttpsUrl(value: unknown): string | null | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (value === null) return null;
|
||||
return httpsUrl(value);
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown, maximum = 100_000): number {
|
||||
if (!Number.isInteger(value) || (value as number) <= 0 || (value as number) > maximum) {
|
||||
throw new Error('invalid Prompt Museum integer');
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function nonNegativeInteger(value: unknown): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
throw new Error('invalid Prompt Museum total');
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function isoTimestamp(value: unknown): string {
|
||||
const timestamp = boundedString(value, 64);
|
||||
if (!Number.isFinite(Date.parse(timestamp))) throw new Error('invalid Prompt Museum timestamp');
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
function boundedArray(value: unknown, maximum: number): unknown[] {
|
||||
if (!Array.isArray(value) || value.length > maximum) {
|
||||
throw new Error('invalid Prompt Museum array');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function projectImage(value: unknown): Record<string, unknown> {
|
||||
const image = asRecord(value);
|
||||
return {
|
||||
url: httpsUrl(image.url),
|
||||
width: positiveInteger(image.width, 32_768),
|
||||
height: positiveInteger(image.height, 32_768),
|
||||
alt: boundedString(image.alt, 500),
|
||||
};
|
||||
}
|
||||
|
||||
function projectCategory(value: unknown): Record<string, unknown> {
|
||||
const category = asRecord(value);
|
||||
const group = boundedString(category.group, 32);
|
||||
if (!['use_case', 'style', 'subject'].includes(group)) {
|
||||
throw new Error('invalid Prompt Museum category');
|
||||
}
|
||||
return {
|
||||
id: boundedString(category.id, 128),
|
||||
name: boundedString(category.name, 200),
|
||||
group,
|
||||
};
|
||||
}
|
||||
|
||||
function projectAttribution(value: unknown): Record<string, unknown> {
|
||||
const attribution = asRecord(value);
|
||||
const author = asRecord(attribution.author);
|
||||
const source = asRecord(attribution.source);
|
||||
const license = asRecord(attribution.license);
|
||||
const authorUrl = nullableHttpsUrl(author.url);
|
||||
const licenseUrl = nullableHttpsUrl(license.url);
|
||||
return {
|
||||
author: {
|
||||
name: boundedString(author.name, 300),
|
||||
...(authorUrl === undefined ? {} : { url: authorUrl }),
|
||||
},
|
||||
source: {
|
||||
name: boundedString(source.name, 300),
|
||||
url: httpsUrl(source.url),
|
||||
},
|
||||
license: {
|
||||
name: boundedString(license.name, 300),
|
||||
...(licenseUrl === undefined ? {} : { url: licenseUrl }),
|
||||
attributionText: boundedString(license.attributionText, 2000),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function projectCard(value: unknown): Record<string, unknown> {
|
||||
const card = asRecord(value);
|
||||
const model = asRecord(card.model);
|
||||
return {
|
||||
id: boundedString(card.id, 128),
|
||||
slug: boundedString(card.slug, 200),
|
||||
title: boundedString(card.title, 300),
|
||||
summary: boundedString(card.summary, 2000),
|
||||
thumbnail: projectImage(card.thumbnail),
|
||||
categories: boundedArray(card.categories, MAX_CATEGORIES).map(projectCategory),
|
||||
model: {
|
||||
id: boundedString(model.id, 128),
|
||||
name: boundedString(model.name, 300),
|
||||
},
|
||||
language: boundedString(card.language, 64),
|
||||
attribution: projectAttribution(card.attribution),
|
||||
publishedAt: isoTimestamp(card.publishedAt),
|
||||
updatedAt: isoTimestamp(card.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
function projectFacet(value: unknown): Record<string, unknown> {
|
||||
const facet = asRecord(value);
|
||||
const count = facet.count === undefined ? undefined : nonNegativeInteger(facet.count);
|
||||
return {
|
||||
id: boundedString(facet.id, 128),
|
||||
name: boundedString(facet.name, 200),
|
||||
...(count === undefined ? {} : { count }),
|
||||
};
|
||||
}
|
||||
|
||||
function projectPage(payload: unknown): Record<string, unknown> {
|
||||
const envelope = asRecord(payload);
|
||||
if (envelope.success !== true) throw new Error('invalid Prompt Museum envelope');
|
||||
const page = asRecord(envelope.data);
|
||||
const facets = asRecord(page.facets);
|
||||
const nextCursor = nullableString(page.nextCursor, 1024);
|
||||
if (nextCursor === undefined) throw new Error('invalid Prompt Museum cursor');
|
||||
const total = page.total === undefined ? undefined : nonNegativeInteger(page.total);
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: boundedArray(page.items, MAX_LIST_ITEMS).map(projectCard),
|
||||
facets: {
|
||||
useCases: boundedArray(facets.useCases, MAX_FACET_ITEMS).map(projectFacet),
|
||||
styles: boundedArray(facets.styles, MAX_FACET_ITEMS).map(projectFacet),
|
||||
subjects: boundedArray(facets.subjects, MAX_FACET_ITEMS).map(projectFacet),
|
||||
},
|
||||
nextCursor,
|
||||
...(total === undefined ? {} : { total }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function projectEntry(payload: unknown): Record<string, unknown> {
|
||||
const envelope = asRecord(payload);
|
||||
if (envelope.success !== true) throw new Error('invalid Prompt Museum envelope');
|
||||
const entry = asRecord(envelope.data);
|
||||
const variables = boundedArray(entry.variables, MAX_VARIABLES).map((value) => {
|
||||
const variable = asRecord(value);
|
||||
const defaultValue = nullableString(variable.defaultValue, 20_000);
|
||||
if (typeof variable.required !== 'boolean') throw new Error('invalid Prompt Museum variable');
|
||||
return {
|
||||
name: boundedString(variable.name, 128),
|
||||
label: boundedString(variable.label, 300),
|
||||
...(defaultValue === undefined ? {} : { defaultValue }),
|
||||
required: variable.required,
|
||||
};
|
||||
});
|
||||
if (typeof entry.requiresReferenceImages !== 'boolean') {
|
||||
throw new Error('invalid Prompt Museum reference flag');
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
...projectCard(entry),
|
||||
prompt: boundedString(entry.prompt, 100_000),
|
||||
variables,
|
||||
images: boundedArray(entry.images, MAX_IMAGES).map(projectImage),
|
||||
requiresReferenceImages: entry.requiresReferenceImages,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function safeError(status: number): { status: number; code: string; error: string } {
|
||||
if (status === 400) {
|
||||
return { status, code: 'PROMPT_MUSEUM_INVALID_QUERY', error: '筛选条件无效,请调整后重试' };
|
||||
}
|
||||
if (status === 401) {
|
||||
return { status, code: 'PROMPT_MUSEUM_AUTH_REQUIRED', error: '请先登录后再获取灵感' };
|
||||
}
|
||||
if (status === 404) {
|
||||
return { status, code: 'PROMPT_MUSEUM_NOT_FOUND', error: '提示词条目不存在' };
|
||||
}
|
||||
const safeStatus = status >= 400 && status <= 599 ? status : 502;
|
||||
return {
|
||||
status: safeStatus,
|
||||
code: 'PROMPT_MUSEUM_UNAVAILABLE',
|
||||
error: safeStatus === 429 ? '请求过于频繁,请稍后再试' : '提示词博物馆暂时不可用',
|
||||
};
|
||||
}
|
||||
|
||||
function sendSafeError(res: ServerResponse, status: number): void {
|
||||
const safe = safeError(status);
|
||||
sendJson(res, safe.status, { success: false, ...safe });
|
||||
}
|
||||
|
||||
function sendInvalidResponse(res: ServerResponse): void {
|
||||
sendJson(res, 502, {
|
||||
success: false,
|
||||
status: 502,
|
||||
code: 'PROMPT_MUSEUM_INVALID_RESPONSE',
|
||||
error: '提示词博物馆返回了无效数据',
|
||||
});
|
||||
}
|
||||
|
||||
export function createImagePromptMuseumRouteHandler(
|
||||
dependencies: PromptMuseumRouteDependencies = {},
|
||||
) {
|
||||
const fetchImpl = dependencies.fetchImpl ?? proxyAwareFetch;
|
||||
const getAccessToken = dependencies.getAccessToken ?? getValidWorksSquareAccessToken;
|
||||
const apiBaseUrl = (dependencies.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/, '');
|
||||
|
||||
return async function handleImagePromptMuseumRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
_ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
if (!isMuseumPath(url.pathname)) return false;
|
||||
if (req.method !== 'GET') {
|
||||
sendJson(res, 405, {
|
||||
success: false,
|
||||
code: 'PROMPT_MUSEUM_METHOD_NOT_ALLOWED',
|
||||
error: '提示词博物馆只支持读取',
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const path = upstreamPath(url.pathname);
|
||||
if (!path) {
|
||||
sendJson(res, 404, {
|
||||
success: false,
|
||||
code: 'PROMPT_MUSEUM_NOT_FOUND',
|
||||
error: '提示词条目不存在',
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = await getAccessToken({ fetchImpl });
|
||||
if (!token) {
|
||||
sendJson(res, 401, {
|
||||
success: false,
|
||||
status: 401,
|
||||
code: 'PROMPT_MUSEUM_AUTH_REQUIRED',
|
||||
error: '请先登录后再获取灵感',
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = (accessToken: string) => fetchImpl(
|
||||
`${apiBaseUrl}${path}${url.pathname === LOCAL_ROOT ? copyAllowedQuery(url) : ''}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
redirect: 'manual',
|
||||
},
|
||||
);
|
||||
|
||||
let response = await request(token);
|
||||
if (response.status === 401) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
const refreshed = await getAccessToken({ fetchImpl, forceRefresh: true });
|
||||
if (!refreshed) {
|
||||
sendSafeError(res, 401);
|
||||
return true;
|
||||
}
|
||||
response = await request(refreshed);
|
||||
}
|
||||
|
||||
const payload = await readPayload(response);
|
||||
if (!response.ok) {
|
||||
sendSafeError(res, response.status);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (payload === null) {
|
||||
sendInvalidResponse(res);
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
sendJson(
|
||||
res,
|
||||
response.status,
|
||||
url.pathname === LOCAL_ROOT ? projectPage(payload) : projectEntry(payload),
|
||||
);
|
||||
} catch {
|
||||
sendInvalidResponse(res);
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
sendSafeError(res, 502);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const handleImagePromptMuseumRoutes = createImagePromptMuseumRouteHandler();
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type DesignCreateConversationInput,
|
||||
type DesignConfirmGenerationInput,
|
||||
type DesignCreateWorkspaceInput,
|
||||
type DesignGenerationParameters,
|
||||
type DesignRenameWorkspaceInput,
|
||||
type DesignSubmitMessageInput,
|
||||
} from '../../../shared/image-workspace';
|
||||
@@ -50,6 +51,25 @@ function asStringArray(value: unknown): string[] {
|
||||
: [];
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
function generationParametersFromBody(body: Record<string, unknown>): DesignGenerationParameters {
|
||||
return {
|
||||
model: asString(body.model),
|
||||
resolution: asString(body.resolution),
|
||||
aspectRatio: asString(body.aspectRatio),
|
||||
durationSeconds: body.durationSeconds === null
|
||||
? null
|
||||
: typeof body.durationSeconds === 'number' && Number.isFinite(body.durationSeconds)
|
||||
? body.durationSeconds
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
const DESIGN_IMAGE_UPLOAD_MIME_TYPES = new Set([
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
@@ -390,6 +410,11 @@ export async function handleImageWorkspaceRoutes(
|
||||
return true;
|
||||
}
|
||||
|
||||
if (segments.length === 2 && segments[0] === 'workspaces' && req.method === 'DELETE') {
|
||||
sendData(res, await ctx.imageWorkspace.deleteWorkspace(segments[1]));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (segments.length === 2 && segments[0] === 'workspaces' && req.method === 'PATCH') {
|
||||
const body = await parseJsonBody<Record<string, unknown>>(req);
|
||||
const input: DesignRenameWorkspaceInput = {
|
||||
@@ -448,6 +473,20 @@ export async function handleImageWorkspaceRoutes(
|
||||
return true;
|
||||
}
|
||||
|
||||
if (segments.length === 4
|
||||
&& segments[0] === 'workspaces'
|
||||
&& segments[2] === 'generation-quotes'
|
||||
&& req.method === 'PATCH') {
|
||||
const body = await parseJsonBody<Record<string, unknown>>(req);
|
||||
sendData(res, await ctx.imageWorkspace.updateGenerationQuote({
|
||||
workspaceId: segments[1],
|
||||
quoteId: segments[3],
|
||||
finalPrompt: typeof body.finalPrompt === 'string' ? body.finalPrompt : '',
|
||||
generationParameters: generationParametersFromBody(body),
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (segments.length === 3
|
||||
&& segments[0] === 'workspaces'
|
||||
&& segments[2] === 'assets'
|
||||
@@ -489,6 +528,8 @@ export async function handleImageWorkspaceRoutes(
|
||||
quoteId: segments[5],
|
||||
clientTurnId: asString(body.clientTurnId),
|
||||
expectedTurnRevision: asInteger(body.expectedTurnRevision),
|
||||
finalPrompt: typeof body.finalPrompt === 'string' ? body.finalPrompt : '',
|
||||
generationParameters: generationParametersFromBody(asRecord(body.generationParameters)),
|
||||
};
|
||||
sendData(res, await ctx.imageWorkspace.confirmGeneration(input));
|
||||
return true;
|
||||
|
||||
@@ -9,6 +9,7 @@ import { handleAppRoutes } from './routes/app';
|
||||
import { handleAuthRoutes } from './routes/auth';
|
||||
import { handleOpencodeRoutes } from './routes/opencode';
|
||||
import { handleImageWorkspaceRoutes } from './routes/image-workspace';
|
||||
import { handleImagePromptMuseumRoutes } from './routes/image-prompt-museum';
|
||||
import { handleWorksRoutes } from './routes/works';
|
||||
import { handleUserSyncRoutes } from './routes/user-sync';
|
||||
import { handleSettingsRoutes } from './routes/settings';
|
||||
@@ -34,6 +35,7 @@ const coreRouteHandlers: RouteHandler[] = [
|
||||
handleAppRoutes,
|
||||
handleAuthRoutes,
|
||||
handleImageWorkspaceRoutes,
|
||||
handleImagePromptMuseumRoutes,
|
||||
handleWorksRoutes,
|
||||
handleAgentBrowserRoutes,
|
||||
handleUserSyncRoutes,
|
||||
|
||||
Reference in New Issue
Block a user