1
This commit is contained in:
147
src/services/big-fish-creation/bigFishCreationClient.ts
Normal file
147
src/services/big-fish-creation/bigFishCreationClient.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import type {
|
||||
BigFishActionResponse,
|
||||
BigFishSessionResponse,
|
||||
BigFishSessionSnapshotResponse,
|
||||
CreateBigFishSessionRequest,
|
||||
ExecuteBigFishActionRequest,
|
||||
SendBigFishMessageRequest,
|
||||
} from '../../../packages/shared/src/contracts/bigFish';
|
||||
import { parseApiErrorMessage } from '../../../packages/shared/src/http';
|
||||
import type { TextStreamOptions } from '../aiTypes';
|
||||
import {
|
||||
type ApiRetryOptions,
|
||||
fetchWithApiAuth,
|
||||
requestJson,
|
||||
} from '../apiClient';
|
||||
import { readCreationAgentSessionFromSse } from '../creation-agent';
|
||||
|
||||
const BIG_FISH_AGENT_API_BASE = '/api/runtime/big-fish/agent/sessions';
|
||||
const BIG_FISH_READ_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 180,
|
||||
maxDelayMs: 480,
|
||||
};
|
||||
const BIG_FISH_WRITE_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 240,
|
||||
maxDelayMs: 640,
|
||||
retryUnsafeMethods: true,
|
||||
};
|
||||
|
||||
export async function createBigFishCreationSession(
|
||||
payload: CreateBigFishSessionRequest = {},
|
||||
) {
|
||||
return requestJson<BigFishSessionResponse>(
|
||||
BIG_FISH_AGENT_API_BASE,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'创建大鱼吃小鱼共创会话失败',
|
||||
{
|
||||
retry: BIG_FISH_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function getBigFishCreationSession(sessionId: string) {
|
||||
return requestJson<BigFishSessionResponse>(
|
||||
`${BIG_FISH_AGENT_API_BASE}/${encodeURIComponent(sessionId)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取大鱼吃小鱼共创会话失败',
|
||||
{
|
||||
retry: BIG_FISH_READ_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendBigFishCreationMessage(
|
||||
sessionId: string,
|
||||
payload: SendBigFishMessageRequest,
|
||||
) {
|
||||
return requestJson<BigFishSessionResponse>(
|
||||
`${BIG_FISH_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/messages`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'发送大鱼吃小鱼共创消息失败',
|
||||
{
|
||||
retry: BIG_FISH_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function streamBigFishCreationMessage(
|
||||
sessionId: string,
|
||||
payload: SendBigFishMessageRequest,
|
||||
options: TextStreamOptions = {},
|
||||
) {
|
||||
const response = await openBigFishCreationSsePost(
|
||||
`${BIG_FISH_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/messages/stream`,
|
||||
payload,
|
||||
'发送大鱼吃小鱼共创消息失败',
|
||||
);
|
||||
|
||||
return readCreationAgentSessionFromSse<BigFishSessionSnapshotResponse>(
|
||||
response,
|
||||
{
|
||||
...options,
|
||||
fallbackMessage: '发送大鱼吃小鱼共创消息失败',
|
||||
incompleteMessage: '大鱼吃小鱼共创消息流式结果不完整',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function executeBigFishCreationAction(
|
||||
sessionId: string,
|
||||
payload: ExecuteBigFishActionRequest,
|
||||
) {
|
||||
return requestJson<BigFishActionResponse>(
|
||||
`${BIG_FISH_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/actions`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'执行大鱼吃小鱼共创操作失败',
|
||||
{
|
||||
retry: BIG_FISH_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function openBigFishCreationSsePost(
|
||||
url: string,
|
||||
payload: unknown,
|
||||
fallbackMessage: string,
|
||||
) {
|
||||
const response = await fetchWithApiAuth(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const responseText = await response.text();
|
||||
throw new Error(parseApiErrorMessage(responseText, fallbackMessage));
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('streaming response body is unavailable');
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export const bigFishCreationClient = {
|
||||
createSession: createBigFishCreationSession,
|
||||
getSession: getBigFishCreationSession,
|
||||
sendMessage: sendBigFishCreationMessage,
|
||||
streamMessage: streamBigFishCreationMessage,
|
||||
executeAction: executeBigFishCreationAction,
|
||||
};
|
||||
8
src/services/big-fish-creation/index.ts
Normal file
8
src/services/big-fish-creation/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
bigFishCreationClient,
|
||||
createBigFishCreationSession,
|
||||
executeBigFishCreationAction,
|
||||
getBigFishCreationSession,
|
||||
sendBigFishCreationMessage,
|
||||
streamBigFishCreationMessage,
|
||||
} from './bigFishCreationClient';
|
||||
68
src/services/big-fish-runtime/bigFishRuntimeClient.ts
Normal file
68
src/services/big-fish-runtime/bigFishRuntimeClient.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type {
|
||||
BigFishRunResponse,
|
||||
SubmitBigFishInputRequest,
|
||||
} from '../../../packages/shared/src/contracts/bigFish';
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
|
||||
const BIG_FISH_RUNTIME_API_BASE = '/api/runtime/big-fish';
|
||||
const BIG_FISH_RUNTIME_READ_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 120,
|
||||
maxDelayMs: 360,
|
||||
};
|
||||
const BIG_FISH_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 120,
|
||||
maxDelayMs: 360,
|
||||
retryUnsafeMethods: true,
|
||||
};
|
||||
|
||||
export async function startBigFishRuntimeRun(sessionId: string) {
|
||||
return requestJson<BigFishRunResponse>(
|
||||
`${BIG_FISH_RUNTIME_API_BASE}/sessions/${encodeURIComponent(sessionId)}/runs`,
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
'启动大鱼吃小鱼测试玩法失败',
|
||||
{
|
||||
retry: BIG_FISH_RUNTIME_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function getBigFishRuntimeRun(runId: string) {
|
||||
return requestJson<BigFishRunResponse>(
|
||||
`${BIG_FISH_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取大鱼吃小鱼运行快照失败',
|
||||
{
|
||||
retry: BIG_FISH_RUNTIME_READ_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function submitBigFishRuntimeInput(
|
||||
runId: string,
|
||||
payload: SubmitBigFishInputRequest,
|
||||
) {
|
||||
return requestJson<BigFishRunResponse>(
|
||||
`${BIG_FISH_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/input`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'提交大鱼吃小鱼移动输入失败',
|
||||
{
|
||||
retry: BIG_FISH_RUNTIME_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export const bigFishRuntimeClient = {
|
||||
startRun: startBigFishRuntimeRun,
|
||||
getRun: getBigFishRuntimeRun,
|
||||
submitInput: submitBigFishRuntimeInput,
|
||||
};
|
||||
6
src/services/big-fish-runtime/index.ts
Normal file
6
src/services/big-fish-runtime/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
bigFishRuntimeClient,
|
||||
getBigFishRuntimeRun,
|
||||
startBigFishRuntimeRun,
|
||||
submitBigFishRuntimeInput,
|
||||
} from './bigFishRuntimeClient';
|
||||
77
src/services/creation-agent/creationAgentProgress.ts
Normal file
77
src/services/creation-agent/creationAgentProgress.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
export type CreationAgentOperationLike = {
|
||||
status?: string | null;
|
||||
};
|
||||
|
||||
export type CreationAgentProgressCopy = {
|
||||
completed?: string;
|
||||
high?: string;
|
||||
medium?: string;
|
||||
low?: string;
|
||||
initial?: string;
|
||||
};
|
||||
|
||||
export function normalizeCreationAgentProgress(progressPercent: number) {
|
||||
if (!Number.isFinite(progressPercent)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.max(0, Math.min(100, Math.round(progressPercent)));
|
||||
}
|
||||
|
||||
export function isCreationAgentOperationBusy(
|
||||
operation: CreationAgentOperationLike | null | undefined,
|
||||
) {
|
||||
return operation?.status === 'queued' || operation?.status === 'running';
|
||||
}
|
||||
|
||||
export function resolveCreationAgentProgressHint(
|
||||
progressPercent: number,
|
||||
copy: CreationAgentProgressCopy = {},
|
||||
) {
|
||||
const normalizedProgress = normalizeCreationAgentProgress(progressPercent);
|
||||
|
||||
if (normalizedProgress >= 100) {
|
||||
return copy.completed || '当前设定已经收束完成,可以进入结果页生成';
|
||||
}
|
||||
|
||||
if (normalizedProgress >= 75) {
|
||||
return copy.high || '关键锚点基本成形,正在收束成可生成草稿的版本';
|
||||
}
|
||||
|
||||
if (normalizedProgress >= 45) {
|
||||
return copy.medium || '方向已经成形,继续补齐会影响体验的关键锚点';
|
||||
}
|
||||
|
||||
if (normalizedProgress >= 15) {
|
||||
return copy.low || '先把玩家一眼能感知的核心体验钉稳';
|
||||
}
|
||||
|
||||
return copy.initial || '先抓住这个创作品类最关键的方向';
|
||||
}
|
||||
|
||||
export function resolveCreationAnchorStatusLabel(status: string) {
|
||||
if (status === 'locked') {
|
||||
return '已锁定';
|
||||
}
|
||||
|
||||
if (status === 'confirmed') {
|
||||
return '已确认';
|
||||
}
|
||||
|
||||
if (status === 'inferred') {
|
||||
return '推断中';
|
||||
}
|
||||
|
||||
return '待补充';
|
||||
}
|
||||
|
||||
export function createCreationAgentClientMessageId(prefix: string) {
|
||||
if (
|
||||
typeof crypto !== 'undefined' &&
|
||||
typeof crypto.randomUUID === 'function'
|
||||
) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
return `${prefix}-client-message-${Date.now()}`;
|
||||
}
|
||||
105
src/services/creation-agent/creationAgentSse.ts
Normal file
105
src/services/creation-agent/creationAgentSse.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { TextStreamOptions } from '../aiTypes';
|
||||
|
||||
type CreationAgentSseOptions<TSession> = TextStreamOptions & {
|
||||
fallbackMessage: string;
|
||||
incompleteMessage: string;
|
||||
resolveSession?: (rawSession: unknown) => TSession | null;
|
||||
};
|
||||
|
||||
function parseSseEventBlock(eventBlock: string) {
|
||||
let eventName = 'message';
|
||||
const dataLines: string[] = [];
|
||||
|
||||
for (const rawLine of eventBlock.split(/\r?\n/u)) {
|
||||
const line = rawLine.trim();
|
||||
|
||||
if (line.startsWith('event:')) {
|
||||
eventName = line.slice(6).trim() || 'message';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('data:')) {
|
||||
dataLines.push(line.slice(5).trim());
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
eventName,
|
||||
data: dataLines.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
function parseJsonObject(data: string) {
|
||||
try {
|
||||
return JSON.parse(data) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readCreationAgentSessionFromSse<TSession>(
|
||||
response: Response,
|
||||
options: CreationAgentSseOptions<TSession>,
|
||||
) {
|
||||
const streamBody = response.body;
|
||||
if (!streamBody) {
|
||||
throw new Error('streaming response body is unavailable');
|
||||
}
|
||||
|
||||
const reader = streamBody.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
const resolveSession =
|
||||
options.resolveSession ??
|
||||
((rawSession: unknown) => (rawSession as TSession | null) ?? null);
|
||||
let buffer = '';
|
||||
let finalSession: TSession | null = null;
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
while (buffer.includes('\n\n')) {
|
||||
const boundary = buffer.indexOf('\n\n');
|
||||
const eventBlock = buffer.slice(0, boundary);
|
||||
buffer = buffer.slice(boundary + 2);
|
||||
const { eventName, data } = parseSseEventBlock(eventBlock);
|
||||
|
||||
if (!data) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsed = parseJsonObject(data);
|
||||
|
||||
if (eventName === 'reply_delta' && parsed) {
|
||||
const text = parsed.text;
|
||||
if (typeof text === 'string') {
|
||||
options.onUpdate?.(text);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (eventName === 'session' && parsed?.session) {
|
||||
finalSession = resolveSession(parsed.session);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (eventName === 'error' && parsed) {
|
||||
const message =
|
||||
typeof parsed.message === 'string' && parsed.message.trim()
|
||||
? parsed.message.trim()
|
||||
: options.fallbackMessage;
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!finalSession) {
|
||||
throw new Error(options.incompleteMessage);
|
||||
}
|
||||
|
||||
return finalSession;
|
||||
}
|
||||
2
src/services/creation-agent/index.ts
Normal file
2
src/services/creation-agent/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './creationAgentProgress';
|
||||
export * from './creationAgentSse';
|
||||
@@ -118,12 +118,17 @@ describe('resolveCustomWorldCoverPresentation', () => {
|
||||
|
||||
it('当第一幕图片缺失时按营地图与地标图顺序回退', () => {
|
||||
const profile = createBaseProfile();
|
||||
const firstSceneChapter = profile.sceneChapterBlueprints?.[0];
|
||||
const firstSceneAct = firstSceneChapter?.acts[0];
|
||||
if (!firstSceneChapter || !firstSceneAct) {
|
||||
throw new Error('expected base profile to provide an opening scene chapter');
|
||||
}
|
||||
profile.sceneChapterBlueprints = [
|
||||
{
|
||||
...profile.sceneChapterBlueprints![0],
|
||||
...firstSceneChapter,
|
||||
acts: [
|
||||
{
|
||||
...profile.sceneChapterBlueprints![0]!.acts[0]!,
|
||||
...firstSceneAct,
|
||||
backgroundImageSrc: null,
|
||||
backgroundAssetId: null,
|
||||
},
|
||||
|
||||
5
src/services/platform-entry/index.ts
Normal file
5
src/services/platform-entry/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* 平台入口服务通用封装。
|
||||
* 先复用既有资料看板读取逻辑,但对 `platform-entry` 暴露通用命名。
|
||||
*/
|
||||
export { getRpgProfileDashboard as getPlatformProfileDashboard } from '../rpg-entry';
|
||||
8
src/services/puzzle-agent/index.ts
Normal file
8
src/services/puzzle-agent/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
createPuzzleAgentSession,
|
||||
executePuzzleAgentAction,
|
||||
getPuzzleAgentSession,
|
||||
puzzleAgentClient,
|
||||
sendPuzzleAgentMessage,
|
||||
streamPuzzleAgentMessage,
|
||||
} from './puzzleAgentClient';
|
||||
168
src/services/puzzle-agent/puzzleAgentClient.ts
Normal file
168
src/services/puzzle-agent/puzzleAgentClient.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import type {
|
||||
PuzzleAgentActionRequest,
|
||||
PuzzleAgentActionResponse,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentActions';
|
||||
import type {
|
||||
CreatePuzzleAgentSessionRequest,
|
||||
CreatePuzzleAgentSessionResponse,
|
||||
PuzzleAgentSessionSnapshot,
|
||||
SendPuzzleAgentMessageRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import { parseApiErrorMessage } from '../../../packages/shared/src/http';
|
||||
import type { TextStreamOptions } from '../aiTypes';
|
||||
import {
|
||||
type ApiRetryOptions,
|
||||
fetchWithApiAuth,
|
||||
requestJson,
|
||||
} from '../apiClient';
|
||||
import { readCreationAgentSessionFromSse } from '../creation-agent';
|
||||
|
||||
const PUZZLE_AGENT_API_BASE = '/api/runtime/puzzle/agent/sessions';
|
||||
const PUZZLE_AGENT_READ_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 180,
|
||||
maxDelayMs: 480,
|
||||
};
|
||||
const PUZZLE_AGENT_WRITE_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 240,
|
||||
maxDelayMs: 640,
|
||||
retryUnsafeMethods: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建拼图 Agent 共创会话。
|
||||
* 首版继续走 Axum facade,前端不直连 SpacetimeDB。
|
||||
*/
|
||||
export async function createPuzzleAgentSession(
|
||||
payload: CreatePuzzleAgentSessionRequest = {},
|
||||
) {
|
||||
return requestJson<CreatePuzzleAgentSessionResponse>(
|
||||
PUZZLE_AGENT_API_BASE,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'创建拼图共创会话失败',
|
||||
{
|
||||
retry: PUZZLE_AGENT_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取拼图 Agent 会话快照。
|
||||
*/
|
||||
export async function getPuzzleAgentSession(sessionId: string) {
|
||||
return requestJson<CreatePuzzleAgentSessionResponse>(
|
||||
`${PUZZLE_AGENT_API_BASE}/${encodeURIComponent(sessionId)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取拼图共创会话失败',
|
||||
{
|
||||
retry: PUZZLE_AGENT_READ_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 非流式发送拼图 Agent 消息。
|
||||
* 当前 UI 主链使用 SSE,但保留普通接口便于后续降级。
|
||||
*/
|
||||
export async function sendPuzzleAgentMessage(
|
||||
sessionId: string,
|
||||
payload: SendPuzzleAgentMessageRequest,
|
||||
) {
|
||||
return requestJson<{ session: PuzzleAgentSessionSnapshot }>(
|
||||
`${PUZZLE_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/messages`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'发送拼图共创消息失败',
|
||||
{
|
||||
retry: PUZZLE_AGENT_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式发送拼图 Agent 消息。
|
||||
* 后端当前会先回传一段 assistant 文本,再附上最新 session 快照。
|
||||
*/
|
||||
export async function streamPuzzleAgentMessage(
|
||||
sessionId: string,
|
||||
payload: SendPuzzleAgentMessageRequest,
|
||||
options: TextStreamOptions = {},
|
||||
) {
|
||||
const response = await openPuzzleAgentSsePost(
|
||||
`${PUZZLE_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/messages/stream`,
|
||||
payload,
|
||||
'发送拼图共创消息失败',
|
||||
);
|
||||
|
||||
return readCreationAgentSessionFromSse<PuzzleAgentSessionSnapshot>(
|
||||
response,
|
||||
{
|
||||
...options,
|
||||
fallbackMessage: '发送拼图共创消息失败',
|
||||
incompleteMessage: '拼图共创消息流式结果不完整',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行拼图结果页相关操作。
|
||||
* 后端会返回 operation 记录,前端再按需刷新 session 或 works/gallery。
|
||||
*/
|
||||
export async function executePuzzleAgentAction(
|
||||
sessionId: string,
|
||||
payload: PuzzleAgentActionRequest,
|
||||
) {
|
||||
return requestJson<PuzzleAgentActionResponse>(
|
||||
`${PUZZLE_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/actions`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'执行拼图共创操作失败',
|
||||
{
|
||||
retry: PUZZLE_AGENT_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function openPuzzleAgentSsePost(
|
||||
url: string,
|
||||
payload: unknown,
|
||||
fallbackMessage: string,
|
||||
) {
|
||||
const response = await fetchWithApiAuth(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const responseText = await response.text();
|
||||
throw new Error(parseApiErrorMessage(responseText, fallbackMessage));
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('streaming response body is unavailable');
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export const puzzleAgentClient = {
|
||||
createSession: createPuzzleAgentSession,
|
||||
getSession: getPuzzleAgentSession,
|
||||
sendMessage: sendPuzzleAgentMessage,
|
||||
streamMessage: streamPuzzleAgentMessage,
|
||||
executeAction: executePuzzleAgentAction,
|
||||
};
|
||||
5
src/services/puzzle-gallery/index.ts
Normal file
5
src/services/puzzle-gallery/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
getPuzzleGalleryDetail,
|
||||
listPuzzleGallery,
|
||||
puzzleGalleryClient,
|
||||
} from './puzzleGalleryClient';
|
||||
49
src/services/puzzle-gallery/puzzleGalleryClient.ts
Normal file
49
src/services/puzzle-gallery/puzzleGalleryClient.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type {
|
||||
PuzzleWorksResponse,
|
||||
PuzzleWorkSummary,
|
||||
} from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
|
||||
const PUZZLE_GALLERY_API_BASE = '/api/runtime/puzzle/gallery';
|
||||
const PUZZLE_GALLERY_READ_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 120,
|
||||
maxDelayMs: 360,
|
||||
};
|
||||
|
||||
/**
|
||||
* 读取拼图广场列表。
|
||||
*/
|
||||
export async function listPuzzleGallery() {
|
||||
return requestJson<PuzzleWorksResponse>(
|
||||
PUZZLE_GALLERY_API_BASE,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取拼图广场失败',
|
||||
{
|
||||
retry: PUZZLE_GALLERY_READ_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取拼图广场详情。
|
||||
*/
|
||||
export async function getPuzzleGalleryDetail(profileId: string) {
|
||||
return requestJson<{ item: PuzzleWorkSummary }>(
|
||||
`${PUZZLE_GALLERY_API_BASE}/${encodeURIComponent(profileId)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取拼图广场详情失败',
|
||||
{
|
||||
retry: PUZZLE_GALLERY_READ_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export const puzzleGalleryClient = {
|
||||
getDetail: getPuzzleGalleryDetail,
|
||||
list: listPuzzleGallery,
|
||||
};
|
||||
8
src/services/puzzle-runtime/index.ts
Normal file
8
src/services/puzzle-runtime/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
advancePuzzleNextLevel,
|
||||
dragPuzzlePieceOrGroup,
|
||||
getPuzzleRun,
|
||||
puzzleRuntimeClient,
|
||||
startPuzzleRun,
|
||||
swapPuzzlePieces,
|
||||
} from './puzzleRuntimeClient';
|
||||
120
src/services/puzzle-runtime/puzzleRuntimeClient.ts
Normal file
120
src/services/puzzle-runtime/puzzleRuntimeClient.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import type {
|
||||
DragPuzzlePieceRequest,
|
||||
PuzzleRunResponse,
|
||||
StartPuzzleRunRequest,
|
||||
SwapPuzzlePiecesRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
|
||||
const PUZZLE_RUNTIME_API_BASE = '/api/runtime/puzzle/runs';
|
||||
const PUZZLE_RUNTIME_READ_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 120,
|
||||
maxDelayMs: 360,
|
||||
};
|
||||
const PUZZLE_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 120,
|
||||
maxDelayMs: 360,
|
||||
retryUnsafeMethods: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* 从某个已发布拼图作品开始一次 run。
|
||||
*/
|
||||
export async function startPuzzleRun(payload: StartPuzzleRunRequest) {
|
||||
return requestJson<PuzzleRunResponse>(
|
||||
PUZZLE_RUNTIME_API_BASE,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'启动拼图玩法失败',
|
||||
{
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取拼图运行态快照。
|
||||
*/
|
||||
export async function getPuzzleRun(runId: string) {
|
||||
return requestJson<PuzzleRunResponse>(
|
||||
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取拼图运行快照失败',
|
||||
{
|
||||
retry: PUZZLE_RUNTIME_READ_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交两块交换请求。
|
||||
*/
|
||||
export async function swapPuzzlePieces(
|
||||
runId: string,
|
||||
payload: SwapPuzzlePiecesRequest,
|
||||
) {
|
||||
return requestJson<PuzzleRunResponse>(
|
||||
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/swap`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'交换拼图块失败',
|
||||
{
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交单块或合并块拖动请求。
|
||||
*/
|
||||
export async function dragPuzzlePieceOrGroup(
|
||||
runId: string,
|
||||
payload: DragPuzzlePieceRequest,
|
||||
) {
|
||||
return requestJson<PuzzleRunResponse>(
|
||||
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/drag`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'拖动拼图块失败',
|
||||
{
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 进入推荐出的下一关。
|
||||
*/
|
||||
export async function advancePuzzleNextLevel(runId: string) {
|
||||
return requestJson<PuzzleRunResponse>(
|
||||
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/next-level`,
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
'进入下一关失败',
|
||||
{
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export const puzzleRuntimeClient = {
|
||||
advanceNextLevel: advancePuzzleNextLevel,
|
||||
drag: dragPuzzlePieceOrGroup,
|
||||
getRun: getPuzzleRun,
|
||||
startRun: startPuzzleRun,
|
||||
swap: swapPuzzlePieces,
|
||||
};
|
||||
6
src/services/puzzle-works/index.ts
Normal file
6
src/services/puzzle-works/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
getPuzzleWorkDetail,
|
||||
listPuzzleWorks,
|
||||
puzzleWorksClient,
|
||||
updatePuzzleWork,
|
||||
} from './puzzleWorksClient';
|
||||
85
src/services/puzzle-works/puzzleWorksClient.ts
Normal file
85
src/services/puzzle-works/puzzleWorksClient.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type {
|
||||
PuzzleWorkDetailResponse,
|
||||
PuzzleWorkMutationResponse,
|
||||
PuzzleWorksResponse,
|
||||
} from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
|
||||
const PUZZLE_WORKS_API_BASE = '/api/runtime/puzzle/works';
|
||||
const PUZZLE_WORKS_READ_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 120,
|
||||
maxDelayMs: 360,
|
||||
};
|
||||
const PUZZLE_WORKS_WRITE_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 120,
|
||||
maxDelayMs: 360,
|
||||
retryUnsafeMethods: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* 读取当前用户的拼图作品列表。
|
||||
*/
|
||||
export async function listPuzzleWorks() {
|
||||
return requestJson<PuzzleWorksResponse>(
|
||||
PUZZLE_WORKS_API_BASE,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取拼图作品列表失败',
|
||||
{
|
||||
retry: PUZZLE_WORKS_READ_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取拼图作品详情。
|
||||
*/
|
||||
export async function getPuzzleWorkDetail(profileId: string) {
|
||||
return requestJson<PuzzleWorkDetailResponse>(
|
||||
`${PUZZLE_WORKS_API_BASE}/${encodeURIComponent(profileId)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取拼图作品详情失败',
|
||||
{
|
||||
retry: PUZZLE_WORKS_READ_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新已发布或草稿态拼图作品的轻量字段。
|
||||
* 只覆盖结果页约定的标题、摘要、标签与正式图。
|
||||
*/
|
||||
export async function updatePuzzleWork(
|
||||
profileId: string,
|
||||
payload: {
|
||||
levelName: string;
|
||||
summary: string;
|
||||
themeTags: string[];
|
||||
coverImageSrc?: string | null;
|
||||
coverAssetId?: string | null;
|
||||
},
|
||||
) {
|
||||
return requestJson<PuzzleWorkMutationResponse>(
|
||||
`${PUZZLE_WORKS_API_BASE}/${encodeURIComponent(profileId)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'更新拼图作品失败',
|
||||
{
|
||||
retry: PUZZLE_WORKS_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export const puzzleWorksClient = {
|
||||
getDetail: getPuzzleWorkDetail,
|
||||
list: listPuzzleWorks,
|
||||
update: updatePuzzleWork,
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
} from '../../../packages/shared/src';
|
||||
import type { RpgAgentActionRequest } from '../../../packages/shared/src';
|
||||
import type { TextStreamOptions } from '../aiTypes';
|
||||
import { readCreationAgentSessionFromSse } from '../creation-agent';
|
||||
import {
|
||||
openRpgCreationSsePost,
|
||||
requestRpgCreationPostJson,
|
||||
@@ -63,84 +64,11 @@ export async function streamRpgCreationMessage(
|
||||
'发送共创消息失败',
|
||||
);
|
||||
|
||||
const streamBody = response.body;
|
||||
if (!streamBody) {
|
||||
throw new Error('streaming response body is unavailable');
|
||||
}
|
||||
|
||||
const reader = streamBody.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
let finalSession: RpgAgentSessionSnapshot | null = null;
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
while (buffer.includes('\n\n')) {
|
||||
const boundary = buffer.indexOf('\n\n');
|
||||
const eventBlock = buffer.slice(0, boundary);
|
||||
buffer = buffer.slice(boundary + 2);
|
||||
|
||||
let eventName = 'message';
|
||||
const dataLines: string[] = [];
|
||||
|
||||
for (const rawLine of eventBlock.split(/\r?\n/u)) {
|
||||
const line = rawLine.trim();
|
||||
if (line.startsWith('event:')) {
|
||||
eventName = line.slice(6).trim() || 'message';
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('data:')) {
|
||||
dataLines.push(line.slice(5).trim());
|
||||
}
|
||||
}
|
||||
|
||||
if (dataLines.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = dataLines.join('\n');
|
||||
let parsed: Record<string, unknown> | null = null;
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(data) as Record<string, unknown>;
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
|
||||
if (eventName === 'reply_delta' && parsed) {
|
||||
const text = parsed.text;
|
||||
if (typeof text === 'string') {
|
||||
options.onUpdate?.(text);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (eventName === 'session' && parsed?.session) {
|
||||
finalSession = parsed.session as RpgAgentSessionSnapshot;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (eventName === 'error' && parsed) {
|
||||
const message =
|
||||
typeof parsed.message === 'string' && parsed.message.trim()
|
||||
? parsed.message.trim()
|
||||
: '发送共创消息失败';
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!finalSession) {
|
||||
throw new Error('共创消息流式结果不完整');
|
||||
}
|
||||
|
||||
return finalSession;
|
||||
return readCreationAgentSessionFromSse<RpgAgentSessionSnapshot>(response, {
|
||||
...options,
|
||||
fallbackMessage: '发送共创消息失败',
|
||||
incompleteMessage: '共创消息流式结果不完整',
|
||||
});
|
||||
}
|
||||
|
||||
export async function executeRpgCreationAction(
|
||||
|
||||
Reference in New Issue
Block a user