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