@qmuse/qmuse-cli 1.0.9 → 1.0.10-dev.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,291 @@
1
+ /**
2
+ * 对话域(C05)与模型列表:基于 musepub 预发已上线路由。
3
+ *
4
+ * - POST /api/chat(mode=continue)已有应用继续对话,复用统一入口与首轮流协议
5
+ * - POST /api/code/queryChatByAppIdAndSprintId 会话与会话历史
6
+ * - POST /api/code/getChatState 会话状态与阶段
7
+ * - POST /api/code/chat/cancel 显式停止当前任务
8
+ * - POST /api/code/clearChatContext 清理会话上下文
9
+ * - GET /api/model/list 可选模型列表
10
+ */
11
+ import { apiCall, authHeaders, buildUrl, requestSourceHeaders } from '../client.js';
12
+ import { chatEventType, parseChatEvent, SseParser } from './sse.js';
13
+ export function queryChatByAppIdAndSprintId(appId, sprintId, cfg) {
14
+ return apiCall('POST', '/api/code/queryChatByAppIdAndSprintId', { appId, sprintId }, cfg);
15
+ }
16
+ export function getChatState(chatId, cfg) {
17
+ return apiCall('POST', '/api/code/getChatState', { chatId }, cfg);
18
+ }
19
+ export function cancelChat(appId, chatId, cfg) {
20
+ return apiCall('POST', '/api/code/chat/cancel', { appId, chatId }, cfg);
21
+ }
22
+ export function clearChatContext(appId, chatId, sprintId, cfg) {
23
+ return apiCall('POST', '/api/code/clearChatContext', { appId, chatId, sprintId }, cfg);
24
+ }
25
+ export function listModels(cfg) {
26
+ return apiCall('GET', '/api/model/list', undefined, cfg);
27
+ }
28
+ /**
29
+ * 消息附件上传(C05.12):POST /api/file/upload(multipart 流)。
30
+ * 服务端契约校验 appId+versionId 的版本写权限;返回上传产物信息。
31
+ */
32
+ export async function uploadAttachment(cfg, filePath, versionId, appId) {
33
+ const { basename } = await import('node:path');
34
+ const form = new FormData();
35
+ const stat = await import('node:fs/promises');
36
+ const bytes = await stat.readFile(filePath);
37
+ form.append('file', new Blob([new Uint8Array(bytes)]), basename(filePath));
38
+ form.append('appId', appId);
39
+ form.append('versionId', versionId);
40
+ const headers = { ...authHeadersOf(cfg) };
41
+ delete headers['Content-Type'];
42
+ let res;
43
+ try {
44
+ res = await fetch(buildUrlOf(cfg, '/api/file/upload'), {
45
+ method: 'POST',
46
+ headers,
47
+ body: form,
48
+ });
49
+ }
50
+ catch (err) {
51
+ return {
52
+ ok: false,
53
+ status: 0,
54
+ raw: null,
55
+ setCookie: [],
56
+ errorCode: 'NETWORK_ERROR',
57
+ errorMessage: `上传失败: ${err instanceof Error ? err.message : String(err)}`,
58
+ };
59
+ }
60
+ const text = await res.text().catch(() => '');
61
+ let json = null;
62
+ try {
63
+ json = text ? JSON.parse(text) : null;
64
+ }
65
+ catch {
66
+ json = null;
67
+ }
68
+ const payload = json && typeof json === 'object' && !Array.isArray(json)
69
+ ? json
70
+ : undefined;
71
+ const data = payload?.data ?? payload?.resultObj;
72
+ return {
73
+ ok: payload?.success === true,
74
+ data: data,
75
+ errorCode: typeof payload?.errorCode === 'string' ? payload.errorCode : undefined,
76
+ errorMessage: typeof payload?.errorMessage === 'string' ? payload.errorMessage : undefined,
77
+ raw: json,
78
+ status: res.status,
79
+ setCookie: [],
80
+ };
81
+ }
82
+ export async function continueChat(cfg, request, opts = {}) {
83
+ const chatId = typeof request.chatId === 'string' ? request.chatId : '';
84
+ const result = {
85
+ status: 'FAILED',
86
+ chatId,
87
+ toolConsumed: false,
88
+ eventCount: 0,
89
+ };
90
+ const headers = {
91
+ ...(authHeadersOf(cfg)),
92
+ Accept: 'text/event-stream',
93
+ 'Cache-Control': 'no-cache',
94
+ };
95
+ let res;
96
+ try {
97
+ res = await fetch(buildUrlOf(cfg, '/api/chat'), {
98
+ method: 'POST',
99
+ headers,
100
+ body: JSON.stringify({ mode: 'continue', request }),
101
+ signal: opts.signal,
102
+ });
103
+ }
104
+ catch (err) {
105
+ if (opts.signal?.aborted)
106
+ return { ...result, status: 'INTERRUPTED' };
107
+ return { ...result, errorText: `请求失败: ${err instanceof Error ? err.message : String(err)}` };
108
+ }
109
+ if (!res.ok) {
110
+ const text = await res.text().catch(() => '');
111
+ result.errorText = summarizeJsonError(text) ?? `HTTP ${res.status}`;
112
+ return result;
113
+ }
114
+ return await readChatStream(res, result, opts);
115
+ }
116
+ // ---------- 低层共享工具 ----------
117
+ function authHeadersOf(cfg) {
118
+ return { ...authHeaders(cfg), ...requestSourceHeaders(cfg) };
119
+ }
120
+ function buildUrlOf(cfg, path) {
121
+ return buildUrl(cfg, path);
122
+ }
123
+ export function summarizeJsonError(text) {
124
+ try {
125
+ const parsed = text ? JSON.parse(text) : null;
126
+ const payload = (parsed && typeof parsed === 'object')
127
+ ? parsed
128
+ : undefined;
129
+ const message = payload?.errorMessage ?? payload?.message;
130
+ if (typeof message === 'string' && message.trim())
131
+ return message;
132
+ }
133
+ catch {
134
+ /* 非 JSON 响应按原文截断 */
135
+ }
136
+ return text && text.trim() ? text.trim().slice(0, 200) : undefined;
137
+ }
138
+ async function readChatStream(res, result, opts) {
139
+ const reader = res.body?.getReader?.();
140
+ if (!reader)
141
+ return { ...result, errorText: result.errorText ?? '响应没有可读生成流' };
142
+ const decoder = new TextDecoder();
143
+ let terminal;
144
+ let errorText;
145
+ let sawInterrupt = false;
146
+ const parser = new SseParser(frame => {
147
+ if (frame.data === null)
148
+ return true;
149
+ const payload = parseChatEvent(frame.data);
150
+ const type = chatEventType(payload);
151
+ opts.onEvent?.({ sequence: ++result.eventCount, type, payload });
152
+ if (type === 'finish') {
153
+ terminal = 'SUCCEEDED';
154
+ return true;
155
+ }
156
+ if (type === 'error' || type === 'llm-error' || type === 'data-llm-error') {
157
+ terminal = 'FAILED';
158
+ errorText = extractStreamError(payload, type);
159
+ return true;
160
+ }
161
+ if (typeof payload === 'object') {
162
+ const record = payload;
163
+ if (type === 'tool-input-start' || type === 'tool-input-end')
164
+ result.toolConsumed = true;
165
+ const interrupts = record.interrupts
166
+ ?? record.data?.interrupts;
167
+ if (Array.isArray(interrupts) && interrupts.length > 0)
168
+ sawInterrupt = true;
169
+ }
170
+ return false;
171
+ });
172
+ try {
173
+ let stopped = false;
174
+ while (!stopped) {
175
+ const { done, value } = await reader.read();
176
+ if (done)
177
+ break;
178
+ stopped = parser.push(decoder.decode(value, { stream: true }));
179
+ }
180
+ if (!stopped)
181
+ parser.end();
182
+ const status = terminal ?? (sawInterrupt ? 'WAITING_INPUT' : undefined);
183
+ return {
184
+ ...result,
185
+ status: status ?? 'FAILED',
186
+ errorText: errorText ?? (!terminal && !sawInterrupt ? '生成流在完成事件前结束' : undefined),
187
+ };
188
+ }
189
+ catch (err) {
190
+ if (opts.signal?.aborted)
191
+ return { ...result, status: 'INTERRUPTED', toolConsumed: result.toolConsumed };
192
+ return {
193
+ ...result,
194
+ status: terminal ?? 'FAILED',
195
+ errorText: errorText ?? `读取生成流失败: ${err instanceof Error ? err.message : String(err)}`,
196
+ };
197
+ }
198
+ finally {
199
+ reader.cancel().catch(() => undefined);
200
+ }
201
+ }
202
+ function extractStreamError(payload, type) {
203
+ if (typeof payload === 'string' && payload.trim())
204
+ return payload;
205
+ if (typeof payload !== 'object')
206
+ return `生成失败(${type || '未知'})`;
207
+ const data = payload.data;
208
+ const candidates = [
209
+ payload.errorText,
210
+ payload.errorMessage,
211
+ payload.message,
212
+ data && typeof data === 'object' ? data.errorText : undefined,
213
+ ];
214
+ for (const candidate of candidates) {
215
+ if (typeof candidate === 'string' && candidate.trim())
216
+ return candidate;
217
+ }
218
+ return type ? `生成失败(${type})` : '生成失败';
219
+ }
220
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
221
+ /**
222
+ * 从 getChatState 现有响应归纳待恢复中断(同 id 去重);纯展示层,不改服务端契约。
223
+ * 表单类给出问题/选项与 answers 结构提示;bash 拦截类给出 continue/skip 决定结构。
224
+ */
225
+ export function summarizePendingInterrupts(data, chatId) {
226
+ const interrupts = isRecord(data) && Array.isArray(data['interrupts']) ? data['interrupts'] : [];
227
+ const seen = new Set();
228
+ const views = [];
229
+ for (const item of interrupts) {
230
+ if (!isRecord(item))
231
+ continue;
232
+ const interruptId = typeof item['id'] === 'string' ? item['id'] : '';
233
+ if (!interruptId || seen.has(interruptId))
234
+ continue;
235
+ seen.add(interruptId);
236
+ const value = isRecord(item['value']) ? item['value'] : {};
237
+ const resumeCommand = `qmuse chat resume ${chatId} --interrupt-id ${interruptId} --file <answer.json>`;
238
+ if (typeof value['formId'] === 'string' || Array.isArray(value['fields'])) {
239
+ const fields = Array.isArray(value['fields']) ? value['fields'] : [];
240
+ const questions = [];
241
+ for (const field of fields) {
242
+ if (!isRecord(field) || typeof field['id'] !== 'string')
243
+ continue;
244
+ const optionList = Array.isArray(field['options'])
245
+ ? field['options']
246
+ .map(option => isRecord(option) && typeof option['label'] === 'string' ? option['label'] : '')
247
+ .filter(Boolean)
248
+ .slice(0, 5)
249
+ : undefined;
250
+ questions.push({
251
+ questionId: field['id'],
252
+ label: typeof field['label'] === 'string' ? field['label'] : field['id'],
253
+ ...(typeof field['control'] === 'string' ? { control: field['control'] } : {}),
254
+ ...(field['required'] === true ? { required: true } : {}),
255
+ ...(optionList && optionList.length > 0 ? { options: optionList } : {}),
256
+ });
257
+ }
258
+ views.push({
259
+ interruptId,
260
+ kind: 'form',
261
+ label: typeof value['title'] === 'string' ? value['title'] : '待确认问题',
262
+ questions,
263
+ answerHint: '{"response":{"answers":[{"questionId":"<问题ID>","selectedOptionIds":["<选项value>"]}]}}',
264
+ resumeCommand,
265
+ });
266
+ continue;
267
+ }
268
+ const payload = isRecord(value['payload']) ? value['payload'] : {};
269
+ if (value['type'] === 'bash-command-interception' || typeof value['interceptionId'] === 'string') {
270
+ const interceptionId = typeof value['interceptionId'] === 'string' ? value['interceptionId'] : '<interceptionId>';
271
+ views.push({
272
+ interruptId,
273
+ kind: 'bash-interception',
274
+ label: `${typeof payload['command'] === 'string' ? payload['command'] : String(value['interceptorId'] ?? '命令拦截')}${typeof payload['message'] === 'string' ? `:${payload['message']}` : ''}`,
275
+ questions: [],
276
+ answerHint: `{"response":{"type":"bash-command-interception","interceptionId":"${interceptionId}","data":{"action":"continue"}}}(跳过则 action=skip)`,
277
+ resumeCommand,
278
+ });
279
+ continue;
280
+ }
281
+ views.push({
282
+ interruptId,
283
+ kind: 'unknown',
284
+ label: '未识别的中断类型(原始结构见 --json)',
285
+ questions: [],
286
+ answerHint: '按服务端中断结构构造 response 后提交',
287
+ resumeCommand,
288
+ });
289
+ }
290
+ return views;
291
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * 云服务域(C14):云服务开通、多环境状态与 local-dev 公开配置。
3
+ * POST /api/appwrite/cloud/storage/enable —— 开通实际云服务(AppwriteStorageActivationService)
4
+ * POST /api/appwrite/environment/status —— 集中查询架构/环境/初始化状态(AppwriteMultiEnvironmentService)
5
+ * POST /api/appwrite/environment/retry-initialization —— 复用原初始化任务的动作(分布式锁幂等)
6
+ * POST /api/appwrite/local-dev context —— 导出实际环境的公开连接与运行配置(不含管理凭证)
7
+ */
8
+ import { apiCall } from '../client.js';
9
+ /** C14.02 实际开通云服务:返回真实资源与初始化状态。 */
10
+ export function enableCloudService(appId, cfg) {
11
+ return apiCall('POST', '/api/appwrite/cloud/storage/enable', { appId }, cfg);
12
+ }
13
+ /** C14.03 查询云服务与环境状态(activationStatus、publishAllowed、失败原因等)。 */
14
+ export function cloudEnvironmentStatus(appId, cfg) {
15
+ return apiCall('POST', '/api/appwrite/environment/status', { appId }, cfg);
16
+ }
17
+ /** C14.08 重试云环境初始化:服务端幂等且受分布式锁保护,返回 INITIALIZING 心跳。 */
18
+ export function retryCloudEnvironment(appId, cfg) {
19
+ return apiCall('POST', '/api/appwrite/environment/retry-initialization', { appId }, cfg);
20
+ }
21
+ /** C14.09 导出安全公开配置:local-dev 上下文(endpoint/projectId/functionId/databaseId/domain)。 */
22
+ export function localDevContext(appId, cfg) {
23
+ return apiCall('POST', '/api/appwrite/local-dev/context', { appId }, cfg);
24
+ }
25
+ /** C29.01 数据库用量摘要:按应用返回 database overview(表数量/文档数等现有状态)。 */
26
+ export function databaseOverview(appId, cfg) {
27
+ return apiCall('POST', '/api/appwrite/usage/database-overview', { appId }, cfg);
28
+ }
@@ -0,0 +1,358 @@
1
+ /**
2
+ * 统一创建与首轮对话(POST /api/chat,mode=create)。
3
+ *
4
+ * 契约要点:
5
+ * - 请求体:{ mode:"create", requestId, create, message, settings? },create 不含 firstMessage,
6
+ * 首条输入只放 message.content;requestId 形如 `cc_<13位毫秒时间戳>_<32位小写十六进制>`,
7
+ * 是业务去重键:同 requestId 恢复身份,不重复创建。
8
+ * - 创建成功先 flush 身份响应头 x-qmuse-app-id / x-qmuse-chat-id / x-qmuse-sprint-id /
9
+ * x-qmuse-chat-reused,随后同一次响应内透传领域聊天 SSE;HTTP 200 不代表生成完成。
10
+ * - reused=true 时响应体是 JSON 身份(CommonHttpResult 包装),沿用已有会话,不启动新一轮生成。
11
+ * - 生成失败保留已创建应用;断开本地流不会取消服务端任务。
12
+ */
13
+ import { randomBytes, createHash } from 'node:crypto';
14
+ import { authHeaders, buildUrl, requestSourceHeaders } from '../client.js';
15
+ import { chatEventType, parseChatEvent, SseParser, } from './sse.js';
16
+ /** 服务端 requestId 校验规则(CreateChatSchema)。 */
17
+ export const CREATE_REQUEST_ID_PATTERN = /^cc_[0-9]{13}_[a-f0-9]{32}$/u;
18
+ /** message.content 上限(服务端 Schema)。 */
19
+ export const CHAT_CONTENT_MAX_LENGTH = 100000;
20
+ /** CLI 应用类型到统一创建 appType 的映射(首期范围:web / h5)。 */
21
+ export const UNIFIED_APP_TYPE_BY_CLI_TYPE = {
22
+ web: 'GENERAL',
23
+ webapp: 'GENERAL',
24
+ h5: 'MOBILE',
25
+ mobile: 'MOBILE',
26
+ ai: 'AI_APP',
27
+ };
28
+ /** CLI 类型映射为服务端 appType;未知类型返回带用法的错误。 */
29
+ export function normalizeAppType(cliType) {
30
+ const value = (cliType || '').trim().toLowerCase();
31
+ const mapped = UNIFIED_APP_TYPE_BY_CLI_TYPE[value];
32
+ if (!mapped) {
33
+ throw new Error(`--type 仅支持 web / h5 / ai(${String(cliType ?? '(空)')} 不是可创建类型)`);
34
+ }
35
+ return mapped;
36
+ }
37
+ /** 生成服务端可校验的创建请求 ID。 */
38
+ export function generateCreateRequestId() {
39
+ return `cc_${Date.now()}_${randomBytes(16).toString('hex')}`;
40
+ }
41
+ /**
42
+ * 首条用户消息的稳定 ID:同一 requestId 与内容的恢复请求使用相同 messageId,
43
+ * 保证重发的 `message` 与首次受理一致。
44
+ */
45
+ export function stableMessageId(requestId, content) {
46
+ const digest = createHash('sha256').update(`${requestId}\n${content}`, 'utf8').digest('hex');
47
+ return `cm_${digest.slice(0, 32)}`;
48
+ }
49
+ /** 从已受理或新发起的请求中构造统一创建请求体。 */
50
+ export function buildCreateChatRequest(input) {
51
+ const content = input.content;
52
+ if (!content.trim())
53
+ throw new Error('首条创作内容不能为空(--file <路径> 或 - 读取 stdin)');
54
+ if (content.length > CHAT_CONTENT_MAX_LENGTH) {
55
+ throw new Error(`首条创作内容超过 ${CHAT_CONTENT_MAX_LENGTH} 字符上限`);
56
+ }
57
+ const requestId = input.requestId?.trim() || generateCreateRequestId();
58
+ if (!CREATE_REQUEST_ID_PATTERN.test(requestId)) {
59
+ throw new Error('--request-id 需匹配 cc_<13位毫秒时间戳>_<32位小写十六进制>,例如 cc_1762060000000_<32位hex>');
60
+ }
61
+ const title = input.title.trim();
62
+ if (!title)
63
+ throw new Error('应用标题不能为空(--title 或从内容首行推导)');
64
+ if (!input.spaceId?.trim())
65
+ throw new Error('缺少目标空间:先执行 qmuse space use <spaceId> 或传 --space-id');
66
+ const messageId = stableMessageId(requestId, content);
67
+ const create = {
68
+ spaceId: input.spaceId.trim(),
69
+ appType: input.appType,
70
+ title,
71
+ // 预发 AppCoreService.createAppMeta 要求 selectedModel 必填(MODEL_REQUIRED);
72
+ // 'auto' 是服务端一等合法值,--model 可覆盖。
73
+ selectedModel: input.selectedModel?.trim() || 'auto',
74
+ };
75
+ for (const key of [
76
+ 'defaultDevice',
77
+ 'width',
78
+ 'height',
79
+ 'promotionMode',
80
+ 'publishType',
81
+ 'sourceType',
82
+ ]) {
83
+ const value = input[key];
84
+ if (typeof value === 'string' && value.trim())
85
+ create[key] = value.trim();
86
+ }
87
+ return {
88
+ requestId,
89
+ messageId,
90
+ body: {
91
+ mode: 'create',
92
+ requestId,
93
+ create,
94
+ message: { id: messageId, role: 'user', content },
95
+ },
96
+ };
97
+ }
98
+ /** 提取身份与错误信息时 tolerated 的响应包装。 */
99
+ function unwrapCommonResult(json) {
100
+ if (json && typeof json === 'object' && !Array.isArray(json)) {
101
+ const record = json;
102
+ const payload = record.data ?? record.resultObj;
103
+ if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
104
+ return payload;
105
+ }
106
+ return record;
107
+ }
108
+ return undefined;
109
+ }
110
+ function readIdentityFromPayload(payload) {
111
+ const pick = (key) => {
112
+ const value = payload?.[key];
113
+ return typeof value === 'string' ? value : '';
114
+ };
115
+ return { appId: pick('appId'), chatId: pick('chatId'), sprintId: pick('sprintId') };
116
+ }
117
+ function extractErrorText(payload, type) {
118
+ if (typeof payload === 'string' && payload.trim())
119
+ return payload;
120
+ if (typeof payload !== 'object')
121
+ return `首轮生成失败(${type || '未知事件'})`;
122
+ const data = payload.data;
123
+ const candidates = [
124
+ payload.errorText,
125
+ payload.errorMessage,
126
+ payload.message,
127
+ data && typeof data === 'object' ? data.errorText : undefined,
128
+ data && typeof data === 'object' ? data.message : undefined,
129
+ ];
130
+ for (const candidate of candidates) {
131
+ if (typeof candidate === 'string' && candidate.trim())
132
+ return candidate;
133
+ }
134
+ return type ? `首轮生成失败(${type})` : '首轮生成失败';
135
+ }
136
+ function extractInterrupts(payload) {
137
+ if (typeof payload !== 'object')
138
+ return false;
139
+ const data = payload.data;
140
+ const candidates = [
141
+ payload.interrupts,
142
+ data && typeof data === 'object' ? data.interrupts : undefined,
143
+ ];
144
+ return candidates.some(candidate => Array.isArray(candidate) && candidate.length > 0);
145
+ }
146
+ /**
147
+ * 执行统一创建:一次请求完成应用创建并消费首轮 SSE。
148
+ * 返回终态结果;不影响服务端任务(本地中断仅结束读取)。
149
+ */
150
+ export async function createAppChat(cfg, input, opts = {}) {
151
+ const { requestId, messageId, body } = buildCreateChatRequest(input);
152
+ const base = {
153
+ status: 'FAILED',
154
+ requestId,
155
+ messageId,
156
+ reused: false,
157
+ httpStatus: 0,
158
+ interrupted: false,
159
+ identity: { appId: '', chatId: '', sprintId: '' },
160
+ textChars: 0,
161
+ eventCount: 0,
162
+ };
163
+ const headers = {
164
+ ...authHeaders(cfg),
165
+ ...requestSourceHeaders(cfg),
166
+ Accept: 'text/event-stream',
167
+ 'Cache-Control': 'no-cache',
168
+ };
169
+ let res;
170
+ try {
171
+ res = await fetch(buildUrl(cfg, '/api/chat'), {
172
+ method: 'POST',
173
+ headers,
174
+ body: JSON.stringify(body),
175
+ signal: opts.signal,
176
+ });
177
+ }
178
+ catch (err) {
179
+ const interrupted = opts.signal?.aborted === true;
180
+ return {
181
+ ...base,
182
+ status: interrupted ? 'INTERRUPTED' : 'FAILED',
183
+ interrupted,
184
+ errorCode: interrupted ? undefined : 'NETWORK_ERROR',
185
+ errorMessage: interrupted
186
+ ? undefined
187
+ : `请求失败 ${buildUrl(cfg, '/api/chat')}: ${err instanceof Error ? err.message : String(err)}`,
188
+ };
189
+ }
190
+ const headerIdentity = {
191
+ appId: res.headers.get('x-qmuse-app-id') ?? '',
192
+ chatId: res.headers.get('x-qmuse-chat-id') ?? '',
193
+ sprintId: res.headers.get('x-qmuse-sprint-id') ?? '',
194
+ };
195
+ const reusedHeader = (res.headers.get('x-qmuse-chat-reused') ?? '').trim().toLowerCase();
196
+ if (!res.ok) {
197
+ const text = await res.text().catch(() => '');
198
+ let parsed;
199
+ try {
200
+ parsed = text ? JSON.parse(text) : null;
201
+ }
202
+ catch {
203
+ parsed = null;
204
+ }
205
+ const payload = unwrapCommonResult(parsed);
206
+ return {
207
+ ...base,
208
+ httpStatus: res.status,
209
+ identity: headerIdentity,
210
+ errorText: typeof payload?.errorMessage === 'string' ? payload.errorMessage : undefined,
211
+ errorCode: typeof payload?.errorCode === 'string' ? payload.errorCode : undefined,
212
+ traceId: typeof payload?.traceId === 'string' ? payload.traceId : undefined,
213
+ errorMessage: `创建请求被拒绝:HTTP ${res.status}${payload?.errorMessage ? `,${payload.errorMessage}` : ''}`,
214
+ };
215
+ }
216
+ if (reusedHeader === 'true') {
217
+ const text = await res.text().catch(() => '');
218
+ let parsed;
219
+ try {
220
+ parsed = text ? JSON.parse(text) : null;
221
+ }
222
+ catch {
223
+ parsed = null;
224
+ }
225
+ const payload = unwrapCommonResult(parsed);
226
+ const identity = readIdentityFromPayload(payload);
227
+ const merged = {
228
+ appId: headerIdentity.appId || identity.appId,
229
+ chatId: headerIdentity.chatId || identity.chatId,
230
+ sprintId: headerIdentity.sprintId || identity.sprintId,
231
+ };
232
+ if (!merged.appId) {
233
+ return {
234
+ ...base,
235
+ httpStatus: res.status,
236
+ reused: true,
237
+ errorText: '已受理请求恢复成功,但响应未返回应用身份',
238
+ };
239
+ }
240
+ return {
241
+ ...base,
242
+ status: 'REUSED',
243
+ httpStatus: res.status,
244
+ reused: true,
245
+ identity: merged,
246
+ created: payload,
247
+ };
248
+ }
249
+ if (!headerIdentity.appId) {
250
+ const text = await res.text().catch(() => '').then(t => t.slice(0, 200));
251
+ return {
252
+ ...base,
253
+ httpStatus: res.status,
254
+ errorText: '创建请求未返回 x-qmuse-app-id 身份响应头',
255
+ errorMessage: text ? `响应片段:${text}` : undefined,
256
+ };
257
+ }
258
+ // 消费同一次响应内的首轮 SSE;终态帧出现后主动停止读取。
259
+ return new Promise(resolve => {
260
+ const identity = { ...headerIdentity };
261
+ let terminal;
262
+ let errorText;
263
+ let versionId;
264
+ let sawInterrupt = false;
265
+ let sequence = 0;
266
+ const decoder = new TextDecoder();
267
+ const dispatch = (payload) => {
268
+ sequence += 1;
269
+ const type = chatEventType(payload);
270
+ opts.onEvent?.({ sequence, type, payload });
271
+ if (type === 'finish') {
272
+ terminal = 'SUCCEEDED';
273
+ return true;
274
+ }
275
+ if (type === 'error' || type === 'llm-error' || type === 'data-llm-error') {
276
+ terminal = 'FAILED';
277
+ errorText = extractErrorText(payload, type);
278
+ return true;
279
+ }
280
+ if (typeof payload === 'object') {
281
+ if (type === 'version-saved' && typeof payload.versionId === 'string') {
282
+ versionId = payload.versionId;
283
+ }
284
+ if (extractInterrupts(payload))
285
+ sawInterrupt = true;
286
+ if (type === 'text-delta' || type === 'text') {
287
+ const delta = payload.delta ?? payload.text;
288
+ if (typeof delta === 'string')
289
+ base.textChars += delta.length;
290
+ }
291
+ }
292
+ return false;
293
+ };
294
+ const parser = new SseParser(frame => {
295
+ if (frame.data === null) {
296
+ // data: [DONE] —— 服务端收尾标记;终态以已收到的 finish/error 为准。
297
+ return true;
298
+ }
299
+ return dispatch(parseChatEvent(frame.data));
300
+ });
301
+ const finish = (status, extra) => {
302
+ resolve({
303
+ ...base,
304
+ status,
305
+ httpStatus: res.status,
306
+ identity,
307
+ ...(versionId ? { versionId } : {}),
308
+ eventCount: sequence,
309
+ ...(errorText ? { errorText } : {}),
310
+ ...extra,
311
+ });
312
+ };
313
+ const consume = async () => {
314
+ const reader = res.body?.getReader?.();
315
+ try {
316
+ if (!reader) {
317
+ finish(terminal ?? 'FAILED', {
318
+ errorText: errorText ?? '创建响应没有可读的生成流',
319
+ });
320
+ return;
321
+ }
322
+ let stopped = false;
323
+ while (!stopped) {
324
+ const { done, value } = await reader.read();
325
+ if (done)
326
+ break;
327
+ const text = decoder.decode(value, { stream: true });
328
+ stopped = parser.push(text);
329
+ }
330
+ if (!stopped) {
331
+ parser.end();
332
+ decoder.decode();
333
+ }
334
+ const status = terminal
335
+ ?? (sawInterrupt ? 'WAITING_INPUT' : undefined);
336
+ finish(status ?? 'FAILED', !terminal && !sawInterrupt
337
+ ? { errorText: errorText ?? '首轮生成流在完成事件前结束' }
338
+ : undefined);
339
+ }
340
+ catch (err) {
341
+ if (opts.signal?.aborted) {
342
+ finish('INTERRUPTED', { interrupted: true });
343
+ return;
344
+ }
345
+ const message = err instanceof Error ? err.message : String(err);
346
+ finish(terminal ?? 'FAILED', {
347
+ errorText: errorText ?? `读取生成流失败:${message}`,
348
+ interrupted: false,
349
+ });
350
+ }
351
+ finally {
352
+ // 终态后停读;服务端会继续收尾,不受客户端断开影响。
353
+ reader?.cancel().catch(() => undefined);
354
+ }
355
+ };
356
+ void consume();
357
+ });
358
+ }