@mokuyoaxis/dsh-iris 0.1.0

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,504 @@
1
+ 'use strict';
2
+ /**
3
+ * Iris 媒体生成后端 —— 多协议适配器。
4
+ * - dashscope:阿里云百炼(图像/视频异步任务 + qwen-tts 同步)
5
+ * - openai-images:OpenAI Images 兼容协议(generations)
6
+ * 所有函数只收显式 key;错误统一 throw Error(人话)。
7
+ */
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+
11
+ const DASHSCOPE = 'https://dashscope.aliyuncs.com/api/v1';
12
+
13
+ /**
14
+ * 网络调用超时档位(2026-09-03 健康检查):挂死的连接绝不能冻住盯守 tick——
15
+ * MAX_WATCH_MS 只在 tick 顶部检查,单次 poll 的 await 挂起就永远走不到那行。
16
+ * 超时抛 TimeoutError,汇入既有错误路径(轮询进 errStreak 容忍、提交进 submitGuard 标 failed)。
17
+ * 各函数带 timeoutMs 参数(默认取档位),便于离线测试注入短超时。
18
+ */
19
+ export const T_SUBMIT = 30000; // 任务提交
20
+ export const T_POLL = 15000; // 单次状态轮询
21
+ export const T_UPLOAD = 60000; // 临时文件上传(几 MB 音频)
22
+ export const T_DOWNLOAD = 120000; // 产物转存(大视频)
23
+ export const T_SYNC_GEN = 180000; // 同步生成(openai-images / qwen-tts)
24
+ export const T_VISION = 120000; // 视觉流式回答
25
+
26
+ function timeoutSignal(ms, external) {
27
+ const t = AbortSignal.timeout(ms);
28
+ return external ? AbortSignal.any([external, t]) : t;
29
+ }
30
+
31
+ function authHeaders(key) {
32
+ return { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' };
33
+ }
34
+
35
+ async function readJson(res) {
36
+ const text = await res.text();
37
+ try {
38
+ return text ? JSON.parse(text) : null;
39
+ } catch (_) {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * 结构化 Provider 错误(阶段 1)。
46
+ * 分类供 failover 决策使用:category 区分 auth / rate_limit / quota /
47
+ * invalid_parameter / server / network / unknown;status 与 rootCause 保留现场。
48
+ * message 保持人话(向后兼容 String(err.message))。
49
+ */
50
+ export class ProviderError extends Error {
51
+ constructor({ category, status, message, rootCause }) {
52
+ super(message);
53
+ this.name = 'ProviderError';
54
+ this.category = category;
55
+ this.status = status;
56
+ this.rootCause = rootCause;
57
+ }
58
+ }
59
+
60
+ function classifyHttpError(status, body, fallback) {
61
+ const msg = (body && (body.message || (body.output && body.output.message))) || (body && body.code) || `HTTP ${status}`;
62
+ const code = String((body && body.code) || '');
63
+ let category = 'unknown';
64
+ let text;
65
+ if (status === 401 || status === 403) {
66
+ category = 'auth';
67
+ text = `鉴权失败:${msg}`;
68
+ } else if (status === 429) {
69
+ category = /quota|insufficient|balance|limit|额度/i.test(msg + ' ' + code) ? 'quota' : 'rate_limit';
70
+ text = `限流或额度不足:${msg}`;
71
+ } else if (/InvalidParameter|NotFound\.Model|unsupported/i.test(code)) {
72
+ category = 'invalid_parameter';
73
+ text = `模型或参数不受支持:${msg}`;
74
+ } else if (status >= 500) {
75
+ category = 'server';
76
+ text = `${fallback}:${msg}`;
77
+ } else {
78
+ text = `${fallback}:${msg}`;
79
+ }
80
+ return new ProviderError({ category, status, message: text, rootCause: msg });
81
+ }
82
+
83
+ /** 包装 fetch 层的网络异常(无 HTTP 状态) */
84
+ export function networkError(err) {
85
+ const e = new ProviderError({
86
+ category: 'network',
87
+ status: 0,
88
+ message: '网络错误:' + String((err && err.message) || err),
89
+ rootCause: err
90
+ });
91
+ return e;
92
+ }
93
+
94
+ function providerError(status, body, fallback) {
95
+ return classifyHttpError(status, body, fallback);
96
+ }
97
+
98
+ /* ---------------- DashScope:图像(旧异步 / 新版多模态) ---------------- */
99
+ /**
100
+ * DashScope 图像模型目前有两套 HTTP 协议:
101
+ * - wan2.5 及以下、qwen-image / qwen-image-plus:旧 text2image 异步协议;
102
+ * - wan2.6+、新版 qwen-image、z-image:新版多模态协议。
103
+ * 未知模型保守走旧协议,避免擅自改变已有自定义模型的调用方式。
104
+ */
105
+ export function dashscopeImageMode(model) {
106
+ const name = String(model || '').trim().toLowerCase();
107
+ if (/^wanx/.test(name) || /^wan2\.[0-5](?:-|$)/.test(name)) return 'legacy-async';
108
+ if (/^qwen-image(?:$|-plus(?:-|$))/.test(name)) return 'legacy-async';
109
+ if (/^(?:wan2\.[67](?:-|$)|qwen-image|z-image)/.test(name)) return 'multimodal-sync';
110
+ return 'legacy-async';
111
+ }
112
+
113
+ /** 新版多模态同步协议:响应中直接返回图片 URL。 */
114
+ export async function generateImageMultimodal({ key, model, prompt, size, n = 1, timeoutMs = T_SYNC_GEN, signal }) {
115
+ const res = await fetch(`${DASHSCOPE}/services/aigc/multimodal-generation/generation`, {
116
+ method: 'POST',
117
+ headers: authHeaders(key),
118
+ signal: timeoutSignal(timeoutMs, signal),
119
+ body: JSON.stringify({
120
+ model,
121
+ input: { messages: [{ role: 'user', content: [{ text: prompt }] }] },
122
+ parameters: { ...(size ? { size } : {}), n: Number(n) || 1 }
123
+ })
124
+ });
125
+ const body = await readJson(res);
126
+ if (!res.ok) throw providerError(res.status, body, '生成失败');
127
+ const urls = [];
128
+ for (const choice of (body && body.output && body.output.choices) || []) {
129
+ for (const item of (choice && choice.message && choice.message.content) || []) {
130
+ const url = item && (item.image || item.image_url || item.url);
131
+ if (typeof url === 'string' && url) urls.push(url);
132
+ }
133
+ }
134
+ if (!urls.length) throw new Error('生成完成但未返回图片');
135
+ return urls;
136
+ }
137
+
138
+ /** 统一启动图像生成;调用方按 remoteTaskId / urls 区分后台任务与同步结果。 */
139
+ export async function startImageGeneration(opts) {
140
+ if (dashscopeImageMode(opts && opts.model) === 'multimodal-sync') {
141
+ return { remoteTaskId: null, urls: await generateImageMultimodal(opts) };
142
+ }
143
+ return { remoteTaskId: await submitImage(opts), urls: [] };
144
+ }
145
+ export async function submitImage({ key, model, prompt, size, n = 1, timeoutMs = T_SUBMIT, signal }) {
146
+ const res = await fetch(`${DASHSCOPE}/services/aigc/text2image/image-synthesis`, {
147
+ method: 'POST',
148
+ headers: { ...authHeaders(key), 'X-DashScope-Async': 'enable' },
149
+ signal: timeoutSignal(timeoutMs, signal),
150
+ body: JSON.stringify({ model, input: { prompt }, parameters: { ...(size ? { size } : {}), n: Number(n) || 1 } })
151
+ });
152
+ const body = await readJson(res);
153
+ if (!res.ok) throw providerError(res.status, body, '提交失败');
154
+ const taskId = body && body.output && body.output.task_id;
155
+ if (!taskId) throw new Error('提交成功但未返回 task_id');
156
+ return taskId;
157
+ }
158
+
159
+ /* ---------------- DashScope:视频(异步) ---------------- */
160
+ /**
161
+ * 提交视频生成任务。按模型自动分流:
162
+ * - wan*s2v(数字人音视频合成):POST /services/aigc/image2video/video-synthesis
163
+ * input 固定 { image_url, audio_url },仅收公网 URL 或 oss:// 临时 URL(不收 data: base64);
164
+ * oss:// 必须带 X-DashScope-OssResourceResolve: enable 头。
165
+ * - 其他(t2v/i2v):POST /services/aigc/video-generation/video-synthesis,
166
+ * imgDataUrl 支持公网 URL 或 data:image base64。
167
+ */
168
+ export async function submitVideo({ key, model, prompt, imgDataUrl, size = '1280*720', duration, audioUrl, resolution, timeoutMs = T_SUBMIT, signal }) {
169
+ const isS2V = /s2v/i.test(model || '');
170
+ let url, input, parameters;
171
+ const headers = { ...authHeaders(key), 'X-DashScope-Async': 'enable' };
172
+ if (isS2V) {
173
+ url = `${DASHSCOPE}/services/aigc/image2video/video-synthesis`;
174
+ input = { image_url: imgDataUrl, audio_url: audioUrl };
175
+ parameters = {};
176
+ if (resolution) parameters.resolution = String(resolution);
177
+ if (/^oss:\/\//.test(input.image_url) || /^oss:\/\//.test(input.audio_url)) {
178
+ headers['X-DashScope-OssResourceResolve'] = 'enable';
179
+ }
180
+ } else {
181
+ url = `${DASHSCOPE}/services/aigc/video-generation/video-synthesis`;
182
+ input = { prompt };
183
+ if (imgDataUrl) input.img_url = imgDataUrl;
184
+ parameters = { size };
185
+ if (duration) parameters.duration = Number(duration);
186
+ }
187
+ const res = await fetch(url, {
188
+ method: 'POST',
189
+ headers,
190
+ signal: timeoutSignal(timeoutMs, signal),
191
+ body: JSON.stringify({ model, input, parameters })
192
+ });
193
+ const body = await readJson(res);
194
+ if (!res.ok) throw providerError(res.status, body, '提交失败');
195
+ const taskId = body && body.output && body.output.task_id;
196
+ if (!taskId) throw new Error('提交成功但未返回 task_id');
197
+ return taskId;
198
+ }
199
+
200
+ /* ---------------- DashScope:本地文件 → oss:// 临时 URL(48h) ---------------- */
201
+ /**
202
+ * 百炼免费临时存储:文件与模型绑定、仅本账号可用、48h 自动清理。
203
+ * 返回 oss:// 形式 URL;调用模型时请求头须带 X-DashScope-OssResourceResolve: enable
204
+ * (submitVideo 的 s2v 分支已自动处理)。
205
+ */
206
+ export async function uploadTempFile({ key, model, filePath, timeoutMs = T_UPLOAD, signal }) {
207
+ const u = new URL(`${DASHSCOPE}/uploads`);
208
+ u.searchParams.set('action', 'getPolicy');
209
+ u.searchParams.set('model', model);
210
+ const pres = await fetch(u, { headers: authHeaders(key), signal: timeoutSignal(timeoutMs, signal) });
211
+ const pbody = await readJson(pres);
212
+ if (!pres.ok || !pbody || !pbody.data) throw providerError(pres.status, pbody, '获取上传凭证失败');
213
+ const pol = pbody.data;
214
+ const name = path.basename(filePath);
215
+ const keyPath = `${pol.upload_dir}/${Date.now()}-${name}`;
216
+ const fd = new FormData();
217
+ fd.append('OSSAccessKeyId', pol.oss_access_key_id);
218
+ fd.append('Signature', pol.signature);
219
+ fd.append('policy', pol.policy);
220
+ fd.append('x-oss-object-acl', pol.x_oss_object_acl);
221
+ fd.append('x-oss-forbid-overwrite', pol.x_oss_forbid_overwrite);
222
+ fd.append('key', keyPath);
223
+ fd.append('success_action_status', '200');
224
+ fd.append('file', new Blob([fs.readFileSync(filePath)]), name);
225
+ const up = await fetch(pol.upload_host, { method: 'POST', body: fd, signal: timeoutSignal(timeoutMs, signal) });
226
+ if (up.status !== 200) throw new Error(`临时上传失败:HTTP ${up.status}`);
227
+ return 'oss://' + keyPath;
228
+ }
229
+
230
+ const TERMINAL = new Set(['SUCCEEDED', 'FAILED', 'CANCELED', 'UNKNOWN']);
231
+
232
+ /** @returns {{done:boolean, ok:boolean, urls:string[], message?:string}} */
233
+ export async function pollTask({ key, remoteTaskId, timeoutMs = T_POLL }) {
234
+ const res = await fetch(`${DASHSCOPE}/tasks/${encodeURIComponent(remoteTaskId)}`, {
235
+ headers: authHeaders(key),
236
+ signal: timeoutSignal(timeoutMs)
237
+ });
238
+ const body = await readJson(res);
239
+ if (!res.ok) throw providerError(res.status, body, '查询任务失败');
240
+ const out = (body && body.output) || {};
241
+ const status = out.task_status || 'UNKNOWN';
242
+ const urls = [];
243
+ const addUrl = (url) => {
244
+ if (typeof url === 'string' && url && !urls.includes(url)) urls.push(url);
245
+ };
246
+ // 结果形态因能力而异(且字段名不统一):t2i/t2v 常为数组 [{url}];
247
+ // s2v 为对象 {video_url};偶见数组元素用 video_url 键。全部兼容。
248
+ if (Array.isArray(out.results)) {
249
+ for (const r of out.results) {
250
+ if (!r || typeof r !== 'object') continue;
251
+ if (typeof r.url === 'string') addUrl(r.url);
252
+ else if (typeof r.video_url === 'string') addUrl(r.video_url);
253
+ }
254
+ } else if (out.results && typeof out.results === 'object') {
255
+ if (typeof out.results.video_url === 'string') addUrl(out.results.video_url);
256
+ if (typeof out.results.url === 'string') addUrl(out.results.url);
257
+ }
258
+ // 新版图像协议把结果放在 choices[].message.content[].image。
259
+ for (const choice of Array.isArray(out.choices) ? out.choices : []) {
260
+ for (const item of (choice && choice.message && choice.message.content) || []) {
261
+ addUrl(item && (item.image || item.image_url || item.url));
262
+ }
263
+ }
264
+ if (typeof out.video_url === 'string') addUrl(out.video_url);
265
+ if (!TERMINAL.has(status)) return { done: false, ok: false, urls };
266
+ return {
267
+ done: true,
268
+ ok: status === 'SUCCEEDED',
269
+ urls,
270
+ message: status === 'SUCCEEDED' ? undefined : (out.message || out.code || `任务终止(${status})`)
271
+ };
272
+ }
273
+
274
+ /* ---------------- DashScope:qwen-tts(同步) ---------------- */
275
+ export async function synthesizeTts({ key, model = 'qwen-tts-latest', text, voice = 'Cherry', timeoutMs = T_SYNC_GEN, signal }) {
276
+ const res = await fetch(`${DASHSCOPE}/services/aigc/multimodal-generation/generation`, {
277
+ method: 'POST',
278
+ headers: authHeaders(key),
279
+ signal: timeoutSignal(timeoutMs, signal),
280
+ body: JSON.stringify({ model, input: { text, voice } })
281
+ });
282
+ const body = await readJson(res);
283
+ if (!res.ok) throw providerError(res.status, body, '合成失败');
284
+ const audio = body && body.output && body.output.audio;
285
+ const url = audio && (audio.url || audio.audio_url);
286
+ const b64 = audio && typeof audio.data === 'string' ? audio.data : null;
287
+ if (!url && !b64) throw new Error('合成完成但未返回音频');
288
+ return { audioUrl: url || null, audioB64: b64 };
289
+ }
290
+
291
+ /* ---------------- OpenAI Images 兼容(同步 base64/url) ---------------- */
292
+ export async function openAiGenerateImage({ key, baseUrl, model, prompt, size = '1024x1024', n = 1, timeoutMs = T_SYNC_GEN, signal }) {
293
+ const base = String(baseUrl || '').replace(/\/+$/, '');
294
+ const res = await fetch(`${base}/images/generations`, {
295
+ method: 'POST',
296
+ headers: authHeaders(key),
297
+ signal: timeoutSignal(timeoutMs, signal),
298
+ body: JSON.stringify({ model, prompt, size, n: Number(n) || 1 })
299
+ });
300
+ const body = await readJson(res);
301
+ if (!res.ok) throw providerError(res.status, body, '生成失败');
302
+ const items = (body && Array.isArray(body.data) && body.data) || [];
303
+ const out = [];
304
+ for (const it of items) {
305
+ if (it && typeof it.b64_json === 'string') out.push({ b64: it.b64_json });
306
+ else if (it && typeof it.url === 'string') out.push({ url: it.url });
307
+ }
308
+ if (!out.length) throw new Error('生成完成但未返回图片数据');
309
+ return out;
310
+ }
311
+
312
+ /* ---------------- 模型发现(OpenAI 兼容 GET /models,各家通用) ---------------- */
313
+ /**
314
+ * 拉取 provider 可用模型 id 列表。OpenAI 标准端点,DashScope compatible-mode 亦支持。
315
+ * 免费只读;返回 [{ id }] 原样(分类由 models.js 规则负责)。
316
+ * @returns {Promise<string[]>}
317
+ */
318
+ export async function listModels({ key, baseUrl, timeoutMs = T_SUBMIT }) {
319
+ const base = String(baseUrl || '').trim().replace(/\/+$/, '');
320
+ if (!base) throw new Error('iris: 供应商缺 baseUrl,无法发现模型');
321
+ const res = await fetch(`${base}/models`, { headers: authHeaders(key), signal: timeoutSignal(timeoutMs) });
322
+ const body = await readJson(res);
323
+ if (!res.ok) throw providerError(res.status, body, '模型发现失败');
324
+ const items = (body && Array.isArray(body.data) && body.data) || (Array.isArray(body && body.models) ? body.models : []);
325
+ const ids = [];
326
+ for (const it of items) {
327
+ const id = typeof it === 'string' ? it : it && it.id;
328
+ if (typeof id === 'string' && id && !ids.includes(id)) ids.push(id);
329
+ }
330
+ return ids;
331
+ }
332
+
333
+ /* ---------------- 视觉理解(OpenAI 兼容 SSE 流式) ---------------- */
334
+ /**
335
+ * qwen-vl 走自持栈的主通道:POST {baseUrl}/chat/completions,SSE 增量拼接。
336
+ * baseUrl 形如 https://dashscope.aliyuncs.com/compatible-mode/v1。
337
+ * @param {AbortSignal} [signal] 取消传播
338
+ * @returns {Promise<string>} 完整回答文本
339
+ */
340
+ export async function visionStream({ key, baseUrl, model = 'qwen-vl-plus', prompt, imageDataUrl, signal, timeoutMs = T_VISION }) {
341
+ const base = String(baseUrl || '').trim().replace(/\/+$/, '');
342
+ let res;
343
+ try {
344
+ res = await fetch(`${base}/chat/completions`, {
345
+ method: 'POST',
346
+ headers: authHeaders(key),
347
+ signal: timeoutSignal(timeoutMs, signal),
348
+ body: JSON.stringify({
349
+ model,
350
+ stream: true,
351
+ messages: [
352
+ {
353
+ role: 'user',
354
+ content: [
355
+ { type: 'text', text: prompt },
356
+ { type: 'image_url', image_url: { url: imageDataUrl } }
357
+ ]
358
+ }
359
+ ]
360
+ })
361
+ });
362
+ } catch (err) {
363
+ throw networkError(err); // 网络层失败归类为 network
364
+ }
365
+ if (!res.ok) {
366
+ const text = await res.text().catch(() => '');
367
+ let body = null;
368
+ try { body = JSON.parse(text); } catch (_) { /* keep null */ }
369
+ throw providerError(res.status, body, '视觉理解失败');
370
+ }
371
+ let full = '';
372
+ const decoder = new TextDecoder();
373
+ let buffer = '';
374
+ for await (const chunk of res.body) {
375
+ if (signal && signal.aborted) break;
376
+ buffer += decoder.decode(chunk, { stream: true });
377
+ let idx;
378
+ while ((idx = buffer.indexOf('\n')) >= 0) {
379
+ const line = buffer.slice(0, idx).trim();
380
+ buffer = buffer.slice(idx + 1);
381
+ if (!line.startsWith('data:')) continue;
382
+ const data = line.slice(5).trim();
383
+ if (!data || data === '[DONE]') continue;
384
+ try {
385
+ const j = JSON.parse(data);
386
+ const delta = j.choices && j.choices[0] && j.choices[0].delta && j.choices[0].delta.content;
387
+ if (typeof delta === 'string' && delta) full += delta;
388
+ } catch (_) {
389
+ /* 跳过不完整行 */
390
+ }
391
+ }
392
+ }
393
+ return full.trim();
394
+ }
395
+
396
+ /* ---------------- 下载 / 扩展名 ---------------- */
397
+ export function extFromUrl(url, fallback = 'bin') {
398
+ try {
399
+ const m = new URL(url).pathname.match(/\.(\w{2,5})$/);
400
+ if (m) return m[1].toLowerCase();
401
+ } catch (_) {
402
+ /* ignore */
403
+ }
404
+ return fallback;
405
+ }
406
+
407
+ export async function downloadTo(url, filePath, { timeoutMs = T_DOWNLOAD, signal } = {}) {
408
+ const res = await fetch(url, { signal: timeoutSignal(timeoutMs, signal) });
409
+ if (!res.ok) throw new Error(`下载结果失败:HTTP ${res.status}`);
410
+ const buf = Buffer.from(await res.arrayBuffer());
411
+ const { writeFileSync, renameSync } = await import('node:fs');
412
+ // 原子落盘:先写 .tmp 再 rename,失败/超时不留半截产物
413
+ const tmp = filePath + '.tmp';
414
+ writeFileSync(tmp, buf);
415
+ renameSync(tmp, filePath);
416
+ return buf.length;
417
+ }
418
+
419
+ /* ---------------- DashScope:非实时语音识别(异步任务) ---------------- */
420
+ /**
421
+ * 提交音频转写任务。音频先通过 uploadTempFile 上传到 oss://,再提交。
422
+ * @param {{key:string, model:string, audioUrl:string}} opts audioUrl 为 oss:// 或公网 URL
423
+ * @returns {Promise<string>} remoteTaskId
424
+ */
425
+ export async function submitTranscription({ key, model = 'qwen-audio-3.0-asr-flash-filetrans', audioUrl, timeoutMs = T_SUBMIT, signal }) {
426
+ const headers = { ...authHeaders(key), 'X-DashScope-Async': 'enable' };
427
+ if (/^oss:\/\//.test(audioUrl || '')) headers['X-DashScope-OssResourceResolve'] = 'enable';
428
+ // Qwen3 Filetrans 使用单值 file_url;Qwen-Audio 3.0、Fun-ASR、Paraformer 使用 file_urls。
429
+ const input = /^qwen3-asr-.*filetrans/i.test(model || '')
430
+ ? { file_url: audioUrl }
431
+ : { file_urls: [audioUrl] };
432
+ const res = await fetch(`${DASHSCOPE}/services/audio/asr/transcription`, {
433
+ method: 'POST',
434
+ headers,
435
+ signal: timeoutSignal(timeoutMs, signal),
436
+ body: JSON.stringify({ model, input })
437
+ });
438
+ const body = await readJson(res);
439
+ if (!res.ok) throw providerError(res.status, body, '提交转写失败');
440
+ const taskId = body && body.output && body.output.task_id;
441
+ if (!taskId) throw new Error('提交成功但未返回 task_id');
442
+ return taskId;
443
+ }
444
+
445
+ /** 从百炼转写结果 JSON 中提取正文,兼容 transcript.text 与 sentences[].text。 */
446
+ export function transcriptionText(payload) {
447
+ const root = (payload && payload.output) || payload || {};
448
+ const parts = [];
449
+ for (const transcript of Array.isArray(root.transcripts) ? root.transcripts : []) {
450
+ if (typeof transcript.text === 'string' && transcript.text.trim()) {
451
+ parts.push(transcript.text.trim());
452
+ continue;
453
+ }
454
+ const sentenceText = (Array.isArray(transcript.sentences) ? transcript.sentences : [])
455
+ .map((sentence) => sentence && sentence.text)
456
+ .filter((text) => typeof text === 'string' && text.trim())
457
+ .join('');
458
+ if (sentenceText) parts.push(sentenceText);
459
+ }
460
+ return parts.join('\n');
461
+ }
462
+
463
+ /** 轮询 ASR 任务;正式结果位于 results[].transcription_url 指向的 JSON。 */
464
+ export async function pollTranscriptionTask({ key, remoteTaskId, timeoutMs = T_POLL }) {
465
+ const res = await fetch(`${DASHSCOPE}/tasks/${encodeURIComponent(remoteTaskId)}`, {
466
+ headers: authHeaders(key),
467
+ signal: timeoutSignal(timeoutMs)
468
+ });
469
+ const body = await readJson(res);
470
+ if (!res.ok) throw providerError(res.status, body, '查询转写任务失败');
471
+ const out = (body && body.output) || {};
472
+ const status = out.task_status || 'UNKNOWN';
473
+ let text = '';
474
+ let subtaskSeen = false;
475
+ let subtaskSucceeded = false;
476
+ if (Array.isArray(out.results)) {
477
+ for (const result of out.results) {
478
+ if (!result || typeof result !== 'object') continue;
479
+ if (typeof result.subtask_status === 'string') {
480
+ subtaskSeen = true;
481
+ if (result.subtask_status === 'SUCCEEDED') subtaskSucceeded = true;
482
+ }
483
+ if (typeof result.transcription_text === 'string') text += result.transcription_text;
484
+ else if (typeof result.text === 'string') text += result.text;
485
+ if (status === 'SUCCEEDED' && result.subtask_status !== 'FAILED' && typeof result.transcription_url === 'string') {
486
+ const transcriptRes = await fetch(result.transcription_url, { signal: timeoutSignal(timeoutMs) });
487
+ if (!transcriptRes.ok) throw new Error(`下载转写结果失败:HTTP ${transcriptRes.status}`);
488
+ const transcriptBody = await readJson(transcriptRes);
489
+ if (!transcriptBody) throw new Error('下载转写结果失败:响应不是 JSON');
490
+ text += transcriptionText(transcriptBody);
491
+ }
492
+ }
493
+ }
494
+ if (typeof out.text === 'string') text = out.text;
495
+ if (typeof out.transcription_text === 'string') text = out.transcription_text;
496
+ if (!TERMINAL.has(status)) return { done: false, text };
497
+ const ok = status === 'SUCCEEDED' && (!subtaskSeen || subtaskSucceeded);
498
+ return {
499
+ done: true,
500
+ ok,
501
+ text,
502
+ message: ok ? undefined : (out.message || out.code || '转写子任务失败')
503
+ };
504
+ }