需求:以设计项目组织固定设计 Agent 对话、方向确认和图片视频任务。 实现:新增 Works Square 云端适配与开发态本地适配,统一 Host API、Quote 确认、任务轮询及私有媒体 Range 代理。
377 lines
12 KiB
TypeScript
377 lines
12 KiB
TypeScript
import type {
|
||
DesignAsset,
|
||
DesignBrief,
|
||
DesignCapabilities,
|
||
DesignConfirmGenerationInput,
|
||
DesignCreateWorkspaceInput,
|
||
DesignGenerationQuote,
|
||
DesignGenerationTask,
|
||
DesignMessage,
|
||
DesignRenameWorkspaceInput,
|
||
DesignSubmitMessageInput,
|
||
DesignWorkspace,
|
||
DesignWorkspaceBootstrap,
|
||
DesignWorkspaceSummary,
|
||
} from '../../shared/image-workspace';
|
||
import { designAssetContentPath } from '../../shared/image-workspace';
|
||
import { WORKS_SQUARE_CONFIG } from '../api/works-config';
|
||
import { proxyAwareFetch } from '../utils/proxy-fetch';
|
||
import { getValidWorksSquareAccessToken } from '../services/works-square-session';
|
||
import { DesignWorkspaceModuleError, type DesignWorkspaceModule } from './module';
|
||
|
||
type ServerBrief = {
|
||
version: number;
|
||
status: DesignBrief['status'];
|
||
medium: DesignBrief['medium'];
|
||
summary: string;
|
||
ready: boolean;
|
||
missing_decision: string | null;
|
||
};
|
||
|
||
type ServerQuote = {
|
||
quote_id: string;
|
||
status: DesignGenerationQuote['status'];
|
||
medium: DesignGenerationQuote['medium'];
|
||
brief_version: number;
|
||
brief_summary: string;
|
||
quoted_design_points: number;
|
||
expires_at: string;
|
||
};
|
||
|
||
type ServerMessage = {
|
||
role: DesignMessage['role'];
|
||
kind: DesignMessage['kind'];
|
||
text: string;
|
||
quick_replies: string[];
|
||
generation_quote: ServerQuote | null;
|
||
turn_revision: number;
|
||
created_at: string;
|
||
};
|
||
|
||
type ServerWorkspaceSummary = {
|
||
workspace_id: string;
|
||
title: string;
|
||
turn_revision: number;
|
||
view_revision: number;
|
||
phase: DesignWorkspaceSummary['phase'];
|
||
brief: ServerBrief;
|
||
updated_at: string;
|
||
};
|
||
|
||
type ServerWorkspace = ServerWorkspaceSummary & {
|
||
messages: ServerMessage[];
|
||
};
|
||
|
||
type ServerAsset = {
|
||
asset_id: string;
|
||
media_type: DesignAsset['mediaType'];
|
||
mime_type: string;
|
||
width: number;
|
||
height: number;
|
||
duration_milliseconds: number | null;
|
||
created_at: string;
|
||
};
|
||
|
||
type ServerTask = {
|
||
task_id: string;
|
||
workspace_id: string;
|
||
medium: DesignGenerationTask['medium'];
|
||
status: DesignGenerationTask['status'];
|
||
brief_version: number;
|
||
brief_summary: string;
|
||
quote_id: string | null;
|
||
quoted_design_points: number | null;
|
||
failure_code: string | null;
|
||
result_assets: ServerAsset[];
|
||
created_at: string;
|
||
updated_at: string;
|
||
};
|
||
|
||
type ServerErrorDetail = {
|
||
code?: unknown;
|
||
message?: unknown;
|
||
};
|
||
|
||
type WorksSquareDesignWorkspaceOptions = {
|
||
apiBaseUrl?: string;
|
||
fetchImpl?: typeof fetch;
|
||
};
|
||
|
||
function mapBrief(brief: ServerBrief): DesignBrief {
|
||
return {
|
||
version: brief.version,
|
||
status: brief.status,
|
||
medium: brief.medium,
|
||
summary: brief.summary,
|
||
ready: brief.ready,
|
||
missingDecision: brief.missing_decision,
|
||
};
|
||
}
|
||
|
||
function mapQuote(quote: ServerQuote | null): DesignGenerationQuote | null {
|
||
if (!quote) return null;
|
||
return {
|
||
quoteId: quote.quote_id,
|
||
status: quote.status,
|
||
medium: quote.medium,
|
||
briefVersion: quote.brief_version,
|
||
briefSummary: quote.brief_summary,
|
||
quotedDesignPoints: quote.quoted_design_points,
|
||
expiresAt: quote.expires_at,
|
||
};
|
||
}
|
||
|
||
function mapWorkspaceSummary(workspace: ServerWorkspaceSummary): DesignWorkspaceSummary {
|
||
return {
|
||
workspaceId: workspace.workspace_id,
|
||
title: workspace.title,
|
||
turnRevision: workspace.turn_revision,
|
||
viewRevision: workspace.view_revision,
|
||
phase: workspace.phase,
|
||
brief: mapBrief(workspace.brief),
|
||
updatedAt: workspace.updated_at,
|
||
};
|
||
}
|
||
|
||
function mapWorkspace(workspace: ServerWorkspace): DesignWorkspace {
|
||
return {
|
||
...mapWorkspaceSummary(workspace),
|
||
messages: workspace.messages.map((message, index) => ({
|
||
id: `${workspace.workspace_id}:${message.turn_revision}:${message.role}:${index}`,
|
||
role: message.role,
|
||
kind: message.kind,
|
||
text: message.text,
|
||
quickReplies: message.quick_replies,
|
||
generationQuote: mapQuote(message.generation_quote),
|
||
turnRevision: message.turn_revision,
|
||
createdAt: message.created_at,
|
||
})),
|
||
};
|
||
}
|
||
|
||
function mapTask(task: ServerTask): DesignGenerationTask {
|
||
return {
|
||
taskId: task.task_id,
|
||
workspaceId: task.workspace_id,
|
||
medium: task.medium,
|
||
status: task.status,
|
||
briefVersion: task.brief_version,
|
||
briefSummary: task.brief_summary,
|
||
quoteId: task.quote_id,
|
||
quotedDesignPoints: task.quoted_design_points,
|
||
failureCode: task.failure_code,
|
||
resultAssets: task.result_assets.map((asset) => ({
|
||
assetId: asset.asset_id,
|
||
workspaceId: task.workspace_id,
|
||
mediaType: asset.media_type,
|
||
mimeType: asset.mime_type,
|
||
width: asset.width,
|
||
height: asset.height,
|
||
durationMilliseconds: asset.duration_milliseconds,
|
||
createdAt: asset.created_at,
|
||
contentPath: designAssetContentPath(task.workspace_id, asset.asset_id),
|
||
})),
|
||
createdAt: task.created_at,
|
||
updatedAt: task.updated_at,
|
||
};
|
||
}
|
||
|
||
async function readPayload(response: Response): Promise<unknown> {
|
||
const text = await response.text();
|
||
if (!text.trim()) return null;
|
||
try {
|
||
return JSON.parse(text) as unknown;
|
||
} catch {
|
||
return text;
|
||
}
|
||
}
|
||
|
||
function asErrorDetail(payload: unknown): ServerErrorDetail {
|
||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return {};
|
||
const detail = (payload as Record<string, unknown>).detail;
|
||
if (detail && typeof detail === 'object' && !Array.isArray(detail)) {
|
||
return detail as ServerErrorDetail;
|
||
}
|
||
return payload as ServerErrorDetail;
|
||
}
|
||
|
||
function userFacingErrorMessage(code: string, fallback: string): string {
|
||
const messages: Record<string, string> = {
|
||
workspace_not_found: '设计项目不存在或无权访问',
|
||
workspace_revision_conflict: '设计项目已更新,请刷新后重试',
|
||
idempotency_conflict: '这次操作与已经提交的请求冲突',
|
||
reference_asset_invalid: '所选参考资产不可用',
|
||
generation_quote_expired: '当前生成方案已过期,请让 Agent 重新确认',
|
||
generation_quote_consumed: '当前生成方案已经确认过',
|
||
generation_quote_invalid: '当前生成方案已失效,请让 Agent 重新确认',
|
||
budget_denied: '当前设计点不足,无法开始生成',
|
||
policy_blocked: '当前内容不符合创作安全规则',
|
||
design_reasoner_unavailable: '设计 Agent 暂时不可用,请稍后重试',
|
||
design_runtime_unavailable: 'AI 设计服务暂时不可用',
|
||
design_production_unavailable: '当前生成能力暂时不可用',
|
||
};
|
||
return messages[code] ?? fallback;
|
||
}
|
||
|
||
export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
|
||
private readonly apiBaseUrl: string;
|
||
private readonly fetchImpl: typeof fetch;
|
||
|
||
constructor(options: WorksSquareDesignWorkspaceOptions = {}) {
|
||
this.apiBaseUrl = (options.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/, '');
|
||
this.fetchImpl = options.fetchImpl ?? proxyAwareFetch;
|
||
}
|
||
|
||
async bootstrap(): Promise<DesignWorkspaceBootstrap> {
|
||
const [capabilities, workspaces] = await Promise.all([
|
||
this.getCapabilities(),
|
||
this.requestJson<ServerWorkspaceSummary[]>('/api/design/workspaces?limit=100&offset=0'),
|
||
]);
|
||
return {
|
||
capabilities,
|
||
workspaces: workspaces.map(mapWorkspaceSummary),
|
||
};
|
||
}
|
||
|
||
getCapabilities(): Promise<DesignCapabilities> {
|
||
return this.requestJson<DesignCapabilities>('/api/design/capabilities');
|
||
}
|
||
|
||
async createWorkspace(input: DesignCreateWorkspaceInput): Promise<DesignWorkspace> {
|
||
const workspace = await this.requestJson<ServerWorkspace>('/api/design/workspaces', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
client_workspace_id: input.clientWorkspaceId,
|
||
title: input.title,
|
||
}),
|
||
});
|
||
return mapWorkspace(workspace);
|
||
}
|
||
|
||
async renameWorkspace(input: DesignRenameWorkspaceInput): Promise<DesignWorkspace> {
|
||
const workspace = await this.requestJson<ServerWorkspace>(
|
||
`/api/design/workspaces/${encodeURIComponent(input.workspaceId)}`,
|
||
{
|
||
method: 'PATCH',
|
||
body: JSON.stringify({ title: input.title }),
|
||
},
|
||
);
|
||
return mapWorkspace(workspace);
|
||
}
|
||
|
||
async getWorkspace(workspaceId: string): Promise<DesignWorkspace> {
|
||
const workspace = await this.requestJson<ServerWorkspace>(
|
||
`/api/design/workspaces/${encodeURIComponent(workspaceId)}`,
|
||
);
|
||
return mapWorkspace(workspace);
|
||
}
|
||
|
||
async submitMessage(input: DesignSubmitMessageInput): Promise<DesignWorkspace> {
|
||
const workspace = await this.requestJson<ServerWorkspace>(
|
||
`/api/design/workspaces/${encodeURIComponent(input.workspaceId)}/turns`,
|
||
{
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
client_turn_id: input.clientTurnId,
|
||
expected_turn_revision: input.expectedTurnRevision,
|
||
message: input.message,
|
||
attachment_asset_ids: input.attachmentAssetIds ?? [],
|
||
action: null,
|
||
}),
|
||
},
|
||
);
|
||
return mapWorkspace(workspace);
|
||
}
|
||
|
||
async confirmGeneration(input: DesignConfirmGenerationInput): Promise<DesignWorkspace> {
|
||
const workspace = await this.requestJson<ServerWorkspace>(
|
||
`/api/design/workspaces/${encodeURIComponent(input.workspaceId)}/turns`,
|
||
{
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
client_turn_id: input.clientTurnId,
|
||
expected_turn_revision: input.expectedTurnRevision,
|
||
message: '确认生成',
|
||
attachment_asset_ids: [],
|
||
action: {
|
||
type: 'confirm_generation',
|
||
quote_id: input.quoteId,
|
||
},
|
||
}),
|
||
},
|
||
);
|
||
return mapWorkspace(workspace);
|
||
}
|
||
|
||
async listTasks(workspaceId: string): Promise<DesignGenerationTask[]> {
|
||
const tasks = await this.requestJson<ServerTask[]>(
|
||
`/api/design/workspaces/${encodeURIComponent(workspaceId)}/generation-tasks?limit=100&offset=0`,
|
||
);
|
||
return tasks.map(mapTask);
|
||
}
|
||
|
||
openAssetContent(workspaceId: string, assetId: string, range?: string): Promise<Response> {
|
||
return this.authorizedFetch(
|
||
`/api/design/workspaces/${encodeURIComponent(workspaceId)}/assets/${encodeURIComponent(assetId)}/content`,
|
||
range ? { headers: { Range: range } } : {},
|
||
);
|
||
}
|
||
|
||
private async requestJson<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||
const response = await this.authorizedFetch(path, {
|
||
...init,
|
||
headers: {
|
||
Accept: 'application/json',
|
||
...(init.body ? { 'Content-Type': 'application/json' } : {}),
|
||
...(init.headers ?? {}),
|
||
},
|
||
});
|
||
const payload = await readPayload(response);
|
||
if (!response.ok) {
|
||
const detail = asErrorDetail(payload);
|
||
const code = typeof detail.code === 'string'
|
||
? detail.code
|
||
: 'DESIGN_WORKSPACE_REQUEST_FAILED';
|
||
const fallback = typeof detail.message === 'string'
|
||
? detail.message
|
||
: `AI 设计请求失败(${response.status})`;
|
||
throw new DesignWorkspaceModuleError(
|
||
response.status,
|
||
code,
|
||
userFacingErrorMessage(code, fallback),
|
||
);
|
||
}
|
||
return payload as T;
|
||
}
|
||
|
||
private async authorizedFetch(path: string, init: RequestInit = {}): Promise<Response> {
|
||
let token = await getValidWorksSquareAccessToken({ fetchImpl: this.fetchImpl });
|
||
if (!token) {
|
||
throw new DesignWorkspaceModuleError(401, 'AUTH_REQUIRED', '请先登录后再使用 AI 设计');
|
||
}
|
||
|
||
let response = await this.fetchWithToken(path, token, init);
|
||
if (response.status !== 401) return response;
|
||
|
||
token = await getValidWorksSquareAccessToken({
|
||
fetchImpl: this.fetchImpl,
|
||
forceRefresh: true,
|
||
});
|
||
if (!token) {
|
||
throw new DesignWorkspaceModuleError(401, 'AUTH_EXPIRED', '登录状态已失效,请重新登录');
|
||
}
|
||
response = await this.fetchWithToken(path, token, init);
|
||
return response;
|
||
}
|
||
|
||
private fetchWithToken(path: string, token: string, init: RequestInit): Promise<Response> {
|
||
return this.fetchImpl(`${this.apiBaseUrl}${path}`, {
|
||
...init,
|
||
headers: {
|
||
...init.headers,
|
||
Authorization: `Bearer ${token}`,
|
||
},
|
||
});
|
||
}
|
||
}
|