dsh-cliproxyapi-pro 1.7.0 → 1.9.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.
- package/CHANGELOG.md +42 -0
- package/README.en.md +159 -17
- package/README.md +159 -17
- package/lib/client.js +3429 -888
- package/lib/index.js +2130 -34
- package/lib/media.js +2542 -0
- package/lib/quota.js +114 -12
- package/package.json +3 -2
package/lib/client.js
CHANGED
|
@@ -22,6 +22,7 @@ window.__ModuleLoader__.load({
|
|
|
22
22
|
save: "api/dsh-cliproxyapi/save",
|
|
23
23
|
addAccount: "api/dsh-cliproxyapi/accounts/add",
|
|
24
24
|
removeAccount: "api/dsh-cliproxyapi/accounts/remove",
|
|
25
|
+
testAccount: "api/dsh-cliproxyapi/accounts/test",
|
|
25
26
|
updateStatus: "api/dsh-cliproxyapi/updates/status",
|
|
26
27
|
updateCheck: "api/dsh-cliproxyapi/updates/check",
|
|
27
28
|
updateSave: "api/dsh-cliproxyapi/updates/save",
|
|
@@ -29,6 +30,14 @@ window.__ModuleLoader__.load({
|
|
|
29
30
|
quotaStatus: "api/dsh-cliproxyapi/quota/status",
|
|
30
31
|
quotaRefresh: "api/dsh-cliproxyapi/quota/refresh",
|
|
31
32
|
quotaSave: "api/dsh-cliproxyapi/quota/save",
|
|
33
|
+
mediaStatus: "api/dsh-cliproxyapi/media/status",
|
|
34
|
+
mediaSave: "api/dsh-cliproxyapi/media/save",
|
|
35
|
+
mediaDiagnose: "api/dsh-cliproxyapi/media/diagnose",
|
|
36
|
+
mediaSession: "api/dsh-cliproxyapi/media/session",
|
|
37
|
+
mediaSessionSave: "api/dsh-cliproxyapi/media/session/save",
|
|
38
|
+
mediaFile: "api/dsh-cliproxyapi/media/file",
|
|
39
|
+
mediaCommand: "api/dsh-cliproxyapi/media/command",
|
|
40
|
+
mediaConfirm: "api/dsh-cliproxyapi/media/confirm",
|
|
32
41
|
};
|
|
33
42
|
|
|
34
43
|
// Public, versioned dshmarket Update API (UPDATE-API-V1.md in dshmarket).
|
|
@@ -300,8 +309,10 @@ window.__ModuleLoader__.load({
|
|
|
300
309
|
qDurDH: "{d}天{h}小时",
|
|
301
310
|
qDurHM: "{h}小时{m}分",
|
|
302
311
|
qDurM: "{m}分钟",
|
|
303
|
-
qErrUnauthorized: "管理密钥无效(401
|
|
312
|
+
qErrUnauthorized: "管理密钥无效(401),请检查后重新保存。",
|
|
304
313
|
qErrForbidden: "CPA 拒绝了管理请求(403):请确认 CPA 已开启 remote-management.allow-remote,且密钥正确。",
|
|
314
|
+
qAuthRetrying: "已连续被拒绝 {n}/{max} 次;连续 {max} 次后停止自动查询。",
|
|
315
|
+
qAuthPaused: "已连续被拒绝 {n} 次,自动查询已停止,以免 CPA 因错误密钥封禁本机地址。保存新的管理密钥后恢复;手动刷新只会再试一次。",
|
|
305
316
|
qErrNetwork: "无法连接 CPA 管理接口,请检查服务地址与网络。",
|
|
306
317
|
qErrTimeout: "CPA 管理接口响应超时,请稍后重试。",
|
|
307
318
|
qErrHttp: "CPA 管理接口返回错误(HTTP {status})。",
|
|
@@ -350,6 +361,196 @@ window.__ModuleLoader__.load({
|
|
|
350
361
|
secondsUnit: "秒 · 所有账号共用",
|
|
351
362
|
paramsHintShort: "修改后点底部「保存并应用」生效。",
|
|
352
363
|
qProviderOther: "其他",
|
|
364
|
+
tabMedia: "生成",
|
|
365
|
+
mediaIntro: "该账号 CPA 上识别为生图 / 生视频的模型。它们由 CPA 的图片和视频接口提供,不会出现在对话模型选择器中。下面的修改会立即保存。",
|
|
366
|
+
mediaLoading: "正在读取生成模型…",
|
|
367
|
+
mediaLoadError: "无法读取生成模型:{error}",
|
|
368
|
+
mediaImageGroup: "生图模型",
|
|
369
|
+
mediaVideoGroup: "生视频模型",
|
|
370
|
+
mediaNone: "该账号的模型目录里没有识别到生图或生视频模型。",
|
|
371
|
+
mediaNoCatalog: "还没有读取到该账号的模型目录。请先在「连接」页连接 CPA,或从标题右侧的 ⋯ 菜单立即探测。",
|
|
372
|
+
mediaEnabledLabel: "提供 {model} 用于生成",
|
|
373
|
+
mediaKindFor: "{model} 的类型",
|
|
374
|
+
mediaKindLabel: "类型",
|
|
375
|
+
mediaKindImage: "生图",
|
|
376
|
+
mediaKindVideo: "生视频",
|
|
377
|
+
mediaKindChat: "对话模型",
|
|
378
|
+
mediaDetectedTag: "自动识别",
|
|
379
|
+
mediaOverriddenTag: "手动指定",
|
|
380
|
+
mediaResetKind: "恢复自动识别",
|
|
381
|
+
mediaAliasTag: "→ {target}",
|
|
382
|
+
mediaAliasHint: "CPA 会把这个模型 id 改写为 {target} 调用。",
|
|
383
|
+
mediaMovedTitle: "已改为对话模型",
|
|
384
|
+
mediaMovedHint: "这些模型被自动识别为生成模型,但你把它们改成了对话模型,因此出现在「模型」页。",
|
|
385
|
+
mediaMarkTitle: "把其他模型标记为生成模型",
|
|
386
|
+
mediaMarkHint: "自动识别漏掉时使用。标记后,该模型会离开「模型」页和对话模型选择器。",
|
|
387
|
+
mediaMarkSelect: "选择模型…",
|
|
388
|
+
mediaMarkBtn: "标记",
|
|
389
|
+
mediaSaved: "已保存",
|
|
390
|
+
mediaKindSaved: "已保存,对话模型选择器已重新同步",
|
|
391
|
+
mediaSettingsInvalid: "media-settings.json 无法读取,当前使用默认设置;下次保存时会把损坏的文件另存一份。",
|
|
392
|
+
mediaErrInvalidAccount: "账号不存在或已删除,请刷新页面。",
|
|
393
|
+
mediaErrInvalidRequest: "请求无效,请刷新页面后重试。",
|
|
394
|
+
mediaErrNoEndpoint: "该账号还没有可用的服务地址,请先在「连接」页连接 CPA。",
|
|
395
|
+
mediaErrGeneric: "操作失败:{error}",
|
|
396
|
+
diagTitle: "只读诊断",
|
|
397
|
+
diagHint: "只发送 GET 请求:用该账号的 Key 读取两份模型目录;再不带 Key 探测两个视频接口是否存在(存在的接口会直接返回 401)。不会生成内容,也不会消耗额度。如果 CPA 没有要求 Key,探测会用一个不存在的任务 id 查询一次,仍是只读请求。",
|
|
398
|
+
diagRun: "运行诊断",
|
|
399
|
+
diagRunning: "正在诊断…",
|
|
400
|
+
diagNoKey: "该账号没有配置 API Key,模型目录可能读取失败。",
|
|
401
|
+
diagEnriched: "模型目录(client_version=pi)",
|
|
402
|
+
diagListing: "模型列表(/v1/models)",
|
|
403
|
+
diagCatalogOk: "共 {n} 个模型,其中 {m} 个疑似生成模型",
|
|
404
|
+
diagCatalogFail: "读取失败({reason})",
|
|
405
|
+
diagRoutes: "视频接口",
|
|
406
|
+
diagPresent: "存在",
|
|
407
|
+
diagMissing: "不存在(CPA 版本可能较旧)",
|
|
408
|
+
diagUnknown: "无法判断(HTTP {status})",
|
|
409
|
+
diagUnreachable: "无法连接",
|
|
410
|
+
diagRaw: "完整结果(JSON,不含 Key)",
|
|
411
|
+
diagCopy: "复制结果",
|
|
412
|
+
diagCopied: "已复制",
|
|
413
|
+
mediaChipLabel: "生图模型:{model}",
|
|
414
|
+
mediaChipChoose: "选择生图模型",
|
|
415
|
+
mediaChipTitle: "生成模型",
|
|
416
|
+
mediaChipHint: "输入 /cpa-image 加提示词生成图片;附带图片即可改图。",
|
|
417
|
+
mediaChipVideoHint: "输入 /cpa-video 加提示词生成视频;可附带一张首帧图,提交后无法取消。",
|
|
418
|
+
mediaChipHintBoth: "/cpa-image 生成图片(附带图片即可改图);/cpa-video 生成视频(可附带一张首帧图,提交后无法取消)。",
|
|
419
|
+
mediaChipVideoLabel: "生视频模型:{model}",
|
|
420
|
+
mediaChipVideoChoose: "选择生视频模型",
|
|
421
|
+
mediaChipJoin: ";",
|
|
422
|
+
mediaChipInherited: "本会话还没有单独选择,正在沿用上一次的选择。",
|
|
423
|
+
mediaAgentSettings: "agent 生成设置",
|
|
424
|
+
mediaBack: "返回",
|
|
425
|
+
mediaActOpen: "用默认应用打开",
|
|
426
|
+
mediaActReveal: "显示文件位置",
|
|
427
|
+
mediaActRevealExplorer: "在文件资源管理器中显示",
|
|
428
|
+
mediaActRevealFinder: "在访达中显示",
|
|
429
|
+
mediaActRevealFiles: "在文件管理器中显示",
|
|
430
|
+
mediaActOpenWith: "用 {app} 打开",
|
|
431
|
+
mediaActOpenWithDefault: "用 {app} 打开(默认)",
|
|
432
|
+
mediaActAppsLoading: "正在读取可用的应用…",
|
|
433
|
+
mediaActOpenFolder: "打开文件夹",
|
|
434
|
+
mediaActCopyImage: "复制图片",
|
|
435
|
+
mediaActCopyPath: "复制文件路径",
|
|
436
|
+
mediaActCopied: "已复制",
|
|
437
|
+
mediaActOpenFailed: "DSH 没能打开这个路径。",
|
|
438
|
+
mediaActCopyFailed: "复制失败:{error}",
|
|
439
|
+
mediaActMenu: "图片操作",
|
|
440
|
+
mediaActMore: "更多操作",
|
|
441
|
+
mediaClosePreview: "收起",
|
|
442
|
+
mediaPickFailed: "无法切换模型:{error}",
|
|
443
|
+
mediaOutputTitle: "保存位置",
|
|
444
|
+
mediaVideoDefaults: "视频默认值",
|
|
445
|
+
mediaVideoDefaultsHint: "/cpa-video 没有写 -s / -r 时使用的时长和分辨率。所有账号共用。视频按秒计费,提交后无法取消。",
|
|
446
|
+
mediaVideoSeconds: "时长",
|
|
447
|
+
mediaVideoResolution: "分辨率",
|
|
448
|
+
mediaSecondsOption: "{n} 秒",
|
|
449
|
+
mediaOutputHint: "生成的文件保存在这里。相对路径以会话的工作目录为基准,也可以填写绝对路径。所有账号共用。",
|
|
450
|
+
mediaOutputSave: "保存",
|
|
451
|
+
mediaErrInvalidOutputDir: "保存位置无效:不能为空、不能含控制字符,且不超过 260 个字符。",
|
|
452
|
+
mediaErrInvalidModel: "该模型已不可用,请刷新后重试。",
|
|
453
|
+
mediaErrInvalidSession: "会话无效,请刷新页面。",
|
|
454
|
+
mediaCardSubmitting: "正在提交…",
|
|
455
|
+
mediaCardRunning: "正在生成 · {time}",
|
|
456
|
+
mediaCardEditing: "正在改图 · {time}",
|
|
457
|
+
mediaCardVideoQueued: "排队中 · {time}",
|
|
458
|
+
mediaCardVideoProgress: "正在生成 {progress}% · {time}",
|
|
459
|
+
mediaCardVideoSaving: "正在保存视频 · {time}",
|
|
460
|
+
mediaCardVideoNoStop: "视频提交后无法取消,会照常计费;可以继续对话。重载插件或重启 DSH(3 小时内)后会继续等待并保存。",
|
|
461
|
+
mediaCardFirstFrame: "首帧图",
|
|
462
|
+
mediaModeReference: "参考图 ×{n}",
|
|
463
|
+
mediaModeFirstLast: "首尾帧",
|
|
464
|
+
mediaModeLastFrame: "尾帧",
|
|
465
|
+
mediaModeEdit: "编辑视频",
|
|
466
|
+
mediaModeExtend: "延长 +{n} 秒",
|
|
467
|
+
mediaEditOpen: "编辑视频…",
|
|
468
|
+
mediaExtendOpen: "延长视频…",
|
|
469
|
+
mediaEditPlaceholder: "要怎么改?例如:把衣服换成红色,其他保持不变",
|
|
470
|
+
mediaExtendPlaceholder: "接下来发生什么?例如:她转身走出画面",
|
|
471
|
+
mediaExtendSeconds: "延长时长",
|
|
472
|
+
mediaEditSubmit: "提交编辑",
|
|
473
|
+
mediaExtendSubmit: "提交延长",
|
|
474
|
+
mediaEditCancel: "取消",
|
|
475
|
+
mediaEditNote: "使用 grok-imagine-video,结果出现在对话下方新的一行;提交后无法取消,会照常计费。约 1 小时内直接用 xAI 的视频链接,之后把本地文件随请求发送。",
|
|
476
|
+
mediaSourceTag: "源:{name}",
|
|
477
|
+
mediaCopyInput: "复制原始输入",
|
|
478
|
+
mediaLocale: "zh",
|
|
479
|
+
mediaConfirmWaiting: "等待你确认",
|
|
480
|
+
mediaConfirmTitle: "agent 想生成图片",
|
|
481
|
+
mediaConfirmTitleVideo: "agent 想生成视频",
|
|
482
|
+
mediaConfirmAllow: "允许生成",
|
|
483
|
+
mediaConfirmDecline: "拒绝",
|
|
484
|
+
mediaConfirmNote: "确认前不会提交生成。10 分钟内没有回答视为不同意;可在生成设置里调整本会话的确认方式。",
|
|
485
|
+
mediaConfirmDeliveryFailed: "回答未送达,请重试;生成尚未获准。",
|
|
486
|
+
mediaConfirmAllowed: "已允许,开始生成",
|
|
487
|
+
mediaConfirmDeclined: "已拒绝",
|
|
488
|
+
mediaConfirmFailed: "没有送达:{error}",
|
|
489
|
+
mediaMasterTitle: "启用图片和视频生成",
|
|
490
|
+
mediaMasterHint: "对所有账号生效。关闭时输入框旁不显示生成按钮,没有 /cpa-image、/cpa-video 等生成命令,也不注册 agent 生成工具;已提交的视频会继续完成,聊天记录里已生成的结果仍可查看。",
|
|
491
|
+
mediaMasterOnNote: "已开启:可以用输入框旁的按钮、斜杠命令生成图片和视频。",
|
|
492
|
+
mediaMasterOffNote: "已关闭:界面上不显示生成功能,也没有生成命令。打开后才会出现下面的模型和设置。",
|
|
493
|
+
mediaAgentTitle: "允许 agent 生成图片 / 视频",
|
|
494
|
+
mediaAgentHint: "打开后,对话里的 agent 可以调用 generate_media 工具,用上面启用的生成模型生图、生视频、编辑或延长视频,文件保存到会话工作目录下的保存位置。关闭时不注册这个工具,不占用上下文。斜杠命令和输入框旁的按钮不受影响。",
|
|
495
|
+
mediaAgentOnNote: "已开启:agent 可以调用 generate_media。确认方式和秒数上限可在输入框旁的生成按钮里按会话单独修改。",
|
|
496
|
+
mediaAgentOffNote: "已关闭:agent 看不到生成工具。",
|
|
497
|
+
mediaVideoConfirm: "agent 生成视频时",
|
|
498
|
+
mediaImageConfirm: "agent 生成图片时",
|
|
499
|
+
mediaConfirmAsk: "每次先问我",
|
|
500
|
+
mediaConfirmAuto: "直接生成",
|
|
501
|
+
mediaVideoBudget: "每个会话的 agent 视频上限",
|
|
502
|
+
mediaVideoBudgetHint: "按视频秒数累计(子 agent 计入所属会话)。只在「直接生成」时拦截超出上限的视频;「每次先问我」时只计数,确认框里会显示已用秒数。修改上限或改为直接生成时重新计数。你手动用 /cpa-video 不计数。",
|
|
503
|
+
mediaBudgetSeconds: "{n} 秒",
|
|
504
|
+
mediaBudgetUnlimited: "不限",
|
|
505
|
+
mediaFollowGlobal: "跟随全局({value})",
|
|
506
|
+
mediaAgentSection: "本会话的 agent 生成",
|
|
507
|
+
mediaAgentUsed: "本会话 agent 已用视频 {used} / {limit} 秒",
|
|
508
|
+
mediaAgentUsedUnlimited: "本会话 agent 已用视频 {used} 秒(不限)",
|
|
509
|
+
mediaCardInterpreting: "正在理解参数 · {time}",
|
|
510
|
+
mediaInterpreted: "参数由 {by} 从提示词中读出:{items}",
|
|
511
|
+
mediaInterpretedNone: "{by} 没有从提示词中读出其他参数",
|
|
512
|
+
mediaInterpretFailed: "参数解读失败({by}),按命令里的选项和原文发送",
|
|
513
|
+
mediaInterpretedPrompt: ";已从提示词中去掉这些参数的说法",
|
|
514
|
+
mediaSentText: "模式:纯文字生成(没有附带图片)",
|
|
515
|
+
mediaSentFirstFrame: "模式:首帧 — 附带的图片就是视频的第一帧",
|
|
516
|
+
mediaSentReference: "模式:参考图 ×{n} — 带入图中的人物、服装、物品,不锁定第一帧",
|
|
517
|
+
mediaSentFirstLast: "模式:首尾帧 — 第 1 张图是第一帧,第 2 张图是最后一帧",
|
|
518
|
+
mediaSentLastFrame: "模式:尾帧 — 视频最后停在附带的图片上",
|
|
519
|
+
mediaSentEdit: "模式:编辑视频(grok-imagine-video)— 原视频:{source}",
|
|
520
|
+
mediaSentExtend: "模式:延长 +{n} 秒(grok-imagine-video)— 原视频:{source}",
|
|
521
|
+
mediaSentImageText: "模式:文生图",
|
|
522
|
+
mediaSentImageEdit: "模式:改图 — {n} 张附带图片作为参考",
|
|
523
|
+
mediaSourceUpload: "上传本地文件 {name}",
|
|
524
|
+
mediaSourceUrl: "公开链接({name}),由 xAI 下载",
|
|
525
|
+
mediaSourceLink: "本会话生成的视频(xAI 结果链接)",
|
|
526
|
+
mediaSourceSession: "本会话生成的视频",
|
|
527
|
+
mediaIgnoredTag: "已忽略 {words}(编辑 / 延长沿用原视频的设置)",
|
|
528
|
+
mediaCopiedInput: "已复制原始输入",
|
|
529
|
+
mediaEditSubmitted: "已提交,结果见对话下方新的一行。",
|
|
530
|
+
mediaEditFailed: "提交失败。",
|
|
531
|
+
mediaDurationS: "{s} 秒",
|
|
532
|
+
mediaDurationMS: "{m} 分 {s} 秒",
|
|
533
|
+
mediaCardNoStop: "生成开始后无法中途停止;可以继续对话,完成后图片会出现在这里。",
|
|
534
|
+
mediaCardDone: "用时 {time} · 已保存到 {folder}",
|
|
535
|
+
mediaCardDoneShort: "已完成",
|
|
536
|
+
mediaCardFailed: "生成失败",
|
|
537
|
+
mediaCardMissing: "图片文件已移动或删除,或已超出每个会话保留的最近 48 条生成记录。",
|
|
538
|
+
mediaCardDetails: "详情",
|
|
539
|
+
mediaCardFiles: "文件",
|
|
540
|
+
mediaCardFolder: "保存目录",
|
|
541
|
+
mediaCardRevised: "上游改写后的提示词",
|
|
542
|
+
mediaCardImage: "图片 {n}",
|
|
543
|
+
mediaCardCount: "{n} 张",
|
|
544
|
+
mediaCardRefs: "{n} 张参考图",
|
|
545
|
+
mediaRunErrInterrupted: "插件重新加载或 DSH 关闭,生成已中断。",
|
|
546
|
+
mediaRunErrTimeout: "CPA 在 3 分钟内没有返回结果。",
|
|
547
|
+
mediaRunErrNetwork: "无法连接 CPA。",
|
|
548
|
+
mediaRunErrNoImage: "CPA 没有返回图片。",
|
|
549
|
+
mediaRunErrInvalid: "CPA 返回的数据不是 PNG、JPEG、WebP 或 GIF 图片。",
|
|
550
|
+
mediaRunErrWrite: "无法写入文件。",
|
|
551
|
+
mediaRunErrExpired: "CPA 已不再记得这个视频:提交已超过 3 小时。",
|
|
552
|
+
mediaRunErrInvalidVideo: "CPA 返回的数据不是 MP4、MOV 或 WebM 视频。",
|
|
553
|
+
mediaRunErrVideoTooLarge: "视频超过 200 MiB,未保存。",
|
|
353
554
|
};
|
|
354
555
|
|
|
355
556
|
const en = {
|
|
@@ -610,8 +811,10 @@ window.__ModuleLoader__.load({
|
|
|
610
811
|
qDurDH: "{d}d {h}h",
|
|
611
812
|
qDurHM: "{h}h {m}m",
|
|
612
813
|
qDurM: "{m}m",
|
|
613
|
-
qErrUnauthorized: "The management key was rejected (401).
|
|
814
|
+
qErrUnauthorized: "The management key was rejected (401). Check it and save it again.",
|
|
614
815
|
qErrForbidden: "CPA refused the management request (403). Make sure remote-management.allow-remote is on and the key is correct.",
|
|
816
|
+
qAuthRetrying: "Rejected {n}/{max} times in a row; automatic checks stop after {max}.",
|
|
817
|
+
qAuthPaused: "Rejected {n} times in a row, so automatic checks have stopped to keep CPA from banning this address for bad keys. Saving a new management key resumes them; a manual refresh tries only once.",
|
|
615
818
|
qErrNetwork: "Could not reach the CPA management API. Check the server address and network.",
|
|
616
819
|
qErrTimeout: "The CPA management API timed out. Retry shortly.",
|
|
617
820
|
qErrHttp: "The CPA management API returned an error (HTTP {status}).",
|
|
@@ -660,6 +863,196 @@ window.__ModuleLoader__.load({
|
|
|
660
863
|
secondsUnit: "s · all accounts",
|
|
661
864
|
paramsHintShort: "Click Save & Apply at the bottom to apply changes.",
|
|
662
865
|
qProviderOther: "Other",
|
|
866
|
+
tabMedia: "Generation",
|
|
867
|
+
mediaIntro: "Models on this account's CPA recognized as image or video generators. CPA serves them through its image and video endpoints, so they never appear in the chat model picker. Changes here are saved at once.",
|
|
868
|
+
mediaLoading: "Loading generation models…",
|
|
869
|
+
mediaLoadError: "Could not load generation models: {error}",
|
|
870
|
+
mediaImageGroup: "Image models",
|
|
871
|
+
mediaVideoGroup: "Video models",
|
|
872
|
+
mediaNone: "No image or video models were recognized in this account's catalog.",
|
|
873
|
+
mediaNoCatalog: "This account's catalog has not been read yet. Connect CPA on the Connection tab, or probe now from the ⋯ menu next to the title.",
|
|
874
|
+
mediaEnabledLabel: "Offer {model} for generation",
|
|
875
|
+
mediaKindFor: "Kind of {model}",
|
|
876
|
+
mediaKindLabel: "Kind",
|
|
877
|
+
mediaKindImage: "Image",
|
|
878
|
+
mediaKindVideo: "Video",
|
|
879
|
+
mediaKindChat: "Chat model",
|
|
880
|
+
mediaDetectedTag: "Detected",
|
|
881
|
+
mediaOverriddenTag: "Set by you",
|
|
882
|
+
mediaResetKind: "Use detected kind",
|
|
883
|
+
mediaAliasTag: "→ {target}",
|
|
884
|
+
mediaAliasHint: "CPA rewrites this model id and calls {target}.",
|
|
885
|
+
mediaMovedTitle: "Changed to chat models",
|
|
886
|
+
mediaMovedHint: "These models were detected as media models, but you changed them to chat models, so they appear on the Models tab.",
|
|
887
|
+
mediaMarkTitle: "Mark another model as a media model",
|
|
888
|
+
mediaMarkHint: "For models detection missed. A marked model leaves the Models tab and the chat model picker.",
|
|
889
|
+
mediaMarkSelect: "Choose a model…",
|
|
890
|
+
mediaMarkBtn: "Mark",
|
|
891
|
+
mediaSaved: "Saved",
|
|
892
|
+
mediaKindSaved: "Saved; the chat model picker was re-synchronized",
|
|
893
|
+
mediaSettingsInvalid: "media-settings.json is unreadable; defaults apply. The next save keeps a copy of the damaged file.",
|
|
894
|
+
mediaErrInvalidAccount: "The account does not exist or was removed. Refresh the page.",
|
|
895
|
+
mediaErrInvalidRequest: "Invalid request. Refresh the page and try again.",
|
|
896
|
+
mediaErrNoEndpoint: "This account has no usable server address yet. Connect CPA on the Connection tab first.",
|
|
897
|
+
mediaErrGeneric: "Failed: {error}",
|
|
898
|
+
diagTitle: "Read-only diagnostic",
|
|
899
|
+
diagHint: "Sends GET requests only: reads both model catalogs with this account's key, then probes two video routes without the key (a route that exists answers 401 at once). Generates nothing and spends no quota. If CPA does not require a key, the probe looks up one nonexistent job id, which is still read-only.",
|
|
900
|
+
diagRun: "Run diagnostic",
|
|
901
|
+
diagRunning: "Diagnosing…",
|
|
902
|
+
diagNoKey: "This account has no API key; reading the catalogs may fail.",
|
|
903
|
+
diagEnriched: "Catalog (client_version=pi)",
|
|
904
|
+
diagListing: "Listing (/v1/models)",
|
|
905
|
+
diagCatalogOk: "{n} models, {m} look like media models",
|
|
906
|
+
diagCatalogFail: "Failed ({reason})",
|
|
907
|
+
diagRoutes: "Video routes",
|
|
908
|
+
diagPresent: "Present",
|
|
909
|
+
diagMissing: "Missing (CPA may be outdated)",
|
|
910
|
+
diagUnknown: "Undetermined (HTTP {status})",
|
|
911
|
+
diagUnreachable: "Unreachable",
|
|
912
|
+
diagRaw: "Full result (JSON, no keys)",
|
|
913
|
+
diagCopy: "Copy result",
|
|
914
|
+
diagCopied: "Copied",
|
|
915
|
+
mediaChipLabel: "Image model: {model}",
|
|
916
|
+
mediaChipChoose: "Choose image model",
|
|
917
|
+
mediaChipTitle: "Generation models",
|
|
918
|
+
mediaChipHint: "Type /cpa-image followed by a prompt to generate an image; attach images to edit them.",
|
|
919
|
+
mediaChipHintBoth: "/cpa-image generates images (attach images to edit them); /cpa-video generates a video (attach one image as the first frame; a submitted video cannot be cancelled).",
|
|
920
|
+
mediaChipVideoLabel: "Video model: {model}",
|
|
921
|
+
mediaChipVideoChoose: "Choose video model",
|
|
922
|
+
mediaChipJoin: "; ",
|
|
923
|
+
mediaChipInherited: "No pick for this session yet; your last pick is used.",
|
|
924
|
+
mediaChipVideoHint: "Type /cpa-video followed by a prompt to generate a video; you can attach one image as the first frame. A submitted video cannot be cancelled.",
|
|
925
|
+
mediaAgentSettings: "Agent generation settings",
|
|
926
|
+
mediaBack: "Back",
|
|
927
|
+
mediaActOpen: "Open with default app",
|
|
928
|
+
mediaActReveal: "Show file location",
|
|
929
|
+
mediaActRevealExplorer: "Show in File Explorer",
|
|
930
|
+
mediaActRevealFinder: "Show in Finder",
|
|
931
|
+
mediaActRevealFiles: "Show in Files",
|
|
932
|
+
mediaActOpenWith: "Open with {app}",
|
|
933
|
+
mediaActOpenWithDefault: "Open with {app} (default)",
|
|
934
|
+
mediaActAppsLoading: "Looking up applications…",
|
|
935
|
+
mediaActOpenFolder: "Open folder",
|
|
936
|
+
mediaActCopyImage: "Copy image",
|
|
937
|
+
mediaActCopyPath: "Copy file path",
|
|
938
|
+
mediaActCopied: "Copied",
|
|
939
|
+
mediaActOpenFailed: "DSH could not open this path.",
|
|
940
|
+
mediaActCopyFailed: "Copy failed: {error}",
|
|
941
|
+
mediaActMenu: "Image actions",
|
|
942
|
+
mediaActMore: "More actions",
|
|
943
|
+
mediaClosePreview: "Close",
|
|
944
|
+
mediaPickFailed: "Could not switch the model: {error}",
|
|
945
|
+
mediaOutputTitle: "Save location",
|
|
946
|
+
mediaVideoDefaults: "Video defaults",
|
|
947
|
+
mediaVideoDefaultsHint: "Length and resolution /cpa-video uses when -s / -r are not given. Shared by all accounts. Videos are billed by the second and cannot be cancelled once submitted.",
|
|
948
|
+
mediaVideoSeconds: "Length",
|
|
949
|
+
mediaVideoResolution: "Resolution",
|
|
950
|
+
mediaSecondsOption: "{n} s",
|
|
951
|
+
mediaOutputHint: "Generated files are saved here. A relative path is resolved against the session's working directory; an absolute path also works. Shared by all accounts.",
|
|
952
|
+
mediaOutputSave: "Save",
|
|
953
|
+
mediaErrInvalidOutputDir: "Invalid save location: it must not be empty, must not contain control characters and is at most 260 characters.",
|
|
954
|
+
mediaErrInvalidModel: "That model is no longer available. Refresh and try again.",
|
|
955
|
+
mediaErrInvalidSession: "Invalid session. Refresh the page.",
|
|
956
|
+
mediaCardSubmitting: "Submitting…",
|
|
957
|
+
mediaCardRunning: "Generating · {time}",
|
|
958
|
+
mediaCardEditing: "Editing · {time}",
|
|
959
|
+
mediaCardVideoQueued: "Queued · {time}",
|
|
960
|
+
mediaCardVideoProgress: "Generating {progress}% · {time}",
|
|
961
|
+
mediaCardVideoSaving: "Saving the video · {time}",
|
|
962
|
+
mediaCardVideoNoStop: "A submitted video cannot be cancelled and is billed as usual; you can keep chatting. After a plugin reload or a DSH restart (within 3 hours) it is still awaited and saved.",
|
|
963
|
+
mediaCardFirstFrame: "first frame",
|
|
964
|
+
mediaModeReference: "references ×{n}",
|
|
965
|
+
mediaModeFirstLast: "first & last frame",
|
|
966
|
+
mediaModeLastFrame: "last frame",
|
|
967
|
+
mediaModeEdit: "video edit",
|
|
968
|
+
mediaModeExtend: "extend +{n} s",
|
|
969
|
+
mediaEditOpen: "Edit video…",
|
|
970
|
+
mediaExtendOpen: "Extend video…",
|
|
971
|
+
mediaEditPlaceholder: "What should change? e.g. make the dress red, keep everything else",
|
|
972
|
+
mediaExtendPlaceholder: "What happens next? e.g. she turns and walks out of frame",
|
|
973
|
+
mediaExtendSeconds: "Extension length",
|
|
974
|
+
mediaEditSubmit: "Submit edit",
|
|
975
|
+
mediaExtendSubmit: "Submit extension",
|
|
976
|
+
mediaEditCancel: "Cancel",
|
|
977
|
+
mediaEditNote: "Uses grok-imagine-video; the result appears in a new row below. A submitted video cannot be cancelled and is billed as usual. For about an hour xAI's own video link is used; after that the local file is sent with the request.",
|
|
978
|
+
mediaSourceTag: "source: {name}",
|
|
979
|
+
mediaCopyInput: "Copy original input",
|
|
980
|
+
mediaLocale: "en",
|
|
981
|
+
mediaConfirmWaiting: "Waiting for your confirmation",
|
|
982
|
+
mediaConfirmTitle: "The agent wants to generate images",
|
|
983
|
+
mediaConfirmTitleVideo: "The agent wants to generate a video",
|
|
984
|
+
mediaConfirmAllow: "Allow",
|
|
985
|
+
mediaConfirmDecline: "Decline",
|
|
986
|
+
mediaConfirmNote: "Nothing is submitted before confirmation. No answer within 10 minutes counts as declined; change this session's confirmation mode in generation settings.",
|
|
987
|
+
mediaConfirmDeliveryFailed: "Answer not delivered. Retry; generation has not been approved.",
|
|
988
|
+
mediaConfirmAllowed: "Allowed; generating",
|
|
989
|
+
mediaConfirmDeclined: "Declined",
|
|
990
|
+
mediaConfirmFailed: "Not delivered: {error}",
|
|
991
|
+
mediaMasterTitle: "Enable image and video generation",
|
|
992
|
+
mediaMasterHint: "Applies to every account. While off, the composer shows no generation buttons, /cpa-image, /cpa-video and the other generation commands do not exist, and no agent generation tool is registered. Videos already submitted still finish, and past results in the chat stay viewable.",
|
|
993
|
+
mediaMasterOnNote: "On: generate images and videos with the composer buttons and slash commands.",
|
|
994
|
+
mediaMasterOffNote: "Off: no generation features appear and no generation commands exist. Turn it on to see the models and settings below.",
|
|
995
|
+
mediaAgentTitle: "Let the agent generate images / videos",
|
|
996
|
+
mediaAgentHint: "When on, the chat agent can call the generate_media tool to generate images and videos, or edit / extend videos, with the generation models enabled above; files go to the save location under the session's working directory. When off the tool is not registered and takes no context. Slash commands and the composer button work either way.",
|
|
997
|
+
mediaAgentOnNote: "On: the agent can call generate_media. Confirmation and the seconds limit can be changed per session in the generation button next to the composer.",
|
|
998
|
+
mediaAgentOffNote: "Off: the agent has no generation tool.",
|
|
999
|
+
mediaVideoConfirm: "Agent videos",
|
|
1000
|
+
mediaImageConfirm: "Agent images",
|
|
1001
|
+
mediaConfirmAsk: "Ask me each time",
|
|
1002
|
+
mediaConfirmAuto: "Generate directly",
|
|
1003
|
+
mediaVideoBudget: "Agent video limit per session",
|
|
1004
|
+
mediaVideoBudgetHint: "Counted in video seconds (a sub-agent counts against its session). Videos over the limit are refused only when generating directly; when asking, they are counted and the prompt shows the seconds used. Changing the limit or switching to direct generation starts the count again. Your own /cpa-video is not counted.",
|
|
1005
|
+
mediaBudgetSeconds: "{n} s",
|
|
1006
|
+
mediaBudgetUnlimited: "No limit",
|
|
1007
|
+
mediaFollowGlobal: "Follow global ({value})",
|
|
1008
|
+
mediaAgentSection: "Agent generation in this session",
|
|
1009
|
+
mediaAgentUsed: "Agent videos in this session: {used} of {limit} s",
|
|
1010
|
+
mediaAgentUsedUnlimited: "Agent videos in this session: {used} s (no limit)",
|
|
1011
|
+
mediaCardInterpreting: "Reading parameters · {time}",
|
|
1012
|
+
mediaInterpreted: "Parameters {by} read from the prompt: {items}",
|
|
1013
|
+
mediaInterpretedNone: "{by} found no further parameters in the prompt",
|
|
1014
|
+
mediaInterpretFailed: "Reading parameters failed ({by}); sent with the command's options and the prompt as written",
|
|
1015
|
+
mediaInterpretedPrompt: "; the words stating them were taken out of the prompt",
|
|
1016
|
+
mediaSentText: "Mode: text only (no attached images)",
|
|
1017
|
+
mediaSentFirstFrame: "Mode: first frame — the attached image is the video's first frame",
|
|
1018
|
+
mediaSentReference: "Mode: references ×{n} — brings in the people, clothing and objects shown, without fixing the first frame",
|
|
1019
|
+
mediaSentFirstLast: "Mode: first & last frame — image 1 is the first frame, image 2 the last",
|
|
1020
|
+
mediaSentLastFrame: "Mode: last frame — the video ends on the attached image",
|
|
1021
|
+
mediaSentEdit: "Mode: video edit (grok-imagine-video) — source: {source}",
|
|
1022
|
+
mediaSentExtend: "Mode: extend +{n} s (grok-imagine-video) — source: {source}",
|
|
1023
|
+
mediaSentImageText: "Mode: text to image",
|
|
1024
|
+
mediaSentImageEdit: "Mode: image edit — {n} attached image(s) as references",
|
|
1025
|
+
mediaSourceUpload: "uploaded local file {name}",
|
|
1026
|
+
mediaSourceUrl: "public link ({name}), downloaded by xAI",
|
|
1027
|
+
mediaSourceLink: "a video made in this session (xAI result link)",
|
|
1028
|
+
mediaSourceSession: "a video made in this session",
|
|
1029
|
+
mediaIgnoredTag: "ignored {words} (edits / extensions keep the source's settings)",
|
|
1030
|
+
mediaCopiedInput: "Original input copied",
|
|
1031
|
+
mediaEditSubmitted: "Submitted; the result appears in a new row below.",
|
|
1032
|
+
mediaEditFailed: "Could not submit.",
|
|
1033
|
+
mediaDurationS: "{s} s",
|
|
1034
|
+
mediaDurationMS: "{m} min {s} s",
|
|
1035
|
+
mediaCardNoStop: "A started generation cannot be stopped; you can keep chatting, and the images appear here when ready.",
|
|
1036
|
+
mediaCardDone: "{time} · saved to {folder}",
|
|
1037
|
+
mediaCardDoneShort: "Done",
|
|
1038
|
+
mediaCardFailed: "Generation failed",
|
|
1039
|
+
mediaCardMissing: "The image files were moved or deleted, or fell out of the last 48 generation records kept per session.",
|
|
1040
|
+
mediaCardDetails: "Details",
|
|
1041
|
+
mediaCardFiles: "Files",
|
|
1042
|
+
mediaCardFolder: "Folder",
|
|
1043
|
+
mediaCardRevised: "Prompt as rewritten upstream",
|
|
1044
|
+
mediaCardImage: "Image {n}",
|
|
1045
|
+
mediaCardCount: "{n} images",
|
|
1046
|
+
mediaCardRefs: "{n} reference image(s)",
|
|
1047
|
+
mediaRunErrInterrupted: "Stopped because the plugin was reloaded or DSH closed.",
|
|
1048
|
+
mediaRunErrTimeout: "CPA did not answer within 3 minutes.",
|
|
1049
|
+
mediaRunErrNetwork: "Could not reach CPA.",
|
|
1050
|
+
mediaRunErrNoImage: "CPA returned no image.",
|
|
1051
|
+
mediaRunErrInvalid: "CPA returned data that is not a PNG, JPEG, WebP or GIF image.",
|
|
1052
|
+
mediaRunErrWrite: "Could not write the file.",
|
|
1053
|
+
mediaRunErrExpired: "CPA no longer knows this video: more than 3 hours have passed since it was submitted.",
|
|
1054
|
+
mediaRunErrInvalidVideo: "CPA returned data that is not an MP4, MOV or WebM video.",
|
|
1055
|
+
mediaRunErrVideoTooLarge: "The video is larger than 200 MiB and was not saved.",
|
|
663
1056
|
};
|
|
664
1057
|
|
|
665
1058
|
const CSS = `
|
|
@@ -1763,6 +2156,151 @@ select.dcpa-q-input { appearance: auto; padding-right: 8px; }
|
|
|
1763
2156
|
.dcpa-models .dcpa-model-row:hover { background: var(--dsw-alias-interactive-bg-hover, transparent); }
|
|
1764
2157
|
.dcpa-models .dcpa-badge { border: 0; background: var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-2)); border-radius: 999px; padding: 1px 8px; }
|
|
1765
2158
|
.dcpa-models .dcpa-editor { border-top: 0; padding: 4px 0 6px 25px; }
|
|
2159
|
+
/* Generation tab: flat rows without checkboxes, kind select + switch on the right. */
|
|
2160
|
+
.dcpa-media .dcpa-model-row { cursor: default; }
|
|
2161
|
+
.dcpa-media .dcpa-model-left { flex-wrap: wrap; }
|
|
2162
|
+
.dcpa-media-block { display: flex; flex-direction: column; }
|
|
2163
|
+
.dcpa-media-group { display: flex; align-items: center; gap: 4px; margin: 12px 0 0; font-size: 13px; line-height: 20px; font-weight: 500; color: var(--dsw-alias-label-primary); }
|
|
2164
|
+
select.dcpa-q-input.dcpa-media-kind { flex: none; width: auto; height: 28px; font-size: 12px; }
|
|
2165
|
+
.dcpa-media-card { display: flex; flex-direction: column; gap: 8px; margin-top: 16px; padding-top: 12px; border-top: 0.5px solid var(--dsw-alias-border-l2); }
|
|
2166
|
+
.dcpa-media-card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
|
2167
|
+
.dcpa-media-diag { display: flex; flex-direction: column; gap: 10px; }
|
|
2168
|
+
.dcpa-media-diag-row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; font-size: 12px; line-height: 18px; }
|
|
2169
|
+
.dcpa-media-diag-list { margin: 4px 0 0; padding-left: 16px; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); overflow-wrap: anywhere; }
|
|
2170
|
+
.dcpa-media-raw > summary { font-size: 12px; color: var(--dsw-alias-label-secondary); cursor: pointer; }
|
|
2171
|
+
.dcpa-media-raw { display: flex; flex-direction: column; gap: 6px; }
|
|
2172
|
+
/* Generation-model buttons and menus (composer / hero). */
|
|
2173
|
+
.dcpa-m-chip-group { display: inline-flex; align-items: center; gap: 2px; min-width: 0; }
|
|
2174
|
+
.dcpa-m-chip-button { min-width: 0; max-width: 190px; }
|
|
2175
|
+
.dcpa-m-chip-label { max-width: 150px; overflow: hidden; text-overflow: ellipsis; }
|
|
2176
|
+
.dcpa-m-chip-button[data-compact] { max-width: none; }
|
|
2177
|
+
.dcpa-m-chip-part { display: inline-flex; align-items: center; gap: 4px; min-width: 0; }
|
|
2178
|
+
.dcpa-m-chip-sep { flex: none; width: 1px; height: 14px; background: var(--dsw-alias-border-l1); }
|
|
2179
|
+
.dcpa-m-section + .dcpa-m-section { margin-top: 4px; padding-top: 4px; border-top: 0.5px solid var(--dsw-alias-border-l1); }
|
|
2180
|
+
.dcpa-m-section-head { display: flex; align-items: center; gap: 6px; padding: 6px 8px 2px; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); }
|
|
2181
|
+
.dcpa-m-chip svg { flex: none; }
|
|
2182
|
+
.dcpa-m-chip:disabled { cursor: default; opacity: 0.6; }
|
|
2183
|
+
.dcpa-m-group { padding: 8px 8px 2px; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); }
|
|
2184
|
+
.dcpa-m-option { display: flex; align-items: center; gap: 6px; width: 100%; }
|
|
2185
|
+
.dcpa-m-option[aria-checked="true"] { font-weight: 500; }
|
|
2186
|
+
.dcpa-m-check { width: 14px; flex: none; color: var(--dsw-alias-state-business-primary, var(--dsw-alias-brand-primary)); }
|
|
2187
|
+
.dcpa-m-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
|
2188
|
+
.dcpa-m-link { font-size: 12px; color: var(--dsw-alias-state-business-primary, var(--dsw-alias-brand-primary)); }
|
|
2189
|
+
.dcpa-m-foot { margin: 6px 4px 4px; padding-top: 6px; border-top: 0.5px solid var(--dsw-alias-border-l1); }
|
|
2190
|
+
.dcpa-m-agent-open { display: flex; align-items: center; justify-content: flex-start; gap: 6px; width: 100%; }
|
|
2191
|
+
.dcpa-m-agent-pane { display: flex; flex-direction: column; gap: 2px; padding: 0 8px 8px; }
|
|
2192
|
+
.dcpa-m-agent-head { display: flex; align-items: center; gap: 6px; }
|
|
2193
|
+
.dcpa-m-back { flex: none; }
|
|
2194
|
+
/* /cpa-image chat row: the request as a user bubble, the result as a reply. */
|
|
2195
|
+
.dcpa-chat { display: flex; flex-direction: column; gap: 12px; min-width: 0; padding: 4px 0; }
|
|
2196
|
+
.dcpa-chat-user { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
|
|
2197
|
+
.dcpa-chat-bubble {
|
|
2198
|
+
max-width: min(calc(var(--dsh-chat-content-width, 748px) * 0.702), 82%);
|
|
2199
|
+
padding: 10px 16px;
|
|
2200
|
+
border-radius: var(--dsw-radius-xl, 18px);
|
|
2201
|
+
background: var(--dsw-specific-bubble, var(--dsw-alias-bg-layer-2));
|
|
2202
|
+
color: var(--dsw-alias-label-primary);
|
|
2203
|
+
font-size: var(--dsh-content-font-size, 14px);
|
|
2204
|
+
line-height: calc(22px + var(--dsh-content-font-delta, 0px));
|
|
2205
|
+
white-space: pre-wrap;
|
|
2206
|
+
word-break: break-word;
|
|
2207
|
+
}
|
|
2208
|
+
.dcpa-chat-tags { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 4px 8px; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary)); }
|
|
2209
|
+
.dcpa-chat-tags button.dcpa-chat-copy { appearance: none; border: 0; background: none; padding: 0; margin: 0; font: inherit; color: inherit; cursor: pointer; text-decoration: underline dotted; text-underline-offset: 2px; }
|
|
2210
|
+
.dcpa-chat-tags button.dcpa-chat-copy:hover { color: var(--dsw-alias-label-secondary, var(--dsw-alias-label-primary)); }
|
|
2211
|
+
.dcpa-chat-tags button.dcpa-chat-copy:focus-visible { outline: 2px solid var(--dsw-alias-interactive-focus, var(--dsw-alias-label-secondary)); outline-offset: 2px; border-radius: 2px; }
|
|
2212
|
+
.dcpa-chat-reply { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; min-width: 0; }
|
|
2213
|
+
.dcpa-chat-head { display: flex; align-items: center; gap: 6px; min-width: 0; font-size: var(--dsh-content-font-size-secondary, 13px); line-height: 22px; color: var(--dsw-alias-label-secondary); }
|
|
2214
|
+
.dcpa-chat-head svg { flex: none; }
|
|
2215
|
+
.dcpa-chat-model { color: var(--dsw-alias-label-primary); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
2216
|
+
.dcpa-media-agent-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
|
2217
|
+
.dcpa-media-agent-row { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
|
|
2218
|
+
.dcpa-media-card[data-agent="off"] .dcpa-media-agent-row { opacity: 0.6; }
|
|
2219
|
+
.dcpa-m-policy-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 6px 0; font-size: 12px; color: var(--dsw-alias-label-secondary); }
|
|
2220
|
+
.dcpa-m-policy-row select { max-width: 60%; }
|
|
2221
|
+
.dcpa-confirm-dock { width: 100%; min-width: 0; color: var(--dsw-alias-label-primary); }
|
|
2222
|
+
.dcpa-chat-confirm { margin: 6px 0; padding: 10px 12px; border-radius: var(--dsw-radius-md, 8px); border: 1px solid var(--dsw-alias-border-default, var(--dsw-alias-label-tertiary)); background: var(--dsw-alias-bg-secondary, transparent); }
|
|
2223
|
+
.dcpa-chat-confirm-title { margin: 0 0 4px; font-weight: 600; color: var(--dsw-alias-label-primary); }
|
|
2224
|
+
.dcpa-chat-confirm-text { margin: 0 0 8px; color: var(--dsw-alias-label-secondary); overflow-wrap: anywhere; }
|
|
2225
|
+
.dcpa-chat-mode { margin: 0; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); overflow-wrap: anywhere; }
|
|
2226
|
+
.dcpa-chat-sep { flex: none; width: 2px; height: 2px; border-radius: 1px; background: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary)); }
|
|
2227
|
+
.dcpa-chat-status { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
2228
|
+
.dcpa-chat-status[data-tone="running"] {
|
|
2229
|
+
background: linear-gradient(90deg, var(--dsw-alias-label-tertiary, #888) 0%, var(--dsw-alias-label-primary, #444) 50%, var(--dsw-alias-label-tertiary, #888) 100%);
|
|
2230
|
+
background-size: 200% 100%;
|
|
2231
|
+
-webkit-background-clip: text;
|
|
2232
|
+
background-clip: text;
|
|
2233
|
+
color: transparent;
|
|
2234
|
+
animation: dcpa-shimmer 1.6s linear infinite;
|
|
2235
|
+
}
|
|
2236
|
+
.dcpa-chat-error { margin: 0; font-size: var(--dsh-content-font-size-secondary, 13px); line-height: 20px; color: var(--dsw-alias-state-error-primary); overflow-wrap: anywhere; }
|
|
2237
|
+
.dcpa-chat-note { margin: 0; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary)); }
|
|
2238
|
+
.dcpa-chat-grid { display: flex; flex-wrap: wrap; gap: 8px; max-width: 100%; }
|
|
2239
|
+
.dcpa-chat-thumb {
|
|
2240
|
+
padding: 0;
|
|
2241
|
+
border: 0.5px solid var(--dsw-alias-border-l1);
|
|
2242
|
+
border-radius: var(--dsw-radius-lg, 12px);
|
|
2243
|
+
overflow: hidden;
|
|
2244
|
+
background: var(--dsw-alias-bg-layer-2);
|
|
2245
|
+
cursor: zoom-in;
|
|
2246
|
+
max-width: 100%;
|
|
2247
|
+
}
|
|
2248
|
+
.dcpa-chat-thumb img { display: block; width: 200px; height: 200px; object-fit: cover; }
|
|
2249
|
+
.dcpa-chat-grid[data-count="1"] .dcpa-chat-thumb img { width: auto; height: auto; max-width: min(100%, 420px); max-height: 420px; object-fit: contain; }
|
|
2250
|
+
.dcpa-chat-skeleton {
|
|
2251
|
+
width: 200px;
|
|
2252
|
+
max-width: 100%;
|
|
2253
|
+
border-radius: var(--dsw-radius-lg, 12px);
|
|
2254
|
+
background: linear-gradient(90deg, var(--dsw-alias-bg-layer-2) 0%, var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-1)) 50%, var(--dsw-alias-bg-layer-2) 100%);
|
|
2255
|
+
background-size: 200% 100%;
|
|
2256
|
+
animation: dcpa-shimmer 1.6s linear infinite;
|
|
2257
|
+
}
|
|
2258
|
+
.dcpa-chat-grid[data-count="1"] .dcpa-chat-skeleton { width: 280px; }
|
|
2259
|
+
.dcpa-chat[data-kind="video"] .dcpa-chat-skeleton { width: 360px; }
|
|
2260
|
+
.dcpa-chat-videos { display: flex; flex-direction: column; gap: 8px; max-width: 100%; }
|
|
2261
|
+
.dcpa-chat-edit { display: flex; flex-direction: column; gap: 6px; width: min(100%, 560px); }
|
|
2262
|
+
.dcpa-chat-edit-input { width: 100%; box-sizing: border-box; resize: vertical; min-height: 56px; font: inherit; }
|
|
2263
|
+
.dcpa-chat-video { display: block; width: min(100%, 560px); max-height: 480px; border-radius: var(--dsw-radius-lg, 12px); background: var(--dsw-alias-bg-layer-2); }
|
|
2264
|
+
.dcpa-media-sublabel { font-size: 12px; color: var(--dsw-alias-label-secondary); }
|
|
2265
|
+
.dcpa-media-select { width: auto; min-width: 88px; }
|
|
2266
|
+
.dcpa-chat-thumb:focus-visible, .dcpa-chat-full-btn:focus-visible { outline: 2px solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary)); outline-offset: 1px; }
|
|
2267
|
+
.dcpa-chat-full { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; max-width: 100%; }
|
|
2268
|
+
.dcpa-chat-full-btn { padding: 0; border: 0; background: transparent; cursor: zoom-out; max-width: 100%; }
|
|
2269
|
+
.dcpa-chat-full-btn img { display: block; max-width: min(100%, 720px); max-height: 640px; border-radius: var(--dsw-radius-lg, 12px); }
|
|
2270
|
+
.dcpa-q-pop.dcpa-chat-menu { width: 240px; display: flex; flex-direction: column; }
|
|
2271
|
+
.dcpa-chat-menu .dcpa-menu-item { width: 100%; text-align: left; }
|
|
2272
|
+
.dcpa-chat-menu-item { display: flex; align-items: center; gap: 8px; }
|
|
2273
|
+
.dcpa-chat-menu-icon { width: 16px; height: 16px; flex: none; object-fit: contain; }
|
|
2274
|
+
.dcpa-chat-menu-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
2275
|
+
.dcpa-chat-menu-sep { height: 0.5px; margin: 4px 6px; background: var(--dsw-alias-border-l1); }
|
|
2276
|
+
.dcpa-chat-menu-hint { padding: 4px 8px; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary)); }
|
|
2277
|
+
.dcpa-chat-link { padding: 0; border: 0; background: transparent; font: inherit; text-align: left; color: var(--dsw-alias-state-business-primary, var(--dsw-alias-brand-primary)); cursor: pointer; overflow-wrap: anywhere; }
|
|
2278
|
+
.dcpa-chat-link:hover { text-decoration: underline; }
|
|
2279
|
+
.dcpa-chat-link:focus-visible { outline: 2px solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary)); outline-offset: 1px; }
|
|
2280
|
+
.dcpa-chat-files { display: flex; flex-direction: column; gap: 2px; white-space: normal; }
|
|
2281
|
+
.dcpa-chat-note[data-tone="error"] { color: var(--dsw-alias-state-error-primary); }
|
|
2282
|
+
.dcpa-chat-details > summary { font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary)); cursor: pointer; }
|
|
2283
|
+
.dcpa-chat-details dl { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 2px 10px; margin: 4px 0 0; font-size: 12px; line-height: 18px; }
|
|
2284
|
+
.dcpa-chat-details dt { color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary)); }
|
|
2285
|
+
.dcpa-chat-details dd { margin: 0; color: var(--dsw-alias-label-secondary); overflow-wrap: anywhere; white-space: pre-wrap; }
|
|
2286
|
+
@keyframes dcpa-shimmer { from { background-position: 100% 0; } to { background-position: -100% 0; } }
|
|
2287
|
+
@media (prefers-reduced-motion: reduce) {
|
|
2288
|
+
.dcpa-chat-status[data-tone="running"], .dcpa-chat-skeleton { animation: none; }
|
|
2289
|
+
}
|
|
2290
|
+
.dcpa-media-pre {
|
|
2291
|
+
margin: 6px 0;
|
|
2292
|
+
max-height: 280px;
|
|
2293
|
+
overflow: auto;
|
|
2294
|
+
padding: 8px;
|
|
2295
|
+
border-radius: var(--dsw-radius-sm, 8px);
|
|
2296
|
+
background: var(--dsw-alias-bg-layer-3, var(--dsw-alias-bg-base));
|
|
2297
|
+
color: var(--dsw-alias-label-primary);
|
|
2298
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
2299
|
+
font-size: 11px;
|
|
2300
|
+
line-height: 16px;
|
|
2301
|
+
white-space: pre-wrap;
|
|
2302
|
+
overflow-wrap: anywhere;
|
|
2303
|
+
}
|
|
1766
2304
|
/* ---- Composer badge: same metrics as the model selector trigger ---- */
|
|
1767
2305
|
.dcpa-q-wrap { display: inline-flex; align-items: center; flex: none; }
|
|
1768
2306
|
.dcpa-q-badge {
|
|
@@ -1895,9 +2433,10 @@ select.dcpa-q-input { appearance: auto; padding-right: 8px; }
|
|
|
1895
2433
|
headers: { "content-type": "application/json" },
|
|
1896
2434
|
body: JSON.stringify(body ?? {}),
|
|
1897
2435
|
});
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
2436
|
+
let payload = null;
|
|
2437
|
+
try { payload = await res.json(); } catch {}
|
|
2438
|
+
if (!res.ok || payload === null || typeof payload !== "object" || payload.ok === false) {
|
|
2439
|
+
throw new Error(payload?.error || `HTTP ${res.status}`);
|
|
1901
2440
|
}
|
|
1902
2441
|
return payload;
|
|
1903
2442
|
}
|
|
@@ -2943,6 +3482,11 @@ select.dcpa-q-input { appearance: auto; padding-right: 8px; }
|
|
|
2943
3482
|
setNotice({ tone: "ok", text: t("removedToast") });
|
|
2944
3483
|
});
|
|
2945
3484
|
},
|
|
3485
|
+
// Fresh account status from another request (a media reclassification
|
|
3486
|
+
// re-synced routes); drafts stay, so `rev` is not bumped.
|
|
3487
|
+
adopt: (payload) => {
|
|
3488
|
+
if (Array.isArray(payload?.accounts)) setData(payload);
|
|
3489
|
+
},
|
|
2946
3490
|
// Reload the drafts from the saved config.
|
|
2947
3491
|
discard: () => {
|
|
2948
3492
|
dirtyRef.current = false;
|
|
@@ -3234,11 +3778,16 @@ select.dcpa-q-input { appearance: auto; padding-right: 8px; }
|
|
|
3234
3778
|
const effectiveUrl = account.config?.effectiveBaseURL || "";
|
|
3235
3779
|
const enabled = account.config?.enabled !== false;
|
|
3236
3780
|
|
|
3781
|
+
// Chat models only: image / video models live on the Generation tab.
|
|
3237
3782
|
const allModels = useMemo(() => {
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
return
|
|
3783
|
+
const list =
|
|
3784
|
+
Array.isArray(st.allModels) && st.allModels.length > 0 ? st.allModels : Array.isArray(st.models) ? st.models : [];
|
|
3785
|
+
return list.filter((m) => (m.kind ?? "chat") === "chat");
|
|
3241
3786
|
}, [st.allModels, st.models]);
|
|
3787
|
+
const mediaCount = useMemo(
|
|
3788
|
+
() => (Array.isArray(st.allModels) ? st.allModels.filter((m) => m.kind === "image" || m.kind === "video").length : 0),
|
|
3789
|
+
[st.allModels]
|
|
3790
|
+
);
|
|
3242
3791
|
|
|
3243
3792
|
const families = useMemo(() => {
|
|
3244
3793
|
const counts = new Map();
|
|
@@ -3265,6 +3814,12 @@ select.dcpa-q-input { appearance: auto; padding-right: 8px; }
|
|
|
3265
3814
|
});
|
|
3266
3815
|
}, [allModels, search, tabFilter, familyFilter, selectedIds]);
|
|
3267
3816
|
|
|
3817
|
+
// A saved selection may still name a model that is now a media model; it
|
|
3818
|
+
// stays saved but is not counted here.
|
|
3819
|
+
const selectedChatCount = useMemo(
|
|
3820
|
+
() => allModels.reduce((n, m) => n + (selectedIds.has(m.id) ? 1 : 0), 0),
|
|
3821
|
+
[allModels, selectedIds]
|
|
3822
|
+
);
|
|
3268
3823
|
const currentIdsKey = useMemo(() => [...selectedIds].sort().join("\0"), [selectedIds]);
|
|
3269
3824
|
const currentOverridesKey = useMemo(() => overridesKey(modelOverrides), [modelOverrides]);
|
|
3270
3825
|
const draftConnection = {
|
|
@@ -3385,7 +3940,8 @@ select.dcpa-q-input { appearance: auto; padding-right: 8px; }
|
|
|
3385
3940
|
|
|
3386
3941
|
const tabs = [
|
|
3387
3942
|
["connection", t("tabConnection")],
|
|
3388
|
-
["models", `${t("tabModels")} ${
|
|
3943
|
+
["models", `${t("tabModels")} ${selectedChatCount}`],
|
|
3944
|
+
["media", mediaCount > 0 ? `${t("tabMedia")} ${mediaCount}` : t("tabMedia")],
|
|
3389
3945
|
["quota", quotaFloor === null ? t("tabQuota") : `${t("tabQuota")} ${pctText(quotaFloor)}`],
|
|
3390
3946
|
];
|
|
3391
3947
|
|
|
@@ -3570,7 +4126,7 @@ select.dcpa-q-input { appearance: auto; padding-right: 8px; }
|
|
|
3570
4126
|
"data-active": tabFilter === "selected" ? "true" : "false",
|
|
3571
4127
|
onClick: () => setTabFilter("selected"),
|
|
3572
4128
|
},
|
|
3573
|
-
`${t("filterSelected")} (${
|
|
4129
|
+
`${t("filterSelected")} (${selectedChatCount})`
|
|
3574
4130
|
),
|
|
3575
4131
|
h(
|
|
3576
4132
|
"button",
|
|
@@ -3580,7 +4136,7 @@ select.dcpa-q-input { appearance: auto; padding-right: 8px; }
|
|
|
3580
4136
|
"data-active": tabFilter === "unselected" ? "true" : "false",
|
|
3581
4137
|
onClick: () => setTabFilter("unselected"),
|
|
3582
4138
|
},
|
|
3583
|
-
`${t("filterUnselected")} (${Math.max(0, allModels.length -
|
|
4139
|
+
`${t("filterUnselected")} (${Math.max(0, allModels.length - selectedChatCount)})`
|
|
3584
4140
|
)
|
|
3585
4141
|
),
|
|
3586
4142
|
h(
|
|
@@ -4008,7 +4564,13 @@ select.dcpa-q-input { appearance: auto; padding-right: 8px; }
|
|
|
4008
4564
|
h(
|
|
4009
4565
|
"div",
|
|
4010
4566
|
{ id: `dcpa-tabpanel-${account.id}-${tab}`, role: "tabpanel", "aria-labelledby": `dcpa-tab-${account.id}-${tab}` },
|
|
4011
|
-
tab === "connection"
|
|
4567
|
+
tab === "connection"
|
|
4568
|
+
? connectionTab
|
|
4569
|
+
: tab === "models"
|
|
4570
|
+
? modelsTab
|
|
4571
|
+
: tab === "media"
|
|
4572
|
+
? h(MediaPanel, { t, account, onStatus: actions.adopt })
|
|
4573
|
+
: h(QuotaPanel, { t, store: sharedQuota, accountId: account.id })
|
|
4012
4574
|
),
|
|
4013
4575
|
dirty || notice
|
|
4014
4576
|
? h(
|
|
@@ -4051,375 +4613,984 @@ select.dcpa-q-input { appearance: auto; padding-right: 8px; }
|
|
|
4051
4613
|
);
|
|
4052
4614
|
}
|
|
4053
4615
|
|
|
4054
|
-
// #region
|
|
4616
|
+
// #region Media (生成): per-account media models and the read-only diagnostic
|
|
4055
4617
|
|
|
4056
|
-
const
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
openai: "OpenAI",
|
|
4064
|
-
devin: "Devin",
|
|
4065
|
-
antigravity: "Antigravity",
|
|
4066
|
-
"gemini-cli": "Gemini CLI",
|
|
4067
|
-
gemini: "Gemini",
|
|
4068
|
-
vertex: "Vertex",
|
|
4069
|
-
aistudio: "AI Studio",
|
|
4070
|
-
kimi: "Kimi",
|
|
4071
|
-
meta: "Meta",
|
|
4072
|
-
xai: "xAI / Grok",
|
|
4073
|
-
qwen: "Qwen",
|
|
4074
|
-
iflow: "iFlow",
|
|
4075
|
-
glm: "GLM",
|
|
4076
|
-
deepseek: "DeepSeek",
|
|
4077
|
-
copilot: "GitHub Copilot",
|
|
4078
|
-
};
|
|
4079
|
-
const WINDOW_KEYS = { "5h": "qWin5h", "7d": "qWin7d", "30d": "qWin30d", "1d": "qWin1d" };
|
|
4080
|
-
const QUOTA_ERROR_KEYS = {
|
|
4081
|
-
unauthorized: "qErrUnauthorized",
|
|
4082
|
-
forbidden: "qErrForbidden",
|
|
4083
|
-
network: "qErrNetwork",
|
|
4084
|
-
timeout: "qErrTimeout",
|
|
4085
|
-
http: "qErrHttp",
|
|
4086
|
-
"invalid-response": "qErrInvalid",
|
|
4087
|
-
"no-endpoint": "qErrNoEndpoint",
|
|
4088
|
-
};
|
|
4089
|
-
const STATUS_KEYS = {
|
|
4090
|
-
active: "qStatusActive",
|
|
4091
|
-
disabled: "qStatusDisabled",
|
|
4092
|
-
unavailable: "qStatusUnavailable",
|
|
4093
|
-
error: "qStatusError",
|
|
4618
|
+
const MEDIA_ERROR_KEYS = {
|
|
4619
|
+
"invalid-account": "mediaErrInvalidAccount",
|
|
4620
|
+
"invalid-request": "mediaErrInvalidRequest",
|
|
4621
|
+
"no-endpoint": "mediaErrNoEndpoint",
|
|
4622
|
+
"invalid-output-dir": "mediaErrInvalidOutputDir",
|
|
4623
|
+
"invalid-model": "mediaErrInvalidModel",
|
|
4624
|
+
"invalid-session": "mediaErrInvalidSession",
|
|
4094
4625
|
};
|
|
4095
|
-
const
|
|
4626
|
+
const MEDIA_KIND_KEYS = { image: "mediaKindImage", video: "mediaKindVideo", chat: "mediaKindChat" };
|
|
4096
4627
|
|
|
4097
|
-
function
|
|
4098
|
-
|
|
4099
|
-
|
|
4100
|
-
return out;
|
|
4628
|
+
function mediaErrorText(t, err) {
|
|
4629
|
+
const code = err instanceof Error ? err.message : String(err);
|
|
4630
|
+
return MEDIA_ERROR_KEYS[code] ? t(MEDIA_ERROR_KEYS[code]) : fmt(t, "mediaErrGeneric", { error: code });
|
|
4101
4631
|
}
|
|
4102
4632
|
|
|
4103
|
-
function
|
|
4104
|
-
|
|
4105
|
-
|
|
4633
|
+
function diagRouteText(t, route) {
|
|
4634
|
+
if (route.verdict === "present") return t("diagPresent");
|
|
4635
|
+
if (route.verdict === "missing") return t("diagMissing");
|
|
4636
|
+
if (route.verdict === "unreachable") return t("diagUnreachable");
|
|
4637
|
+
return fmt(t, "diagUnknown", { status: route.status ?? "?" });
|
|
4106
4638
|
}
|
|
4107
4639
|
|
|
4108
|
-
function
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
return fmt(t, key, { status: account.errorStatus ?? "?" });
|
|
4640
|
+
function diagCatalogText(t, catalog) {
|
|
4641
|
+
if (catalog?.ok) return fmt(t, "diagCatalogOk", { n: catalog.count ?? 0, m: catalog.media?.length ?? 0 });
|
|
4642
|
+
return fmt(t, "diagCatalogFail", { reason: catalog?.status ? `HTTP ${catalog.status}` : catalog?.error ?? "?" });
|
|
4112
4643
|
}
|
|
4113
4644
|
|
|
4114
|
-
function
|
|
4115
|
-
|
|
4116
|
-
|
|
4645
|
+
function diagFieldsText(fields) {
|
|
4646
|
+
return Object.entries(fields ?? {})
|
|
4647
|
+
.map(([key, value]) => `${key}: ${Array.isArray(value) ? `[${value.join(", ")}]` : String(value)}`)
|
|
4648
|
+
.join(" · ");
|
|
4117
4649
|
}
|
|
4118
4650
|
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
|
|
4126
|
-
|
|
4127
|
-
|
|
4651
|
+
/**
|
|
4652
|
+
* The Generation tab of one account. Hook order matters for the test
|
|
4653
|
+
* harness, which seeds slot 0 (data) and slot 1 (loading).
|
|
4654
|
+
*/
|
|
4655
|
+
/** Seconds limits offered for the agent's videos (plus the current value). */
|
|
4656
|
+
const MEDIA_BUDGET_CHOICES = [30, 60, 120, 300, 600, 1800, 3600];
|
|
4657
|
+
const budgetValue = (budget) => (budget?.mode === "limited" ? `limited:${budget.seconds}` : "unlimited");
|
|
4658
|
+
const budgetFromValue = (value) => (value === "unlimited" ? { mode: "unlimited" } : { mode: "limited", seconds: Number(String(value).split(":")[1]) });
|
|
4659
|
+
const budgetChoices = (current) => {
|
|
4660
|
+
const list = MEDIA_BUDGET_CHOICES.slice();
|
|
4661
|
+
if (current?.mode === "limited" && !list.includes(current.seconds)) list.push(current.seconds);
|
|
4662
|
+
return list.sort((a, b) => a - b);
|
|
4663
|
+
};
|
|
4128
4664
|
|
|
4129
|
-
function
|
|
4130
|
-
const
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
4665
|
+
function MediaPanel({ t, account, onStatus }) {
|
|
4666
|
+
const [data, setData] = useState(null);
|
|
4667
|
+
const [loading, setLoading] = useState(true);
|
|
4668
|
+
const [error, setError] = useState(null);
|
|
4669
|
+
const [busy, setBusy] = useState(null); // null | "save" | "diag"
|
|
4670
|
+
const [notice, setNotice] = useState(null);
|
|
4671
|
+
const [diag, setDiag] = useState(null);
|
|
4672
|
+
const [diagError, setDiagError] = useState(null);
|
|
4673
|
+
const [markId, setMarkId] = useState("");
|
|
4674
|
+
const [markKind, setMarkKind] = useState("image");
|
|
4675
|
+
const [copied, setCopied] = useState(false);
|
|
4676
|
+
// null = not edited; the saved value is shown.
|
|
4677
|
+
const [outputDraft, setOutputDraft] = useState(null);
|
|
4678
|
+
const mounted = useRef(true);
|
|
4134
4679
|
|
|
4135
|
-
|
|
4136
|
-
|
|
4137
|
-
|
|
4138
|
-
|
|
4139
|
-
|
|
4140
|
-
|
|
4141
|
-
if (s < 3600) return fmt(t, "qAgoMinutes", { n: Math.floor(s / 60) });
|
|
4142
|
-
if (s < 86400) return fmt(t, "qAgoHours", { n: Math.floor(s / 3600) });
|
|
4143
|
-
return fmt(t, "qAgoDays", { n: Math.floor(s / 86400) });
|
|
4144
|
-
}
|
|
4680
|
+
useEffect(() => {
|
|
4681
|
+
mounted.current = true;
|
|
4682
|
+
return () => {
|
|
4683
|
+
mounted.current = false;
|
|
4684
|
+
};
|
|
4685
|
+
}, []);
|
|
4145
4686
|
|
|
4146
|
-
|
|
4147
|
-
|
|
4148
|
-
|
|
4149
|
-
|
|
4150
|
-
|
|
4151
|
-
|
|
4152
|
-
|
|
4687
|
+
const load = useCallback(async (signal) => {
|
|
4688
|
+
try {
|
|
4689
|
+
const res = await fetch(API.mediaStatus, { cache: "no-store", signal });
|
|
4690
|
+
const body = await res.json();
|
|
4691
|
+
if (!res.ok || body.ok === false) throw new Error(body.error || `HTTP ${res.status}`);
|
|
4692
|
+
if (signal?.aborted) return;
|
|
4693
|
+
setData(body.media ?? null);
|
|
4694
|
+
setError(null);
|
|
4695
|
+
} catch (err) {
|
|
4696
|
+
if (signal?.aborted) return;
|
|
4697
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
4698
|
+
} finally {
|
|
4699
|
+
if (!signal?.aborted) setLoading(false);
|
|
4700
|
+
}
|
|
4701
|
+
}, []);
|
|
4153
4702
|
|
|
4154
|
-
|
|
4155
|
-
|
|
4156
|
-
|
|
4703
|
+
// Reload when the account's catalog changes (a probe or save elsewhere).
|
|
4704
|
+
useEffect(() => {
|
|
4705
|
+
const controller = new AbortController();
|
|
4706
|
+
void load(controller.signal);
|
|
4707
|
+
return () => controller.abort();
|
|
4708
|
+
}, [load, account.id, account.status?.refreshedAt, account.status?.totalDiscovered]);
|
|
4157
4709
|
|
|
4158
|
-
|
|
4159
|
-
|
|
4160
|
-
|
|
4161
|
-
|
|
4162
|
-
|
|
4710
|
+
useEffect(() => {
|
|
4711
|
+
if (notice?.tone !== "ok") return undefined;
|
|
4712
|
+
const id = setTimeout(() => {
|
|
4713
|
+
if (mounted.current) setNotice(null);
|
|
4714
|
+
}, 4000);
|
|
4715
|
+
return () => clearTimeout(id);
|
|
4716
|
+
}, [notice]);
|
|
4163
4717
|
|
|
4164
|
-
|
|
4165
|
-
|
|
4166
|
-
|
|
4167
|
-
|
|
4168
|
-
|
|
4169
|
-
|
|
4170
|
-
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
4174
|
-
|
|
4175
|
-
|
|
4176
|
-
|
|
4177
|
-
|
|
4178
|
-
|
|
4179
|
-
|
|
4718
|
+
useEffect(() => {
|
|
4719
|
+
if (!copied) return undefined;
|
|
4720
|
+
const id = setTimeout(() => {
|
|
4721
|
+
if (mounted.current) setCopied(false);
|
|
4722
|
+
}, 2000);
|
|
4723
|
+
return () => clearTimeout(id);
|
|
4724
|
+
}, [copied]);
|
|
4725
|
+
|
|
4726
|
+
const mine = (data?.accounts ?? []).find((a) => a.id === account.id);
|
|
4727
|
+
const models = Array.isArray(mine?.models) ? mine.models : [];
|
|
4728
|
+
const images = models.filter((m) => m.kind === "image");
|
|
4729
|
+
const videos = models.filter((m) => m.kind === "video");
|
|
4730
|
+
const movedToChat = Object.entries(mine?.kindOverrides ?? {})
|
|
4731
|
+
.filter(([, kind]) => kind === "chat")
|
|
4732
|
+
.map(([id]) => id);
|
|
4733
|
+
const markable = (mine?.chatModelIds ?? []).filter((id) => !movedToChat.includes(id));
|
|
4734
|
+
|
|
4735
|
+
const save = async (accountPatch) => {
|
|
4736
|
+
setBusy("save");
|
|
4737
|
+
setNotice(null);
|
|
4738
|
+
try {
|
|
4739
|
+
const body = await postJson(API.mediaSave, { account: { accountId: account.id, ...accountPatch } });
|
|
4740
|
+
if (!mounted.current) return;
|
|
4741
|
+
setData(body.media ?? null);
|
|
4742
|
+
if (body.status !== undefined) onStatus?.(body.status);
|
|
4743
|
+
setNotice({ tone: "ok", text: t(body.status !== undefined ? "mediaKindSaved" : "mediaSaved") });
|
|
4744
|
+
} catch (err) {
|
|
4745
|
+
if (mounted.current) setNotice({ tone: "error", text: mediaErrorText(t, err) });
|
|
4746
|
+
} finally {
|
|
4747
|
+
if (mounted.current) setBusy(null);
|
|
4748
|
+
}
|
|
4749
|
+
};
|
|
4180
4750
|
|
|
4181
|
-
|
|
4182
|
-
const
|
|
4183
|
-
|
|
4184
|
-
|
|
4751
|
+
// Global (all-account) preferences, each saved on its own.
|
|
4752
|
+
const saveGlobal = async (patch, after) => {
|
|
4753
|
+
setBusy("save");
|
|
4754
|
+
setNotice(null);
|
|
4755
|
+
try {
|
|
4756
|
+
const body = await postJson(API.mediaSave, patch);
|
|
4757
|
+
if (!mounted.current) return;
|
|
4758
|
+
setData(body.media ?? null);
|
|
4759
|
+
after?.();
|
|
4760
|
+
if ("enabled" in patch) announceMediaGeneration();
|
|
4761
|
+
setNotice({ tone: "ok", text: t("mediaSaved") });
|
|
4762
|
+
} catch (err) {
|
|
4763
|
+
if (mounted.current) setNotice({ tone: "error", text: mediaErrorText(t, err) });
|
|
4764
|
+
} finally {
|
|
4765
|
+
if (mounted.current) setBusy(null);
|
|
4766
|
+
}
|
|
4767
|
+
};
|
|
4768
|
+
const saveOutputDir = async () => {
|
|
4769
|
+
if (outputDraft === null) return;
|
|
4770
|
+
await saveGlobal({ outputDir: outputDraft.trim() }, () => setOutputDraft(null));
|
|
4771
|
+
};
|
|
4185
4772
|
|
|
4186
|
-
|
|
4187
|
-
|
|
4188
|
-
|
|
4189
|
-
|
|
4190
|
-
|
|
4191
|
-
return values.length > 0 ? Math.min(...values) : null;
|
|
4192
|
-
}
|
|
4773
|
+
const toggleEnabled = (model) => {
|
|
4774
|
+
const off = Array.isArray(mine?.disabledModels) ? mine.disabledModels : [];
|
|
4775
|
+
const next = model.enabled ? [...new Set([...off, model.id])] : off.filter((id) => id !== model.id);
|
|
4776
|
+
return save({ disabledModels: next });
|
|
4777
|
+
};
|
|
4193
4778
|
|
|
4194
|
-
|
|
4195
|
-
|
|
4196
|
-
return accounts.find((a) => Array.isArray(a.routes) && a.routes.includes(route)) ?? null;
|
|
4197
|
-
}
|
|
4779
|
+
const setKind = (model, kind) =>
|
|
4780
|
+
save({ kindOverrides: { [model.id]: kind === model.detectedKind ? null : kind } });
|
|
4198
4781
|
|
|
4199
|
-
|
|
4200
|
-
|
|
4201
|
-
|
|
4202
|
-
|
|
4203
|
-
|
|
4204
|
-
|
|
4205
|
-
|
|
4206
|
-
|
|
4782
|
+
const runDiagnostic = async () => {
|
|
4783
|
+
setBusy("diag");
|
|
4784
|
+
setDiagError(null);
|
|
4785
|
+
try {
|
|
4786
|
+
const body = await postJson(API.mediaDiagnose, { accountId: account.id });
|
|
4787
|
+
if (mounted.current) setDiag(body.diagnostic ?? null);
|
|
4788
|
+
} catch (err) {
|
|
4789
|
+
if (mounted.current) setDiagError(mediaErrorText(t, err));
|
|
4790
|
+
} finally {
|
|
4791
|
+
if (mounted.current) setBusy(null);
|
|
4792
|
+
}
|
|
4793
|
+
};
|
|
4207
4794
|
|
|
4208
|
-
|
|
4209
|
-
|
|
4210
|
-
|
|
4211
|
-
|
|
4212
|
-
|
|
4213
|
-
|
|
4214
|
-
const account = quotaAccountForRoute(data, route);
|
|
4215
|
-
if (account === null) return null;
|
|
4216
|
-
const credentials = Array.isArray(account.credentials) ? account.credentials : [];
|
|
4217
|
-
const byId = new Map(credentials.map((c) => [c.id, c]));
|
|
4218
|
-
const ids = Array.isArray(account.modelIndex?.[modelId]) ? account.modelIndex[modelId] : [];
|
|
4219
|
-
const entries = ids
|
|
4220
|
-
.map((id) => byId.get(id))
|
|
4221
|
-
.filter(Boolean)
|
|
4222
|
-
.map((cred) => ({ cred, headroom: credentialHeadroom(cred, modelId), windows: credentialWindows(cred, modelId) }))
|
|
4223
|
-
.sort(byHeadroomDesc);
|
|
4224
|
-
const usable = entries.filter((e) => e.cred.status === "active");
|
|
4225
|
-
const known = usable.map((e) => e.headroom).filter((v) => v !== null);
|
|
4226
|
-
return {
|
|
4227
|
-
account,
|
|
4228
|
-
entries,
|
|
4229
|
-
usableCount: usable.length,
|
|
4230
|
-
best: known.length > 0 ? Math.max(...known) : null,
|
|
4795
|
+
const copyDiagnostic = async () => {
|
|
4796
|
+
try {
|
|
4797
|
+
if (typeof navigator === "undefined" || typeof navigator.clipboard?.writeText !== "function") return;
|
|
4798
|
+
await navigator.clipboard.writeText(JSON.stringify(diag, null, 2));
|
|
4799
|
+
if (mounted.current) setCopied(true);
|
|
4800
|
+
} catch {}
|
|
4231
4801
|
};
|
|
4232
|
-
}
|
|
4233
4802
|
|
|
4234
|
-
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
else if (level === "ok" || level === "mid") out.healthy++;
|
|
4247
|
-
else out.unknown++;
|
|
4248
|
-
}
|
|
4249
|
-
return out;
|
|
4250
|
-
}
|
|
4803
|
+
const kindSelect = (model) =>
|
|
4804
|
+
h(
|
|
4805
|
+
"select",
|
|
4806
|
+
{
|
|
4807
|
+
className: "dcpa-q-input dcpa-media-kind",
|
|
4808
|
+
value: model.kind,
|
|
4809
|
+
"aria-label": fmt(t, "mediaKindFor", { model: model.id }),
|
|
4810
|
+
disabled: busy !== null,
|
|
4811
|
+
onChange: (e) => void setKind(model, e.target.value),
|
|
4812
|
+
},
|
|
4813
|
+
["image", "video", "chat"].map((kind) => h("option", { key: kind, value: kind }, t(MEDIA_KIND_KEYS[kind])))
|
|
4814
|
+
);
|
|
4251
4815
|
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
|
|
4256
|
-
|
|
4257
|
-
|
|
4258
|
-
|
|
4259
|
-
|
|
4260
|
-
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
|
|
4267
|
-
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
|
|
4271
|
-
|
|
4272
|
-
|
|
4273
|
-
|
|
4274
|
-
|
|
4275
|
-
|
|
4276
|
-
|
|
4277
|
-
|
|
4278
|
-
|
|
4279
|
-
|
|
4280
|
-
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
|
|
4286
|
-
|
|
4287
|
-
|
|
4288
|
-
|
|
4289
|
-
|
|
4290
|
-
|
|
4816
|
+
const modelRow = (model) =>
|
|
4817
|
+
h(
|
|
4818
|
+
"div",
|
|
4819
|
+
{ key: model.id, className: "dcpa-model-row dcpa-media-row" },
|
|
4820
|
+
h(
|
|
4821
|
+
"div",
|
|
4822
|
+
{ className: "dcpa-model-left" },
|
|
4823
|
+
h("span", { className: "dcpa-model-id" }, model.id),
|
|
4824
|
+
model.name && model.name !== model.id ? h("span", { className: "dcpa-model-name" }, model.name) : null,
|
|
4825
|
+
model.aliasOf
|
|
4826
|
+
? h(
|
|
4827
|
+
"span",
|
|
4828
|
+
{ className: "dcpa-model-tags" },
|
|
4829
|
+
h("span", { className: "dcpa-q-tag", "data-tone": "info" }, fmt(t, "mediaAliasTag", { target: model.aliasOf })),
|
|
4830
|
+
h(InfoTip, { t, text: fmt(t, "mediaAliasHint", { target: model.aliasOf }) })
|
|
4831
|
+
)
|
|
4832
|
+
: null,
|
|
4833
|
+
h(
|
|
4834
|
+
"span",
|
|
4835
|
+
{ className: "dcpa-q-tag", ...(model.overridden ? { "data-tone": "warning" } : {}) },
|
|
4836
|
+
t(model.overridden ? "mediaOverriddenTag" : "mediaDetectedTag")
|
|
4837
|
+
)
|
|
4838
|
+
),
|
|
4839
|
+
h(
|
|
4840
|
+
"div",
|
|
4841
|
+
{ className: "dcpa-q-actions" },
|
|
4842
|
+
kindSelect(model),
|
|
4843
|
+
model.overridden
|
|
4844
|
+
? h(
|
|
4845
|
+
"button",
|
|
4846
|
+
{ type: "button", className: "dcpa-q-btn", disabled: busy !== null, onClick: () => void save({ kindOverrides: { [model.id]: null } }) },
|
|
4847
|
+
t("mediaResetKind")
|
|
4848
|
+
)
|
|
4849
|
+
: null,
|
|
4850
|
+
h(
|
|
4851
|
+
"button",
|
|
4852
|
+
{
|
|
4853
|
+
type: "button",
|
|
4854
|
+
role: "switch",
|
|
4855
|
+
className: "dcpa-switch",
|
|
4856
|
+
"aria-checked": model.enabled === true,
|
|
4857
|
+
"aria-label": fmt(t, "mediaEnabledLabel", { model: model.id }),
|
|
4858
|
+
disabled: busy !== null,
|
|
4859
|
+
onClick: () => void toggleEnabled(model),
|
|
4860
|
+
},
|
|
4861
|
+
h("span", { className: "dcpa-switch-thumb" })
|
|
4862
|
+
)
|
|
4863
|
+
)
|
|
4864
|
+
);
|
|
4291
4865
|
|
|
4292
|
-
const
|
|
4293
|
-
|
|
4294
|
-
|
|
4295
|
-
|
|
4296
|
-
|
|
4297
|
-
|
|
4298
|
-
|
|
4299
|
-
|
|
4300
|
-
|
|
4301
|
-
};
|
|
4866
|
+
const group = (titleKey, list) =>
|
|
4867
|
+
list.length === 0
|
|
4868
|
+
? null
|
|
4869
|
+
: h(
|
|
4870
|
+
"div",
|
|
4871
|
+
{ className: "dcpa-media-block" },
|
|
4872
|
+
h("h4", { className: "dcpa-media-group" }, `${t(titleKey)} (${list.length})`),
|
|
4873
|
+
h("div", { className: "dcpa-model-list" }, list.map(modelRow))
|
|
4874
|
+
);
|
|
4302
4875
|
|
|
4303
|
-
const
|
|
4304
|
-
const mine = ++seq;
|
|
4305
|
-
emit({ loading: true });
|
|
4306
|
-
try {
|
|
4307
|
-
const body = await request(API.quotaStatus);
|
|
4308
|
-
if (mine === seq) emit({ data: body.quota ?? null, error: null, loading: false, loadedAt: Date.now() });
|
|
4309
|
-
} catch (error) {
|
|
4310
|
-
if (disposed) return;
|
|
4311
|
-
if (mine === seq) emit({ error: error instanceof Error ? error.message : String(error), loading: false });
|
|
4312
|
-
}
|
|
4313
|
-
};
|
|
4876
|
+
const catalogRead = (mine?.discovered ?? 0) > 0;
|
|
4314
4877
|
|
|
4315
|
-
const
|
|
4316
|
-
|
|
4317
|
-
|
|
4318
|
-
|
|
4319
|
-
|
|
4320
|
-
|
|
4321
|
-
|
|
4322
|
-
|
|
4323
|
-
|
|
4324
|
-
|
|
4325
|
-
|
|
4326
|
-
|
|
4327
|
-
|
|
4328
|
-
|
|
4329
|
-
|
|
4330
|
-
|
|
4331
|
-
|
|
4332
|
-
|
|
4333
|
-
|
|
4878
|
+
const diagnostic = h(
|
|
4879
|
+
"div",
|
|
4880
|
+
{ className: "dcpa-media-card" },
|
|
4881
|
+
h(
|
|
4882
|
+
"div",
|
|
4883
|
+
{ className: "dcpa-media-card-head" },
|
|
4884
|
+
h("span", { className: "dcpa-frow-label" }, t("diagTitle"), h(InfoTip, { t, text: t("diagHint") })),
|
|
4885
|
+
h(
|
|
4886
|
+
"button",
|
|
4887
|
+
{ type: "button", className: "dcpa-q-btn", "data-variant": "outline", disabled: busy !== null, onClick: () => void runDiagnostic() },
|
|
4888
|
+
busy === "diag" ? t("diagRunning") : t("diagRun")
|
|
4889
|
+
)
|
|
4890
|
+
),
|
|
4891
|
+
diagError ? h("p", { className: "dcpa-q-msg", "data-tone": "error", role: "alert" }, diagError) : null,
|
|
4892
|
+
diag
|
|
4893
|
+
? h(
|
|
4894
|
+
"div",
|
|
4895
|
+
{ className: "dcpa-media-diag" },
|
|
4896
|
+
diag.keyConfigured === false ? h("p", { className: "dcpa-q-sub" }, t("diagNoKey")) : null,
|
|
4897
|
+
[
|
|
4898
|
+
["diagEnriched", diag.catalogs?.enriched],
|
|
4899
|
+
["diagListing", diag.catalogs?.listing],
|
|
4900
|
+
].map(([labelKey, catalog]) =>
|
|
4901
|
+
h(
|
|
4902
|
+
"div",
|
|
4903
|
+
{ key: labelKey, className: "dcpa-media-diag-item" },
|
|
4904
|
+
h(
|
|
4905
|
+
"div",
|
|
4906
|
+
{ className: "dcpa-media-diag-row" },
|
|
4907
|
+
h("span", null, t(labelKey)),
|
|
4908
|
+
h("span", { className: "dcpa-q-sub" }, diagCatalogText(t, catalog))
|
|
4909
|
+
),
|
|
4910
|
+
Array.isArray(catalog?.media) && catalog.media.length > 0
|
|
4911
|
+
? h(
|
|
4912
|
+
"ul",
|
|
4913
|
+
{ className: "dcpa-media-diag-list" },
|
|
4914
|
+
catalog.media.map((entry) =>
|
|
4915
|
+
h(
|
|
4916
|
+
"li",
|
|
4917
|
+
{ key: entry.id },
|
|
4918
|
+
h("span", { className: "dcpa-model-id" }, entry.id),
|
|
4919
|
+
` · ${t(MEDIA_KIND_KEYS[entry.kind] ?? "mediaKindChat")}`,
|
|
4920
|
+
entry.fields && Object.keys(entry.fields).length > 0 ? ` · ${diagFieldsText(entry.fields)}` : ""
|
|
4921
|
+
)
|
|
4922
|
+
)
|
|
4923
|
+
)
|
|
4924
|
+
: null
|
|
4925
|
+
)
|
|
4926
|
+
),
|
|
4927
|
+
h(
|
|
4928
|
+
"div",
|
|
4929
|
+
{ className: "dcpa-media-diag-item" },
|
|
4930
|
+
h("div", { className: "dcpa-media-diag-row" }, h("span", null, t("diagRoutes"))),
|
|
4931
|
+
h(
|
|
4932
|
+
"ul",
|
|
4933
|
+
{ className: "dcpa-media-diag-list" },
|
|
4934
|
+
(diag.routes ?? []).map((route) =>
|
|
4935
|
+
h("li", { key: route.route }, h("span", { className: "dcpa-model-id" }, route.route), ` · ${diagRouteText(t, route)}`)
|
|
4936
|
+
)
|
|
4937
|
+
)
|
|
4938
|
+
),
|
|
4939
|
+
h(
|
|
4940
|
+
"details",
|
|
4941
|
+
{ className: "dcpa-media-raw" },
|
|
4942
|
+
h("summary", null, t("diagRaw")),
|
|
4943
|
+
h("pre", { className: "dcpa-media-pre" }, JSON.stringify(diag, null, 2)),
|
|
4944
|
+
h(
|
|
4945
|
+
"button",
|
|
4946
|
+
{ type: "button", className: "dcpa-q-btn", "data-variant": "outline", onClick: () => void copyDiagnostic() },
|
|
4947
|
+
copied ? t("diagCopied") : t("diagCopy")
|
|
4948
|
+
)
|
|
4949
|
+
)
|
|
4950
|
+
)
|
|
4951
|
+
: null
|
|
4952
|
+
);
|
|
4334
4953
|
|
|
4335
|
-
|
|
4336
|
-
|
|
4337
|
-
|
|
4338
|
-
|
|
4339
|
-
|
|
4340
|
-
|
|
4341
|
-
|
|
4342
|
-
|
|
4343
|
-
|
|
4344
|
-
|
|
4345
|
-
|
|
4346
|
-
|
|
4347
|
-
|
|
4348
|
-
|
|
4349
|
-
|
|
4350
|
-
|
|
4351
|
-
|
|
4352
|
-
|
|
4353
|
-
|
|
4354
|
-
|
|
4355
|
-
|
|
4356
|
-
|
|
4357
|
-
|
|
4358
|
-
|
|
4359
|
-
|
|
4360
|
-
|
|
4361
|
-
|
|
4362
|
-
|
|
4363
|
-
|
|
4364
|
-
|
|
4954
|
+
// The optional agent tool (generate_media): switch, confirmation and the video seconds limit.
|
|
4955
|
+
const agentOn = data?.settings?.agentTool === true;
|
|
4956
|
+
const confirmSelect = (field, labelKey) =>
|
|
4957
|
+
h(
|
|
4958
|
+
"label",
|
|
4959
|
+
{ className: "dcpa-inline dcpa-media-agent-row" },
|
|
4960
|
+
h("span", { className: "dcpa-media-sublabel" }, t(labelKey)),
|
|
4961
|
+
h(
|
|
4962
|
+
"select",
|
|
4963
|
+
{
|
|
4964
|
+
className: "dcpa-q-input dcpa-media-select",
|
|
4965
|
+
value: data?.settings?.[field] ?? (field === "videoConfirm" ? "ask" : "auto"),
|
|
4966
|
+
disabled: busy !== null || !agentOn,
|
|
4967
|
+
onChange: (e) => void saveGlobal({ [field]: e.target.value }),
|
|
4968
|
+
},
|
|
4969
|
+
h("option", { value: "ask" }, t("mediaConfirmAsk")),
|
|
4970
|
+
h("option", { value: "auto" }, t("mediaConfirmAuto"))
|
|
4971
|
+
)
|
|
4972
|
+
);
|
|
4973
|
+
const globalBudget = data?.settings?.videoBudget ?? { mode: "limited", seconds: 60 };
|
|
4974
|
+
const agentCard = h(
|
|
4975
|
+
"div",
|
|
4976
|
+
{ className: "dcpa-media-card", "data-agent": agentOn ? "on" : "off" },
|
|
4977
|
+
h(
|
|
4978
|
+
"div",
|
|
4979
|
+
{ className: "dcpa-media-agent-head" },
|
|
4980
|
+
h("span", { className: "dcpa-frow-label" }, t("mediaAgentTitle"), h(InfoTip, { t, text: t("mediaAgentHint") })),
|
|
4981
|
+
h(
|
|
4982
|
+
"button",
|
|
4983
|
+
{
|
|
4984
|
+
type: "button",
|
|
4985
|
+
role: "switch",
|
|
4986
|
+
className: "dcpa-switch",
|
|
4987
|
+
"aria-checked": agentOn,
|
|
4988
|
+
"aria-label": t("mediaAgentTitle"),
|
|
4989
|
+
disabled: busy !== null,
|
|
4990
|
+
onClick: () => void saveGlobal({ agentTool: !agentOn }),
|
|
4991
|
+
},
|
|
4992
|
+
h("span", { className: "dcpa-switch-thumb" })
|
|
4993
|
+
)
|
|
4994
|
+
),
|
|
4995
|
+
h("p", { className: "dcpa-q-sub" }, t(agentOn ? "mediaAgentOnNote" : "mediaAgentOffNote")),
|
|
4996
|
+
confirmSelect("videoConfirm", "mediaVideoConfirm"),
|
|
4997
|
+
confirmSelect("imageConfirm", "mediaImageConfirm"),
|
|
4998
|
+
h(
|
|
4999
|
+
"div",
|
|
5000
|
+
{ className: "dcpa-inline dcpa-media-agent-row" },
|
|
5001
|
+
h("span", { className: "dcpa-media-sublabel" }, t("mediaVideoBudget"), h(InfoTip, { t, text: t("mediaVideoBudgetHint") })),
|
|
5002
|
+
h(
|
|
5003
|
+
"select",
|
|
5004
|
+
{
|
|
5005
|
+
className: "dcpa-q-input dcpa-media-select",
|
|
5006
|
+
"aria-label": t("mediaVideoBudget"),
|
|
5007
|
+
value: budgetValue(globalBudget),
|
|
5008
|
+
disabled: busy !== null || !agentOn,
|
|
5009
|
+
onChange: (e) => void saveGlobal({ videoBudget: budgetFromValue(e.target.value) }),
|
|
5010
|
+
},
|
|
5011
|
+
budgetChoices(globalBudget).map((n) => h("option", { key: n, value: `limited:${n}` }, fmt(t, "mediaBudgetSeconds", { n }))),
|
|
5012
|
+
h("option", { value: "unlimited" }, t("mediaBudgetUnlimited"))
|
|
5013
|
+
)
|
|
5014
|
+
)
|
|
5015
|
+
);
|
|
4365
5016
|
|
|
4366
|
-
|
|
5017
|
+
if (loading && data === null) {
|
|
5018
|
+
return h("div", { className: "dcpa-tabbody dcpa-models dcpa-media" }, h("p", { className: "dcpa-q-sub" }, t("mediaLoading")));
|
|
5019
|
+
}
|
|
4367
5020
|
|
|
4368
|
-
|
|
4369
|
-
|
|
4370
|
-
const
|
|
4371
|
-
|
|
4372
|
-
|
|
4373
|
-
|
|
4374
|
-
|
|
4375
|
-
|
|
4376
|
-
|
|
4377
|
-
|
|
4378
|
-
|
|
4379
|
-
|
|
4380
|
-
|
|
4381
|
-
|
|
4382
|
-
|
|
4383
|
-
|
|
5021
|
+
// The master switch (all accounts): off hides every generation feature.
|
|
5022
|
+
const generationOn = data?.settings?.enabled === true;
|
|
5023
|
+
const masterCard = h(
|
|
5024
|
+
"div",
|
|
5025
|
+
{ className: "dcpa-media-card", "data-generation": generationOn ? "on" : "off" },
|
|
5026
|
+
h(
|
|
5027
|
+
"div",
|
|
5028
|
+
{ className: "dcpa-media-agent-head" },
|
|
5029
|
+
h("span", { className: "dcpa-frow-label" }, t("mediaMasterTitle"), h(InfoTip, { t, text: t("mediaMasterHint") })),
|
|
5030
|
+
h(
|
|
5031
|
+
"button",
|
|
5032
|
+
{
|
|
5033
|
+
type: "button",
|
|
5034
|
+
role: "switch",
|
|
5035
|
+
className: "dcpa-switch",
|
|
5036
|
+
"aria-checked": generationOn,
|
|
5037
|
+
"aria-label": t("mediaMasterTitle"),
|
|
5038
|
+
disabled: busy !== null || data === null,
|
|
5039
|
+
onClick: () => void saveGlobal({ enabled: !generationOn }),
|
|
5040
|
+
},
|
|
5041
|
+
h("span", { className: "dcpa-switch-thumb" })
|
|
5042
|
+
)
|
|
5043
|
+
),
|
|
5044
|
+
h("p", { className: "dcpa-q-sub" }, t(generationOn ? "mediaMasterOnNote" : "mediaMasterOffNote"))
|
|
5045
|
+
);
|
|
5046
|
+
const messages = [
|
|
5047
|
+
error ? h("p", { key: "err", className: "dcpa-q-msg", "data-tone": "error", role: "alert" }, fmt(t, "mediaLoadError", { error })) : null,
|
|
5048
|
+
data?.settingsError ? h("p", { key: "invalid", className: "dcpa-q-msg", "data-tone": "error", role: "alert" }, t("mediaSettingsInvalid")) : null,
|
|
5049
|
+
notice ? h("p", { key: "notice", className: "dcpa-q-msg", "data-tone": notice.tone, role: notice.tone === "error" ? "alert" : "status" }, notice.text) : null,
|
|
5050
|
+
];
|
|
4384
5051
|
|
|
4385
|
-
|
|
4386
|
-
|
|
4387
|
-
|
|
4388
|
-
* input state exists), but the New Session hero shows the model selector
|
|
4389
|
-
* earlier. The hero badge (conversation.hero.options) therefore renders
|
|
4390
|
-
* only while no composer badge is mounted for the same session, so the
|
|
4391
|
-
* user always sees exactly one badge. Plain in-memory counts; no DOM.
|
|
4392
|
-
*/
|
|
4393
|
-
function createBadgePresence() {
|
|
4394
|
-
const counts = new Map();
|
|
4395
|
-
const listeners = new Set();
|
|
4396
|
-
const notify = () => {
|
|
4397
|
-
for (const fn of [...listeners]) fn();
|
|
4398
|
-
};
|
|
4399
|
-
return {
|
|
4400
|
-
add(id) {
|
|
4401
|
-
counts.set(id, (counts.get(id) ?? 0) + 1);
|
|
4402
|
-
notify();
|
|
4403
|
-
let done = false;
|
|
4404
|
-
return () => {
|
|
4405
|
-
if (done) return;
|
|
4406
|
-
done = true;
|
|
4407
|
-
const n = (counts.get(id) ?? 1) - 1;
|
|
4408
|
-
if (n > 0) counts.set(id, n);
|
|
4409
|
-
else counts.delete(id);
|
|
4410
|
-
notify();
|
|
4411
|
-
};
|
|
4412
|
-
},
|
|
4413
|
-
has: (id) => (counts.get(id) ?? 0) > 0,
|
|
4414
|
-
subscribe(fn) {
|
|
4415
|
-
listeners.add(fn);
|
|
4416
|
-
return () => listeners.delete(fn);
|
|
4417
|
-
},
|
|
4418
|
-
};
|
|
4419
|
-
}
|
|
4420
|
-
const composerBadges = createBadgePresence();
|
|
5052
|
+
if (!generationOn) {
|
|
5053
|
+
return h("div", { className: "dcpa-tabbody dcpa-models dcpa-media" }, masterCard, ...messages);
|
|
5054
|
+
}
|
|
4421
5055
|
|
|
4422
|
-
|
|
5056
|
+
return h(
|
|
5057
|
+
"div",
|
|
5058
|
+
{ className: "dcpa-tabbody dcpa-models dcpa-media" },
|
|
5059
|
+
masterCard,
|
|
5060
|
+
h("p", { className: "dcpa-q-sub" }, t("mediaIntro")),
|
|
5061
|
+
...messages,
|
|
5062
|
+
mine && models.length === 0
|
|
5063
|
+
? h("p", { className: "dcpa-hint" }, t(catalogRead ? "mediaNone" : "mediaNoCatalog"))
|
|
5064
|
+
: null,
|
|
5065
|
+
group("mediaImageGroup", images),
|
|
5066
|
+
group("mediaVideoGroup", videos),
|
|
5067
|
+
movedToChat.length > 0
|
|
5068
|
+
? h(
|
|
5069
|
+
"div",
|
|
5070
|
+
{ className: "dcpa-media-block" },
|
|
5071
|
+
h("h4", { className: "dcpa-media-group" }, t("mediaMovedTitle"), h(InfoTip, { t, text: t("mediaMovedHint") })),
|
|
5072
|
+
h(
|
|
5073
|
+
"div",
|
|
5074
|
+
{ className: "dcpa-model-list" },
|
|
5075
|
+
movedToChat.map((id) =>
|
|
5076
|
+
h(
|
|
5077
|
+
"div",
|
|
5078
|
+
{ key: id, className: "dcpa-model-row dcpa-media-row" },
|
|
5079
|
+
h("div", { className: "dcpa-model-left" }, h("span", { className: "dcpa-model-id" }, id)),
|
|
5080
|
+
h(
|
|
5081
|
+
"button",
|
|
5082
|
+
{ type: "button", className: "dcpa-q-btn", disabled: busy !== null, onClick: () => void save({ kindOverrides: { [id]: null } }) },
|
|
5083
|
+
t("mediaResetKind")
|
|
5084
|
+
)
|
|
5085
|
+
)
|
|
5086
|
+
)
|
|
5087
|
+
)
|
|
5088
|
+
)
|
|
5089
|
+
: null,
|
|
5090
|
+
markable.length > 0
|
|
5091
|
+
? h(
|
|
5092
|
+
"div",
|
|
5093
|
+
{ className: "dcpa-media-card" },
|
|
5094
|
+
h("span", { className: "dcpa-frow-label" }, t("mediaMarkTitle"), h(InfoTip, { t, text: t("mediaMarkHint") })),
|
|
5095
|
+
h(
|
|
5096
|
+
"div",
|
|
5097
|
+
{ className: "dcpa-inline" },
|
|
5098
|
+
h(
|
|
5099
|
+
"select",
|
|
5100
|
+
{
|
|
5101
|
+
className: "dcpa-q-input",
|
|
5102
|
+
value: markId,
|
|
5103
|
+
"aria-label": t("mediaMarkSelect"),
|
|
5104
|
+
onChange: (e) => setMarkId(e.target.value),
|
|
5105
|
+
},
|
|
5106
|
+
h("option", { value: "" }, t("mediaMarkSelect")),
|
|
5107
|
+
markable.map((id) => h("option", { key: id, value: id }, id))
|
|
5108
|
+
),
|
|
5109
|
+
h(
|
|
5110
|
+
"select",
|
|
5111
|
+
{
|
|
5112
|
+
className: "dcpa-q-input dcpa-media-kind",
|
|
5113
|
+
value: markKind,
|
|
5114
|
+
"aria-label": t("mediaKindLabel"),
|
|
5115
|
+
onChange: (e) => setMarkKind(e.target.value),
|
|
5116
|
+
},
|
|
5117
|
+
["image", "video"].map((kind) => h("option", { key: kind, value: kind }, t(MEDIA_KIND_KEYS[kind])))
|
|
5118
|
+
),
|
|
5119
|
+
h(
|
|
5120
|
+
"button",
|
|
5121
|
+
{
|
|
5122
|
+
type: "button",
|
|
5123
|
+
className: "dcpa-q-btn",
|
|
5124
|
+
"data-variant": "outline",
|
|
5125
|
+
disabled: busy !== null || markId === "",
|
|
5126
|
+
onClick: () => {
|
|
5127
|
+
const id = markId;
|
|
5128
|
+
setMarkId("");
|
|
5129
|
+
void save({ kindOverrides: { [id]: markKind } });
|
|
5130
|
+
},
|
|
5131
|
+
},
|
|
5132
|
+
t("mediaMarkBtn")
|
|
5133
|
+
)
|
|
5134
|
+
)
|
|
5135
|
+
)
|
|
5136
|
+
: null,
|
|
5137
|
+
h(
|
|
5138
|
+
"div",
|
|
5139
|
+
{ className: "dcpa-media-card" },
|
|
5140
|
+
h("label", { className: "dcpa-frow-label", htmlFor: `dcpa-media-out-${account.id}` }, t("mediaOutputTitle"), h(InfoTip, { t, text: t("mediaOutputHint") })),
|
|
5141
|
+
h(
|
|
5142
|
+
"div",
|
|
5143
|
+
{ className: "dcpa-inline" },
|
|
5144
|
+
h("input", {
|
|
5145
|
+
id: `dcpa-media-out-${account.id}`,
|
|
5146
|
+
className: "dcpa-q-input",
|
|
5147
|
+
value: outputDraft ?? data?.settings?.outputDir ?? "",
|
|
5148
|
+
onChange: (e) => setOutputDraft(e.target.value),
|
|
5149
|
+
onKeyDown: (e) => {
|
|
5150
|
+
if (e.key === "Enter") void saveOutputDir();
|
|
5151
|
+
},
|
|
5152
|
+
}),
|
|
5153
|
+
h(
|
|
5154
|
+
"button",
|
|
5155
|
+
{
|
|
5156
|
+
type: "button",
|
|
5157
|
+
className: "dcpa-q-btn",
|
|
5158
|
+
"data-variant": "outline",
|
|
5159
|
+
disabled: busy !== null || outputDraft === null || outputDraft.trim() === (data?.settings?.outputDir ?? ""),
|
|
5160
|
+
onClick: () => void saveOutputDir(),
|
|
5161
|
+
},
|
|
5162
|
+
t("mediaOutputSave")
|
|
5163
|
+
)
|
|
5164
|
+
)
|
|
5165
|
+
),
|
|
5166
|
+
videos.length > 0
|
|
5167
|
+
? h(
|
|
5168
|
+
"div",
|
|
5169
|
+
{ className: "dcpa-media-card" },
|
|
5170
|
+
h("div", { className: "dcpa-frow-label" }, t("mediaVideoDefaults"), h(InfoTip, { t, text: t("mediaVideoDefaultsHint") })),
|
|
5171
|
+
h(
|
|
5172
|
+
"div",
|
|
5173
|
+
{ className: "dcpa-inline" },
|
|
5174
|
+
h("label", { className: "dcpa-media-sublabel", htmlFor: `dcpa-media-vs-${account.id}` }, t("mediaVideoSeconds")),
|
|
5175
|
+
h(
|
|
5176
|
+
"select",
|
|
5177
|
+
{
|
|
5178
|
+
id: `dcpa-media-vs-${account.id}`,
|
|
5179
|
+
className: "dcpa-q-input dcpa-media-select",
|
|
5180
|
+
value: String(data?.settings?.defaultVideoSeconds ?? 6),
|
|
5181
|
+
disabled: busy !== null,
|
|
5182
|
+
onChange: (e) => void saveGlobal({ defaultVideoSeconds: Number(e.target.value) }),
|
|
5183
|
+
},
|
|
5184
|
+
Array.from({ length: 15 }, (_, i) => h("option", { key: i + 1, value: String(i + 1) }, fmt(t, "mediaSecondsOption", { n: i + 1 })))
|
|
5185
|
+
),
|
|
5186
|
+
h("label", { className: "dcpa-media-sublabel", htmlFor: `dcpa-media-vr-${account.id}` }, t("mediaVideoResolution")),
|
|
5187
|
+
h(
|
|
5188
|
+
"select",
|
|
5189
|
+
{
|
|
5190
|
+
id: `dcpa-media-vr-${account.id}`,
|
|
5191
|
+
className: "dcpa-q-input dcpa-media-select",
|
|
5192
|
+
value: data?.settings?.defaultVideoResolution ?? "720p",
|
|
5193
|
+
disabled: busy !== null,
|
|
5194
|
+
onChange: (e) => void saveGlobal({ defaultVideoResolution: e.target.value }),
|
|
5195
|
+
},
|
|
5196
|
+
["480p", "720p"].map((r) => h("option", { key: r, value: r }, r))
|
|
5197
|
+
)
|
|
5198
|
+
)
|
|
5199
|
+
)
|
|
5200
|
+
: null,
|
|
5201
|
+
images.length + videos.length > 0 ? agentCard : null,
|
|
5202
|
+
diagnostic
|
|
5203
|
+
);
|
|
5204
|
+
}
|
|
5205
|
+
|
|
5206
|
+
// #endregion
|
|
5207
|
+
|
|
5208
|
+
// #region Quota (额度): shared store, model matching, tab page, composer badge
|
|
5209
|
+
|
|
5210
|
+
const QUOTA_POLL_MS = 60 * 1000;
|
|
5211
|
+
// While the host is still collecting a refresh, look again soon.
|
|
5212
|
+
const QUOTA_BUSY_POLL_MS = 4 * 1000;
|
|
5213
|
+
const PROVIDER_NAMES = {
|
|
5214
|
+
claude: "Claude",
|
|
5215
|
+
anthropic: "Claude",
|
|
5216
|
+
codex: "Codex",
|
|
5217
|
+
openai: "OpenAI",
|
|
5218
|
+
devin: "Devin",
|
|
5219
|
+
antigravity: "Antigravity",
|
|
5220
|
+
"gemini-cli": "Gemini CLI",
|
|
5221
|
+
gemini: "Gemini",
|
|
5222
|
+
vertex: "Vertex",
|
|
5223
|
+
aistudio: "AI Studio",
|
|
5224
|
+
kimi: "Kimi",
|
|
5225
|
+
meta: "Meta",
|
|
5226
|
+
xai: "xAI / Grok",
|
|
5227
|
+
qwen: "Qwen",
|
|
5228
|
+
iflow: "iFlow",
|
|
5229
|
+
glm: "GLM",
|
|
5230
|
+
deepseek: "DeepSeek",
|
|
5231
|
+
copilot: "GitHub Copilot",
|
|
5232
|
+
};
|
|
5233
|
+
const WINDOW_KEYS = { "5h": "qWin5h", "7d": "qWin7d", "30d": "qWin30d", "1d": "qWin1d" };
|
|
5234
|
+
const QUOTA_ERROR_KEYS = {
|
|
5235
|
+
unauthorized: "qErrUnauthorized",
|
|
5236
|
+
forbidden: "qErrForbidden",
|
|
5237
|
+
network: "qErrNetwork",
|
|
5238
|
+
timeout: "qErrTimeout",
|
|
5239
|
+
http: "qErrHttp",
|
|
5240
|
+
"invalid-response": "qErrInvalid",
|
|
5241
|
+
"no-endpoint": "qErrNoEndpoint",
|
|
5242
|
+
};
|
|
5243
|
+
const STATUS_KEYS = {
|
|
5244
|
+
active: "qStatusActive",
|
|
5245
|
+
disabled: "qStatusDisabled",
|
|
5246
|
+
unavailable: "qStatusUnavailable",
|
|
5247
|
+
error: "qStatusError",
|
|
5248
|
+
};
|
|
5249
|
+
const MATCH_KEYS = { "model-list": "qMatchModelList", prefix: "qMatchPrefix", family: "qMatchFamily" };
|
|
5250
|
+
|
|
5251
|
+
function fmt(t, key, vars = {}) {
|
|
5252
|
+
let out = String(t(key));
|
|
5253
|
+
for (const [name, value] of Object.entries(vars)) out = out.split(`{${name}}`).join(String(value));
|
|
5254
|
+
return out;
|
|
5255
|
+
}
|
|
5256
|
+
|
|
5257
|
+
function providerName(t, provider) {
|
|
5258
|
+
const key = String(provider ?? "").toLowerCase();
|
|
5259
|
+
return PROVIDER_NAMES[key] ?? (key === "" ? t("qProviderOther") : key);
|
|
5260
|
+
}
|
|
5261
|
+
|
|
5262
|
+
function quotaErrorText(t, account) {
|
|
5263
|
+
const key = QUOTA_ERROR_KEYS[account?.errorCode];
|
|
5264
|
+
if (key === undefined) return null;
|
|
5265
|
+
const text = fmt(t, key, { status: account.errorStatus ?? "?" });
|
|
5266
|
+
// Consecutive management-key rejections: the count, or that background checks stopped.
|
|
5267
|
+
const n = account.authFailures;
|
|
5268
|
+
if ((account.errorCode !== "unauthorized" && account.errorCode !== "forbidden") || !Number.isInteger(n) || n <= 0) return text;
|
|
5269
|
+
const max = account.authFailureLimit ?? 3;
|
|
5270
|
+
return `${text} ${fmt(t, account.authPaused ? "qAuthPaused" : "qAuthRetrying", { n, max })}`;
|
|
5271
|
+
}
|
|
5272
|
+
|
|
5273
|
+
function windowLabel(t, w) {
|
|
5274
|
+
const base = WINDOW_KEYS[w.kind] !== undefined ? t(WINDOW_KEYS[w.kind]) : w.label || t("qWinOther");
|
|
5275
|
+
return w.group ? `${w.group} · ${base}` : base;
|
|
5276
|
+
}
|
|
5277
|
+
|
|
5278
|
+
function durationText(t, ms) {
|
|
5279
|
+
const total = Math.max(1, Math.ceil(ms / 60000));
|
|
5280
|
+
const d = Math.floor(total / 1440);
|
|
5281
|
+
const hrs = Math.floor((total % 1440) / 60);
|
|
5282
|
+
const m = total % 60;
|
|
5283
|
+
if (d > 0) return fmt(t, "qDurDH", { d, h: hrs });
|
|
5284
|
+
if (hrs > 0) return fmt(t, "qDurHM", { h: hrs, m });
|
|
5285
|
+
return fmt(t, "qDurM", { m });
|
|
5286
|
+
}
|
|
5287
|
+
|
|
5288
|
+
function resetText(t, iso, now) {
|
|
5289
|
+
const at = Date.parse(iso ?? "");
|
|
5290
|
+
if (!Number.isFinite(at)) return "";
|
|
5291
|
+
return at <= now ? t("qResetSoon") : fmt(t, "qResetIn", { time: durationText(t, at - now) });
|
|
5292
|
+
}
|
|
5293
|
+
|
|
5294
|
+
function agoText(t, iso, now) {
|
|
5295
|
+
const at = Date.parse(iso ?? "");
|
|
5296
|
+
if (!Number.isFinite(at)) return "";
|
|
5297
|
+
const s = Math.max(0, Math.floor((now - at) / 1000));
|
|
5298
|
+
if (s < 10) return t("qAgoJustNow");
|
|
5299
|
+
if (s < 60) return fmt(t, "qAgoSeconds", { n: s });
|
|
5300
|
+
if (s < 3600) return fmt(t, "qAgoMinutes", { n: Math.floor(s / 60) });
|
|
5301
|
+
if (s < 86400) return fmt(t, "qAgoHours", { n: Math.floor(s / 3600) });
|
|
5302
|
+
return fmt(t, "qAgoDays", { n: Math.floor(s / 86400) });
|
|
5303
|
+
}
|
|
5304
|
+
|
|
5305
|
+
function quotaLevel(pct) {
|
|
5306
|
+
if (typeof pct !== "number" || !Number.isFinite(pct)) return "unknown";
|
|
5307
|
+
if (pct <= 0) return "empty";
|
|
5308
|
+
if (pct < 20) return "low";
|
|
5309
|
+
if (pct < 50) return "mid";
|
|
5310
|
+
return "ok";
|
|
5311
|
+
}
|
|
5312
|
+
|
|
5313
|
+
function pctText(pct) {
|
|
5314
|
+
return typeof pct === "number" && Number.isFinite(pct) ? `${Math.round(pct)}%` : "—";
|
|
5315
|
+
}
|
|
5316
|
+
|
|
5317
|
+
function bareModel(id) {
|
|
5318
|
+
const lower = String(id ?? "").toLowerCase();
|
|
5319
|
+
const slash = lower.indexOf("/");
|
|
5320
|
+
return slash >= 0 ? lower.slice(slash + 1) : lower;
|
|
5321
|
+
}
|
|
5322
|
+
|
|
5323
|
+
/** Whether a quota window applies to `modelId` (untagged windows apply to every model). */
|
|
5324
|
+
function windowAppliesTo(w, modelId) {
|
|
5325
|
+
if (!modelId) return true;
|
|
5326
|
+
const full = String(modelId).toLowerCase();
|
|
5327
|
+
const bare = bareModel(modelId);
|
|
5328
|
+
if (Array.isArray(w.modelIds) && w.modelIds.length > 0) {
|
|
5329
|
+
return w.modelIds.some((m) => {
|
|
5330
|
+
const x = String(m).toLowerCase();
|
|
5331
|
+
return x === full || x === bare || bareModel(x) === bare;
|
|
5332
|
+
});
|
|
5333
|
+
}
|
|
5334
|
+
if (Array.isArray(w.modelPrefixes) && w.modelPrefixes.length > 0) {
|
|
5335
|
+
return w.modelPrefixes.some((p) => bare.startsWith(String(p).toLowerCase()));
|
|
5336
|
+
}
|
|
5337
|
+
return true;
|
|
5338
|
+
}
|
|
5339
|
+
|
|
5340
|
+
function credentialWindows(cred, modelId) {
|
|
5341
|
+
const windows = Array.isArray(cred?.windows) ? cred.windows : [];
|
|
5342
|
+
return modelId ? windows.filter((w) => windowAppliesTo(w, modelId)) : windows;
|
|
5343
|
+
}
|
|
5344
|
+
|
|
5345
|
+
/** The tightest remaining percentage of the windows that apply, or null. */
|
|
5346
|
+
function credentialHeadroom(cred, modelId) {
|
|
5347
|
+
const values = credentialWindows(cred, modelId)
|
|
5348
|
+
.map((w) => w.remainingPercent)
|
|
5349
|
+
.filter((v) => typeof v === "number" && Number.isFinite(v));
|
|
5350
|
+
return values.length > 0 ? Math.min(...values) : null;
|
|
5351
|
+
}
|
|
5352
|
+
|
|
5353
|
+
function quotaAccountForRoute(data, route) {
|
|
5354
|
+
const accounts = Array.isArray(data?.accounts) ? data.accounts : [];
|
|
5355
|
+
return accounts.find((a) => Array.isArray(a.routes) && a.routes.includes(route)) ?? null;
|
|
5356
|
+
}
|
|
5357
|
+
|
|
5358
|
+
function byHeadroomDesc(a, b) {
|
|
5359
|
+
const ua = a.cred.status === "active" ? 0 : 1;
|
|
5360
|
+
const ub = b.cred.status === "active" ? 0 : 1;
|
|
5361
|
+
if (ua !== ub) return ua - ub;
|
|
5362
|
+
const ha = a.headroom ?? -1;
|
|
5363
|
+
const hb = b.headroom ?? -1;
|
|
5364
|
+
return hb - ha;
|
|
5365
|
+
}
|
|
5366
|
+
|
|
5367
|
+
/**
|
|
5368
|
+
* Quota for one picker selection: the account owning the route, then
|
|
5369
|
+
* exactly the credentials the host matched for this model id on that
|
|
5370
|
+
* server (modelIndex), never credentials of another account.
|
|
5371
|
+
*/
|
|
5372
|
+
function summarizeModelQuota(data, route, modelId) {
|
|
5373
|
+
const account = quotaAccountForRoute(data, route);
|
|
5374
|
+
if (account === null) return null;
|
|
5375
|
+
const credentials = Array.isArray(account.credentials) ? account.credentials : [];
|
|
5376
|
+
const byId = new Map(credentials.map((c) => [c.id, c]));
|
|
5377
|
+
const ids = Array.isArray(account.modelIndex?.[modelId]) ? account.modelIndex[modelId] : [];
|
|
5378
|
+
const entries = ids
|
|
5379
|
+
.map((id) => byId.get(id))
|
|
5380
|
+
.filter(Boolean)
|
|
5381
|
+
.map((cred) => ({ cred, headroom: credentialHeadroom(cred, modelId), windows: credentialWindows(cred, modelId) }))
|
|
5382
|
+
.sort(byHeadroomDesc);
|
|
5383
|
+
const usable = entries.filter((e) => e.cred.status === "active");
|
|
5384
|
+
const known = usable.map((e) => e.headroom).filter((v) => v !== null);
|
|
5385
|
+
return {
|
|
5386
|
+
account,
|
|
5387
|
+
entries,
|
|
5388
|
+
usableCount: usable.length,
|
|
5389
|
+
best: known.length > 0 ? Math.max(...known) : null,
|
|
5390
|
+
};
|
|
5391
|
+
}
|
|
5392
|
+
|
|
5393
|
+
/** Totals for the overview tiles. */
|
|
5394
|
+
function quotaStats(credentials) {
|
|
5395
|
+
const out = { total: 0, healthy: 0, low: 0, empty: 0, issue: 0, unknown: 0 };
|
|
5396
|
+
for (const cred of credentials) {
|
|
5397
|
+
out.total++;
|
|
5398
|
+
if (cred.status !== "active" || cred.quotaState === "error") {
|
|
5399
|
+
out.issue++;
|
|
5400
|
+
continue;
|
|
5401
|
+
}
|
|
5402
|
+
const level = quotaLevel(credentialHeadroom(cred));
|
|
5403
|
+
if (level === "empty") out.empty++;
|
|
5404
|
+
else if (level === "low") out.low++;
|
|
5405
|
+
else if (level === "ok" || level === "mid") out.healthy++;
|
|
5406
|
+
else out.unknown++;
|
|
5407
|
+
}
|
|
5408
|
+
return out;
|
|
5409
|
+
}
|
|
5410
|
+
|
|
5411
|
+
/**
|
|
5412
|
+
* One page-wide quota client: GET the host snapshot (cached by the host,
|
|
5413
|
+
* which refreshes stale accounts in the background) while anything is
|
|
5414
|
+
* subscribed; POST refresh / key changes. Disposed with the activation.
|
|
5415
|
+
*/
|
|
5416
|
+
function createQuotaStore(io = {}) {
|
|
5417
|
+
const fetchImpl = io.fetch ?? ((...args) => fetch(...args));
|
|
5418
|
+
const setT = io.setTimeout ?? ((fn, ms) => setTimeout(fn, ms));
|
|
5419
|
+
const clearT = io.clearTimeout ?? ((id) => clearTimeout(id));
|
|
5420
|
+
const listeners = new Set();
|
|
5421
|
+
let state = { data: null, loading: false, error: null, busy: null, busyAccount: null, loadedAt: 0 };
|
|
5422
|
+
let timer = null;
|
|
5423
|
+
let disposed = false;
|
|
5424
|
+
let seq = 0;
|
|
5425
|
+
const inflight = new Set();
|
|
5426
|
+
|
|
5427
|
+
const emit = (patch) => {
|
|
5428
|
+
if (disposed) return;
|
|
5429
|
+
state = { ...state, ...patch };
|
|
5430
|
+
for (const fn of [...listeners]) fn();
|
|
5431
|
+
};
|
|
5432
|
+
|
|
5433
|
+
const request = async (url, init) => {
|
|
5434
|
+
const controller = new AbortController();
|
|
5435
|
+
inflight.add(controller);
|
|
5436
|
+
try {
|
|
5437
|
+
const res = await fetchImpl(url, { cache: "no-store", ...init, signal: controller.signal });
|
|
5438
|
+
let body = null;
|
|
5439
|
+
try {
|
|
5440
|
+
body = await res.json();
|
|
5441
|
+
} catch {}
|
|
5442
|
+
if (!res.ok || body === null || body.ok === false) {
|
|
5443
|
+
throw new Error(body?.error || `HTTP ${res.status}`);
|
|
5444
|
+
}
|
|
5445
|
+
return body;
|
|
5446
|
+
} finally {
|
|
5447
|
+
inflight.delete(controller);
|
|
5448
|
+
}
|
|
5449
|
+
};
|
|
5450
|
+
|
|
5451
|
+
const schedule = () => {
|
|
5452
|
+
if (timer !== null) clearT(timer);
|
|
5453
|
+
timer = null;
|
|
5454
|
+
if (disposed || listeners.size === 0) return;
|
|
5455
|
+
const delay = state.data?.refreshing ? QUOTA_BUSY_POLL_MS : QUOTA_POLL_MS;
|
|
5456
|
+
timer = setT(() => {
|
|
5457
|
+
timer = null;
|
|
5458
|
+
void load().finally(schedule);
|
|
5459
|
+
}, delay);
|
|
5460
|
+
};
|
|
5461
|
+
|
|
5462
|
+
const load = async () => {
|
|
5463
|
+
const mine = ++seq;
|
|
5464
|
+
emit({ loading: true });
|
|
5465
|
+
try {
|
|
5466
|
+
const body = await request(API.quotaStatus);
|
|
5467
|
+
if (mine === seq) emit({ data: body.quota ?? null, error: null, loading: false, loadedAt: Date.now() });
|
|
5468
|
+
} catch (error) {
|
|
5469
|
+
if (disposed) return;
|
|
5470
|
+
if (mine === seq) emit({ error: error instanceof Error ? error.message : String(error), loading: false });
|
|
5471
|
+
}
|
|
5472
|
+
};
|
|
5473
|
+
|
|
5474
|
+
const mutate = async (kind, url, payload) => {
|
|
5475
|
+
const mine = ++seq;
|
|
5476
|
+
emit({ busy: kind, busyAccount: payload?.accountId ?? null, error: null });
|
|
5477
|
+
try {
|
|
5478
|
+
const body = await request(url, {
|
|
5479
|
+
method: "POST",
|
|
5480
|
+
headers: { "content-type": "application/json" },
|
|
5481
|
+
body: JSON.stringify(payload ?? {}),
|
|
5482
|
+
});
|
|
5483
|
+
if (mine === seq) emit({ data: body.quota ?? state.data, loadedAt: Date.now() });
|
|
5484
|
+
emit({ busy: null, busyAccount: null });
|
|
5485
|
+
schedule();
|
|
5486
|
+
return { ok: true };
|
|
5487
|
+
} catch (error) {
|
|
5488
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
5489
|
+
emit({ busy: null, busyAccount: null, ...(mine === seq ? { error: message } : {}) });
|
|
5490
|
+
return { ok: false, error: message };
|
|
5491
|
+
}
|
|
5492
|
+
};
|
|
5493
|
+
|
|
5494
|
+
return {
|
|
5495
|
+
getSnapshot: () => state,
|
|
5496
|
+
subscribe(fn) {
|
|
5497
|
+
listeners.add(fn);
|
|
5498
|
+
if (listeners.size === 1) {
|
|
5499
|
+
if (state.data === null || Date.now() - state.loadedAt > QUOTA_POLL_MS) void load().finally(schedule);
|
|
5500
|
+
else schedule();
|
|
5501
|
+
}
|
|
5502
|
+
return () => {
|
|
5503
|
+
listeners.delete(fn);
|
|
5504
|
+
if (listeners.size === 0 && timer !== null) {
|
|
5505
|
+
clearT(timer);
|
|
5506
|
+
timer = null;
|
|
5507
|
+
}
|
|
5508
|
+
};
|
|
5509
|
+
},
|
|
5510
|
+
load,
|
|
5511
|
+
refresh: (accountId) => mutate("refresh", API.quotaRefresh, accountId ? { accountId } : {}),
|
|
5512
|
+
saveKey: (accountId, managementKey) => mutate("save", API.quotaSave, { accountId, managementKey }),
|
|
5513
|
+
clearKey: (accountId) => mutate("clear", API.quotaSave, { accountId, clear: true }),
|
|
5514
|
+
dispose() {
|
|
5515
|
+
disposed = true;
|
|
5516
|
+
if (timer !== null) clearT(timer);
|
|
5517
|
+
timer = null;
|
|
5518
|
+
for (const c of inflight) c.abort();
|
|
5519
|
+
inflight.clear();
|
|
5520
|
+
listeners.clear();
|
|
5521
|
+
},
|
|
5522
|
+
};
|
|
5523
|
+
}
|
|
5524
|
+
|
|
5525
|
+
let sharedQuota = null;
|
|
5526
|
+
|
|
5527
|
+
/** Subscribe a component to the shared quota store (null when inactive). */
|
|
5528
|
+
function useQuota(store) {
|
|
5529
|
+
const [, setTick] = useState(0);
|
|
5530
|
+
useEffect(() => {
|
|
5531
|
+
if (store === null || store === undefined) return undefined;
|
|
5532
|
+
let alive = true;
|
|
5533
|
+
const off = store.subscribe(() => {
|
|
5534
|
+
if (alive) setTick((n) => n + 1);
|
|
5535
|
+
});
|
|
5536
|
+
return () => {
|
|
5537
|
+
alive = false;
|
|
5538
|
+
off();
|
|
5539
|
+
};
|
|
5540
|
+
}, [store]);
|
|
5541
|
+
return store ? store.getSnapshot() : { data: null, loading: false, error: null, busy: null };
|
|
5542
|
+
}
|
|
5543
|
+
|
|
5544
|
+
/**
|
|
5545
|
+
* Which sessions currently have a composer badge mounted. The composer
|
|
5546
|
+
* renders conversation.input.right only once the session is bound (its
|
|
5547
|
+
* input state exists), but the New Session hero shows the model selector
|
|
5548
|
+
* earlier. The hero badge (conversation.hero.options) therefore renders
|
|
5549
|
+
* only while no composer badge is mounted for the same session, so the
|
|
5550
|
+
* user always sees exactly one badge. Plain in-memory counts; no DOM.
|
|
5551
|
+
*/
|
|
5552
|
+
function createBadgePresence() {
|
|
5553
|
+
const counts = new Map();
|
|
5554
|
+
const listeners = new Set();
|
|
5555
|
+
const notify = () => {
|
|
5556
|
+
for (const fn of [...listeners]) fn();
|
|
5557
|
+
};
|
|
5558
|
+
return {
|
|
5559
|
+
add(id) {
|
|
5560
|
+
counts.set(id, (counts.get(id) ?? 0) + 1);
|
|
5561
|
+
notify();
|
|
5562
|
+
let done = false;
|
|
5563
|
+
return () => {
|
|
5564
|
+
if (done) return;
|
|
5565
|
+
done = true;
|
|
5566
|
+
const n = (counts.get(id) ?? 1) - 1;
|
|
5567
|
+
if (n > 0) counts.set(id, n);
|
|
5568
|
+
else counts.delete(id);
|
|
5569
|
+
notify();
|
|
5570
|
+
};
|
|
5571
|
+
},
|
|
5572
|
+
has: (id) => (counts.get(id) ?? 0) > 0,
|
|
5573
|
+
subscribe(fn) {
|
|
5574
|
+
listeners.add(fn);
|
|
5575
|
+
return () => listeners.delete(fn);
|
|
5576
|
+
},
|
|
5577
|
+
};
|
|
5578
|
+
}
|
|
5579
|
+
const composerBadges = createBadgePresence();
|
|
5580
|
+
// The generation-model button has its own composer/hero presence.
|
|
5581
|
+
const mediaChips = createBadgePresence();
|
|
5582
|
+
// The Generation tab tells the composer buttons when the master switch
|
|
5583
|
+
// changed, so they appear / disappear without waiting for a retry.
|
|
5584
|
+
const mediaGenerationListeners = new Set();
|
|
5585
|
+
const announceMediaGeneration = () => {
|
|
5586
|
+
for (const fn of [...mediaGenerationListeners]) {
|
|
5587
|
+
try {
|
|
5588
|
+
fn();
|
|
5589
|
+
} catch {}
|
|
5590
|
+
}
|
|
5591
|
+
};
|
|
5592
|
+
|
|
5593
|
+
/**
|
|
4423
5594
|
* Composer placement announces itself for its session; hero placement
|
|
4424
5595
|
* reports whether a composer badge already covers the session.
|
|
4425
5596
|
*/
|
|
@@ -4438,646 +5609,1943 @@ select.dcpa-q-input { appearance: auto; padding-right: 8px; }
|
|
|
4438
5609
|
off();
|
|
4439
5610
|
};
|
|
4440
5611
|
}
|
|
4441
|
-
return presence.add(sessionId);
|
|
4442
|
-
}, [placement, sessionId, presence]);
|
|
4443
|
-
return placement === "hero" && typeof sessionId === "string" && presence.has(sessionId);
|
|
5612
|
+
return presence.add(sessionId);
|
|
5613
|
+
}, [placement, sessionId, presence]);
|
|
5614
|
+
return placement === "hero" && typeof sessionId === "string" && presence.has(sessionId);
|
|
5615
|
+
}
|
|
5616
|
+
|
|
5617
|
+
/**
|
|
5618
|
+
* The session's model-directory store, resolved at render time. DSH caches
|
|
5619
|
+
* a slot entry's inject() result per session binding, and right after a
|
|
5620
|
+
* page load, host restart or session switch `directoryFor()` can still
|
|
5621
|
+
* throw (no scope/binding yet). Resolving inside inject would cache that
|
|
5622
|
+
* miss for good, so inject hands over a resolver and this hook retries
|
|
5623
|
+
* (1 s for the first 30 s, then every 5 s) until the directory exists.
|
|
5624
|
+
*/
|
|
5625
|
+
function useDirectory(direct, resolve) {
|
|
5626
|
+
const [, setTick] = useState(0);
|
|
5627
|
+
let resolved = direct ?? null;
|
|
5628
|
+
if (resolved === null && typeof resolve === "function") {
|
|
5629
|
+
try {
|
|
5630
|
+
resolved = resolve() ?? null;
|
|
5631
|
+
} catch {
|
|
5632
|
+
resolved = null;
|
|
5633
|
+
}
|
|
5634
|
+
}
|
|
5635
|
+
const missing = resolved === null && typeof resolve === "function";
|
|
5636
|
+
useEffect(() => {
|
|
5637
|
+
if (!missing) return undefined;
|
|
5638
|
+
let alive = true;
|
|
5639
|
+
let id = null;
|
|
5640
|
+
let tries = 0;
|
|
5641
|
+
const tick = () => {
|
|
5642
|
+
id = null;
|
|
5643
|
+
if (!alive) return;
|
|
5644
|
+
tries += 1;
|
|
5645
|
+
setTick((n) => n + 1);
|
|
5646
|
+
id = setTimeout(tick, tries < 30 ? 1000 : 5000);
|
|
5647
|
+
};
|
|
5648
|
+
id = setTimeout(tick, 300);
|
|
5649
|
+
return () => {
|
|
5650
|
+
alive = false;
|
|
5651
|
+
if (id !== null) clearTimeout(id);
|
|
5652
|
+
};
|
|
5653
|
+
}, [missing, resolve]);
|
|
5654
|
+
return resolved;
|
|
5655
|
+
}
|
|
5656
|
+
|
|
5657
|
+
/** Re-render every `ms` so relative times stay current. */
|
|
5658
|
+
function useNow(ms) {
|
|
5659
|
+
const [now, setNow] = useState(() => Date.now());
|
|
5660
|
+
useEffect(() => {
|
|
5661
|
+
const id = setInterval(() => setNow(Date.now()), ms);
|
|
5662
|
+
return () => clearInterval(id);
|
|
5663
|
+
}, [ms]);
|
|
5664
|
+
return now;
|
|
5665
|
+
}
|
|
5666
|
+
|
|
5667
|
+
function QuotaMeter({ t, w, now }) {
|
|
5668
|
+
const pct = typeof w.remainingPercent === "number" ? Math.max(0, Math.min(100, w.remainingPercent)) : null;
|
|
5669
|
+
const level = quotaLevel(pct);
|
|
5670
|
+
const reset = resetText(t, w.resetAt, now);
|
|
5671
|
+
const label = windowLabel(t, w);
|
|
5672
|
+
return h(
|
|
5673
|
+
"div",
|
|
5674
|
+
{ className: "dcpa-q-meter", "data-level": level },
|
|
5675
|
+
h(
|
|
5676
|
+
"div",
|
|
5677
|
+
{ className: "dcpa-q-meter-top" },
|
|
5678
|
+
h("span", { title: label }, label),
|
|
5679
|
+
h("span", { className: "dcpa-q-pct" }, pctText(pct))
|
|
5680
|
+
),
|
|
5681
|
+
h(
|
|
5682
|
+
"div",
|
|
5683
|
+
{
|
|
5684
|
+
className: "dcpa-q-track",
|
|
5685
|
+
role: "meter",
|
|
5686
|
+
"aria-label": label,
|
|
5687
|
+
"aria-valuemin": 0,
|
|
5688
|
+
"aria-valuemax": 100,
|
|
5689
|
+
...(pct === null ? {} : { "aria-valuenow": Math.round(pct) }),
|
|
5690
|
+
},
|
|
5691
|
+
h("span", { className: "dcpa-q-fill", style: { width: `${pct ?? 0}%` } })
|
|
5692
|
+
),
|
|
5693
|
+
reset ? h("span", { className: "dcpa-q-reset" }, reset) : null
|
|
5694
|
+
);
|
|
5695
|
+
}
|
|
5696
|
+
|
|
5697
|
+
function CredentialTags({ t, cred }) {
|
|
5698
|
+
return [
|
|
5699
|
+
cred.plan ? h("span", { key: "plan", className: "dcpa-q-tag", "data-tone": "info" }, cred.plan) : null,
|
|
5700
|
+
cred.prefix ? h("span", { key: "prefix", className: "dcpa-q-tag" }, `${cred.prefix}/`) : null,
|
|
5701
|
+
cred.status && cred.status !== "active"
|
|
5702
|
+
? h("span", { key: "status", className: "dcpa-q-tag", "data-tone": "danger" }, t(STATUS_KEYS[cred.status] ?? "qStatusError"))
|
|
5703
|
+
: null,
|
|
5704
|
+
];
|
|
5705
|
+
}
|
|
5706
|
+
|
|
5707
|
+
function CredentialWindows({ t, cred, windows, now }) {
|
|
5708
|
+
if (windows.length > 0) {
|
|
5709
|
+
return h(
|
|
5710
|
+
"div",
|
|
5711
|
+
{ className: "dcpa-q-meters" },
|
|
5712
|
+
windows.map((w, i) => h(QuotaMeter, { key: w.id ?? i, t, w, now }))
|
|
5713
|
+
);
|
|
5714
|
+
}
|
|
5715
|
+
const lines = [
|
|
5716
|
+
cred.quotaState === "error"
|
|
5717
|
+
? h("p", { key: "q", className: "dcpa-q-note", "data-tone": "error" }, t("qQuotaError"))
|
|
5718
|
+
: h("p", { key: "q", className: "dcpa-q-note" }, t(cred.quotaState === "unsupported" ? "qQuotaUnsupported" : "qQuotaNone")),
|
|
5719
|
+
cred.recent ? h("p", { key: "r", className: "dcpa-q-note" }, fmt(t, "qRecent", cred.recent)) : null,
|
|
5720
|
+
];
|
|
5721
|
+
return h("div", { className: "dcpa-q-meters" }, h("div", null, lines));
|
|
5722
|
+
}
|
|
5723
|
+
|
|
5724
|
+
function CredentialRow({ t, cred, now, pickerIds }) {
|
|
5725
|
+
const [open, setOpen] = useState(false);
|
|
5726
|
+
const models = Array.isArray(cred.models) ? cred.models : null;
|
|
5727
|
+
return h(
|
|
5728
|
+
"div",
|
|
5729
|
+
{ className: "dcpa-q-cred", "data-status": cred.status ?? "active" },
|
|
5730
|
+
h(
|
|
5731
|
+
"div",
|
|
5732
|
+
{ className: "dcpa-q-who" },
|
|
5733
|
+
h("div", { className: "dcpa-q-name", title: cred.label || cred.id }, cred.label || cred.id),
|
|
5734
|
+
h(
|
|
5735
|
+
"div",
|
|
5736
|
+
{ className: "dcpa-q-meta" },
|
|
5737
|
+
CredentialTags({ t, cred }),
|
|
5738
|
+
models === null
|
|
5739
|
+
? h("span", null, t("qModelsUnknown"))
|
|
5740
|
+
: h(
|
|
5741
|
+
"button",
|
|
5742
|
+
{ type: "button", className: "dcpa-q-link", "aria-expanded": open, onClick: () => setOpen((v) => !v) },
|
|
5743
|
+
open ? t("qHideModels") : fmt(t, "qShowModels", { n: models.length })
|
|
5744
|
+
),
|
|
5745
|
+
cred.statusMessage ? h("span", { title: cred.statusMessage }, cred.statusMessage) : null
|
|
5746
|
+
)
|
|
5747
|
+
),
|
|
5748
|
+
h(CredentialWindows, { t, cred, windows: credentialWindows(cred), now }),
|
|
5749
|
+
open && models !== null
|
|
5750
|
+
? h(
|
|
5751
|
+
"div",
|
|
5752
|
+
{ className: "dcpa-q-models" },
|
|
5753
|
+
models.map((m) => h("code", { key: m, "data-picker": pickerIds.has(m) ? "true" : "false" }, m))
|
|
5754
|
+
)
|
|
5755
|
+
: null
|
|
5756
|
+
);
|
|
5757
|
+
}
|
|
5758
|
+
|
|
5759
|
+
function ModelChannelMap({ t, account }) {
|
|
5760
|
+
const picker = Array.isArray(account.pickerModels) ? account.pickerModels : [];
|
|
5761
|
+
return h(
|
|
5762
|
+
"details",
|
|
5763
|
+
{ className: "dcpa-q-map" },
|
|
5764
|
+
h(
|
|
5765
|
+
"summary",
|
|
5766
|
+
null,
|
|
5767
|
+
h(
|
|
5768
|
+
"div",
|
|
5769
|
+
{ className: "dcpa-q-row-main" },
|
|
5770
|
+
h("div", { className: "dcpa-q-title" }, t("qMapTitle"), h(InfoTip, { t, text: t("qMapHint") }))
|
|
5771
|
+
)
|
|
5772
|
+
),
|
|
5773
|
+
picker.length === 0
|
|
5774
|
+
? h("p", { className: "dcpa-q-msg" }, t("qMapEmpty"))
|
|
5775
|
+
: picker.map((m) => {
|
|
5776
|
+
const summary = summarizeModelQuota({ accounts: [account] }, m.route, m.id);
|
|
5777
|
+
const entries = summary?.entries ?? [];
|
|
5778
|
+
const matchedBy = account.modelMatch?.[m.id];
|
|
5779
|
+
return h(
|
|
5780
|
+
"div",
|
|
5781
|
+
{ key: `${m.route}:${m.id}`, className: "dcpa-q-map-row" },
|
|
5782
|
+
h("span", { className: "dcpa-q-map-model" }, m.id),
|
|
5783
|
+
h(
|
|
5784
|
+
"span",
|
|
5785
|
+
{ className: "dcpa-q-map-creds" },
|
|
5786
|
+
entries.length === 0
|
|
5787
|
+
? h("span", null, t("qMapNone"))
|
|
5788
|
+
: entries.map(({ cred }) =>
|
|
5789
|
+
h(
|
|
5790
|
+
"span",
|
|
5791
|
+
{ key: cred.id, className: "dcpa-q-tag", "data-tone": cred.status === "active" ? undefined : "danger", title: cred.label || cred.id },
|
|
5792
|
+
`${providerName(t, cred.provider)} · ${cred.label || cred.id}`
|
|
5793
|
+
)
|
|
5794
|
+
),
|
|
5795
|
+
matchedBy && MATCH_KEYS[matchedBy] ? h("span", null, t(MATCH_KEYS[matchedBy])) : null
|
|
5796
|
+
),
|
|
5797
|
+
h("span", { className: "dcpa-q-map-pct dcpa-q-pct" }, pctText(summary?.best))
|
|
5798
|
+
);
|
|
5799
|
+
})
|
|
5800
|
+
);
|
|
5801
|
+
}
|
|
5802
|
+
|
|
5803
|
+
function QuotaSummary({ t, credentials }) {
|
|
5804
|
+
const stats = quotaStats(credentials);
|
|
5805
|
+
const parts = [
|
|
5806
|
+
["ok", stats.healthy, "qStatHealthy"],
|
|
5807
|
+
["low", stats.low, "qStatLow"],
|
|
5808
|
+
["empty", stats.empty, "qStatEmpty"],
|
|
5809
|
+
["issue", stats.issue, "qStatIssue"],
|
|
5810
|
+
["unknown", stats.unknown, "qStatUnknown"],
|
|
5811
|
+
];
|
|
5812
|
+
return h(
|
|
5813
|
+
"div",
|
|
5814
|
+
{ className: "dcpa-q-summary" },
|
|
5815
|
+
h(
|
|
5816
|
+
"div",
|
|
5817
|
+
{ className: "dcpa-q-dist", role: "img", "aria-label": fmt(t, "qTotalCreds", { n: stats.total }) },
|
|
5818
|
+
parts.filter(([, n]) => n > 0).map(([level, n]) => h("span", { key: level, "data-level": level, style: { flex: `${n} 1 0` } }))
|
|
5819
|
+
),
|
|
5820
|
+
h(
|
|
5821
|
+
"div",
|
|
5822
|
+
{ className: "dcpa-q-legend" },
|
|
5823
|
+
h("span", null, fmt(t, "qTotalCreds", { n: stats.total })),
|
|
5824
|
+
parts.filter(([level, n]) => level !== "unknown" || n > 0).map(([level, n, key]) =>
|
|
5825
|
+
h("span", { key: level, "data-level": level }, h("i", { className: "dcpa-q-swatch" }), t(key), h("b", null, String(n)))
|
|
5826
|
+
)
|
|
5827
|
+
)
|
|
5828
|
+
);
|
|
5829
|
+
}
|
|
5830
|
+
|
|
5831
|
+
/** The "额度" tab: flat DSH settings rows, one account at a time. */
|
|
5832
|
+
function QuotaPanel({ t, store = sharedQuota, accountId }) {
|
|
5833
|
+
const snap = useQuota(store);
|
|
5834
|
+
const now = useNow(30 * 1000);
|
|
5835
|
+
const [activeId, setActiveId] = useState(null);
|
|
5836
|
+
const [keyDraft, setKeyDraft] = useState("");
|
|
5837
|
+
const [notice, setNotice] = useState(null);
|
|
5838
|
+
const [providerFilter, setProviderFilter] = useState("all");
|
|
5839
|
+
const [search, setSearch] = useState("");
|
|
5840
|
+
|
|
5841
|
+
const accounts = Array.isArray(snap.data?.accounts) ? snap.data.accounts : [];
|
|
5842
|
+
const scoped = accountId !== undefined;
|
|
5843
|
+
const account = scoped
|
|
5844
|
+
? accounts.find((a) => a.accountId === accountId) ?? null
|
|
5845
|
+
: accounts.find((a) => a.accountId === activeId) ?? accounts[0] ?? null;
|
|
5846
|
+
const credentials = Array.isArray(account?.credentials) ? account.credentials : [];
|
|
5847
|
+
const pickerIds = useMemo(() => new Set((account?.pickerModels ?? []).map((m) => m.id)), [account]);
|
|
5848
|
+
const busy = snap.busy !== null;
|
|
5849
|
+
|
|
5850
|
+
const providers = useMemo(() => {
|
|
5851
|
+
const counts = new Map();
|
|
5852
|
+
for (const c of credentials) counts.set(c.provider || "", (counts.get(c.provider || "") ?? 0) + 1);
|
|
5853
|
+
return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
5854
|
+
}, [credentials]);
|
|
5855
|
+
|
|
5856
|
+
const groups = useMemo(() => {
|
|
5857
|
+
const q = search.trim().toLowerCase();
|
|
5858
|
+
const map = new Map();
|
|
5859
|
+
for (const c of credentials) {
|
|
5860
|
+
if (providerFilter !== "all" && (c.provider || "") !== providerFilter) continue;
|
|
5861
|
+
if (
|
|
5862
|
+
q !== "" &&
|
|
5863
|
+
!String(c.label ?? "").toLowerCase().includes(q) &&
|
|
5864
|
+
!String(c.id ?? "").toLowerCase().includes(q) &&
|
|
5865
|
+
!(Array.isArray(c.models) && c.models.some((m) => String(m).toLowerCase().includes(q)))
|
|
5866
|
+
) continue;
|
|
5867
|
+
const key = c.provider || "";
|
|
5868
|
+
if (!map.has(key)) map.set(key, []);
|
|
5869
|
+
map.get(key).push(c);
|
|
5870
|
+
}
|
|
5871
|
+
// Problems first, then least remaining, so what needs attention is on top.
|
|
5872
|
+
const rank = (c) => (c.status !== "active" ? 1 : 0);
|
|
5873
|
+
return [...map.entries()].map(([key, list]) => [
|
|
5874
|
+
key,
|
|
5875
|
+
[...list].sort((a, b) => rank(a) - rank(b) || (credentialHeadroom(a) ?? 101) - (credentialHeadroom(b) ?? 101)),
|
|
5876
|
+
]);
|
|
5877
|
+
}, [credentials, providerFilter, search]);
|
|
5878
|
+
|
|
5879
|
+
if (store === null || store === undefined) return null;
|
|
5880
|
+
|
|
5881
|
+
const saveKey = async () => {
|
|
5882
|
+
if (account === null || keyDraft.trim() === "") return;
|
|
5883
|
+
setNotice(null);
|
|
5884
|
+
const result = await store.saveKey(account.accountId, keyDraft.trim());
|
|
5885
|
+
if (result.ok) {
|
|
5886
|
+
setKeyDraft("");
|
|
5887
|
+
setNotice(t("qKeySaved"));
|
|
5888
|
+
}
|
|
5889
|
+
};
|
|
5890
|
+
const clearKey = async () => {
|
|
5891
|
+
if (account === null) return;
|
|
5892
|
+
const name = account.displayName || account.accountId;
|
|
5893
|
+
if (typeof window !== "undefined" && typeof window.confirm === "function" && !window.confirm(fmt(t, "qKeyClearConfirm", { name }))) return;
|
|
5894
|
+
setNotice(null);
|
|
5895
|
+
const result = await store.clearKey(account.accountId);
|
|
5896
|
+
if (result.ok) setNotice(t("qKeyCleared"));
|
|
5897
|
+
};
|
|
5898
|
+
|
|
5899
|
+
const errorText = account ? quotaErrorText(t, account) : null;
|
|
5900
|
+
const refreshing = account?.refreshing === true || (snap.busy === "refresh" && snap.busyAccount === account?.accountId);
|
|
5901
|
+
const shown = groups.reduce((n, [, list]) => n + list.length, 0);
|
|
5902
|
+
|
|
5903
|
+
return h(
|
|
5904
|
+
"div",
|
|
5905
|
+
{ className: "dcpa-q", "data-dsh-plugin": MODULE_ID },
|
|
5906
|
+
scoped
|
|
5907
|
+
? null
|
|
5908
|
+
: h(
|
|
5909
|
+
"div",
|
|
5910
|
+
{ className: "dcpa-q-head" },
|
|
5911
|
+
h("h3", { className: "dcpa-q-h" }, t("qTitle")),
|
|
5912
|
+
h("p", { className: "dcpa-q-desc" }, t("qSubtitle"))
|
|
5913
|
+
),
|
|
5914
|
+
scoped && account === null ? h("p", { className: "dcpa-q-msg" }, t("qPeekLoading")) : null,
|
|
5915
|
+
!scoped && accounts.length > 1
|
|
5916
|
+
? h(
|
|
5917
|
+
"div",
|
|
5918
|
+
{ className: "dcpa-q-seg", role: "tablist", "aria-label": t("qAccountsLabel") },
|
|
5919
|
+
accounts.map((a) =>
|
|
5920
|
+
h(
|
|
5921
|
+
"button",
|
|
5922
|
+
{
|
|
5923
|
+
key: a.accountId,
|
|
5924
|
+
type: "button",
|
|
5925
|
+
role: "tab",
|
|
5926
|
+
className: "dcpa-q-seg-tab",
|
|
5927
|
+
"aria-selected": account?.accountId === a.accountId,
|
|
5928
|
+
onClick: () => {
|
|
5929
|
+
setActiveId(a.accountId);
|
|
5930
|
+
setKeyDraft("");
|
|
5931
|
+
setNotice(null);
|
|
5932
|
+
setProviderFilter("all");
|
|
5933
|
+
setSearch("");
|
|
5934
|
+
},
|
|
5935
|
+
},
|
|
5936
|
+
a.displayName || a.accountId,
|
|
5937
|
+
a.keyConfigured ? null : h("span", { className: "dcpa-q-seg-note" }, t("qKeyStatusUnset"))
|
|
5938
|
+
)
|
|
5939
|
+
)
|
|
5940
|
+
)
|
|
5941
|
+
: null,
|
|
5942
|
+
snap.data !== null && accounts.length === 0 ? h("p", { className: "dcpa-q-msg" }, t("qNoAccounts")) : null,
|
|
5943
|
+
account
|
|
5944
|
+
? h(
|
|
5945
|
+
"div",
|
|
5946
|
+
{ className: "dcpa-q-row" },
|
|
5947
|
+
h(
|
|
5948
|
+
"div",
|
|
5949
|
+
{ className: "dcpa-q-row-main" },
|
|
5950
|
+
h(
|
|
5951
|
+
"div",
|
|
5952
|
+
{ className: "dcpa-q-title" },
|
|
5953
|
+
h("label", { htmlFor: `dcpa-q-key-${account.accountId}` }, t("qKeyLabel")),
|
|
5954
|
+
h(InfoTip, { t, text: fmt(t, "qKeyHint", { ref: account.keyRef ?? "" }) })
|
|
5955
|
+
)
|
|
5956
|
+
),
|
|
5957
|
+
h(
|
|
5958
|
+
"div",
|
|
5959
|
+
{ className: "dcpa-q-actions" },
|
|
5960
|
+
h(
|
|
5961
|
+
"span",
|
|
5962
|
+
{ className: "dcpa-q-tag", "data-tone": account.keyConfigured ? "success" : undefined },
|
|
5963
|
+
account.keyConfigured ? t("qKeyStatusSet") : t("qKeyStatusUnset")
|
|
5964
|
+
)
|
|
5965
|
+
)
|
|
5966
|
+
)
|
|
5967
|
+
: null,
|
|
5968
|
+
account
|
|
5969
|
+
? h(
|
|
5970
|
+
"div",
|
|
5971
|
+
{ className: "dcpa-q-form" },
|
|
5972
|
+
h("input", {
|
|
5973
|
+
id: `dcpa-q-key-${account.accountId}`,
|
|
5974
|
+
className: "dcpa-q-input",
|
|
5975
|
+
type: "password",
|
|
5976
|
+
autoComplete: "off",
|
|
5977
|
+
spellCheck: false,
|
|
5978
|
+
value: keyDraft,
|
|
5979
|
+
placeholder: account.keyConfigured ? t("qKeySetPlaceholder") : t("qKeyUnsetPlaceholder"),
|
|
5980
|
+
onChange: (e) => setKeyDraft(e.target.value),
|
|
5981
|
+
onKeyDown: (e) => {
|
|
5982
|
+
if (e.key === "Enter") void saveKey();
|
|
5983
|
+
},
|
|
5984
|
+
}),
|
|
5985
|
+
h(
|
|
5986
|
+
"button",
|
|
5987
|
+
{ type: "button", className: "dcpa-q-btn", "data-variant": "primary", disabled: busy || keyDraft.trim() === "", onClick: () => void saveKey() },
|
|
5988
|
+
snap.busy === "save" ? t("qKeySaving") : t("qKeySave")
|
|
5989
|
+
),
|
|
5990
|
+
account.keyConfigured
|
|
5991
|
+
? h(
|
|
5992
|
+
"button",
|
|
5993
|
+
{ type: "button", className: "dcpa-q-btn", "data-variant": "danger", disabled: busy, onClick: () => void clearKey() },
|
|
5994
|
+
t("qKeyClear")
|
|
5995
|
+
)
|
|
5996
|
+
: null
|
|
5997
|
+
)
|
|
5998
|
+
: null,
|
|
5999
|
+
account && account.keyConfigured
|
|
6000
|
+
? h(
|
|
6001
|
+
"div",
|
|
6002
|
+
{ className: "dcpa-q-row" },
|
|
6003
|
+
h(
|
|
6004
|
+
"div",
|
|
6005
|
+
{ className: "dcpa-q-row-main" },
|
|
6006
|
+
h("div", { className: "dcpa-q-title" }, t("qDataLabel")),
|
|
6007
|
+
h(
|
|
6008
|
+
"div",
|
|
6009
|
+
{ className: "dcpa-q-sub" },
|
|
6010
|
+
refreshing
|
|
6011
|
+
? t("qRefreshing")
|
|
6012
|
+
: account.fetchedAt
|
|
6013
|
+
? fmt(t, "qUpdated", { time: agoText(t, account.fetchedAt, now) })
|
|
6014
|
+
: t("qNeverUpdated"),
|
|
6015
|
+
account.enabled === false ? ` · ${t("qAccountDisabled")}` : ""
|
|
6016
|
+
)
|
|
6017
|
+
),
|
|
6018
|
+
h(
|
|
6019
|
+
"div",
|
|
6020
|
+
{ className: "dcpa-q-actions" },
|
|
6021
|
+
h(
|
|
6022
|
+
"button",
|
|
6023
|
+
{ type: "button", className: "dcpa-q-btn", "data-variant": "outline", disabled: busy || refreshing, onClick: () => void store.refresh(account.accountId) },
|
|
6024
|
+
t("qRefresh")
|
|
6025
|
+
)
|
|
6026
|
+
)
|
|
6027
|
+
)
|
|
6028
|
+
: null,
|
|
6029
|
+
errorText ? h("p", { className: "dcpa-q-msg", "data-tone": "error", role: "alert" }, errorText, credentials.length > 0 ? ` ${t("qStale")}` : "") : null,
|
|
6030
|
+
snap.error ? h("p", { className: "dcpa-q-msg", "data-tone": "error", role: "alert" }, fmt(t, "qErrLoad", { error: snap.error })) : null,
|
|
6031
|
+
notice ? h("p", { className: "dcpa-q-msg", "data-tone": "ok", role: "status" }, notice) : null,
|
|
6032
|
+
account && account.keyConfigured && credentials.length === 0 && account.state === "ok"
|
|
6033
|
+
? h("p", { className: "dcpa-q-msg" }, t("qNoCredentials"))
|
|
6034
|
+
: null,
|
|
6035
|
+
account && account.keyConfigured && credentials.length > 0 ? h(QuotaSummary, { t, credentials }) : null,
|
|
6036
|
+
account && account.keyConfigured && (providers.length > 1 || credentials.length > 8)
|
|
6037
|
+
? h(
|
|
6038
|
+
"div",
|
|
6039
|
+
{ className: "dcpa-q-tools" },
|
|
6040
|
+
providers.length > 1
|
|
6041
|
+
? [["all", credentials.length], ...providers].map(([key, count]) =>
|
|
6042
|
+
h(
|
|
6043
|
+
"button",
|
|
6044
|
+
{
|
|
6045
|
+
key: key || "_",
|
|
6046
|
+
type: "button",
|
|
6047
|
+
className: "dcpa-q-filter",
|
|
6048
|
+
"aria-pressed": providerFilter === key,
|
|
6049
|
+
onClick: () => setProviderFilter(key),
|
|
6050
|
+
},
|
|
6051
|
+
key === "all" ? t("qFilterAll") : providerName(t, key),
|
|
6052
|
+
h("small", null, String(count))
|
|
6053
|
+
)
|
|
6054
|
+
)
|
|
6055
|
+
: null,
|
|
6056
|
+
credentials.length > 8
|
|
6057
|
+
? h("input", {
|
|
6058
|
+
className: "dcpa-q-input",
|
|
6059
|
+
type: "search",
|
|
6060
|
+
value: search,
|
|
6061
|
+
placeholder: t("qSearchPlaceholder"),
|
|
6062
|
+
"aria-label": t("qSearchPlaceholder"),
|
|
6063
|
+
onChange: (e) => setSearch(e.target.value),
|
|
6064
|
+
})
|
|
6065
|
+
: null
|
|
6066
|
+
)
|
|
6067
|
+
: null,
|
|
6068
|
+
account && account.keyConfigured && credentials.length > 0 && shown === 0 ? h("p", { className: "dcpa-q-msg" }, t("qNoMatch")) : null,
|
|
6069
|
+
account && account.keyConfigured
|
|
6070
|
+
? groups.map(([key, list]) =>
|
|
6071
|
+
h(
|
|
6072
|
+
"section",
|
|
6073
|
+
{ key: key || "_", "aria-label": providerName(t, key) },
|
|
6074
|
+
h("div", { className: "dcpa-q-group-title" }, providerName(t, key), h("small", null, String(list.length))),
|
|
6075
|
+
list.map((cred) => h(CredentialRow, { key: cred.id, t, cred, now, pickerIds }))
|
|
6076
|
+
)
|
|
6077
|
+
)
|
|
6078
|
+
: null,
|
|
6079
|
+
account && account.keyConfigured && credentials.length > 0 ? h(ModelChannelMap, { t, account }) : null
|
|
6080
|
+
);
|
|
4444
6081
|
}
|
|
4445
6082
|
|
|
4446
|
-
/**
|
|
4447
|
-
|
|
4448
|
-
|
|
4449
|
-
|
|
4450
|
-
|
|
4451
|
-
|
|
4452
|
-
|
|
4453
|
-
*/
|
|
4454
|
-
function useDirectory(direct, resolve) {
|
|
6083
|
+
/** The CLIProxyAPI settings section: accounts, each with 连接 / 模型 / 额度 tabs. */
|
|
6084
|
+
function CpaSectionPage({ t }) {
|
|
6085
|
+
return h(CpaSettingsPanel, { t });
|
|
6086
|
+
}
|
|
6087
|
+
|
|
6088
|
+
/** Snapshot of the session's model directory (current or pending selection). */
|
|
6089
|
+
function useSelection(directory) {
|
|
4455
6090
|
const [, setTick] = useState(0);
|
|
4456
|
-
let resolved = direct ?? null;
|
|
4457
|
-
if (resolved === null && typeof resolve === "function") {
|
|
4458
|
-
try {
|
|
4459
|
-
resolved = resolve() ?? null;
|
|
4460
|
-
} catch {
|
|
4461
|
-
resolved = null;
|
|
4462
|
-
}
|
|
4463
|
-
}
|
|
4464
|
-
const missing = resolved === null && typeof resolve === "function";
|
|
4465
6091
|
useEffect(() => {
|
|
4466
|
-
if (!
|
|
6092
|
+
if (!directory || typeof directory.subscribe !== "function") return undefined;
|
|
4467
6093
|
let alive = true;
|
|
4468
|
-
|
|
4469
|
-
|
|
4470
|
-
|
|
4471
|
-
id = null;
|
|
4472
|
-
if (!alive) return;
|
|
4473
|
-
tries += 1;
|
|
4474
|
-
setTick((n) => n + 1);
|
|
4475
|
-
id = setTimeout(tick, tries < 30 ? 1000 : 5000);
|
|
4476
|
-
};
|
|
4477
|
-
id = setTimeout(tick, 300);
|
|
6094
|
+
const off = directory.subscribe(() => {
|
|
6095
|
+
if (alive) setTick((n) => n + 1);
|
|
6096
|
+
});
|
|
4478
6097
|
return () => {
|
|
4479
6098
|
alive = false;
|
|
4480
|
-
|
|
6099
|
+
off?.();
|
|
4481
6100
|
};
|
|
4482
|
-
}, [
|
|
4483
|
-
|
|
6101
|
+
}, [directory]);
|
|
6102
|
+
const snapshot = directory && typeof directory.getSnapshot === "function" ? directory.getSnapshot() : null;
|
|
6103
|
+
const current = snapshot?.pending ?? snapshot?.current ?? null;
|
|
6104
|
+
return current && typeof current.provider === "string" && typeof current.model === "string" ? current : null;
|
|
4484
6105
|
}
|
|
4485
6106
|
|
|
4486
|
-
/**
|
|
4487
|
-
|
|
4488
|
-
|
|
4489
|
-
|
|
4490
|
-
|
|
4491
|
-
|
|
4492
|
-
|
|
4493
|
-
|
|
4494
|
-
|
|
6107
|
+
/**
|
|
6108
|
+
* Composer badge left of the model picker: signal bars + best remaining
|
|
6109
|
+
* percentage of the credentials that serve the selected model on the
|
|
6110
|
+
* selected model's own CPA account; click opens a peek list. Accounts
|
|
6111
|
+
* without a management key show nothing.
|
|
6112
|
+
*/
|
|
6113
|
+
function QuotaBadge({ t, directory: directProp = null, resolveDirectory = null, store = sharedQuota, sessionId = null, placement = "composer", presence = composerBadges }) {
|
|
6114
|
+
const shadowed = useBadgePlacement(placement, sessionId, presence);
|
|
6115
|
+
const directory = useDirectory(directProp, shadowed ? null : resolveDirectory);
|
|
6116
|
+
const selection = useSelection(shadowed ? null : directory);
|
|
6117
|
+
const snap = useQuota(selection ? store : null);
|
|
6118
|
+
const now = useNow(60 * 1000);
|
|
6119
|
+
const [open, setOpen] = useState(false);
|
|
6120
|
+
const [pos, setPos] = useState(null);
|
|
6121
|
+
const wrapRef = useRef(null);
|
|
6122
|
+
const buttonRef = useRef(null);
|
|
4495
6123
|
|
|
4496
|
-
|
|
4497
|
-
const
|
|
4498
|
-
const level = quotaLevel(pct);
|
|
4499
|
-
const reset = resetText(t, w.resetAt, now);
|
|
4500
|
-
const label = windowLabel(t, w);
|
|
4501
|
-
return h(
|
|
4502
|
-
"div",
|
|
4503
|
-
{ className: "dcpa-q-meter", "data-level": level },
|
|
4504
|
-
h(
|
|
4505
|
-
"div",
|
|
4506
|
-
{ className: "dcpa-q-meter-top" },
|
|
4507
|
-
h("span", { title: label }, label),
|
|
4508
|
-
h("span", { className: "dcpa-q-pct" }, pctText(pct))
|
|
4509
|
-
),
|
|
4510
|
-
h(
|
|
4511
|
-
"div",
|
|
4512
|
-
{
|
|
4513
|
-
className: "dcpa-q-track",
|
|
4514
|
-
role: "meter",
|
|
4515
|
-
"aria-label": label,
|
|
4516
|
-
"aria-valuemin": 0,
|
|
4517
|
-
"aria-valuemax": 100,
|
|
4518
|
-
...(pct === null ? {} : { "aria-valuenow": Math.round(pct) }),
|
|
4519
|
-
},
|
|
4520
|
-
h("span", { className: "dcpa-q-fill", style: { width: `${pct ?? 0}%` } })
|
|
4521
|
-
),
|
|
4522
|
-
reset ? h("span", { className: "dcpa-q-reset" }, reset) : null
|
|
4523
|
-
);
|
|
4524
|
-
}
|
|
6124
|
+
const summary = selection ? summarizeModelQuota(snap.data, selection.provider, selection.model) : null;
|
|
6125
|
+
const visible = !shadowed && summary !== null && summary.account.keyConfigured === true;
|
|
4525
6126
|
|
|
4526
|
-
|
|
4527
|
-
|
|
4528
|
-
|
|
4529
|
-
|
|
4530
|
-
|
|
4531
|
-
|
|
4532
|
-
|
|
4533
|
-
|
|
4534
|
-
|
|
6127
|
+
useEffect(() => {
|
|
6128
|
+
if (!open) return undefined;
|
|
6129
|
+
const place = () => {
|
|
6130
|
+
const rect = buttonRef.current?.getBoundingClientRect?.();
|
|
6131
|
+
if (!rect || typeof window === "undefined") return;
|
|
6132
|
+
const width = Math.min(340, window.innerWidth - 32);
|
|
6133
|
+
const left = Math.max(16, Math.min(rect.left, window.innerWidth - width - 16));
|
|
6134
|
+
setPos({ left, bottom: Math.max(16, window.innerHeight - rect.top + 6), width });
|
|
6135
|
+
};
|
|
6136
|
+
place();
|
|
6137
|
+
const onDown = (e) => {
|
|
6138
|
+
if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false);
|
|
6139
|
+
};
|
|
6140
|
+
const onKey = (e) => {
|
|
6141
|
+
if (e.key === "Escape") {
|
|
6142
|
+
setOpen(false);
|
|
6143
|
+
buttonRef.current?.focus?.();
|
|
6144
|
+
}
|
|
6145
|
+
};
|
|
6146
|
+
document.addEventListener("mousedown", onDown, true);
|
|
6147
|
+
document.addEventListener("keydown", onKey, true);
|
|
6148
|
+
window.addEventListener("resize", place);
|
|
6149
|
+
return () => {
|
|
6150
|
+
document.removeEventListener("mousedown", onDown, true);
|
|
6151
|
+
document.removeEventListener("keydown", onKey, true);
|
|
6152
|
+
window.removeEventListener("resize", place);
|
|
6153
|
+
};
|
|
6154
|
+
}, [open]);
|
|
4535
6155
|
|
|
4536
|
-
|
|
4537
|
-
|
|
4538
|
-
|
|
4539
|
-
"div",
|
|
4540
|
-
{ className: "dcpa-q-meters" },
|
|
4541
|
-
windows.map((w, i) => h(QuotaMeter, { key: w.id ?? i, t, w, now }))
|
|
4542
|
-
);
|
|
4543
|
-
}
|
|
4544
|
-
const lines = [
|
|
4545
|
-
cred.quotaState === "error"
|
|
4546
|
-
? h("p", { key: "q", className: "dcpa-q-note", "data-tone": "error" }, t("qQuotaError"))
|
|
4547
|
-
: h("p", { key: "q", className: "dcpa-q-note" }, t(cred.quotaState === "unsupported" ? "qQuotaUnsupported" : "qQuotaNone")),
|
|
4548
|
-
cred.recent ? h("p", { key: "r", className: "dcpa-q-note" }, fmt(t, "qRecent", cred.recent)) : null,
|
|
4549
|
-
];
|
|
4550
|
-
return h("div", { className: "dcpa-q-meters" }, h("div", null, lines));
|
|
4551
|
-
}
|
|
6156
|
+
useEffect(() => {
|
|
6157
|
+
if (!visible && open) setOpen(false);
|
|
6158
|
+
}, [visible, open]);
|
|
4552
6159
|
|
|
4553
|
-
|
|
4554
|
-
const
|
|
4555
|
-
const
|
|
4556
|
-
|
|
4557
|
-
|
|
4558
|
-
|
|
4559
|
-
|
|
4560
|
-
"div",
|
|
4561
|
-
{ className: "dcpa-q-who" },
|
|
4562
|
-
h("div", { className: "dcpa-q-name", title: cred.label || cred.id }, cred.label || cred.id),
|
|
4563
|
-
h(
|
|
4564
|
-
"div",
|
|
4565
|
-
{ className: "dcpa-q-meta" },
|
|
4566
|
-
CredentialTags({ t, cred }),
|
|
4567
|
-
models === null
|
|
4568
|
-
? h("span", null, t("qModelsUnknown"))
|
|
4569
|
-
: h(
|
|
4570
|
-
"button",
|
|
4571
|
-
{ type: "button", className: "dcpa-q-link", "aria-expanded": open, onClick: () => setOpen((v) => !v) },
|
|
4572
|
-
open ? t("qHideModels") : fmt(t, "qShowModels", { n: models.length })
|
|
4573
|
-
),
|
|
4574
|
-
cred.statusMessage ? h("span", { title: cred.statusMessage }, cred.statusMessage) : null
|
|
4575
|
-
)
|
|
4576
|
-
),
|
|
4577
|
-
h(CredentialWindows, { t, cred, windows: credentialWindows(cred), now }),
|
|
4578
|
-
open && models !== null
|
|
4579
|
-
? h(
|
|
4580
|
-
"div",
|
|
4581
|
-
{ className: "dcpa-q-models" },
|
|
4582
|
-
models.map((m) => h("code", { key: m, "data-picker": pickerIds.has(m) ? "true" : "false" }, m))
|
|
4583
|
-
)
|
|
4584
|
-
: null
|
|
4585
|
-
);
|
|
4586
|
-
}
|
|
6160
|
+
if (!visible) return null;
|
|
6161
|
+
const { account, entries, best, usableCount } = summary;
|
|
6162
|
+
const loadingFirst = account.fetchedAt === undefined && (account.state === "loading" || account.refreshing === true || account.state === "idle");
|
|
6163
|
+
const level = quotaLevel(best);
|
|
6164
|
+
const label = fmt(t, "qBadgeLabel", { model: selection.model });
|
|
6165
|
+
const errorText = quotaErrorText(t, account);
|
|
6166
|
+
const refreshing = account.refreshing === true || snap.busy === "refresh";
|
|
4587
6167
|
|
|
4588
|
-
|
|
4589
|
-
|
|
4590
|
-
return h(
|
|
4591
|
-
"details",
|
|
4592
|
-
{ className: "dcpa-q-map" },
|
|
4593
|
-
h(
|
|
4594
|
-
"summary",
|
|
4595
|
-
null,
|
|
4596
|
-
h(
|
|
6168
|
+
const peek = open
|
|
6169
|
+
? h(
|
|
4597
6170
|
"div",
|
|
4598
|
-
{
|
|
4599
|
-
|
|
4600
|
-
|
|
4601
|
-
|
|
4602
|
-
|
|
4603
|
-
|
|
4604
|
-
|
|
4605
|
-
|
|
4606
|
-
|
|
4607
|
-
|
|
4608
|
-
|
|
6171
|
+
{
|
|
6172
|
+
className: "dcpa-q-pop",
|
|
6173
|
+
role: "dialog",
|
|
6174
|
+
"aria-label": label,
|
|
6175
|
+
style: pos ? { left: `${pos.left}px`, bottom: `${pos.bottom}px`, width: `${pos.width}px` } : { visibility: "hidden" },
|
|
6176
|
+
},
|
|
6177
|
+
h(
|
|
6178
|
+
"div",
|
|
6179
|
+
{ className: "dcpa-q-pop-head" },
|
|
6180
|
+
h("div", { className: "dcpa-q-pop-model" }, selection.model),
|
|
6181
|
+
h("div", { className: "dcpa-q-pop-sub" }, `${account.displayName || account.accountId} · ${fmt(t, "qPeekCreds", { n: usableCount })}`)
|
|
6182
|
+
),
|
|
6183
|
+
errorText ? h("p", { className: "dcpa-q-pop-text", "data-tone": "error" }, errorText) : null,
|
|
6184
|
+
loadingFirst ? h("p", { className: "dcpa-q-pop-text" }, t("qPeekLoading")) : null,
|
|
6185
|
+
!loadingFirst && entries.length === 0 ? h("p", { className: "dcpa-q-pop-text" }, t("qPeekNoCreds")) : null,
|
|
6186
|
+
entries.map(({ cred, windows, headroom }, index) =>
|
|
6187
|
+
h(
|
|
4609
6188
|
"div",
|
|
4610
|
-
{ key:
|
|
4611
|
-
h("span", { className: "dcpa-q-map-model" }, m.id),
|
|
6189
|
+
{ key: cred.id, className: "dcpa-q-pop-cred" },
|
|
4612
6190
|
h(
|
|
4613
|
-
"
|
|
4614
|
-
{ className: "dcpa-q-
|
|
4615
|
-
|
|
4616
|
-
|
|
4617
|
-
|
|
4618
|
-
|
|
4619
|
-
|
|
4620
|
-
|
|
4621
|
-
`${providerName(t, cred.provider)} · ${cred.label || cred.id}`
|
|
4622
|
-
)
|
|
4623
|
-
),
|
|
4624
|
-
matchedBy && MATCH_KEYS[matchedBy] ? h("span", null, t(MATCH_KEYS[matchedBy])) : null
|
|
6191
|
+
"div",
|
|
6192
|
+
{ className: "dcpa-q-pop-who" },
|
|
6193
|
+
h("span", { className: "dcpa-q-tag" }, providerName(t, cred.provider)),
|
|
6194
|
+
h("span", { className: "dcpa-q-name", title: cred.label || cred.id }, cred.label || cred.id),
|
|
6195
|
+
index === 0 && cred.status === "active" && headroom !== null && entries.length > 1
|
|
6196
|
+
? h("span", { className: "dcpa-q-tag", "data-tone": "success" }, t("qPeekBest"))
|
|
6197
|
+
: null,
|
|
6198
|
+
CredentialTags({ t, cred })
|
|
4625
6199
|
),
|
|
4626
|
-
h(
|
|
4627
|
-
)
|
|
4628
|
-
|
|
4629
|
-
|
|
4630
|
-
|
|
6200
|
+
h(CredentialWindows, { t, cred, windows, now })
|
|
6201
|
+
)
|
|
6202
|
+
),
|
|
6203
|
+
h(
|
|
6204
|
+
"div",
|
|
6205
|
+
{ className: "dcpa-q-pop-foot" },
|
|
6206
|
+
h("span", null, account.fetchedAt ? fmt(t, "qUpdated", { time: agoText(t, account.fetchedAt, now) }) : t("qPeekFooter")),
|
|
6207
|
+
h(
|
|
6208
|
+
"button",
|
|
6209
|
+
{
|
|
6210
|
+
type: "button",
|
|
6211
|
+
className: "dcpa-q-btn",
|
|
6212
|
+
disabled: snap.busy !== null || account.refreshing === true,
|
|
6213
|
+
onClick: () => void store?.refresh(account.accountId),
|
|
6214
|
+
},
|
|
6215
|
+
refreshing ? t("qRefreshing") : t("qRefresh")
|
|
6216
|
+
)
|
|
6217
|
+
)
|
|
6218
|
+
)
|
|
6219
|
+
: null;
|
|
4631
6220
|
|
|
4632
|
-
function QuotaSummary({ t, credentials }) {
|
|
4633
|
-
const stats = quotaStats(credentials);
|
|
4634
|
-
const parts = [
|
|
4635
|
-
["ok", stats.healthy, "qStatHealthy"],
|
|
4636
|
-
["low", stats.low, "qStatLow"],
|
|
4637
|
-
["empty", stats.empty, "qStatEmpty"],
|
|
4638
|
-
["issue", stats.issue, "qStatIssue"],
|
|
4639
|
-
["unknown", stats.unknown, "qStatUnknown"],
|
|
4640
|
-
];
|
|
4641
6221
|
return h(
|
|
4642
|
-
"
|
|
4643
|
-
{ className: "dcpa-q-
|
|
6222
|
+
"span",
|
|
6223
|
+
{ ref: wrapRef, className: "dcpa-q-wrap", "data-placement": placement, "data-dsh-plugin": MODULE_ID },
|
|
4644
6224
|
h(
|
|
4645
|
-
"
|
|
4646
|
-
{
|
|
4647
|
-
|
|
6225
|
+
"button",
|
|
6226
|
+
{
|
|
6227
|
+
ref: buttonRef,
|
|
6228
|
+
type: "button",
|
|
6229
|
+
className: "dcpa-q-badge",
|
|
6230
|
+
"data-level": level,
|
|
6231
|
+
"aria-label": label,
|
|
6232
|
+
"aria-haspopup": "dialog",
|
|
6233
|
+
"aria-expanded": open,
|
|
6234
|
+
title: label,
|
|
6235
|
+
onClick: () => setOpen((v) => !v),
|
|
6236
|
+
},
|
|
6237
|
+
h("span", { className: "dcpa-q-bars", "data-level": level, "aria-hidden": "true" }, h("i"), h("i"), h("i")),
|
|
6238
|
+
h("span", { className: "dcpa-q-badge-pct" }, loadingFirst ? "…" : pctText(best))
|
|
4648
6239
|
),
|
|
4649
|
-
|
|
4650
|
-
"div",
|
|
4651
|
-
{ className: "dcpa-q-legend" },
|
|
4652
|
-
h("span", null, fmt(t, "qTotalCreds", { n: stats.total })),
|
|
4653
|
-
parts.filter(([level, n]) => level !== "unknown" || n > 0).map(([level, n, key]) =>
|
|
4654
|
-
h("span", { key: level, "data-level": level }, h("i", { className: "dcpa-q-swatch" }), t(key), h("b", null, String(n)))
|
|
4655
|
-
)
|
|
4656
|
-
)
|
|
6240
|
+
peek
|
|
4657
6241
|
);
|
|
4658
6242
|
}
|
|
4659
6243
|
|
|
4660
|
-
|
|
4661
|
-
function QuotaPanel({ t, store = sharedQuota, accountId }) {
|
|
4662
|
-
const snap = useQuota(store);
|
|
4663
|
-
const now = useNow(30 * 1000);
|
|
4664
|
-
const [activeId, setActiveId] = useState(null);
|
|
4665
|
-
const [keyDraft, setKeyDraft] = useState("");
|
|
4666
|
-
const [notice, setNotice] = useState(null);
|
|
4667
|
-
const [providerFilter, setProviderFilter] = useState("all");
|
|
4668
|
-
const [search, setSearch] = useState("");
|
|
6244
|
+
// #endregion
|
|
4669
6245
|
|
|
4670
|
-
|
|
4671
|
-
const scoped = accountId !== undefined;
|
|
4672
|
-
const account = scoped
|
|
4673
|
-
? accounts.find((a) => a.accountId === accountId) ?? null
|
|
4674
|
-
: accounts.find((a) => a.accountId === activeId) ?? accounts[0] ?? null;
|
|
4675
|
-
const credentials = Array.isArray(account?.credentials) ? account.credentials : [];
|
|
4676
|
-
const pickerIds = useMemo(() => new Set((account?.pickerModels ?? []).map((m) => m.id)), [account]);
|
|
4677
|
-
const busy = snap.busy !== null;
|
|
6246
|
+
// #region Generation-model button (composer / hero)
|
|
4678
6247
|
|
|
4679
|
-
|
|
4680
|
-
|
|
4681
|
-
|
|
4682
|
-
return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
4683
|
-
}, [credentials]);
|
|
6248
|
+
function mediaFileURL(sessionId, id) {
|
|
6249
|
+
return `${API.mediaFile}?sessionId=${encodeURIComponent(sessionId)}&id=${encodeURIComponent(id)}`;
|
|
6250
|
+
}
|
|
4684
6251
|
|
|
4685
|
-
|
|
4686
|
-
|
|
4687
|
-
|
|
4688
|
-
for (const c of credentials) {
|
|
4689
|
-
if (providerFilter !== "all" && (c.provider || "") !== providerFilter) continue;
|
|
4690
|
-
if (
|
|
4691
|
-
q !== "" &&
|
|
4692
|
-
!String(c.label ?? "").toLowerCase().includes(q) &&
|
|
4693
|
-
!String(c.id ?? "").toLowerCase().includes(q) &&
|
|
4694
|
-
!(Array.isArray(c.models) && c.models.some((m) => String(m).toLowerCase().includes(q)))
|
|
4695
|
-
) continue;
|
|
4696
|
-
const key = c.provider || "";
|
|
4697
|
-
if (!map.has(key)) map.set(key, []);
|
|
4698
|
-
map.get(key).push(c);
|
|
4699
|
-
}
|
|
4700
|
-
// Problems first, then least remaining, so what needs attention is on top.
|
|
4701
|
-
const rank = (c) => (c.status !== "active" ? 1 : 0);
|
|
4702
|
-
return [...map.entries()].map(([key, list]) => [
|
|
4703
|
-
key,
|
|
4704
|
-
[...list].sort((a, b) => rank(a) - rank(b) || (credentialHeadroom(a) ?? 101) - (credentialHeadroom(b) ?? 101)),
|
|
4705
|
-
]);
|
|
4706
|
-
}, [credentials, providerFilter, search]);
|
|
6252
|
+
function mediaSessionURL(sessionId) {
|
|
6253
|
+
return typeof sessionId === "string" && sessionId !== "" ? `${API.mediaSession}?sessionId=${encodeURIComponent(sessionId)}` : API.mediaSession;
|
|
6254
|
+
}
|
|
4707
6255
|
|
|
4708
|
-
|
|
6256
|
+
const IMAGE_ICON = () =>
|
|
6257
|
+
h(
|
|
6258
|
+
"svg",
|
|
6259
|
+
{ width: 14, height: 14, viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true" },
|
|
6260
|
+
h("rect", { x: 1.75, y: 2.75, width: 12.5, height: 10.5, rx: 2, stroke: "currentColor", strokeWidth: 1.3 }),
|
|
6261
|
+
h("circle", { cx: 5.5, cy: 6.25, r: 1.25, fill: "currentColor" }),
|
|
6262
|
+
h("path", { d: "M2.5 12l3.6-3.4 2.4 2.2 2.2-2 2.8 2.6", stroke: "currentColor", strokeWidth: 1.3, strokeLinejoin: "round" })
|
|
6263
|
+
);
|
|
4709
6264
|
|
|
4710
|
-
|
|
4711
|
-
|
|
4712
|
-
|
|
4713
|
-
|
|
4714
|
-
|
|
4715
|
-
|
|
4716
|
-
|
|
6265
|
+
/** Composer row width (px) below which the image / video buttons show icons only. */
|
|
6266
|
+
const MEDIA_COMPACT_WIDTH = 640;
|
|
6267
|
+
|
|
6268
|
+
const VIDEO_ICON = () =>
|
|
6269
|
+
h(
|
|
6270
|
+
"svg",
|
|
6271
|
+
{ width: 14, height: 14, viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true" },
|
|
6272
|
+
h("rect", { x: 1.75, y: 3.75, width: 9, height: 8.5, rx: 2, stroke: "currentColor", strokeWidth: 1.3 }),
|
|
6273
|
+
h("path", { d: "M10.75 7l3.5-2v6l-3.5-2", stroke: "currentColor", strokeWidth: 1.3, strokeLinejoin: "round" })
|
|
6274
|
+
);
|
|
6275
|
+
|
|
6276
|
+
const AGENT_ICON = () =>
|
|
6277
|
+
h(
|
|
6278
|
+
"svg",
|
|
6279
|
+
{ width: 14, height: 14, viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true" },
|
|
6280
|
+
h("circle", { cx: 8, cy: 8, r: 2.15, stroke: "currentColor", strokeWidth: 1.25 }),
|
|
6281
|
+
h("path", {
|
|
6282
|
+
d: "M8 1.9v1.5M8 12.6v1.5M1.9 8h1.5M12.6 8h1.5M3.7 3.7l1.1 1.1M11.2 11.2l1.1 1.1M12.3 3.7l-1.1 1.1M4.8 11.2l-1.1 1.1",
|
|
6283
|
+
stroke: "currentColor",
|
|
6284
|
+
strokeWidth: 1.2,
|
|
6285
|
+
strokeLinecap: "round",
|
|
6286
|
+
})
|
|
6287
|
+
);
|
|
6288
|
+
|
|
6289
|
+
/**
|
|
6290
|
+
* Pick the session's image and video models next to the composer (results
|
|
6291
|
+
* show in the /cpa-image and /cpa-video chat rows). Each kind has its own
|
|
6292
|
+
* button and menu; the agent rules are a secondary menu of either one.
|
|
6293
|
+
* Renders nothing without a generation model.
|
|
6294
|
+
* Hook order: slot 0 = data, slot 1 = loading (test harness).
|
|
6295
|
+
*/
|
|
6296
|
+
function MediaChip({ t, sessionId = null, placement = "composer", presence = mediaChips }) {
|
|
6297
|
+
const [data, setData] = useState(null);
|
|
6298
|
+
const [loading, setLoading] = useState(true);
|
|
6299
|
+
// Which kind's model menu is open (image / video), or null.
|
|
6300
|
+
const [openKind, setOpenKind] = useState(null);
|
|
6301
|
+
const [pos, setPos] = useState(null);
|
|
6302
|
+
const [busy, setBusy] = useState(false);
|
|
6303
|
+
const [error, setError] = useState(null);
|
|
6304
|
+
// Bumped to reload while no image model is known yet.
|
|
6305
|
+
const [retry, setRetry] = useState(0);
|
|
6306
|
+
// Secondary menu: this session's agent tool rules.
|
|
6307
|
+
const [agentPane, setAgentPane] = useState(false);
|
|
6308
|
+
const wrapRef = useRef(null);
|
|
6309
|
+
const buttonRef = useRef(null);
|
|
6310
|
+
const mounted = useRef(true);
|
|
6311
|
+
const shadowed = useBadgePlacement(placement, sessionId, presence);
|
|
6312
|
+
// Icon-only buttons while the composer row is narrow (names move to aria-label).
|
|
6313
|
+
const [compact, setCompact] = useState(false);
|
|
6314
|
+
useEffect(() => {
|
|
6315
|
+
// Measure the composer card (the nearest ancestor holding the text input),
|
|
6316
|
+
// not the small button cluster this chip sits in.
|
|
6317
|
+
if (typeof document === "undefined") return undefined;
|
|
6318
|
+
let row = wrapRef.current?.parentElement ?? null;
|
|
6319
|
+
for (let el = row; el && el !== document.body; el = el.parentElement) {
|
|
6320
|
+
if (el.querySelector?.("textarea, [contenteditable='true'], [contenteditable='plaintext-only']")) { row = el; break; }
|
|
4717
6321
|
}
|
|
4718
|
-
|
|
4719
|
-
|
|
4720
|
-
|
|
4721
|
-
const
|
|
4722
|
-
|
|
4723
|
-
|
|
4724
|
-
|
|
4725
|
-
|
|
6322
|
+
if (!row || typeof ResizeObserver === "undefined") return undefined;
|
|
6323
|
+
const measure = () => setCompact(row.clientWidth > 0 && row.clientWidth < MEDIA_COMPACT_WIDTH);
|
|
6324
|
+
measure();
|
|
6325
|
+
const observer = new ResizeObserver(measure);
|
|
6326
|
+
observer.observe(row);
|
|
6327
|
+
return () => observer.disconnect();
|
|
6328
|
+
}, [shadowed, data !== null]);
|
|
6329
|
+
|
|
6330
|
+
const closeMenu = () => {
|
|
6331
|
+
setOpenKind(null);
|
|
6332
|
+
setAgentPane(false);
|
|
4726
6333
|
};
|
|
4727
6334
|
|
|
4728
|
-
|
|
4729
|
-
|
|
4730
|
-
|
|
6335
|
+
useEffect(() => {
|
|
6336
|
+
mounted.current = true;
|
|
6337
|
+
return () => {
|
|
6338
|
+
mounted.current = false;
|
|
6339
|
+
};
|
|
6340
|
+
}, []);
|
|
4731
6341
|
|
|
4732
|
-
|
|
4733
|
-
|
|
4734
|
-
|
|
4735
|
-
|
|
4736
|
-
|
|
4737
|
-
|
|
4738
|
-
|
|
4739
|
-
|
|
4740
|
-
|
|
4741
|
-
|
|
4742
|
-
|
|
4743
|
-
|
|
4744
|
-
|
|
4745
|
-
|
|
4746
|
-
|
|
4747
|
-
|
|
4748
|
-
|
|
4749
|
-
|
|
4750
|
-
|
|
4751
|
-
|
|
4752
|
-
|
|
4753
|
-
|
|
4754
|
-
|
|
4755
|
-
|
|
4756
|
-
|
|
4757
|
-
|
|
4758
|
-
|
|
4759
|
-
|
|
4760
|
-
|
|
4761
|
-
|
|
4762
|
-
|
|
4763
|
-
|
|
4764
|
-
|
|
4765
|
-
|
|
4766
|
-
|
|
4767
|
-
|
|
4768
|
-
|
|
4769
|
-
|
|
4770
|
-
|
|
4771
|
-
|
|
4772
|
-
|
|
4773
|
-
|
|
4774
|
-
|
|
4775
|
-
|
|
4776
|
-
|
|
4777
|
-
|
|
4778
|
-
|
|
4779
|
-
|
|
4780
|
-
|
|
4781
|
-
|
|
4782
|
-
|
|
4783
|
-
|
|
4784
|
-
|
|
4785
|
-
|
|
4786
|
-
|
|
4787
|
-
|
|
4788
|
-
|
|
4789
|
-
|
|
4790
|
-
|
|
4791
|
-
|
|
4792
|
-
|
|
4793
|
-
|
|
4794
|
-
|
|
4795
|
-
|
|
4796
|
-
|
|
4797
|
-
|
|
4798
|
-
|
|
4799
|
-
|
|
4800
|
-
|
|
4801
|
-
|
|
4802
|
-
|
|
4803
|
-
|
|
4804
|
-
|
|
4805
|
-
|
|
4806
|
-
|
|
4807
|
-
|
|
4808
|
-
|
|
4809
|
-
|
|
4810
|
-
|
|
4811
|
-
|
|
4812
|
-
|
|
4813
|
-
|
|
4814
|
-
|
|
4815
|
-
|
|
4816
|
-
|
|
4817
|
-
|
|
4818
|
-
|
|
4819
|
-
|
|
4820
|
-
|
|
4821
|
-
|
|
4822
|
-
|
|
4823
|
-
|
|
4824
|
-
|
|
4825
|
-
|
|
4826
|
-
|
|
4827
|
-
|
|
4828
|
-
|
|
4829
|
-
?
|
|
6342
|
+
// Load on mount, on session change and whenever a model menu opens. While
|
|
6343
|
+
// the host reports no generation model, or says its catalog missed the
|
|
6344
|
+
// plain listing that carries some image models, look again after 3 s,
|
|
6345
|
+
// 10 s, then every 30 s; otherwise there is no background polling.
|
|
6346
|
+
useEffect(() => {
|
|
6347
|
+
if (shadowed) return undefined;
|
|
6348
|
+
const controller = new AbortController();
|
|
6349
|
+
let again = null;
|
|
6350
|
+
(async () => {
|
|
6351
|
+
let needsRetry = true;
|
|
6352
|
+
try {
|
|
6353
|
+
const res = await fetch(mediaSessionURL(sessionId), { cache: "no-store", signal: controller.signal });
|
|
6354
|
+
const body = await res.json();
|
|
6355
|
+
if (controller.signal.aborted) return;
|
|
6356
|
+
if (!res.ok || body.ok === false) throw new Error(body.error || `HTTP ${res.status}`);
|
|
6357
|
+
setData(body.session ?? null);
|
|
6358
|
+
// Generation switched off: nothing to show and nothing to wait for
|
|
6359
|
+
// (the Generation tab announces when it is switched on again).
|
|
6360
|
+
needsRetry = body.session?.generation !== false && (body.session?.catalogIncomplete === true ||
|
|
6361
|
+
!["image", "video"].some((kind) => Array.isArray(body.session?.options?.[kind]) && body.session.options[kind].length > 0));
|
|
6362
|
+
} catch (err) {
|
|
6363
|
+
if (!controller.signal.aborted) setError(err instanceof Error ? err.message : String(err));
|
|
6364
|
+
} finally {
|
|
6365
|
+
if (!controller.signal.aborted) setLoading(false);
|
|
6366
|
+
}
|
|
6367
|
+
// 3 s, 10 s, then every 30 s (the host also syncs once on this request).
|
|
6368
|
+
if (needsRetry && !controller.signal.aborted) again = setTimeout(() => setRetry((n) => n + 1), [3000, 10000][retry] ?? 30 * 1000);
|
|
6369
|
+
})();
|
|
6370
|
+
return () => {
|
|
6371
|
+
controller.abort();
|
|
6372
|
+
if (again !== null) clearTimeout(again);
|
|
6373
|
+
};
|
|
6374
|
+
}, [sessionId, shadowed, openKind, retry]);
|
|
6375
|
+
|
|
6376
|
+
// The master switch changed on the Generation tab: load again now.
|
|
6377
|
+
useEffect(() => {
|
|
6378
|
+
const reload = () => {
|
|
6379
|
+
if (!mounted.current) return;
|
|
6380
|
+
closeMenu();
|
|
6381
|
+
setRetry((n) => n + 1);
|
|
6382
|
+
};
|
|
6383
|
+
mediaGenerationListeners.add(reload);
|
|
6384
|
+
return () => {
|
|
6385
|
+
mediaGenerationListeners.delete(reload);
|
|
6386
|
+
};
|
|
6387
|
+
}, []);
|
|
6388
|
+
|
|
6389
|
+
useEffect(() => {
|
|
6390
|
+
if (openKind === null) return undefined;
|
|
6391
|
+
const place = () => {
|
|
6392
|
+
// Anchor the menu to the whole two-button group; it sits left of the
|
|
6393
|
+
// model selector either way.
|
|
6394
|
+
const rect = wrapRef.current?.getBoundingClientRect?.();
|
|
6395
|
+
if (!rect || typeof window === "undefined") return;
|
|
6396
|
+
const width = Math.min(340, window.innerWidth - 32);
|
|
6397
|
+
const left = Math.max(16, Math.min(rect.left, window.innerWidth - width - 16));
|
|
6398
|
+
setPos({ left, bottom: Math.max(16, window.innerHeight - rect.top + 6), width });
|
|
6399
|
+
};
|
|
6400
|
+
place();
|
|
6401
|
+
const onDown = (e) => {
|
|
6402
|
+
if (wrapRef.current && !wrapRef.current.contains(e.target)) closeMenu();
|
|
6403
|
+
};
|
|
6404
|
+
const onKey = (e) => {
|
|
6405
|
+
if (e.key === "Escape") {
|
|
6406
|
+
closeMenu();
|
|
6407
|
+
buttonRef.current?.focus?.();
|
|
6408
|
+
}
|
|
6409
|
+
};
|
|
6410
|
+
document.addEventListener("mousedown", onDown, true);
|
|
6411
|
+
document.addEventListener("keydown", onKey, true);
|
|
6412
|
+
window.addEventListener("resize", place);
|
|
6413
|
+
return () => {
|
|
6414
|
+
document.removeEventListener("mousedown", onDown, true);
|
|
6415
|
+
document.removeEventListener("keydown", onKey, true);
|
|
6416
|
+
window.removeEventListener("resize", place);
|
|
6417
|
+
};
|
|
6418
|
+
}, [openKind]);
|
|
6419
|
+
|
|
6420
|
+
const hasSession = typeof sessionId === "string" && sessionId !== "";
|
|
6421
|
+
const agent = data?.agent ?? null;
|
|
6422
|
+
const agentAvailable = agent?.enabled === true && hasSession;
|
|
6423
|
+
const kinds = ["image", "video"]
|
|
6424
|
+
.map((kind) => {
|
|
6425
|
+
const options = Array.isArray(data?.options?.[kind]) ? data.options[kind] : [];
|
|
6426
|
+
const currentRef = data?.current?.[kind]?.ref ?? null;
|
|
6427
|
+
const current = options.find((o) => o.ref === currentRef) ?? null;
|
|
6428
|
+
return { kind, options, currentRef, source: data?.current?.[kind]?.source ?? null, name: current ? current.name || current.modelId : null };
|
|
6429
|
+
})
|
|
6430
|
+
.filter((k) => k.options.length > 0);
|
|
6431
|
+
if (shadowed || kinds.length === 0) return null;
|
|
6432
|
+
|
|
6433
|
+
const imageKind = kinds.find((k) => k.kind === "image") ?? null;
|
|
6434
|
+
const videoKind = kinds.find((k) => k.kind === "video") ?? null;
|
|
6435
|
+
const activeKind = openKind === "image" ? imageKind : openKind === "video" ? videoKind : null;
|
|
6436
|
+
const showingAgent = openKind !== null && agentPane && agentAvailable;
|
|
6437
|
+
const kindLabel = (k) =>
|
|
6438
|
+
k.name
|
|
6439
|
+
? fmt(t, k.kind === "image" ? "mediaChipLabel" : "mediaChipVideoLabel", { model: k.name })
|
|
6440
|
+
: t(k.kind === "image" ? "mediaChipChoose" : "mediaChipVideoChoose");
|
|
6441
|
+
const openMenu = (kind, element) => {
|
|
6442
|
+
if (openKind === kind) {
|
|
6443
|
+
closeMenu();
|
|
6444
|
+
return;
|
|
6445
|
+
}
|
|
6446
|
+
buttonRef.current = element ?? null;
|
|
6447
|
+
setAgentPane(false);
|
|
6448
|
+
setOpenKind(kind);
|
|
6449
|
+
};
|
|
6450
|
+
|
|
6451
|
+
const pick = async (kind, ref) => {
|
|
6452
|
+
setBusy(true);
|
|
6453
|
+
setError(null);
|
|
6454
|
+
try {
|
|
6455
|
+
const body = await postJson(API.mediaSessionSave, { ...(hasSession ? { sessionId } : {}), kind, ref });
|
|
6456
|
+
if (mounted.current) setData(body.session ?? null);
|
|
6457
|
+
} catch (err) {
|
|
6458
|
+
if (mounted.current) setError(mediaErrorText(t, err));
|
|
6459
|
+
} finally {
|
|
6460
|
+
if (mounted.current) setBusy(false);
|
|
6461
|
+
}
|
|
6462
|
+
};
|
|
6463
|
+
|
|
6464
|
+
const optionGroups = (k) => {
|
|
6465
|
+
const groups = [];
|
|
6466
|
+
for (const option of k.options) {
|
|
6467
|
+
const group = groups.find((g) => g.accountId === option.accountId);
|
|
6468
|
+
if (group) group.items.push(option);
|
|
6469
|
+
else groups.push({ accountId: option.accountId, name: option.accountName, items: [option] });
|
|
6470
|
+
}
|
|
6471
|
+
return h(
|
|
6472
|
+
"div",
|
|
6473
|
+
{ className: "dcpa-m-section", "data-kind": k.kind },
|
|
6474
|
+
k.source !== null && k.source !== "session" && hasSession ? h("p", { className: "dcpa-q-pop-text" }, t("mediaChipInherited")) : null,
|
|
6475
|
+
groups.map((group) =>
|
|
6476
|
+
h(
|
|
4830
6477
|
"div",
|
|
4831
|
-
{
|
|
4832
|
-
h(
|
|
4833
|
-
|
|
4834
|
-
{ className: "dcpa-q-row-main" },
|
|
4835
|
-
h("div", { className: "dcpa-q-title" }, t("qDataLabel")),
|
|
4836
|
-
h(
|
|
4837
|
-
"div",
|
|
4838
|
-
{ className: "dcpa-q-sub" },
|
|
4839
|
-
refreshing
|
|
4840
|
-
? t("qRefreshing")
|
|
4841
|
-
: account.fetchedAt
|
|
4842
|
-
? fmt(t, "qUpdated", { time: agoText(t, account.fetchedAt, now) })
|
|
4843
|
-
: t("qNeverUpdated"),
|
|
4844
|
-
account.enabled === false ? ` · ${t("qAccountDisabled")}` : ""
|
|
4845
|
-
)
|
|
4846
|
-
),
|
|
4847
|
-
h(
|
|
4848
|
-
"div",
|
|
4849
|
-
{ className: "dcpa-q-actions" },
|
|
6478
|
+
{ key: group.accountId, role: "group", "aria-label": group.name },
|
|
6479
|
+
groups.length > 1 ? h("div", { className: "dcpa-m-group" }, group.name) : null,
|
|
6480
|
+
group.items.map((option) =>
|
|
4850
6481
|
h(
|
|
4851
6482
|
"button",
|
|
4852
|
-
{
|
|
4853
|
-
|
|
6483
|
+
{
|
|
6484
|
+
key: option.ref,
|
|
6485
|
+
type: "button",
|
|
6486
|
+
role: "menuitemradio",
|
|
6487
|
+
"aria-checked": option.ref === k.currentRef,
|
|
6488
|
+
className: "dcpa-menu-item dcpa-m-option",
|
|
6489
|
+
disabled: busy,
|
|
6490
|
+
onClick: () => void pick(k.kind, option.ref),
|
|
6491
|
+
},
|
|
6492
|
+
h("span", { className: "dcpa-m-check", "aria-hidden": "true" }, option.ref === k.currentRef ? "✓" : ""),
|
|
6493
|
+
h("span", { className: "dcpa-m-name" }, option.name || option.modelId),
|
|
6494
|
+
option.aliasOf ? h("span", { className: "dcpa-q-tag", "data-tone": "info" }, fmt(t, "mediaAliasTag", { target: option.aliasOf })) : null
|
|
4854
6495
|
)
|
|
4855
6496
|
)
|
|
4856
6497
|
)
|
|
4857
|
-
|
|
4858
|
-
|
|
4859
|
-
|
|
4860
|
-
|
|
4861
|
-
|
|
4862
|
-
|
|
4863
|
-
|
|
4864
|
-
|
|
4865
|
-
|
|
6498
|
+
)
|
|
6499
|
+
);
|
|
6500
|
+
};
|
|
6501
|
+
|
|
6502
|
+
// This session's rules for the agent tool: a secondary menu reached from
|
|
6503
|
+
// either model menu, never mixed into the model lists.
|
|
6504
|
+
const setPolicy = async (patch) => {
|
|
6505
|
+
setBusy(true);
|
|
6506
|
+
setError(null);
|
|
6507
|
+
try {
|
|
6508
|
+
const body = await postJson(API.mediaSessionSave, { sessionId, ...patch });
|
|
6509
|
+
if (mounted.current) setData(body.session ?? null);
|
|
6510
|
+
} catch (err) {
|
|
6511
|
+
if (mounted.current) setError(mediaErrorText(t, err));
|
|
6512
|
+
} finally {
|
|
6513
|
+
if (mounted.current) setBusy(false);
|
|
6514
|
+
}
|
|
6515
|
+
};
|
|
6516
|
+
const policySelect = (field, labelKey) =>
|
|
6517
|
+
h(
|
|
6518
|
+
"label",
|
|
6519
|
+
{ className: "dcpa-m-policy-row" },
|
|
6520
|
+
h("span", null, t(labelKey)),
|
|
6521
|
+
h(
|
|
6522
|
+
"select",
|
|
6523
|
+
{
|
|
6524
|
+
className: "dcpa-q-input dcpa-media-select",
|
|
6525
|
+
value: agent.sources?.[field] === "session" ? agent[field] : "inherit",
|
|
6526
|
+
disabled: busy,
|
|
6527
|
+
onChange: (e) => void setPolicy({ [field]: e.target.value }),
|
|
6528
|
+
},
|
|
6529
|
+
h("option", { value: "inherit" }, fmt(t, "mediaFollowGlobal", { value: t((agent.global?.[field] ?? agent[field]) === "ask" ? "mediaConfirmAsk" : "mediaConfirmAuto") })),
|
|
6530
|
+
h("option", { value: "ask" }, t("mediaConfirmAsk")),
|
|
6531
|
+
h("option", { value: "auto" }, t("mediaConfirmAuto"))
|
|
6532
|
+
)
|
|
6533
|
+
);
|
|
6534
|
+
const sessionBudget = agent?.videoBudget ?? null;
|
|
6535
|
+
const agentSettings = !agentAvailable ? null : h(
|
|
6536
|
+
"div",
|
|
6537
|
+
{ className: "dcpa-m-agent-pane", "data-kind": "agent" },
|
|
6538
|
+
policySelect("videoConfirm", "mediaVideoConfirm"),
|
|
6539
|
+
policySelect("imageConfirm", "mediaImageConfirm"),
|
|
6540
|
+
h(
|
|
6541
|
+
"label",
|
|
6542
|
+
{ className: "dcpa-m-policy-row" },
|
|
6543
|
+
h("span", null, t("mediaVideoBudget")),
|
|
6544
|
+
h(
|
|
6545
|
+
"select",
|
|
6546
|
+
{
|
|
6547
|
+
className: "dcpa-q-input dcpa-media-select",
|
|
6548
|
+
value: agent.sources?.videoBudget === "session" ? budgetValue(sessionBudget) : "inherit",
|
|
6549
|
+
disabled: busy,
|
|
6550
|
+
onChange: (e) => void setPolicy({ videoBudget: e.target.value === "inherit" ? "inherit" : budgetFromValue(e.target.value) }),
|
|
6551
|
+
},
|
|
6552
|
+
h(
|
|
6553
|
+
"option",
|
|
6554
|
+
{ value: "inherit" },
|
|
6555
|
+
fmt(t, "mediaFollowGlobal", { value: (agent.global?.videoBudget ?? sessionBudget)?.mode === "limited" ? fmt(t, "mediaBudgetSeconds", { n: (agent.global?.videoBudget ?? sessionBudget).seconds }) : t("mediaBudgetUnlimited") })
|
|
6556
|
+
),
|
|
6557
|
+
budgetChoices(sessionBudget).map((n) => h("option", { key: n, value: `limited:${n}` }, fmt(t, "mediaBudgetSeconds", { n }))),
|
|
6558
|
+
h("option", { value: "unlimited" }, t("mediaBudgetUnlimited"))
|
|
6559
|
+
)
|
|
6560
|
+
),
|
|
6561
|
+
h(
|
|
6562
|
+
"p",
|
|
6563
|
+
{ className: "dcpa-q-pop-text", "data-agent-used": String(agent.used ?? 0) },
|
|
6564
|
+
sessionBudget?.mode === "limited"
|
|
6565
|
+
? fmt(t, "mediaAgentUsed", { used: agent.used ?? 0, limit: sessionBudget.seconds })
|
|
6566
|
+
: fmt(t, "mediaAgentUsedUnlimited", { used: agent.used ?? 0 })
|
|
6567
|
+
)
|
|
6568
|
+
);
|
|
6569
|
+
const agentButton = h(
|
|
6570
|
+
"button",
|
|
6571
|
+
{ type: "button", className: "dcpa-q-btn dcpa-m-agent-open", onClick: () => setAgentPane(true) },
|
|
6572
|
+
h(AGENT_ICON),
|
|
6573
|
+
h("span", null, t("mediaAgentSettings"))
|
|
6574
|
+
);
|
|
6575
|
+
const popVisible = openKind !== null && (showingAgent || activeKind !== null);
|
|
6576
|
+
const head = !popVisible
|
|
6577
|
+
? null
|
|
6578
|
+
: showingAgent
|
|
6579
|
+
? h(
|
|
6580
|
+
"div",
|
|
6581
|
+
{ className: "dcpa-q-pop-head dcpa-m-agent-head" },
|
|
6582
|
+
h(
|
|
6583
|
+
"button",
|
|
6584
|
+
{ type: "button", className: "dcpa-q-btn dcpa-m-back", "aria-label": t("mediaBack"), onClick: () => setAgentPane(false) },
|
|
6585
|
+
"‹ ",
|
|
6586
|
+
t("mediaBack")
|
|
6587
|
+
),
|
|
6588
|
+
h("div", { className: "dcpa-q-pop-model" }, t("mediaAgentSection"))
|
|
6589
|
+
)
|
|
6590
|
+
: h(
|
|
6591
|
+
"div",
|
|
6592
|
+
{ className: "dcpa-q-pop-head" },
|
|
6593
|
+
h("div", { className: "dcpa-q-pop-model" }, t(activeKind.kind === "image" ? "mediaImageGroup" : "mediaVideoGroup")),
|
|
6594
|
+
h("div", { className: "dcpa-q-pop-sub" }, t(activeKind.kind === "image" ? "mediaChipHint" : "mediaChipVideoHint"))
|
|
6595
|
+
);
|
|
6596
|
+
|
|
6597
|
+
const pop = popVisible
|
|
4866
6598
|
? h(
|
|
4867
6599
|
"div",
|
|
4868
|
-
{
|
|
4869
|
-
|
|
4870
|
-
|
|
4871
|
-
|
|
4872
|
-
|
|
4873
|
-
|
|
4874
|
-
|
|
4875
|
-
|
|
4876
|
-
|
|
4877
|
-
"aria-pressed": providerFilter === key,
|
|
4878
|
-
onClick: () => setProviderFilter(key),
|
|
4879
|
-
},
|
|
4880
|
-
key === "all" ? t("qFilterAll") : providerName(t, key),
|
|
4881
|
-
h("small", null, String(count))
|
|
4882
|
-
)
|
|
4883
|
-
)
|
|
4884
|
-
: null,
|
|
4885
|
-
credentials.length > 8
|
|
4886
|
-
? h("input", {
|
|
4887
|
-
className: "dcpa-q-input",
|
|
4888
|
-
type: "search",
|
|
4889
|
-
value: search,
|
|
4890
|
-
placeholder: t("qSearchPlaceholder"),
|
|
4891
|
-
"aria-label": t("qSearchPlaceholder"),
|
|
4892
|
-
onChange: (e) => setSearch(e.target.value),
|
|
4893
|
-
})
|
|
4894
|
-
: null
|
|
4895
|
-
)
|
|
4896
|
-
: null,
|
|
4897
|
-
account && account.keyConfigured && credentials.length > 0 && shown === 0 ? h("p", { className: "dcpa-q-msg" }, t("qNoMatch")) : null,
|
|
4898
|
-
account && account.keyConfigured
|
|
4899
|
-
? groups.map(([key, list]) =>
|
|
4900
|
-
h(
|
|
4901
|
-
"section",
|
|
4902
|
-
{ key: key || "_", "aria-label": providerName(t, key) },
|
|
4903
|
-
h("div", { className: "dcpa-q-group-title" }, providerName(t, key), h("small", null, String(list.length))),
|
|
4904
|
-
list.map((cred) => h(CredentialRow, { key: cred.id, t, cred, now, pickerIds }))
|
|
4905
|
-
)
|
|
6600
|
+
{
|
|
6601
|
+
className: "dcpa-q-pop dcpa-m-pop",
|
|
6602
|
+
role: "dialog",
|
|
6603
|
+
"aria-label": showingAgent ? t("mediaAgentSection") : t(activeKind.kind === "image" ? "mediaImageGroup" : "mediaVideoGroup"),
|
|
6604
|
+
style: pos ? { left: `${pos.left}px`, bottom: `${pos.bottom}px`, width: `${pos.width}px` } : { visibility: "hidden" },
|
|
6605
|
+
},
|
|
6606
|
+
head,
|
|
6607
|
+
...(showingAgent ? [agentSettings] : [optionGroups(activeKind), agentAvailable ? h("div", { className: "dcpa-m-foot" }, agentButton) : null]),
|
|
6608
|
+
error ? h("p", { className: "dcpa-q-pop-text", "data-tone": "error", role: "alert" }, fmt(t, "mediaPickFailed", { error })) : null
|
|
4906
6609
|
)
|
|
4907
|
-
: null
|
|
4908
|
-
|
|
6610
|
+
: null;
|
|
6611
|
+
|
|
6612
|
+
const chipButton = (k) =>
|
|
6613
|
+
h(
|
|
6614
|
+
"button",
|
|
6615
|
+
{
|
|
6616
|
+
type: "button",
|
|
6617
|
+
className: "dcpa-q-badge dcpa-m-chip dcpa-m-chip-button",
|
|
6618
|
+
"data-kind": k.kind,
|
|
6619
|
+
"data-compact": compact ? "true" : undefined,
|
|
6620
|
+
"aria-label": kindLabel(k),
|
|
6621
|
+
"aria-haspopup": "dialog",
|
|
6622
|
+
"aria-expanded": openKind === k.kind,
|
|
6623
|
+
disabled: loading && data === null,
|
|
6624
|
+
onClick: (e) => openMenu(k.kind, e?.currentTarget ?? null),
|
|
6625
|
+
},
|
|
6626
|
+
h(
|
|
6627
|
+
"span",
|
|
6628
|
+
{ className: "dcpa-m-chip-part" },
|
|
6629
|
+
h(k.kind === "image" ? IMAGE_ICON : VIDEO_ICON),
|
|
6630
|
+
compact ? null : h("span", { className: "dcpa-m-chip-label" }, k.name ?? t(k.kind === "image" ? "mediaChipChoose" : "mediaChipVideoChoose"))
|
|
6631
|
+
)
|
|
6632
|
+
);
|
|
6633
|
+
|
|
6634
|
+
return h(
|
|
6635
|
+
"span",
|
|
6636
|
+
{ ref: wrapRef, className: "dcpa-q-wrap dcpa-m-chip-group", "data-placement": placement, "data-dsh-plugin": MODULE_ID },
|
|
6637
|
+
imageKind !== null ? chipButton(imageKind) : null,
|
|
6638
|
+
imageKind !== null && videoKind !== null ? h("span", { className: "dcpa-m-chip-sep", "aria-hidden": "true" }) : null,
|
|
6639
|
+
videoKind !== null ? chipButton(videoKind) : null,
|
|
6640
|
+
pop
|
|
4909
6641
|
);
|
|
4910
6642
|
}
|
|
6643
|
+
// #endregion
|
|
4911
6644
|
|
|
4912
|
-
|
|
4913
|
-
|
|
4914
|
-
|
|
6645
|
+
// #region generate_media tool rows (tool.call.toolview, key "generate_media")
|
|
6646
|
+
|
|
6647
|
+
/** The run id the host derives from a tool call id (mirrors lib/media.js toolRunId). */
|
|
6648
|
+
function toolRunId(callId) {
|
|
6649
|
+
const raw = String(callId ?? "").replace(/[^A-Za-z0-9._:-]/g, "_").replace(/^[^A-Za-z0-9]+/, "").slice(0, 119);
|
|
6650
|
+
return /^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/.test(raw) ? raw : null;
|
|
4915
6651
|
}
|
|
4916
6652
|
|
|
4917
|
-
|
|
4918
|
-
|
|
4919
|
-
|
|
6653
|
+
// Compatibility path for profiles without the native question service.
|
|
6654
|
+
// Only the composer owns these controls; chat rows never ask for consent.
|
|
6655
|
+
function MediaConfirmationDock({ t, sessionId = null }) {
|
|
6656
|
+
const [data, setData] = useState(null);
|
|
6657
|
+
const [loading, setLoading] = useState(true);
|
|
6658
|
+
const [notice, setNotice] = useState(null);
|
|
6659
|
+
const [busy, setBusy] = useState(null);
|
|
6660
|
+
const lifetime = useRef(null);
|
|
6661
|
+
const answering = useRef(null);
|
|
6662
|
+
const answered = useRef(new Set());
|
|
4920
6663
|
useEffect(() => {
|
|
4921
|
-
|
|
4922
|
-
|
|
4923
|
-
|
|
4924
|
-
|
|
4925
|
-
|
|
4926
|
-
|
|
4927
|
-
|
|
4928
|
-
|
|
6664
|
+
const controller = new AbortController();
|
|
6665
|
+
lifetime.current = controller;
|
|
6666
|
+
answering.current = null;
|
|
6667
|
+
answered.current = new Set();
|
|
6668
|
+
setData(null);
|
|
6669
|
+
setNotice(null);
|
|
6670
|
+
setBusy(null);
|
|
6671
|
+
let timer;
|
|
6672
|
+
const load = async () => {
|
|
6673
|
+
try {
|
|
6674
|
+
const res = await fetch(mediaSessionURL(sessionId), { cache: "no-store", signal: controller.signal });
|
|
6675
|
+
const body = await res.json();
|
|
6676
|
+
if (controller.signal.aborted) return;
|
|
6677
|
+
if (!res.ok || body.ok === false) throw new Error(`HTTP ${res.status}`);
|
|
6678
|
+
setData(body.session?.sessionId === sessionId ? body.session : null);
|
|
6679
|
+
} catch {
|
|
6680
|
+
// Missing/offline service is not an empty consent grant. Retry;
|
|
6681
|
+
// pending Host requests still time out closed.
|
|
6682
|
+
} finally {
|
|
6683
|
+
if (!controller.signal.aborted) {
|
|
6684
|
+
setLoading(false);
|
|
6685
|
+
timer = setTimeout(load, 2000);
|
|
6686
|
+
}
|
|
6687
|
+
}
|
|
4929
6688
|
};
|
|
4930
|
-
|
|
4931
|
-
|
|
4932
|
-
|
|
4933
|
-
|
|
6689
|
+
if (typeof sessionId === "string" && sessionId !== "") void load();
|
|
6690
|
+
return () => { controller.abort(); clearTimeout(timer); };
|
|
6691
|
+
}, [sessionId]);
|
|
6692
|
+
const confirms = data?.sessionId === sessionId && Array.isArray(data.pendingConfirmations)
|
|
6693
|
+
? data.pendingConfirmations.filter((item) => !answered.current.has(item.commandId)) : [];
|
|
6694
|
+
const answer = async (commandId, allow) => {
|
|
6695
|
+
if (answering.current !== null || answered.current.has(commandId)) return;
|
|
6696
|
+
const controller = lifetime.current;
|
|
6697
|
+
if (!controller || controller.signal.aborted) return;
|
|
6698
|
+
answering.current = commandId;
|
|
6699
|
+
setBusy(commandId);
|
|
6700
|
+
try {
|
|
6701
|
+
await postJson(API.mediaConfirm, { sessionId, commandId, allow });
|
|
6702
|
+
if (controller.signal.aborted) return;
|
|
6703
|
+
answered.current.add(commandId);
|
|
6704
|
+
setData((current) => current ? { ...current, pendingConfirmations: current.pendingConfirmations.filter((item) => item.commandId !== commandId) } : current);
|
|
6705
|
+
setNotice(null);
|
|
6706
|
+
} catch {
|
|
6707
|
+
if (!controller.signal.aborted) setNotice(t("mediaConfirmDeliveryFailed"));
|
|
6708
|
+
} finally {
|
|
6709
|
+
if (!controller.signal.aborted) { answering.current = null; setBusy(null); }
|
|
6710
|
+
}
|
|
6711
|
+
};
|
|
6712
|
+
if (loading || confirms.length === 0) return null;
|
|
6713
|
+
return h("section", { className: "dcpa-confirm-dock", role: "region", "aria-label": t("mediaConfirmWaiting") },
|
|
6714
|
+
...confirms.map((confirm) => h("div", { className: "dcpa-chat-confirm", key: confirm.commandId, role: "group", "aria-label": t(confirm.kind === "video" ? "mediaConfirmTitleVideo" : "mediaConfirmTitle") },
|
|
6715
|
+
h("p", { className: "dcpa-chat-confirm-title" }, t(confirm.kind === "video" ? "mediaConfirmTitleVideo" : "mediaConfirmTitle")),
|
|
6716
|
+
h("p", { className: "dcpa-chat-confirm-text" }, t("mediaLocale") === "zh" ? confirm.zh : confirm.en),
|
|
6717
|
+
h("div", { className: "dcpa-q-actions" },
|
|
6718
|
+
h("button", { type: "button", className: "dcpa-q-btn", "data-variant": "primary", disabled: busy !== null, onClick: () => void answer(confirm.commandId, true) }, t("mediaConfirmAllow")),
|
|
6719
|
+
h("button", { type: "button", className: "dcpa-q-btn", disabled: busy !== null, onClick: () => void answer(confirm.commandId, false) }, t("mediaConfirmDecline"))),
|
|
6720
|
+
h("p", { className: "dcpa-chat-note" }, t("mediaConfirmNote")))),
|
|
6721
|
+
notice ? h("p", { role: "alert", className: "dcpa-chat-note" }, notice) : null);
|
|
6722
|
+
}
|
|
6723
|
+
|
|
6724
|
+
const MEDIA_NODE_KIND = "dsh-cliproxyapi-media";
|
|
6725
|
+
|
|
6726
|
+
// Fold the actual durable tool lifecycle, not an invented user command.
|
|
6727
|
+
// A tool result carries no tool name, so unrelated result-only contexts
|
|
6728
|
+
// intentionally remain invisible until a matching start is loaded.
|
|
6729
|
+
const mediaEventResult = (event) => event.type === "tool/result" ? event.data : event.type === "tool/ptc-dispatch" ? {
|
|
6730
|
+
message: { content: event.data.content ?? [], isError: event.data.isError === true },
|
|
6731
|
+
meta: event.data.meta,
|
|
6732
|
+
} : null;
|
|
6733
|
+
const mediaDispatchMatch = (event) => event.data?.name === "generate_media" && typeof event.data.subCallId === "string"
|
|
6734
|
+
? { id: event.data.subCallId, role: "start" } : null;
|
|
6735
|
+
const mediaConversationDefinition = {
|
|
6736
|
+
kind: MEDIA_NODE_KIND,
|
|
6737
|
+
target: "chat",
|
|
6738
|
+
match: {
|
|
6739
|
+
"tool/call": (event) => event.data?.name === "generate_media" && typeof event.data.callId === "string"
|
|
6740
|
+
? { id: event.data.callId, role: "start" } : null,
|
|
6741
|
+
"tool/result": (event) => event.surfaceOp === "append" && typeof event.data?.message?.source?.callId === "string"
|
|
6742
|
+
? { id: event.data.message.source.callId, role: "update" } : null,
|
|
6743
|
+
"tool/ptc-dispatch-start": mediaDispatchMatch,
|
|
6744
|
+
"tool/ptc-dispatch": mediaDispatchMatch,
|
|
6745
|
+
},
|
|
6746
|
+
start: (_context, match) => ({
|
|
6747
|
+
callId: match.event.data.callId ?? match.event.data.subCallId,
|
|
6748
|
+
argsRaw: typeof match.event.data.arguments === "string" ? match.event.data.arguments : JSON.stringify(match.event.data.arguments ?? {}),
|
|
6749
|
+
result: mediaEventResult(match.event),
|
|
6750
|
+
}),
|
|
6751
|
+
update: (context, match) => {
|
|
6752
|
+
if (context.state == null) return null;
|
|
6753
|
+
const result = mediaEventResult(match.event);
|
|
6754
|
+
return result === null ? context.state : { ...context.state, result };
|
|
6755
|
+
},
|
|
6756
|
+
buildViewNode: (context) => {
|
|
6757
|
+
if (context.state == null || context.start === undefined) return null;
|
|
6758
|
+
const { callId, argsRaw, result } = context.state;
|
|
6759
|
+
const location = context.start.location;
|
|
6760
|
+
const interrupted = result === null && (
|
|
6761
|
+
location?.kind === "step" && location.step.status === "closed" ||
|
|
6762
|
+
(location?.kind === "step" || location?.kind === "turn") && location.turn.status === "closed"
|
|
6763
|
+
);
|
|
6764
|
+
const done = result !== null || interrupted;
|
|
6765
|
+
const block = done ? {
|
|
6766
|
+
kind: "tool-result", callId,
|
|
6767
|
+
call: { name: "generate_media", argsRaw },
|
|
6768
|
+
content: result?.message?.content ?? [],
|
|
6769
|
+
isError: interrupted || result?.message?.isError === true,
|
|
6770
|
+
...(interrupted ? { error: { code: "interrupted" } } : {}),
|
|
6771
|
+
meta: result?.meta,
|
|
6772
|
+
} : { callId, phase: "start", argsRaw };
|
|
6773
|
+
return {
|
|
6774
|
+
key: context.key, id: context.id, kind: MEDIA_NODE_KIND, target: "chat",
|
|
6775
|
+
anchorSeq: context.start.event.seq, location,
|
|
6776
|
+
visibility: "visible",
|
|
6777
|
+
// Explicitly outside process. Keeping the source step location alone
|
|
6778
|
+
// (as the old synthetic command did) leaves it inside thinking.
|
|
6779
|
+
groupTree: "turn",
|
|
6780
|
+
data: { callId, phase: done ? "result" : "start", block },
|
|
6781
|
+
};
|
|
6782
|
+
},
|
|
6783
|
+
};
|
|
6784
|
+
|
|
6785
|
+
/** Read only the typed, wrapped chat nodes, never flat legacy payloads. */
|
|
6786
|
+
function hasMediaNode(snapshot, runId) {
|
|
6787
|
+
return runId !== null && Array.isArray(snapshot?.order) && typeof snapshot.nodes?.get === "function" &&
|
|
6788
|
+
snapshot.order.some((key) => {
|
|
6789
|
+
const node = snapshot.nodes.get(key);
|
|
6790
|
+
return node?.kind === MEDIA_NODE_KIND && toolRunId(node.data?.callId) === runId;
|
|
6791
|
+
});
|
|
4934
6792
|
}
|
|
4935
6793
|
|
|
4936
6794
|
/**
|
|
4937
|
-
*
|
|
4938
|
-
*
|
|
4939
|
-
*
|
|
4940
|
-
*
|
|
6795
|
+
* The agent tool's call row, drawn like the matching slash command's row:
|
|
6796
|
+
* the request as a bubble, then progress and the result (polled through
|
|
6797
|
+
* `media/command` with the run id derived from the call id).
|
|
6798
|
+
* Owner props from DSH: `{ callId, phase, block }`.
|
|
4941
6799
|
*/
|
|
4942
|
-
function
|
|
4943
|
-
const
|
|
4944
|
-
const
|
|
4945
|
-
const
|
|
4946
|
-
|
|
4947
|
-
|
|
4948
|
-
|
|
4949
|
-
|
|
4950
|
-
const
|
|
4951
|
-
|
|
6800
|
+
function MediaToolRow({ t, owner = {}, sessionId = null, fileActions = null, runCommand = null, useChat = null, standalone = false }) {
|
|
6801
|
+
const block = owner.block ?? null;
|
|
6802
|
+
const done = block !== null && typeof block === "object" && "kind" in block;
|
|
6803
|
+
const runId = toolRunId(owner.callId ?? block?.callId);
|
|
6804
|
+
// New calls are mirrored as a top-level command row by the host. If that
|
|
6805
|
+
// row exists, drawing the full card here as well showed the same run
|
|
6806
|
+
// twice: once inside the folded process group and once as the command
|
|
6807
|
+
// row. Older calls have no mirrored row, so they keep their card.
|
|
6808
|
+
const mirrored = typeof useChat === "function"
|
|
6809
|
+
? useChat((snapshot) => {
|
|
6810
|
+
return hasMediaNode(snapshot, runId);
|
|
6811
|
+
})
|
|
6812
|
+
: false;
|
|
6813
|
+
if (!standalone && mirrored) return null;
|
|
6814
|
+
let args = null;
|
|
6815
|
+
try {
|
|
6816
|
+
const raw = done ? block.call?.argsRaw : block?.argsRaw;
|
|
6817
|
+
const parsed = typeof raw === "string" && raw !== "" ? JSON.parse(raw) : null;
|
|
6818
|
+
args = parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
6819
|
+
} catch {
|
|
6820
|
+
args = null;
|
|
6821
|
+
}
|
|
6822
|
+
const video = args?.kind === "video";
|
|
6823
|
+
const name = !video ? "cpa-image" : args.mode === "edit" ? "cpa-video-edit" : args.mode === "extend" ? "cpa-video-extend" : "cpa-video";
|
|
6824
|
+
const errorText = done && block.isError
|
|
6825
|
+
? (Array.isArray(block.content) ? block.content.filter((c) => c?.type === "text").map((c) => c.text).join("\n") : "").replace(/^Error:\s*/, "") || t("mediaCardFailed")
|
|
6826
|
+
: null;
|
|
6827
|
+
const outcome = errorText !== null ? { kind: "error", text: errorText } : done ? { kind: "success" } : owner.phase === "preparing" || args === null ? null : { kind: "success" };
|
|
6828
|
+
const node = { name, commandId: toolRunId(owner.callId ?? block?.callId), args: typeof args?.prompt === "string" ? `-- ${args.prompt}` : "", outcome };
|
|
6829
|
+
return h(ImageCommandCard, { t, node, sessionId, fileActions, runCommand, pending: !done, label: "generate_media", showCopy: false, showConfirm: false });
|
|
6830
|
+
}
|
|
6831
|
+
|
|
6832
|
+
function MediaCommandRow({ useChat = null, node = null, ...props }) {
|
|
6833
|
+
const mirrored = typeof useChat === "function" ? useChat((snapshot) => hasMediaNode(snapshot, node?.commandId ?? null)) : false;
|
|
6834
|
+
return mirrored ? null : h(ImageCommandCard, { ...props, node, showConfirm: false });
|
|
6835
|
+
}
|
|
6836
|
+
|
|
6837
|
+
// #endregion
|
|
6838
|
+
|
|
6839
|
+
// #region /cpa-image and /cpa-video chat rows (conversation.chat.commandview, keys "cpa-image" / "cpa-video")
|
|
6840
|
+
|
|
6841
|
+
/** Leading `/cpa-image` / `/cpa-video` options of a recorded command line, then the prompt (mirrors the host parsers, never throws). */
|
|
6842
|
+
function splitImageArgs(raw) {
|
|
6843
|
+
let rest = String(raw ?? "").trim();
|
|
6844
|
+
const out = { prompt: "" };
|
|
6845
|
+
for (;;) {
|
|
6846
|
+
if (rest.startsWith("-- ") || rest === "--") {
|
|
6847
|
+
rest = rest.slice(2).trim();
|
|
6848
|
+
break;
|
|
6849
|
+
}
|
|
6850
|
+
const match = rest.match(/^(-m|--model|--aspect|-n|--count|-s|--seconds|-r|--resolution)(?:=|\s+)(\S+)(?:\s+|$)/);
|
|
6851
|
+
if (match === null) break;
|
|
6852
|
+
if (match[1] === "-m" || match[1] === "--model") out.model = match[2];
|
|
6853
|
+
else if (match[1] === "--aspect") out.aspect = match[2];
|
|
6854
|
+
else if (match[1] === "-s" || match[1] === "--seconds") out.seconds = Number(match[2]) || undefined;
|
|
6855
|
+
else if (match[1] === "-r" || match[1] === "--resolution") out.resolution = match[2].toLowerCase();
|
|
6856
|
+
else out.count = Number(match[2]) || undefined;
|
|
6857
|
+
rest = rest.slice(match[0].length);
|
|
6858
|
+
}
|
|
6859
|
+
out.prompt = rest;
|
|
6860
|
+
return out;
|
|
6861
|
+
}
|
|
6862
|
+
|
|
6863
|
+
const RUN_ERROR_KEYS = {
|
|
6864
|
+
interrupted: "mediaRunErrInterrupted",
|
|
6865
|
+
timeout: "mediaRunErrTimeout",
|
|
6866
|
+
network: "mediaRunErrNetwork",
|
|
6867
|
+
"no-image": "mediaRunErrNoImage",
|
|
6868
|
+
"invalid-image": "mediaRunErrInvalid",
|
|
6869
|
+
"invalid-response": "mediaRunErrInvalid",
|
|
6870
|
+
"write-failed": "mediaRunErrWrite",
|
|
6871
|
+
expired: "mediaRunErrExpired",
|
|
6872
|
+
"invalid-video": "mediaRunErrInvalidVideo",
|
|
6873
|
+
"video-too-large": "mediaRunErrVideoTooLarge",
|
|
6874
|
+
};
|
|
6875
|
+
|
|
6876
|
+
/** "9 秒" / "1 分 23 秒" (zh), "9 s" / "1 min 23 s" (en). */
|
|
6877
|
+
const elapsedText = (t, seconds) => {
|
|
6878
|
+
const s = Math.max(0, Math.round(seconds));
|
|
6879
|
+
return s < 60 ? fmt(t, "mediaDurationS", { s }) : fmt(t, "mediaDurationMS", { m: Math.floor(s / 60), s: s % 60 });
|
|
6880
|
+
};
|
|
6881
|
+
|
|
6882
|
+
const secondsBetween = (from, to) => {
|
|
6883
|
+
const start = Date.parse(from ?? "");
|
|
6884
|
+
return Number.isFinite(start) ? Math.max(0, Math.round((to - start) / 1000)) : 0;
|
|
6885
|
+
};
|
|
6886
|
+
|
|
6887
|
+
const REVEAL_KEYS = { explorer: "mediaActRevealExplorer", finder: "mediaActRevealFinder", filemanager: "mediaActRevealFiles" };
|
|
6888
|
+
|
|
6889
|
+
/** An application icon the Host reported: images by data URL or same-origin path only. */
|
|
6890
|
+
function safeIconSource(value) {
|
|
6891
|
+
if (typeof value !== "string" || value === "" || value.length > 200000) return null;
|
|
6892
|
+
if (/^data:image\/(png|jpeg|gif|webp|svg\+xml|x-icon|vnd\.microsoft\.icon);/i.test(value)) return value;
|
|
6893
|
+
return /^(\/|\.{0,2}\/|[a-z0-9-]+\/)/i.test(value) && !/^[a-z][a-z0-9+.-]*:/i.test(value) ? value : null;
|
|
6894
|
+
}
|
|
6895
|
+
|
|
6896
|
+
/** Copy one generated image as PNG (the clipboard takes PNG only, so other types are re-encoded). */
|
|
6897
|
+
async function copyImageToClipboard(url) {
|
|
6898
|
+
if (typeof navigator === "undefined" || !navigator.clipboard?.write || typeof ClipboardItem === "undefined") throw new Error("clipboard unavailable");
|
|
6899
|
+
const png = (async () => {
|
|
6900
|
+
const res = await fetch(url, { cache: "no-store" });
|
|
6901
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
6902
|
+
const blob = await res.blob();
|
|
6903
|
+
if (blob.type === "image/png") return blob;
|
|
6904
|
+
const bitmap = await createImageBitmap(blob);
|
|
6905
|
+
const canvas = document.createElement("canvas");
|
|
6906
|
+
canvas.width = bitmap.width;
|
|
6907
|
+
canvas.height = bitmap.height;
|
|
6908
|
+
canvas.getContext("2d").drawImage(bitmap, 0, 0);
|
|
6909
|
+
return await new Promise((resolve, reject) => canvas.toBlob((b) => (b ? resolve(b) : reject(new Error("png"))), "image/png"));
|
|
6910
|
+
})();
|
|
6911
|
+
// A pending blob keeps the click's user activation for the write.
|
|
6912
|
+
await navigator.clipboard.write([new ClipboardItem({ "image/png": png })]);
|
|
6913
|
+
}
|
|
6914
|
+
|
|
6915
|
+
async function copyTextToClipboard(text) {
|
|
6916
|
+
if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) throw new Error("clipboard unavailable");
|
|
6917
|
+
await navigator.clipboard.writeText(text);
|
|
6918
|
+
}
|
|
6919
|
+
|
|
6920
|
+
/**
|
|
6921
|
+
* Replaces the generic command row of `/cpa-image` with a short exchange:
|
|
6922
|
+
* the request as a user bubble, then the result as a reply — progress while
|
|
6923
|
+
* the background job runs (polled through `media/command`), the images
|
|
6924
|
+
* when done (click to enlarge, right-click for open / show in folder /
|
|
6925
|
+
* copy), or the reason it failed. `fileActions` opens and reveals paths
|
|
6926
|
+
* through DSH's Session Remote; without it those items are hidden.
|
|
6927
|
+
* Hook order: slot 0 = data ({ run, files, folderPath }), slot 1 = loading (test harness).
|
|
6928
|
+
*/
|
|
6929
|
+
// `pending`: an agent tool call still executing (its run record may not exist yet);
|
|
6930
|
+
// `label` replaces the command tag; `showCopy` hides "Copy original input" for tool rows.
|
|
6931
|
+
function ImageCommandCard({ t, node = null, sessionId = null, fileActions = null, runCommand = null, pending = false, label = null, showCopy = true, showConfirm = false }) {
|
|
6932
|
+
const [data, setData] = useState(null);
|
|
6933
|
+
const [loading, setLoading] = useState(true);
|
|
6934
|
+
const [expanded, setExpanded] = useState(null);
|
|
6935
|
+
const [now, setNow] = useState(() => Date.now());
|
|
6936
|
+
const [menu, setMenu] = useState(null);
|
|
6937
|
+
const [notice, setNotice] = useState(null);
|
|
6938
|
+
const [canOpen, setCanOpen] = useState(false);
|
|
6939
|
+
// path → Host applications for it (array), "loading", or null after a failure.
|
|
6940
|
+
const [apps, setApps] = useState({});
|
|
6941
|
+
// Inline "edit / extend this video" form: null | "edit" | "extend".
|
|
6942
|
+
const [editMode, setEditMode] = useState(null);
|
|
6943
|
+
const [editDraft, setEditDraft] = useState("");
|
|
6944
|
+
const [editSeconds, setEditSeconds] = useState(6);
|
|
6945
|
+
const [editBusy, setEditBusy] = useState(false);
|
|
6946
|
+
const [confirmBusy, setConfirmBusy] = useState(false);
|
|
6947
|
+
const menuRef = useRef(null);
|
|
6948
|
+
const mounted = useRef(true);
|
|
6949
|
+
const outcome = node?.outcome ?? null;
|
|
6950
|
+
const commandId = typeof node?.commandId === "string" ? node.commandId : null;
|
|
6951
|
+
const admitted = outcome?.kind === "success";
|
|
6952
|
+
const canLoad = admitted && typeof sessionId === "string" && sessionId !== "" && commandId !== null;
|
|
6953
|
+
const run = data?.run ?? null;
|
|
6954
|
+
const kind = String(node?.name ?? "").startsWith("cpa-video") || run?.kind === "video" ? "video" : "image";
|
|
6955
|
+
const video = kind === "video";
|
|
6956
|
+
const running = outcome === null || (admitted && (run === null ? pending || (canLoad && loading) : run.status === "running"));
|
|
6957
|
+
const files = Array.isArray(data?.files) ? data.files : [];
|
|
6958
|
+
// The generated files this row shows (images, or the one video).
|
|
6959
|
+
const images = files.filter((f) => f.kind === kind);
|
|
4952
6960
|
|
|
4953
|
-
|
|
4954
|
-
|
|
6961
|
+
useEffect(() => {
|
|
6962
|
+
mounted.current = true;
|
|
6963
|
+
return () => {
|
|
6964
|
+
mounted.current = false;
|
|
6965
|
+
};
|
|
6966
|
+
}, []);
|
|
4955
6967
|
|
|
6968
|
+
// Load once; while the run is still going, look again every 2 s.
|
|
4956
6969
|
useEffect(() => {
|
|
4957
|
-
if (!
|
|
4958
|
-
const
|
|
4959
|
-
|
|
4960
|
-
|
|
4961
|
-
|
|
4962
|
-
|
|
4963
|
-
|
|
6970
|
+
if (!canLoad) return undefined;
|
|
6971
|
+
const controller = new AbortController();
|
|
6972
|
+
let timer = null;
|
|
6973
|
+
let failures = 0;
|
|
6974
|
+
const load = async () => {
|
|
6975
|
+
let again = false;
|
|
6976
|
+
try {
|
|
6977
|
+
const url = `${API.mediaCommand}?sessionId=${encodeURIComponent(sessionId)}&commandId=${encodeURIComponent(commandId)}`;
|
|
6978
|
+
const res = await fetch(url, { cache: "no-store", signal: controller.signal });
|
|
6979
|
+
const body = await res.json();
|
|
6980
|
+
if (controller.signal.aborted) return;
|
|
6981
|
+
if (res.ok && body.ok !== false) {
|
|
6982
|
+
setData({
|
|
6983
|
+
run: body.run ?? null,
|
|
6984
|
+
files: Array.isArray(body.files) ? body.files : [],
|
|
6985
|
+
folderPath: typeof body.folderPath === "string" ? body.folderPath : null,
|
|
6986
|
+
desktop: typeof body.desktop === "string" ? body.desktop : null,
|
|
6987
|
+
...(body.confirm && typeof body.confirm === "object" ? { confirm: body.confirm } : {}),
|
|
6988
|
+
...(body.actions && typeof body.actions === "object" ? { actions: body.actions } : {}),
|
|
6989
|
+
});
|
|
6990
|
+
again = body.run?.status === "running" || (pending && (body.run ?? null) === null);
|
|
6991
|
+
}
|
|
6992
|
+
} catch {
|
|
6993
|
+
if (controller.signal.aborted) return;
|
|
6994
|
+
again = ++failures < 5;
|
|
6995
|
+
} finally {
|
|
6996
|
+
if (!controller.signal.aborted) setLoading(false);
|
|
6997
|
+
}
|
|
6998
|
+
if (again && !controller.signal.aborted) timer = setTimeout(load, video ? 3000 : 2000);
|
|
4964
6999
|
};
|
|
4965
|
-
|
|
7000
|
+
void load();
|
|
7001
|
+
return () => {
|
|
7002
|
+
controller.abort();
|
|
7003
|
+
if (timer !== null) clearTimeout(timer);
|
|
7004
|
+
};
|
|
7005
|
+
}, [canLoad, sessionId, commandId, video, pending]);
|
|
7006
|
+
|
|
7007
|
+
// The elapsed-time clock, only while running.
|
|
7008
|
+
useEffect(() => {
|
|
7009
|
+
if (!running) return undefined;
|
|
7010
|
+
const timer = setTimeout(() => setNow(Date.now()), 1000);
|
|
7011
|
+
return () => clearTimeout(timer);
|
|
7012
|
+
}, [running, now]);
|
|
7013
|
+
|
|
7014
|
+
// Whether the Host desktop can open paths (asked once per page by fileActions).
|
|
7015
|
+
const hasFiles = images.length > 0;
|
|
7016
|
+
useEffect(() => {
|
|
7017
|
+
if (!hasFiles || fileActions === null) return undefined;
|
|
7018
|
+
let live = true;
|
|
7019
|
+
fileActions.available().then((value) => {
|
|
7020
|
+
if (live) setCanOpen(value === true);
|
|
7021
|
+
}, () => {});
|
|
7022
|
+
return () => {
|
|
7023
|
+
live = false;
|
|
7024
|
+
};
|
|
7025
|
+
}, [hasFiles, fileActions]);
|
|
7026
|
+
|
|
7027
|
+
// A transient notice ("Copied", a failure) under the images.
|
|
7028
|
+
useEffect(() => {
|
|
7029
|
+
if (notice === null) return undefined;
|
|
7030
|
+
const timer = setTimeout(() => setNotice(null), 2500);
|
|
7031
|
+
return () => clearTimeout(timer);
|
|
7032
|
+
}, [notice]);
|
|
7033
|
+
|
|
7034
|
+
// The context menu closes on an outside press, Escape, scroll or resize.
|
|
7035
|
+
useEffect(() => {
|
|
7036
|
+
if (menu === null || typeof document === "undefined") return undefined;
|
|
7037
|
+
const close = () => setMenu(null);
|
|
4966
7038
|
const onDown = (e) => {
|
|
4967
|
-
if (
|
|
7039
|
+
if (menuRef.current && !menuRef.current.contains(e.target)) close();
|
|
4968
7040
|
};
|
|
4969
7041
|
const onKey = (e) => {
|
|
4970
|
-
if (e.key === "Escape")
|
|
4971
|
-
setOpen(false);
|
|
4972
|
-
buttonRef.current?.focus?.();
|
|
4973
|
-
}
|
|
7042
|
+
if (e.key === "Escape") close();
|
|
4974
7043
|
};
|
|
4975
7044
|
document.addEventListener("mousedown", onDown, true);
|
|
4976
7045
|
document.addEventListener("keydown", onKey, true);
|
|
4977
|
-
window.addEventListener("resize",
|
|
7046
|
+
window.addEventListener("resize", close);
|
|
7047
|
+
window.addEventListener("scroll", close, true);
|
|
4978
7048
|
return () => {
|
|
4979
7049
|
document.removeEventListener("mousedown", onDown, true);
|
|
4980
7050
|
document.removeEventListener("keydown", onKey, true);
|
|
4981
|
-
window.removeEventListener("resize",
|
|
7051
|
+
window.removeEventListener("resize", close);
|
|
7052
|
+
window.removeEventListener("scroll", close, true);
|
|
4982
7053
|
};
|
|
4983
|
-
}, [
|
|
4984
|
-
|
|
4985
|
-
useEffect(() => {
|
|
4986
|
-
if (!visible && open) setOpen(false);
|
|
4987
|
-
}, [visible, open]);
|
|
4988
|
-
|
|
4989
|
-
if (!visible) return null;
|
|
4990
|
-
const { account, entries, best, usableCount } = summary;
|
|
4991
|
-
const loadingFirst = account.fetchedAt === undefined && (account.state === "loading" || account.refreshing === true || account.state === "idle");
|
|
4992
|
-
const level = quotaLevel(best);
|
|
4993
|
-
const label = fmt(t, "qBadgeLabel", { model: selection.model });
|
|
4994
|
-
const errorText = quotaErrorText(t, account);
|
|
4995
|
-
const refreshing = account.refreshing === true || snap.busy === "refresh";
|
|
7054
|
+
}, [menu]);
|
|
4996
7055
|
|
|
4997
|
-
const
|
|
4998
|
-
|
|
4999
|
-
|
|
5000
|
-
|
|
5001
|
-
|
|
5002
|
-
|
|
5003
|
-
|
|
5004
|
-
|
|
7056
|
+
const say = (tone, text) => {
|
|
7057
|
+
if (mounted.current) setNotice({ tone, text });
|
|
7058
|
+
};
|
|
7059
|
+
const act = async (action, file, application) => {
|
|
7060
|
+
setMenu(null);
|
|
7061
|
+
try {
|
|
7062
|
+
if (action === "copy-image") {
|
|
7063
|
+
await copyImageToClipboard(mediaFileURL(sessionId, file.id));
|
|
7064
|
+
say("ok", t("mediaActCopied"));
|
|
7065
|
+
} else if (action === "copy-path") {
|
|
7066
|
+
await copyTextToClipboard(file.path);
|
|
7067
|
+
say("ok", t("mediaActCopied"));
|
|
7068
|
+
} else if (fileActions !== null) {
|
|
7069
|
+
const ok = await fileActions.open(file.path, action === "reveal" ? "reveal" : "open", action === "app" ? application : undefined);
|
|
7070
|
+
if (!ok) say("error", t("mediaActOpenFailed"));
|
|
7071
|
+
}
|
|
7072
|
+
} catch (err) {
|
|
7073
|
+
say("error", fmt(t, "mediaActCopyFailed", { error: err instanceof Error ? err.message : String(err) }));
|
|
7074
|
+
}
|
|
7075
|
+
};
|
|
7076
|
+
// The Host desktop's file manager names the reveal item (Explorer / Finder / Files).
|
|
7077
|
+
const revealLabel = t(REVEAL_KEYS[data?.desktop] ?? "mediaActReveal");
|
|
7078
|
+
/** The always-available actions: the buttons under an enlarged image. */
|
|
7079
|
+
const baseEntries = (file) => [
|
|
7080
|
+
...(canOpen && file.path
|
|
7081
|
+
? [
|
|
7082
|
+
{ key: "open", label: t("mediaActOpen"), run: () => act("open", file) },
|
|
7083
|
+
{ key: "reveal", label: revealLabel, run: () => act("reveal", file) },
|
|
7084
|
+
]
|
|
7085
|
+
: []),
|
|
7086
|
+
...(file.kind === "image" ? [{ key: "copy-image", label: t("mediaActCopyImage"), run: () => act("copy-image", file) }] : []),
|
|
7087
|
+
...(file.path ? [{ key: "copy-path", label: t("mediaActCopyPath"), run: () => act("copy-path", file) }] : []),
|
|
7088
|
+
];
|
|
7089
|
+
/**
|
|
7090
|
+
* The context menu: the Host's applications for this file (default
|
|
7091
|
+
* first, as DSH's document preview lists them), the file manager, then
|
|
7092
|
+
* the copy actions. While the list loads or when it is empty or failed,
|
|
7093
|
+
* a single "open with the default app" item stands in.
|
|
7094
|
+
*/
|
|
7095
|
+
const menuEntries = (file) => {
|
|
7096
|
+
const out = [];
|
|
7097
|
+
if (canOpen && file.path) {
|
|
7098
|
+
const list = Array.isArray(apps[file.path]) ? apps[file.path] : [];
|
|
7099
|
+
const sorted = [...list.filter((app) => app.default), ...list.filter((app) => !app.default)];
|
|
7100
|
+
if (sorted.length > 0) {
|
|
7101
|
+
for (const app of sorted) {
|
|
7102
|
+
out.push({
|
|
7103
|
+
key: `app:${app.id}`,
|
|
7104
|
+
label: fmt(t, app.default ? "mediaActOpenWithDefault" : "mediaActOpenWith", { app: app.name }),
|
|
7105
|
+
icon: safeIconSource(app.icon),
|
|
7106
|
+
run: () => act("app", file, app.id),
|
|
7107
|
+
});
|
|
7108
|
+
}
|
|
7109
|
+
} else {
|
|
7110
|
+
out.push({ key: "open", label: t("mediaActOpen"), run: () => act("open", file) });
|
|
7111
|
+
}
|
|
7112
|
+
out.push({ key: "reveal", label: revealLabel, separated: true, run: () => act("reveal", file) });
|
|
7113
|
+
}
|
|
7114
|
+
if (file.kind === "image") out.push({ key: "copy-image", label: t("mediaActCopyImage"), separated: out.length > 0, run: () => act("copy-image", file) });
|
|
7115
|
+
if (file.path) out.push({ key: "copy-path", label: t("mediaActCopyPath"), separated: file.kind !== "image" && out.length > 0, run: () => act("copy-path", file) });
|
|
7116
|
+
return out;
|
|
7117
|
+
};
|
|
7118
|
+
const openMenu = (file) => (e) => {
|
|
7119
|
+
e.preventDefault?.();
|
|
7120
|
+
// Ask the Host which applications open this file (once per path).
|
|
7121
|
+
if (canOpen && file.path && fileActions?.applications && apps[file.path] === undefined) {
|
|
7122
|
+
setApps((current) => ({ ...current, [file.path]: "loading" }));
|
|
7123
|
+
fileActions.applications(file.path).then(
|
|
7124
|
+
(list) => {
|
|
7125
|
+
if (mounted.current) setApps((current) => ({ ...current, [file.path]: Array.isArray(list) ? list.slice(0, 12) : null }));
|
|
5005
7126
|
},
|
|
5006
|
-
|
|
7127
|
+
() => {
|
|
7128
|
+
if (mounted.current) setApps((current) => ({ ...current, [file.path]: null }));
|
|
7129
|
+
}
|
|
7130
|
+
);
|
|
7131
|
+
}
|
|
7132
|
+
const width = 240;
|
|
7133
|
+
const height = 64 + 32 * (6 + (Array.isArray(apps[file.path]) ? apps[file.path].length : 1));
|
|
7134
|
+
const vw = (typeof window === "undefined" ? 0 : window.innerWidth) || 1024;
|
|
7135
|
+
const vh = (typeof window === "undefined" ? 0 : window.innerHeight) || 768;
|
|
7136
|
+
setMenu({ id: file.id, left: Math.max(8, Math.min(e.clientX ?? 0, vw - width - 8)), top: Math.max(8, Math.min(e.clientY ?? 0, vh - height - 8)) });
|
|
7137
|
+
};
|
|
7138
|
+
const parsed = splitImageArgs(node?.args);
|
|
7139
|
+
const rawArgs = typeof node?.args === "string" ? node.args.trim() : "";
|
|
7140
|
+
const rawLine = showCopy && rawArgs !== "" ? `/${node?.name ?? "cpa-image"} ${rawArgs}` : null;
|
|
7141
|
+
const prompt = run?.prompt || parsed.prompt;
|
|
7142
|
+
const aspect = run?.aspect ?? parsed.aspect;
|
|
7143
|
+
const count = video ? 1 : run?.count ?? parsed.count ?? 1;
|
|
7144
|
+
const seconds = run?.seconds ?? parsed.seconds;
|
|
7145
|
+
const resolution = run?.resolution ?? parsed.resolution;
|
|
7146
|
+
const references = run?.references ?? 0;
|
|
7147
|
+
const modelName = run?.modelName || parsed.model || node?.name || "cpa-image";
|
|
7148
|
+
const shown = expanded ? images.find((f) => f.id === expanded) : undefined;
|
|
7149
|
+
const menuFile = menu ? images.find((f) => f.id === menu.id) : undefined;
|
|
7150
|
+
|
|
7151
|
+
let errorText = null;
|
|
7152
|
+
if (outcome?.kind === "error") errorText = String(outcome.text ?? "").replace(/^\/cpa-image:\s*/, "") || t("mediaCardFailed");
|
|
7153
|
+
else if (run?.status === "error") errorText = RUN_ERROR_KEYS[run.errorCode] ? t(RUN_ERROR_KEYS[run.errorCode]) : run.error || t("mediaCardFailed");
|
|
7154
|
+
|
|
7155
|
+
let status;
|
|
7156
|
+
if (errorText !== null) status = t("mediaCardFailed");
|
|
7157
|
+
else if (run === null && data?.confirm) status = t("mediaConfirmWaiting");
|
|
7158
|
+
else if (outcome === null || (run === null && (pending || (canLoad && loading)))) status = t("mediaCardSubmitting");
|
|
7159
|
+
else if (run?.status === "running") {
|
|
7160
|
+
const time = elapsedText(t, secondsBetween(run.startedAt, now));
|
|
7161
|
+
if (run.phase === "interpreting") status = fmt(t, "mediaCardInterpreting", { time });
|
|
7162
|
+
else if (!video) status = fmt(t, references > 0 ? "mediaCardEditing" : "mediaCardRunning", { time });
|
|
7163
|
+
else if (run.phase === "submitting") status = t("mediaCardSubmitting");
|
|
7164
|
+
else if (run.phase === "downloading") status = fmt(t, "mediaCardVideoSaving", { time });
|
|
7165
|
+
else if (run.phase === "queued") status = fmt(t, "mediaCardVideoQueued", { time });
|
|
7166
|
+
else status = run.progress !== undefined ? fmt(t, "mediaCardVideoProgress", { time, progress: run.progress }) : fmt(t, "mediaCardRunning", { time });
|
|
7167
|
+
} else if (run?.status === "done" && run.folder) {
|
|
7168
|
+
status = fmt(t, "mediaCardDone", { time: elapsedText(t, secondsBetween(run.startedAt, Date.parse(run.finishedAt ?? "") || now)), folder: run.folder });
|
|
7169
|
+
}
|
|
7170
|
+
else status = t("mediaCardDoneShort");
|
|
7171
|
+
const tone = errorText !== null ? "error" : running ? "running" : "done";
|
|
7172
|
+
|
|
7173
|
+
const mode = run?.mode ?? (node?.name === "cpa-video-edit" ? "edit" : node?.name === "cpa-video-extend" ? "extend" : undefined);
|
|
7174
|
+
// What was actually sent, stated in the reply: the mode the host chose
|
|
7175
|
+
// (from the images and flags) and, for edits / extensions, the source.
|
|
7176
|
+
// Runs recorded before modes existed: an attached image was the first frame.
|
|
7177
|
+
const sentMode = video ? mode ?? (references > 0 ? "first-frame" : "text") : references > 0 ? "image-edit" : "image-text";
|
|
7178
|
+
const sourceName = typeof run?.sourceName === "string" && run.sourceName !== "" ? run.sourceName : null;
|
|
7179
|
+
const sourceText =
|
|
7180
|
+
run?.sourceKind === "upload" ? fmt(t, "mediaSourceUpload", { name: sourceName ?? "" })
|
|
7181
|
+
: run?.sourceKind === "url" ? fmt(t, "mediaSourceUrl", { name: sourceName ?? "" })
|
|
7182
|
+
: run?.sourceKind === "link" ? t("mediaSourceLink")
|
|
7183
|
+
: sourceName !== null ? fmt(t, "mediaSourceTag", { name: sourceName })
|
|
7184
|
+
: null;
|
|
7185
|
+
const modeLine = run === null
|
|
7186
|
+
? null
|
|
7187
|
+
: sentMode === "reference" ? fmt(t, "mediaSentReference", { n: references })
|
|
7188
|
+
: sentMode === "edit" || sentMode === "extend"
|
|
7189
|
+
? fmt(t, sentMode === "edit" ? "mediaSentEdit" : "mediaSentExtend", { n: seconds ?? 6, source: sourceText ?? t("mediaSourceSession") })
|
|
7190
|
+
: sentMode === "image-edit" ? fmt(t, "mediaSentImageEdit", { n: references })
|
|
7191
|
+
: t({ text: "mediaSentText", "first-frame": "mediaSentFirstFrame", "first-last": "mediaSentFirstLast", "last-frame": "mediaSentLastFrame", "image-text": "mediaSentImageText" }[sentMode] ?? "mediaSentText");
|
|
7192
|
+
// A generate_media call waiting for the user's confirmation in this row.
|
|
7193
|
+
const confirm = showConfirm && run === null && data?.confirm ? data.confirm : null;
|
|
7194
|
+
const answerConfirm = async (allow) => {
|
|
7195
|
+
if (confirm === null || commandId === null || confirmBusy) return;
|
|
7196
|
+
setConfirmBusy(true);
|
|
7197
|
+
try {
|
|
7198
|
+
await postJson(API.mediaConfirm, { sessionId, commandId, allow });
|
|
7199
|
+
if (mounted.current) setData((current) => (current ? { ...current, confirm: undefined } : current));
|
|
7200
|
+
if (mounted.current) setNotice({ tone: "ok", text: t(allow ? "mediaConfirmAllowed" : "mediaConfirmDeclined") });
|
|
7201
|
+
} catch (err) {
|
|
7202
|
+
if (mounted.current) setNotice({ tone: "error", text: fmt(t, "mediaConfirmFailed", { error: err instanceof Error ? err.message : String(err) }) });
|
|
7203
|
+
} finally {
|
|
7204
|
+
if (mounted.current) setConfirmBusy(false);
|
|
7205
|
+
}
|
|
7206
|
+
};
|
|
7207
|
+
const confirmBox =
|
|
7208
|
+
confirm !== null
|
|
7209
|
+
? h(
|
|
5007
7210
|
"div",
|
|
5008
|
-
{ className: "dcpa-
|
|
5009
|
-
h("
|
|
5010
|
-
h("
|
|
5011
|
-
),
|
|
5012
|
-
errorText ? h("p", { className: "dcpa-q-pop-text", "data-tone": "error" }, errorText) : null,
|
|
5013
|
-
loadingFirst ? h("p", { className: "dcpa-q-pop-text" }, t("qPeekLoading")) : null,
|
|
5014
|
-
!loadingFirst && entries.length === 0 ? h("p", { className: "dcpa-q-pop-text" }, t("qPeekNoCreds")) : null,
|
|
5015
|
-
entries.map(({ cred, windows, headroom }, index) =>
|
|
7211
|
+
{ className: "dcpa-chat-confirm", role: "group", "aria-label": t("mediaConfirmTitle") },
|
|
7212
|
+
h("p", { className: "dcpa-chat-confirm-title" }, t(confirm.kind === "video" ? "mediaConfirmTitleVideo" : "mediaConfirmTitle")),
|
|
7213
|
+
h("p", { className: "dcpa-chat-confirm-text" }, t("mediaLocale") === "zh" ? confirm.zh : confirm.en),
|
|
5016
7214
|
h(
|
|
5017
7215
|
"div",
|
|
5018
|
-
{
|
|
5019
|
-
h(
|
|
5020
|
-
|
|
5021
|
-
|
|
5022
|
-
|
|
5023
|
-
|
|
5024
|
-
|
|
5025
|
-
|
|
5026
|
-
|
|
5027
|
-
|
|
5028
|
-
|
|
5029
|
-
|
|
7216
|
+
{ className: "dcpa-q-actions" },
|
|
7217
|
+
h("button", { type: "button", className: "dcpa-q-btn", "data-variant": "primary", disabled: confirmBusy, onClick: () => void answerConfirm(true) }, t("mediaConfirmAllow")),
|
|
7218
|
+
h("button", { type: "button", className: "dcpa-q-btn", disabled: confirmBusy, onClick: () => void answerConfirm(false) }, t("mediaConfirmDecline"))
|
|
7219
|
+
),
|
|
7220
|
+
h("p", { className: "dcpa-chat-note" }, t("mediaConfirmNote"))
|
|
7221
|
+
)
|
|
7222
|
+
: null;
|
|
7223
|
+
// Which parameters the session's chat model read from the prompt.
|
|
7224
|
+
const interp = run?.interpreted ?? null;
|
|
7225
|
+
const interpretLine = interp === null || typeof interp.by !== "string"
|
|
7226
|
+
? null
|
|
7227
|
+
: interp.failed ? fmt(t, "mediaInterpretFailed", { by: interp.by })
|
|
7228
|
+
: (Array.isArray(interp.items) && interp.items.length > 0 ? fmt(t, "mediaInterpreted", { by: interp.by, items: interp.items.join(" · ") }) : fmt(t, "mediaInterpretedNone", { by: interp.by }))
|
|
7229
|
+
+ (interp.prompt ? t("mediaInterpretedPrompt") : "");
|
|
7230
|
+
const tags = [
|
|
7231
|
+
label ?? "/" + (node?.name ?? "cpa-image"),
|
|
7232
|
+
...(aspect ? [aspect] : []),
|
|
7233
|
+
...(video && seconds && mode !== "edit" && mode !== "extend" ? [fmt(t, "mediaSecondsOption", { n: seconds })] : []),
|
|
7234
|
+
...(video && resolution ? [resolution] : []),
|
|
7235
|
+
...(count > 1 ? [fmt(t, "mediaCardCount", { n: count })] : []),
|
|
7236
|
+
...(video && Array.isArray(run?.ignored) && run.ignored.length > 0 ? [fmt(t, "mediaIgnoredTag", { words: run.ignored.join(" ") })] : []),
|
|
7237
|
+
];
|
|
7238
|
+
|
|
7239
|
+
const ratio = /^(\d+):(\d+)$/.exec(aspect ?? (video ? "16:9" : ""));
|
|
7240
|
+
const skeletons = running && errorText === null
|
|
7241
|
+
? h(
|
|
7242
|
+
"div",
|
|
7243
|
+
{ className: "dcpa-chat-grid", "data-count": String(count), "aria-hidden": "true" },
|
|
7244
|
+
Array.from({ length: Math.min(4, Math.max(1, count)) }, (_, index) =>
|
|
7245
|
+
h("div", { key: index, className: "dcpa-chat-skeleton", style: { aspectRatio: ratio ? `${ratio[1]} / ${ratio[2]}` : "1 / 1" } })
|
|
7246
|
+
)
|
|
7247
|
+
)
|
|
7248
|
+
: null;
|
|
7249
|
+
|
|
7250
|
+
const actionButtons = (file) =>
|
|
7251
|
+
baseEntries(file).map((entry) => h("button", { key: entry.key, type: "button", className: "dcpa-q-btn", onClick: () => void entry.run() }, entry.label));
|
|
7252
|
+
|
|
7253
|
+
const gallery = video
|
|
7254
|
+
? images.length > 0
|
|
7255
|
+
? h(
|
|
7256
|
+
"div",
|
|
7257
|
+
{ className: "dcpa-chat-videos" },
|
|
7258
|
+
images.map((file) =>
|
|
7259
|
+
h("video", {
|
|
7260
|
+
key: file.id,
|
|
7261
|
+
className: "dcpa-chat-video",
|
|
7262
|
+
src: mediaFileURL(sessionId, file.id),
|
|
7263
|
+
controls: true,
|
|
7264
|
+
preload: "metadata",
|
|
7265
|
+
playsInline: true,
|
|
7266
|
+
"aria-label": file.name,
|
|
7267
|
+
onContextMenu: openMenu(file),
|
|
7268
|
+
})
|
|
5030
7269
|
)
|
|
7270
|
+
)
|
|
7271
|
+
: null
|
|
7272
|
+
: shown
|
|
7273
|
+
? h(
|
|
7274
|
+
"div",
|
|
7275
|
+
{ className: "dcpa-chat-full" },
|
|
7276
|
+
h(
|
|
7277
|
+
"button",
|
|
7278
|
+
{ type: "button", className: "dcpa-chat-full-btn", "aria-label": t("mediaClosePreview"), onClick: () => setExpanded(null), onContextMenu: openMenu(shown) },
|
|
7279
|
+
h("img", { src: mediaFileURL(sessionId, shown.id), alt: shown.name })
|
|
5031
7280
|
),
|
|
5032
7281
|
h(
|
|
5033
7282
|
"div",
|
|
5034
|
-
{ className: "dcpa-q-
|
|
5035
|
-
|
|
7283
|
+
{ className: "dcpa-q-actions" },
|
|
7284
|
+
actionButtons(shown),
|
|
7285
|
+
h("button", { type: "button", className: "dcpa-q-btn", onClick: () => setExpanded(null) }, t("mediaClosePreview"))
|
|
7286
|
+
)
|
|
7287
|
+
)
|
|
7288
|
+
: images.length > 0
|
|
7289
|
+
? h(
|
|
7290
|
+
"div",
|
|
7291
|
+
{ className: "dcpa-chat-grid", "data-count": String(images.length) },
|
|
7292
|
+
images.map((file, index) =>
|
|
5036
7293
|
h(
|
|
5037
7294
|
"button",
|
|
5038
7295
|
{
|
|
7296
|
+
key: file.id,
|
|
5039
7297
|
type: "button",
|
|
5040
|
-
className: "dcpa-
|
|
5041
|
-
|
|
5042
|
-
onClick: () =>
|
|
7298
|
+
className: "dcpa-chat-thumb",
|
|
7299
|
+
"aria-label": fmt(t, "mediaCardImage", { n: index + 1 }),
|
|
7300
|
+
onClick: () => setExpanded(file.id),
|
|
7301
|
+
onContextMenu: openMenu(file),
|
|
5043
7302
|
},
|
|
5044
|
-
|
|
7303
|
+
h("img", { src: mediaFileURL(sessionId, file.id), alt: file.name, loading: "lazy" })
|
|
5045
7304
|
)
|
|
5046
7305
|
)
|
|
5047
7306
|
)
|
|
5048
7307
|
: null;
|
|
5049
7308
|
|
|
7309
|
+
const contextMenu = menuFile
|
|
7310
|
+
? h(
|
|
7311
|
+
"div",
|
|
7312
|
+
{ ref: menuRef, className: "dcpa-q-pop dcpa-chat-menu", role: "menu", "aria-label": t("mediaActMenu"), style: { left: `${menu.left}px`, top: `${menu.top}px` } },
|
|
7313
|
+
menuEntries(menuFile).flatMap((entry) => [
|
|
7314
|
+
entry.separated ? h("div", { key: `${entry.key}-sep`, role: "separator", className: "dcpa-chat-menu-sep" }) : null,
|
|
7315
|
+
h(
|
|
7316
|
+
"button",
|
|
7317
|
+
{ key: entry.key, type: "button", role: "menuitem", className: "dcpa-menu-item dcpa-chat-menu-item", onClick: () => void entry.run() },
|
|
7318
|
+
entry.icon ? h("img", { className: "dcpa-chat-menu-icon", src: entry.icon, alt: "", "aria-hidden": "true" }) : h("span", { className: "dcpa-chat-menu-icon", "aria-hidden": "true" }),
|
|
7319
|
+
h("span", { className: "dcpa-chat-menu-label" }, entry.label)
|
|
7320
|
+
),
|
|
7321
|
+
]),
|
|
7322
|
+
menuFile.path && canOpen && apps[menuFile.path] === "loading" ? h("div", { key: "loading", className: "dcpa-chat-menu-hint", role: "status" }, t("mediaActAppsLoading")) : null
|
|
7323
|
+
)
|
|
7324
|
+
: null;
|
|
7325
|
+
|
|
7326
|
+
const pathLink = (path, label, action) =>
|
|
7327
|
+
canOpen
|
|
7328
|
+
? h("button", { type: "button", className: "dcpa-chat-link", onClick: () => void act(action, { path }) }, label)
|
|
7329
|
+
: h("span", null, label);
|
|
7330
|
+
|
|
7331
|
+
const finished = admitted && !running && errorText === null;
|
|
7332
|
+
const actions = finished && video ? data?.actions ?? null : null;
|
|
7333
|
+
const submitEdit = async () => {
|
|
7334
|
+
const prompt = editDraft.trim();
|
|
7335
|
+
if (prompt === "" || runCommand === null || commandId === null) return;
|
|
7336
|
+
setEditBusy(true);
|
|
7337
|
+
const line = `/cpa-video-${editMode} --from ${commandId} ${editMode === "extend" ? `-s ${editSeconds} ` : ""}-- ${prompt}`;
|
|
7338
|
+
const outcome = await runCommand(sessionId, line);
|
|
7339
|
+
if (!mounted.current) return;
|
|
7340
|
+
setEditBusy(false);
|
|
7341
|
+
if (outcome.ok) {
|
|
7342
|
+
setEditMode(null);
|
|
7343
|
+
setEditDraft("");
|
|
7344
|
+
say("ok", t("mediaEditSubmitted"));
|
|
7345
|
+
} else {
|
|
7346
|
+
say("error", outcome.text || t("mediaEditFailed"));
|
|
7347
|
+
}
|
|
7348
|
+
};
|
|
7349
|
+
const editForm =
|
|
7350
|
+
editMode !== null
|
|
7351
|
+
? h(
|
|
7352
|
+
"div",
|
|
7353
|
+
{ className: "dcpa-chat-edit" },
|
|
7354
|
+
h("textarea", {
|
|
7355
|
+
className: "dcpa-q-input dcpa-chat-edit-input",
|
|
7356
|
+
rows: 2,
|
|
7357
|
+
value: editDraft,
|
|
7358
|
+
placeholder: t(editMode === "extend" ? "mediaExtendPlaceholder" : "mediaEditPlaceholder"),
|
|
7359
|
+
"aria-label": t(editMode === "extend" ? "mediaExtendPlaceholder" : "mediaEditPlaceholder"),
|
|
7360
|
+
disabled: editBusy,
|
|
7361
|
+
onChange: (e) => setEditDraft(e.target.value),
|
|
7362
|
+
onKeyDown: (e) => {
|
|
7363
|
+
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent?.isComposing) {
|
|
7364
|
+
e.preventDefault?.();
|
|
7365
|
+
void submitEdit();
|
|
7366
|
+
}
|
|
7367
|
+
},
|
|
7368
|
+
}),
|
|
7369
|
+
h(
|
|
7370
|
+
"div",
|
|
7371
|
+
{ className: "dcpa-q-actions" },
|
|
7372
|
+
editMode === "extend"
|
|
7373
|
+
? h(
|
|
7374
|
+
"select",
|
|
7375
|
+
{
|
|
7376
|
+
className: "dcpa-q-input dcpa-media-select",
|
|
7377
|
+
"aria-label": t("mediaExtendSeconds"),
|
|
7378
|
+
value: String(editSeconds),
|
|
7379
|
+
disabled: editBusy,
|
|
7380
|
+
onChange: (e) => setEditSeconds(Number(e.target.value)),
|
|
7381
|
+
},
|
|
7382
|
+
Array.from({ length: 9 }, (_, i) => h("option", { key: i + 2, value: String(i + 2) }, fmt(t, "mediaModeExtend", { n: i + 2 })))
|
|
7383
|
+
)
|
|
7384
|
+
: null,
|
|
7385
|
+
h("button", { type: "button", className: "dcpa-q-btn", "data-variant": "primary", disabled: editBusy || editDraft.trim() === "", onClick: () => void submitEdit() }, t(editMode === "extend" ? "mediaExtendSubmit" : "mediaEditSubmit")),
|
|
7386
|
+
h("button", { type: "button", className: "dcpa-q-btn", disabled: editBusy, onClick: () => setEditMode(null) }, t("mediaEditCancel"))
|
|
7387
|
+
),
|
|
7388
|
+
h("p", { className: "dcpa-chat-note" }, t("mediaEditNote"))
|
|
7389
|
+
)
|
|
7390
|
+
: null;
|
|
7391
|
+
const editButtons =
|
|
7392
|
+
actions !== null && runCommand !== null && editMode === null && (actions.edit || actions.extend)
|
|
7393
|
+
? [
|
|
7394
|
+
actions.edit ? h("button", { key: "edit", type: "button", className: "dcpa-q-btn", onClick: () => setEditMode("edit") }, t("mediaEditOpen")) : null,
|
|
7395
|
+
actions.extend ? h("button", { key: "extend", type: "button", className: "dcpa-q-btn", onClick: () => setEditMode("extend") }, t("mediaExtendOpen")) : null,
|
|
7396
|
+
]
|
|
7397
|
+
: [];
|
|
7398
|
+
const folderPath = typeof data?.folderPath === "string" ? data.folderPath : null;
|
|
7399
|
+
const details = finished && (images.length > 0 || run?.revisedPrompt)
|
|
7400
|
+
? h(
|
|
7401
|
+
"details",
|
|
7402
|
+
{ className: "dcpa-chat-details" },
|
|
7403
|
+
h("summary", null, t("mediaCardDetails")),
|
|
7404
|
+
h(
|
|
7405
|
+
"dl",
|
|
7406
|
+
null,
|
|
7407
|
+
folderPath ? [h("dt", { key: "dt" }, t("mediaCardFolder")), h("dd", { key: "dd" }, pathLink(folderPath, folderPath, "open"))] : null,
|
|
7408
|
+
images.length > 0
|
|
7409
|
+
? [
|
|
7410
|
+
h("dt", { key: "ft" }, t("mediaCardFiles")),
|
|
7411
|
+
h("dd", { key: "fd", className: "dcpa-chat-files" }, images.map((f) => h("span", { key: f.id }, f.path ? pathLink(f.path, f.name, "open") : f.name))),
|
|
7412
|
+
]
|
|
7413
|
+
: null,
|
|
7414
|
+
run?.revisedPrompt ? [h("dt", { key: "rt" }, t("mediaCardRevised")), h("dd", { key: "rd" }, run.revisedPrompt)] : null
|
|
7415
|
+
)
|
|
7416
|
+
)
|
|
7417
|
+
: null;
|
|
7418
|
+
|
|
5050
7419
|
return h(
|
|
5051
|
-
"
|
|
5052
|
-
{
|
|
7420
|
+
"div",
|
|
7421
|
+
{ className: "dcpa-chat", "data-dsh-plugin": MODULE_ID, "data-state": tone, "data-kind": kind },
|
|
5053
7422
|
h(
|
|
5054
|
-
"
|
|
5055
|
-
{
|
|
5056
|
-
|
|
5057
|
-
|
|
5058
|
-
|
|
5059
|
-
"
|
|
5060
|
-
"
|
|
5061
|
-
|
|
5062
|
-
|
|
5063
|
-
|
|
5064
|
-
|
|
5065
|
-
|
|
5066
|
-
|
|
5067
|
-
|
|
7423
|
+
"div",
|
|
7424
|
+
{ className: "dcpa-chat-user" },
|
|
7425
|
+
prompt !== "" ? h("div", { className: "dcpa-chat-bubble" }, prompt) : null,
|
|
7426
|
+
h(
|
|
7427
|
+
"div",
|
|
7428
|
+
{ className: "dcpa-chat-tags" },
|
|
7429
|
+
tags.map((tag, index) => h("span", { key: index }, tag)),
|
|
7430
|
+
// The command line exactly as typed (the bubble shows the parsed prompt).
|
|
7431
|
+
rawLine !== null
|
|
7432
|
+
? h(
|
|
7433
|
+
"button",
|
|
7434
|
+
{
|
|
7435
|
+
key: "copy",
|
|
7436
|
+
type: "button",
|
|
7437
|
+
className: "dcpa-chat-copy",
|
|
7438
|
+
onClick: () => {
|
|
7439
|
+
copyTextToClipboard(rawLine).then(
|
|
7440
|
+
() => say("ok", t("mediaCopiedInput")),
|
|
7441
|
+
(err) => say("error", fmt(t, "mediaActCopyFailed", { error: err instanceof Error ? err.message : String(err) }))
|
|
7442
|
+
);
|
|
7443
|
+
},
|
|
7444
|
+
},
|
|
7445
|
+
t("mediaCopyInput")
|
|
7446
|
+
)
|
|
7447
|
+
: null
|
|
7448
|
+
)
|
|
5068
7449
|
),
|
|
5069
|
-
|
|
7450
|
+
h(
|
|
7451
|
+
"div",
|
|
7452
|
+
{ className: "dcpa-chat-reply" },
|
|
7453
|
+
h(
|
|
7454
|
+
"div",
|
|
7455
|
+
{ className: "dcpa-chat-head" },
|
|
7456
|
+
h(IMAGE_ICON),
|
|
7457
|
+
h("span", { className: "dcpa-chat-model" }, run?.accountName ? `${modelName} · ${run.accountName}` : modelName),
|
|
7458
|
+
h("span", { className: "dcpa-chat-sep", "aria-hidden": "true" }),
|
|
7459
|
+
h("span", { className: "dcpa-chat-status", "data-tone": tone, role: running ? "status" : undefined }, status)
|
|
7460
|
+
),
|
|
7461
|
+
modeLine !== null ? h("p", { className: "dcpa-chat-mode", "data-mode": sentMode }, modeLine) : null,
|
|
7462
|
+
interpretLine !== null ? h("p", { className: "dcpa-chat-mode", "data-interpreted": interp.failed ? "failed" : "ok" }, interpretLine) : null,
|
|
7463
|
+
confirmBox,
|
|
7464
|
+
errorText !== null ? h("p", { className: "dcpa-chat-error", role: "alert" }, errorText) : null,
|
|
7465
|
+
skeletons,
|
|
7466
|
+
running && errorText === null && admitted && run !== null ? h("p", { className: "dcpa-chat-note" }, t(video ? "mediaCardVideoNoStop" : "mediaCardNoStop")) : null,
|
|
7467
|
+
video && finished && images.length > 0 ? h("div", { className: "dcpa-q-actions" }, actionButtons(images[0]), editButtons) : null,
|
|
7468
|
+
editForm,
|
|
7469
|
+
gallery,
|
|
7470
|
+
notice ? h("p", { className: "dcpa-chat-note", role: "status", "data-tone": notice.tone }, notice.text) : null,
|
|
7471
|
+
finished && !loading && data !== null && images.length === 0 ? h("p", { className: "dcpa-chat-note" }, t("mediaCardMissing")) : null,
|
|
7472
|
+
details
|
|
7473
|
+
),
|
|
7474
|
+
contextMenu
|
|
5070
7475
|
);
|
|
5071
7476
|
}
|
|
5072
|
-
|
|
5073
7477
|
// #endregion
|
|
5074
|
-
|
|
5075
7478
|
const inject = ["slots", "locale"];
|
|
5076
7479
|
|
|
5077
7480
|
function apply(ctx) {
|
|
5078
7481
|
ctx.effect(() => ctx.locale.register(NS, { zh, en }), "dsh-cliproxyapi: dictionaries");
|
|
5079
7482
|
const t = ctx.locale.bind(NS);
|
|
5080
7483
|
|
|
7484
|
+
// Open / reveal generated files through DSH's own Session Remote (the
|
|
7485
|
+
// same calls the document preview uses); the Host re-verifies each path.
|
|
7486
|
+
// Desktop availability is asked once per activation.
|
|
7487
|
+
let desktopAvailable = null;
|
|
7488
|
+
// The generated Remote namespace is its own service, "remote.session".
|
|
7489
|
+
const sessionRemote = () => {
|
|
7490
|
+
try {
|
|
7491
|
+
return ctx.get?.("remote.session") ?? ctx.get?.("remote")?.session;
|
|
7492
|
+
} catch {
|
|
7493
|
+
return undefined;
|
|
7494
|
+
}
|
|
7495
|
+
};
|
|
7496
|
+
// Run a follow-up slash command (edit / extend a video) in the session,
|
|
7497
|
+
// the way the composer does; its row appears in the conversation.
|
|
7498
|
+
const runCommand = async (sessionId, line) => {
|
|
7499
|
+
try {
|
|
7500
|
+
const commands = ctx.get?.("remote.commands") ?? ctx.get?.("remote")?.commands;
|
|
7501
|
+
if (typeof commands?.execute !== "function" || typeof sessionId !== "string") return { ok: false };
|
|
7502
|
+
const result = await commands.execute(sessionId, line, []);
|
|
7503
|
+
if (result?.ok !== true || result.value === undefined) return { ok: false, text: result?.error?.message };
|
|
7504
|
+
const outcome = result.value.result;
|
|
7505
|
+
return outcome?.kind === "error" ? { ok: false, text: outcome.text } : { ok: true };
|
|
7506
|
+
} catch (error) {
|
|
7507
|
+
return { ok: false, text: error instanceof Error ? error.message : String(error) };
|
|
7508
|
+
}
|
|
7509
|
+
};
|
|
7510
|
+
const fileActions = {
|
|
7511
|
+
available() {
|
|
7512
|
+
if (desktopAvailable !== null) return desktopAvailable;
|
|
7513
|
+
const remote = sessionRemote();
|
|
7514
|
+
// Not mounted yet: answer no for now and ask again next time.
|
|
7515
|
+
if (typeof remote?.canOpenWorkspacePath !== "function") return Promise.resolve(false);
|
|
7516
|
+
desktopAvailable = (async () => {
|
|
7517
|
+
try {
|
|
7518
|
+
const result = await remote.canOpenWorkspacePath();
|
|
7519
|
+
return result?.ok === true && result.value === true;
|
|
7520
|
+
} catch {
|
|
7521
|
+
desktopAvailable = null;
|
|
7522
|
+
return false;
|
|
7523
|
+
}
|
|
7524
|
+
})();
|
|
7525
|
+
return desktopAvailable;
|
|
7526
|
+
},
|
|
7527
|
+
async applications(path) {
|
|
7528
|
+
try {
|
|
7529
|
+
const result = await sessionRemote()?.workspacePathApplications?.({ path });
|
|
7530
|
+
return result?.ok === true && Array.isArray(result.value)
|
|
7531
|
+
? result.value.filter((app) => app && typeof app.id === "string" && typeof app.name === "string")
|
|
7532
|
+
: null;
|
|
7533
|
+
} catch {
|
|
7534
|
+
return null;
|
|
7535
|
+
}
|
|
7536
|
+
},
|
|
7537
|
+
async open(path, action, application) {
|
|
7538
|
+
try {
|
|
7539
|
+
const result = await sessionRemote()?.openWorkspacePath?.(
|
|
7540
|
+
action === "reveal" ? { path, action } : application === undefined ? { path } : { path, application }
|
|
7541
|
+
);
|
|
7542
|
+
return result?.ok === true;
|
|
7543
|
+
} catch {
|
|
7544
|
+
return false;
|
|
7545
|
+
}
|
|
7546
|
+
},
|
|
7547
|
+
};
|
|
7548
|
+
|
|
5081
7549
|
// Each activation owns its stylesheet. A hot reload can start the new
|
|
5082
7550
|
// activation before the old one is disposed; sharing one element would
|
|
5083
7551
|
// let the old disposal remove the new activation's styles.
|
|
@@ -5177,6 +7645,79 @@ select.dcpa-q-input { appearance: auto; padding-right: 8px; }
|
|
|
5177
7645
|
});
|
|
5178
7646
|
}
|
|
5179
7647
|
|
|
7648
|
+
// Generation-model button, left of the quota badge. It only needs the
|
|
7649
|
+
// session id; like the badge, the hero copy steps aside while the
|
|
7650
|
+
// composer copy of the same session is mounted.
|
|
7651
|
+
const mediaEntry = (name, id, placement) =>
|
|
7652
|
+
ctx.slots.inject(name, () =>
|
|
7653
|
+
registerListEntry(
|
|
7654
|
+
ctx.slots,
|
|
7655
|
+
{
|
|
7656
|
+
name,
|
|
7657
|
+
id,
|
|
7658
|
+
order: 9990,
|
|
7659
|
+
label: () => t("tabMedia"),
|
|
7660
|
+
locale: NS,
|
|
7661
|
+
inject: (sessionId) => ({ sessionId: typeof sessionId === "string" ? sessionId : null }),
|
|
7662
|
+
},
|
|
7663
|
+
(props = {}) => h(MediaChip, { t, sessionId: props.sessionId ?? null, placement })
|
|
7664
|
+
)
|
|
7665
|
+
);
|
|
7666
|
+
mediaEntry("conversation.input.right", `${NS}:media`, "composer");
|
|
7667
|
+
mediaEntry("conversation.hero.options", `${NS}:media-hero`, "hero");
|
|
7668
|
+
|
|
7669
|
+
// A real Conversation node explicitly escapes both process folds. Its
|
|
7670
|
+
// definition is replayed by DSH; no session rescans or synthetic events.
|
|
7671
|
+
if (typeof ctx.inject === "function") {
|
|
7672
|
+
ctx.inject(["uiConversation"], (scope) => {
|
|
7673
|
+
if (typeof scope.uiConversation?.events?.register !== "function") return;
|
|
7674
|
+
scope.effect(() => scope.uiConversation.events.register(mediaConversationDefinition), "cpa: media conversation");
|
|
7675
|
+
scope.slots.inject("conversation.chat.node", () => registerListEntry(scope.slots, {
|
|
7676
|
+
name: "conversation.chat.node", key: MEDIA_NODE_KIND, locale: NS,
|
|
7677
|
+
inject: (sessionId) => ({ sessionId: typeof sessionId === "string" ? sessionId : null }),
|
|
7678
|
+
}, (props = {}) => h(MediaToolRow, {
|
|
7679
|
+
t, owner: props.node?.data ?? {}, sessionId: props.sessionId ?? null,
|
|
7680
|
+
fileActions, runCommand, standalone: true,
|
|
7681
|
+
})));
|
|
7682
|
+
});
|
|
7683
|
+
}
|
|
7684
|
+
|
|
7685
|
+
ctx.slots.inject("conversation.composer.dock", () => registerListEntry(ctx.slots, {
|
|
7686
|
+
name: "conversation.composer.dock", id: `${NS}:media-confirmation`, order: -10, locale: NS,
|
|
7687
|
+
inject: (sessionId) => ({ sessionId: typeof sessionId === "string" ? sessionId : null }),
|
|
7688
|
+
}, (props = {}) => h(MediaConfirmationDock, { t, sessionId: props.sessionId ?? null })));
|
|
7689
|
+
|
|
7690
|
+
// The /cpa-image and /cpa-video chat rows, keyed by command name; other
|
|
7691
|
+
// commands keep the generic row.
|
|
7692
|
+
for (const key of ["cpa-image", "cpa-video", "cpa-video-edit", "cpa-video-extend"]) {
|
|
7693
|
+
ctx.slots.inject("conversation.chat.commandview", () =>
|
|
7694
|
+
registerListEntry(
|
|
7695
|
+
ctx.slots,
|
|
7696
|
+
{
|
|
7697
|
+
name: "conversation.chat.commandview",
|
|
7698
|
+
key,
|
|
7699
|
+
locale: NS,
|
|
7700
|
+
inject: (sessionId) => ({ sessionId: typeof sessionId === "string" ? sessionId : null }),
|
|
7701
|
+
},
|
|
7702
|
+
(props = {}) => h(MediaCommandRow, { t, node: props.node ?? null, sessionId: props.sessionId ?? null, fileActions, runCommand, useChat: props.useChat ?? null })
|
|
7703
|
+
)
|
|
7704
|
+
);
|
|
7705
|
+
}
|
|
7706
|
+
|
|
7707
|
+
// The generate_media agent tool's call row (replaces DSH's generic tool row).
|
|
7708
|
+
ctx.slots.inject("tool.call.toolview", () =>
|
|
7709
|
+
registerListEntry(
|
|
7710
|
+
ctx.slots,
|
|
7711
|
+
{
|
|
7712
|
+
name: "tool.call.toolview",
|
|
7713
|
+
key: "generate_media",
|
|
7714
|
+
locale: NS,
|
|
7715
|
+
inject: (sessionId) => ({ sessionId: typeof sessionId === "string" ? sessionId : null }),
|
|
7716
|
+
},
|
|
7717
|
+
(props = {}) => h(MediaToolRow, { t, owner: props, sessionId: props.sessionId ?? null, fileActions, runCommand, useChat: props.useChat ?? null })
|
|
7718
|
+
)
|
|
7719
|
+
);
|
|
7720
|
+
|
|
5180
7721
|
// 1. Accounts and models: a page of its own in the Settings dialog's left
|
|
5181
7722
|
// navigation (like dshmarket's "Market" section). The slot owner gives
|
|
5182
7723
|
// unregistered ids the default nav glyph; no DOM is touched here.
|