feat: 完善图像工作区与创作工具体验
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled

This commit is contained in:
inman
2026-08-16 14:08:02 +08:00
parent bfcb88cfef
commit 26b52d76e3
92 changed files with 16678 additions and 2975 deletions

View File

@@ -0,0 +1,158 @@
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;
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 errorMessage(payload: unknown, fallback: string): string {
if (typeof payload === 'object' && payload !== null && !Array.isArray(payload)) {
const value = (payload as Record<string, unknown>).error;
if (typeof value === 'string' && value.trim()) return value;
}
return fallback;
}
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) response = await request(refreshed);
}
const payload = await readPayload(response);
if (!response.ok) {
sendJson(res, response.status, {
success: false,
status: response.status,
code: typeof payload === 'object' && payload !== null && !Array.isArray(payload)
&& typeof (payload as Record<string, unknown>).code === 'string'
? (payload as Record<string, unknown>).code
: response.status === 404 ? 'PROMPT_MUSEUM_NOT_FOUND' : 'PROMPT_MUSEUM_UNAVAILABLE',
error: errorMessage(payload, '提示词博物馆暂时不可用'),
});
return true;
}
if (payload === null) {
sendJson(res, 502, {
success: false,
status: 502,
code: 'PROMPT_MUSEUM_INVALID_RESPONSE',
error: '提示词博物馆返回了无效数据',
});
return true;
}
sendJson(res, response.status, payload);
return true;
} catch (error) {
sendJson(res, 502, {
success: false,
status: 502,
code: 'PROMPT_MUSEUM_UNAVAILABLE',
error: error instanceof Error ? error.message : '提示词博物馆暂时不可用',
});
return true;
}
};
}
export const handleImagePromptMuseumRoutes = createImagePromptMuseumRouteHandler();

View File

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

View File

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