Preserve partial creation replies on stream failure
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
kdletters
2026-05-05 11:31:50 +08:00
parent 100fee7e7a
commit 995661e7cc
299 changed files with 13805 additions and 1429 deletions

View File

@@ -51,3 +51,28 @@ test('readCreationAgentSessionFromSse flushes decoder tail and handles CRLF boun
title: '世界共创',
});
});
test('readCreationAgentSessionFromSse keeps streamed updates before error event', async () => {
const encoder = new TextEncoder();
const response = createChunkedStreamResponse([
encoder.encode(
'event: reply_delta\ndata: {"text":"先把方洞万能的反差定住。"}\n\n',
),
encoder.encode(
'event: error\ndata: {"message":"方洞挑战聊天生成失败LLM 请求超时"}\n\n',
),
]);
const updates: string[] = [];
await expect(
readCreationAgentSessionFromSse(response, {
fallbackMessage: '发送失败',
incompleteMessage: '结果不完整',
onUpdate: (text) => {
updates.push(text);
},
}),
).rejects.toThrow('方洞挑战聊天生成失败LLM 请求超时');
expect(updates).toEqual(['先把方洞万能的反差定住。']);
});

View File

@@ -29,6 +29,14 @@ export function buildMatch3DPublicWorkCode(profileId: string) {
return `M3-${suffix}`;
}
export function buildSquareHolePublicWorkCode(profileId: string) {
const normalized = normalizePublicCodeText(profileId);
const fallback = normalized || '00000000';
const suffix = fallback.slice(-8).padStart(8, '0');
return `SH-${suffix}`;
}
export function isSamePuzzlePublicWorkCode(keyword: string, profileId: string) {
const normalizedKeyword = normalizePublicCodeText(keyword);
@@ -61,3 +69,16 @@ export function isSameMatch3DPublicWorkCode(keyword: string, profileId: string)
normalizedKeyword === normalizePublicCodeText(profileId)
);
}
export function isSameSquareHolePublicWorkCode(
keyword: string,
profileId: string,
) {
const normalizedKeyword = normalizePublicCodeText(keyword);
return (
normalizedKeyword ===
normalizePublicCodeText(buildSquareHolePublicWorkCode(profileId)) ||
normalizedKeyword === normalizePublicCodeText(profileId)
);
}

View File

@@ -0,0 +1,8 @@
export {
createSquareHoleCreationSession,
executeSquareHoleCreationAction,
getSquareHoleCreationSession,
sendSquareHoleCreationMessage,
squareHoleCreationClient,
streamSquareHoleCreationMessage,
} from './squareHoleCreationClient';

View File

@@ -0,0 +1,87 @@
import type {
CreateSquareHoleSessionRequest,
ExecuteSquareHoleActionRequest,
SendSquareHoleMessageRequest,
SquareHoleActionResponse,
SquareHoleSessionResponse,
SquareHoleSessionSnapshot,
} from '../../../packages/shared/src/contracts/squareHoleAgent';
import type { TextStreamOptions } from '../aiTypes';
import { createCreationAgentClient } from '../creation-agent';
const SQUARE_HOLE_AGENT_API_BASE = '/api/creation/square-hole/sessions';
const squareHoleAgentHttpClient = createCreationAgentClient<
CreateSquareHoleSessionRequest,
SquareHoleSessionResponse,
SquareHoleSessionResponse,
SquareHoleSessionSnapshot,
SendSquareHoleMessageRequest,
SquareHoleSessionResponse,
ExecuteSquareHoleActionRequest,
SquareHoleActionResponse
>({
apiBase: SQUARE_HOLE_AGENT_API_BASE,
messages: {
createSession: '创建方洞挑战共创会话失败',
getSession: '读取方洞挑战共创会话失败',
sendMessage: '发送方洞挑战共创消息失败',
streamIncomplete: '方洞挑战共创消息流式结果不完整',
executeAction: '执行方洞挑战共创操作失败',
},
});
/**
* 创建方洞挑战 Agent 共创会话。
*/
export function createSquareHoleCreationSession(
payload: CreateSquareHoleSessionRequest = {},
) {
return squareHoleAgentHttpClient.createSession(payload);
}
/**
* 读取方洞挑战 Agent 会话快照。
*/
export function getSquareHoleCreationSession(sessionId: string) {
return squareHoleAgentHttpClient.getSession(sessionId);
}
/**
* 非流式发送方洞挑战 Agent 消息,保留为 SSE 降级入口。
*/
export function sendSquareHoleCreationMessage(
sessionId: string,
payload: SendSquareHoleMessageRequest,
) {
return squareHoleAgentHttpClient.sendMessage(sessionId, payload);
}
/**
* 流式发送方洞挑战 Agent 消息。
*/
export function streamSquareHoleCreationMessage(
sessionId: string,
payload: SendSquareHoleMessageRequest,
options: TextStreamOptions = {},
) {
return squareHoleAgentHttpClient.streamMessage(sessionId, payload, options);
}
/**
* 执行方洞挑战创作操作,例如生成草稿作品。
*/
export function executeSquareHoleCreationAction(
sessionId: string,
payload: ExecuteSquareHoleActionRequest,
) {
return squareHoleAgentHttpClient.executeAction(sessionId, payload);
}
export const squareHoleCreationClient = {
createSession: createSquareHoleCreationSession,
getSession: getSquareHoleCreationSession,
sendMessage: sendSquareHoleCreationMessage,
streamMessage: streamSquareHoleCreationMessage,
executeAction: executeSquareHoleCreationAction,
};

View File

@@ -0,0 +1,9 @@
export {
dropSquareHoleShape,
finishSquareHoleTimeUp,
getSquareHoleRun,
restartSquareHoleRun,
squareHoleRuntimeClient,
startSquareHoleRun,
stopSquareHoleRun,
} from './squareHoleRuntimeClient';

View File

@@ -0,0 +1,123 @@
import type {
DropSquareHoleShapeRequest,
SquareHoleDropResponse,
SquareHoleRunResponse,
StopSquareHoleRunRequest,
} from '../../../packages/shared/src/contracts/squareHoleRuntime';
import { type ApiRetryOptions, requestJson } from '../apiClient';
const SQUARE_HOLE_RUNTIME_READ_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 120,
maxDelayMs: 360,
};
const SQUARE_HOLE_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 120,
maxDelayMs: 360,
retryUnsafeMethods: true,
};
/**
* 基于作品启动一局方洞挑战正式 run。
*/
export function startSquareHoleRun(profileId: string) {
return requestJson<SquareHoleRunResponse>(
`/api/runtime/square-hole/works/${encodeURIComponent(profileId)}/runs`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profileId }),
},
'启动方洞挑战失败',
{ retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY },
);
}
/**
* 读取方洞挑战运行态快照。
*/
export function getSquareHoleRun(runId: string) {
return requestJson<SquareHoleRunResponse>(
`/api/runtime/square-hole/runs/${encodeURIComponent(runId)}`,
{ method: 'GET' },
'读取方洞挑战运行快照失败',
{ retry: SQUARE_HOLE_RUNTIME_READ_RETRY },
);
}
/**
* 提交一次洞口选择,由后端做权威确认。
*/
export function dropSquareHoleShape(
runId: string,
payload: DropSquareHoleShapeRequest,
) {
return requestJson<SquareHoleDropResponse>(
`/api/runtime/square-hole/runs/${encodeURIComponent(runId)}/drop`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...payload,
runId: payload.runId ?? runId,
}),
},
'确认方洞挑战投入失败',
{ retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY },
);
}
/**
* 停止当前方洞挑战运行态。
*/
export function stopSquareHoleRun(
runId: string,
payload: StopSquareHoleRunRequest = {
clientActionId: `square-hole-stop-${Date.now()}`,
},
) {
return requestJson<SquareHoleRunResponse>(
`/api/runtime/square-hole/runs/${encodeURIComponent(runId)}/stop`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'停止方洞挑战失败',
{ retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY },
);
}
/**
* 基于当前 run 重开一局。
*/
export function restartSquareHoleRun(runId: string) {
return requestJson<SquareHoleRunResponse>(
`/api/runtime/square-hole/runs/${encodeURIComponent(runId)}/restart`,
{ method: 'POST' },
'重新开始方洞挑战失败',
{ retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY },
);
}
/**
* 前端倒计时归零后通知后端确认失败状态。
*/
export function finishSquareHoleTimeUp(runId: string) {
return requestJson<SquareHoleRunResponse>(
`/api/runtime/square-hole/runs/${encodeURIComponent(runId)}/time-up`,
{ method: 'POST' },
'同步方洞挑战倒计时失败',
{ retry: SQUARE_HOLE_RUNTIME_WRITE_RETRY },
);
}
export const squareHoleRuntimeClient = {
dropShape: dropSquareHoleShape,
finishTimeUp: finishSquareHoleTimeUp,
getRun: getSquareHoleRun,
restartRun: restartSquareHoleRun,
startRun: startSquareHoleRun,
stopRun: stopSquareHoleRun,
};

View File

@@ -0,0 +1,9 @@
export {
deleteSquareHoleWork,
getSquareHoleWorkDetail,
listSquareHoleGallery,
listSquareHoleWorks,
publishSquareHoleWork,
squareHoleWorksClient,
updateSquareHoleWork,
} from './squareHoleWorksClient';

View File

@@ -0,0 +1,113 @@
import type {
PutSquareHoleWorkRequest,
SquareHoleWorkDetailResponse,
SquareHoleWorkMutationResponse,
SquareHoleWorksResponse,
} from '../../../packages/shared/src/contracts/squareHoleWorks';
import { type ApiRetryOptions, requestJson } from '../apiClient';
const SQUARE_HOLE_WORKS_API_BASE = '/api/creation/square-hole/works';
const SQUARE_HOLE_GALLERY_API_BASE = '/api/runtime/square-hole/gallery';
const SQUARE_HOLE_WORKS_READ_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 120,
maxDelayMs: 360,
};
const SQUARE_HOLE_WORKS_WRITE_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 120,
maxDelayMs: 360,
retryUnsafeMethods: true,
};
/**
* 读取当前用户的方洞挑战作品列表。
*/
export function listSquareHoleWorks() {
return requestJson<SquareHoleWorksResponse>(
SQUARE_HOLE_WORKS_API_BASE,
{ method: 'GET' },
'读取方洞挑战作品列表失败',
{ retry: SQUARE_HOLE_WORKS_READ_RETRY },
);
}
/**
* 读取公开方洞挑战作品列表。
*/
export function listSquareHoleGallery() {
return requestJson<SquareHoleWorksResponse>(
SQUARE_HOLE_GALLERY_API_BASE,
{ method: 'GET' },
'读取方洞挑战广场失败',
{
retry: SQUARE_HOLE_WORKS_READ_RETRY,
skipAuth: true,
skipRefresh: true,
},
);
}
/**
* 读取方洞挑战作品详情。
*/
export function getSquareHoleWorkDetail(profileId: string) {
return requestJson<SquareHoleWorkDetailResponse>(
`${SQUARE_HOLE_WORKS_API_BASE}/${encodeURIComponent(profileId)}`,
{ method: 'GET' },
'读取方洞挑战作品详情失败',
{ retry: SQUARE_HOLE_WORKS_READ_RETRY },
);
}
/**
* 保存结果页可编辑字段。
*/
export function updateSquareHoleWork(
profileId: string,
payload: PutSquareHoleWorkRequest,
) {
return requestJson<SquareHoleWorkMutationResponse>(
`${SQUARE_HOLE_WORKS_API_BASE}/${encodeURIComponent(profileId)}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'更新方洞挑战作品失败',
{ retry: SQUARE_HOLE_WORKS_WRITE_RETRY },
);
}
/**
* 发布方洞挑战作品。发布门槛由后端最终确认。
*/
export function publishSquareHoleWork(profileId: string) {
return requestJson<SquareHoleWorkMutationResponse>(
`${SQUARE_HOLE_WORKS_API_BASE}/${encodeURIComponent(profileId)}/publish`,
{ method: 'POST' },
'发布方洞挑战作品失败',
{ retry: SQUARE_HOLE_WORKS_WRITE_RETRY },
);
}
/**
* 删除当前用户的方洞挑战作品,并返回删除后的列表。
*/
export function deleteSquareHoleWork(profileId: string) {
return requestJson<SquareHoleWorksResponse>(
`${SQUARE_HOLE_WORKS_API_BASE}/${encodeURIComponent(profileId)}`,
{ method: 'DELETE' },
'删除方洞挑战作品失败',
{ retry: SQUARE_HOLE_WORKS_WRITE_RETRY },
);
}
export const squareHoleWorksClient = {
delete: deleteSquareHoleWork,
getDetail: getSquareHoleWorkDetail,
listGallery: listSquareHoleGallery,
list: listSquareHoleWorks,
publish: publishSquareHoleWork,
update: updateSquareHoleWork,
};