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/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
- const payload = await res.json();
1899
- if (!res.ok || payload.ok === false) {
1900
- throw new Error(payload.error || `HTTP ${res.status}`);
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
- if (Array.isArray(st.allModels) && st.allModels.length > 0) return st.allModels;
3239
- if (Array.isArray(st.models)) return st.models;
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")} ${selectedIds.size}`],
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")} (${selectedIds.size})`
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 - selectedIds.size)})`
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" ? connectionTab : tab === "models" ? modelsTab : h(QuotaPanel, { t, store: sharedQuota, accountId: account.id })
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 Quota (额度): shared store, model matching, tab page, composer badge
4604
+ // #region Media (生成): per-account media models and the read-only diagnostic
4055
4605
 
4056
- const QUOTA_POLL_MS = 60 * 1000;
4057
- // While the host is still collecting a refresh, look again soon.
4058
- const QUOTA_BUSY_POLL_MS = 4 * 1000;
4059
- const PROVIDER_NAMES = {
4060
- claude: "Claude",
4061
- anthropic: "Claude",
4062
- codex: "Codex",
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 MATCH_KEYS = { "model-list": "qMatchModelList", prefix: "qMatchPrefix", family: "qMatchFamily" };
4614
+ const MEDIA_KIND_KEYS = { image: "mediaKindImage", video: "mediaKindVideo", chat: "mediaKindChat" };
4096
4615
 
4097
- function fmt(t, key, vars = {}) {
4098
- let out = String(t(key));
4099
- for (const [name, value] of Object.entries(vars)) out = out.split(`{${name}}`).join(String(value));
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 providerName(t, provider) {
4104
- const key = String(provider ?? "").toLowerCase();
4105
- return PROVIDER_NAMES[key] ?? (key === "" ? t("qProviderOther") : key);
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 quotaErrorText(t, account) {
4109
- const key = QUOTA_ERROR_KEYS[account?.errorCode];
4110
- if (key === undefined) return null;
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 windowLabel(t, w) {
4115
- const base = WINDOW_KEYS[w.kind] !== undefined ? t(WINDOW_KEYS[w.kind]) : w.label || t("qWinOther");
4116
- return w.group ? `${w.group} · ${base}` : base;
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
- function durationText(t, ms) {
4120
- const total = Math.max(1, Math.ceil(ms / 60000));
4121
- const d = Math.floor(total / 1440);
4122
- const hrs = Math.floor((total % 1440) / 60);
4123
- const m = total % 60;
4124
- if (d > 0) return fmt(t, "qDurDH", { d, h: hrs });
4125
- if (hrs > 0) return fmt(t, "qDurHM", { h: hrs, m });
4126
- return fmt(t, "qDurM", { m });
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 resetText(t, iso, now) {
4130
- const at = Date.parse(iso ?? "");
4131
- if (!Number.isFinite(at)) return "";
4132
- return at <= now ? t("qResetSoon") : fmt(t, "qResetIn", { time: durationText(t, at - now) });
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
- function agoText(t, iso, now) {
4136
- const at = Date.parse(iso ?? "");
4137
- if (!Number.isFinite(at)) return "";
4138
- const s = Math.max(0, Math.floor((now - at) / 1000));
4139
- if (s < 10) return t("qAgoJustNow");
4140
- if (s < 60) return fmt(t, "qAgoSeconds", { n: s });
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
- function quotaLevel(pct) {
4147
- if (typeof pct !== "number" || !Number.isFinite(pct)) return "unknown";
4148
- if (pct <= 0) return "empty";
4149
- if (pct < 20) return "low";
4150
- if (pct < 50) return "mid";
4151
- return "ok";
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
- function pctText(pct) {
4155
- return typeof pct === "number" && Number.isFinite(pct) ? `${Math.round(pct)}%` : "—";
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
- function bareModel(id) {
4159
- const lower = String(id ?? "").toLowerCase();
4160
- const slash = lower.indexOf("/");
4161
- return slash >= 0 ? lower.slice(slash + 1) : lower;
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
- /** Whether a quota window applies to `modelId` (untagged windows apply to every model). */
4165
- function windowAppliesTo(w, modelId) {
4166
- if (!modelId) return true;
4167
- const full = String(modelId).toLowerCase();
4168
- const bare = bareModel(modelId);
4169
- if (Array.isArray(w.modelIds) && w.modelIds.length > 0) {
4170
- return w.modelIds.some((m) => {
4171
- const x = String(m).toLowerCase();
4172
- return x === full || x === bare || bareModel(x) === bare;
4173
- });
4174
- }
4175
- if (Array.isArray(w.modelPrefixes) && w.modelPrefixes.length > 0) {
4176
- return w.modelPrefixes.some((p) => bare.startsWith(String(p).toLowerCase()));
4177
- }
4178
- return true;
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
- function credentialWindows(cred, modelId) {
4182
- const windows = Array.isArray(cred?.windows) ? cred.windows : [];
4183
- return modelId ? windows.filter((w) => windowAppliesTo(w, modelId)) : windows;
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
- /** The tightest remaining percentage of the windows that apply, or null. */
4187
- function credentialHeadroom(cred, modelId) {
4188
- const values = credentialWindows(cred, modelId)
4189
- .map((w) => w.remainingPercent)
4190
- .filter((v) => typeof v === "number" && Number.isFinite(v));
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
- function quotaAccountForRoute(data, route) {
4195
- const accounts = Array.isArray(data?.accounts) ? data.accounts : [];
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
- function byHeadroomDesc(a, b) {
4200
- const ua = a.cred.status === "active" ? 0 : 1;
4201
- const ub = b.cred.status === "active" ? 0 : 1;
4202
- if (ua !== ub) return ua - ub;
4203
- const ha = a.headroom ?? -1;
4204
- const hb = b.headroom ?? -1;
4205
- return hb - ha;
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
- * Quota for one picker selection: the account owning the route, then
4210
- * exactly the credentials the host matched for this model id on that
4211
- * server (modelIndex), never credentials of another account.
4212
- */
4213
- function summarizeModelQuota(data, route, modelId) {
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
- /** Totals for the overview tiles. */
4235
- function quotaStats(credentials) {
4236
- const out = { total: 0, healthy: 0, low: 0, empty: 0, issue: 0, unknown: 0 };
4237
- for (const cred of credentials) {
4238
- out.total++;
4239
- if (cred.status !== "active" || cred.quotaState === "error") {
4240
- out.issue++;
4241
- continue;
4242
- }
4243
- const level = quotaLevel(credentialHeadroom(cred));
4244
- if (level === "empty") out.empty++;
4245
- else if (level === "low") out.low++;
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
- * One page-wide quota client: GET the host snapshot (cached by the host,
4254
- * which refreshes stale accounts in the background) while anything is
4255
- * subscribed; POST refresh / key changes. Disposed with the activation.
4256
- */
4257
- function createQuotaStore(io = {}) {
4258
- const fetchImpl = io.fetch ?? ((...args) => fetch(...args));
4259
- const setT = io.setTimeout ?? ((fn, ms) => setTimeout(fn, ms));
4260
- const clearT = io.clearTimeout ?? ((id) => clearTimeout(id));
4261
- const listeners = new Set();
4262
- let state = { data: null, loading: false, error: null, busy: null, busyAccount: null, loadedAt: 0 };
4263
- let timer = null;
4264
- let disposed = false;
4265
- let seq = 0;
4266
- const inflight = new Set();
4267
-
4268
- const emit = (patch) => {
4269
- if (disposed) return;
4270
- state = { ...state, ...patch };
4271
- for (const fn of [...listeners]) fn();
4272
- };
4273
-
4274
- const request = async (url, init) => {
4275
- const controller = new AbortController();
4276
- inflight.add(controller);
4277
- try {
4278
- const res = await fetchImpl(url, { cache: "no-store", ...init, signal: controller.signal });
4279
- let body = null;
4280
- try {
4281
- body = await res.json();
4282
- } catch {}
4283
- if (!res.ok || body === null || body.ok === false) {
4284
- throw new Error(body?.error || `HTTP ${res.status}`);
4285
- }
4286
- return body;
4287
- } finally {
4288
- inflight.delete(controller);
4289
- }
4290
- };
4291
-
4292
- const schedule = () => {
4293
- if (timer !== null) clearT(timer);
4294
- timer = null;
4295
- if (disposed || listeners.size === 0) return;
4296
- const delay = state.data?.refreshing ? QUOTA_BUSY_POLL_MS : QUOTA_POLL_MS;
4297
- timer = setT(() => {
4298
- timer = null;
4299
- void load().finally(schedule);
4300
- }, delay);
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 mutate = async (kind, url, payload) => {
4316
- const mine = ++seq;
4317
- emit({ busy: kind, busyAccount: payload?.accountId ?? null, error: null });
4318
- try {
4319
- const body = await request(url, {
4320
- method: "POST",
4321
- headers: { "content-type": "application/json" },
4322
- body: JSON.stringify(payload ?? {}),
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
- return {
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
- let sharedQuota = null;
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
- /** Subscribe a component to the shared quota store (null when inactive). */
4369
- function useQuota(store) {
4370
- const [, setTick] = useState(0);
4371
- useEffect(() => {
4372
- if (store === null || store === undefined) return undefined;
4373
- let alive = true;
4374
- const off = store.subscribe(() => {
4375
- if (alive) setTick((n) => n + 1);
4376
- });
4377
- return () => {
4378
- alive = false;
4379
- off();
4380
- };
4381
- }, [store]);
4382
- return store ? store.getSnapshot() : { data: null, loading: false, error: null, busy: null };
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
- * Which sessions currently have a composer badge mounted. The composer
4387
- * renders conversation.input.right only once the session is bound (its
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
- * Composer placement announces itself for its session; hero placement
4424
- * reports whether a composer badge already covers the session.
4425
- */
4426
- function useBadgePlacement(placement, sessionId, presence = composerBadges) {
4427
- const [, setTick] = useState(0);
4428
- useEffect(() => {
4429
- if (typeof sessionId !== "string" || sessionId === "") return undefined;
4430
- if (placement === "hero") {
4431
- let alive = true;
4432
- const off = presence.subscribe(() => {
4433
- if (alive) setTick((n) => n + 1);
4434
- });
4435
- setTick((n) => n + 1);
4436
- return () => {
4437
- alive = false;
4438
- off();
4439
- };
4440
- }
4441
- return presence.add(sessionId);
4442
- }, [placement, sessionId, presence]);
4443
- return placement === "hero" && typeof sessionId === "string" && presence.has(sessionId);
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
- * The session's model-directory store, resolved at render time. DSH caches
4448
- * a slot entry's inject() result per session binding, and right after a
4449
- * page load, host restart or session switch `directoryFor()` can still
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 useDirectory(direct, resolve) {
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 (!missing) return undefined;
6030
+ if (!directory || typeof directory.subscribe !== "function") return undefined;
4467
6031
  let alive = true;
4468
- let id = null;
4469
- let tries = 0;
4470
- const tick = () => {
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
- if (id !== null) clearTimeout(id);
6037
+ off?.();
4481
6038
  };
4482
- }, [missing, resolve]);
4483
- return resolved;
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
- /** Re-render every `ms` so relative times stay current. */
4487
- function useNow(ms) {
4488
- const [now, setNow] = useState(() => Date.now());
4489
- useEffect(() => {
4490
- const id = setInterval(() => setNow(Date.now()), ms);
4491
- return () => clearInterval(id);
4492
- }, [ms]);
4493
- return now;
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
- function QuotaMeter({ t, w, now }) {
4497
- const pct = typeof w.remainingPercent === "number" ? Math.max(0, Math.min(100, w.remainingPercent)) : null;
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
- function CredentialTags({ t, cred }) {
4527
- return [
4528
- cred.plan ? h("span", { key: "plan", className: "dcpa-q-tag", "data-tone": "info" }, cred.plan) : null,
4529
- cred.prefix ? h("span", { key: "prefix", className: "dcpa-q-tag" }, `${cred.prefix}/`) : null,
4530
- cred.status && cred.status !== "active"
4531
- ? h("span", { key: "status", className: "dcpa-q-tag", "data-tone": "danger" }, t(STATUS_KEYS[cred.status] ?? "qStatusError"))
4532
- : null,
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
- function CredentialWindows({ t, cred, windows, now }) {
4537
- if (windows.length > 0) {
4538
- return h(
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
- function CredentialRow({ t, cred, now, pickerIds }) {
4554
- const [open, setOpen] = useState(false);
4555
- const models = Array.isArray(cred.models) ? cred.models : null;
4556
- return h(
4557
- "div",
4558
- { className: "dcpa-q-cred", "data-status": cred.status ?? "active" },
4559
- h(
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
- function ModelChannelMap({ t, account }) {
4589
- const picker = Array.isArray(account.pickerModels) ? account.pickerModels : [];
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
- { className: "dcpa-q-row-main" },
4599
- h("div", { className: "dcpa-q-title" }, t("qMapTitle"), h(InfoTip, { t, text: t("qMapHint") }))
4600
- )
4601
- ),
4602
- picker.length === 0
4603
- ? h("p", { className: "dcpa-q-msg" }, t("qMapEmpty"))
4604
- : picker.map((m) => {
4605
- const summary = summarizeModelQuota({ accounts: [account] }, m.route, m.id);
4606
- const entries = summary?.entries ?? [];
4607
- const matchedBy = account.modelMatch?.[m.id];
4608
- return h(
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: `${m.route}:${m.id}`, className: "dcpa-q-map-row" },
4611
- h("span", { className: "dcpa-q-map-model" }, m.id),
6127
+ { key: cred.id, className: "dcpa-q-pop-cred" },
4612
6128
  h(
4613
- "span",
4614
- { className: "dcpa-q-map-creds" },
4615
- entries.length === 0
4616
- ? h("span", null, t("qMapNone"))
4617
- : entries.map(({ cred }) =>
4618
- h(
4619
- "span",
4620
- { key: cred.id, className: "dcpa-q-tag", "data-tone": cred.status === "active" ? undefined : "danger", title: cred.label || cred.id },
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("span", { className: "dcpa-q-map-pct dcpa-q-pct" }, pctText(summary?.best))
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
- "div",
4643
- { className: "dcpa-q-summary" },
6160
+ "span",
6161
+ { ref: wrapRef, className: "dcpa-q-wrap", "data-placement": placement, "data-dsh-plugin": MODULE_ID },
4644
6162
  h(
4645
- "div",
4646
- { className: "dcpa-q-dist", role: "img", "aria-label": fmt(t, "qTotalCreds", { n: stats.total }) },
4647
- parts.filter(([, n]) => n > 0).map(([level, n]) => h("span", { key: level, "data-level": level, style: { flex: `${n} 1 0` } }))
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
- h(
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
- /** The "额度" tab: flat DSH settings rows, one account at a time. */
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
- const accounts = Array.isArray(snap.data?.accounts) ? snap.data.accounts : [];
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
- const providers = useMemo(() => {
4680
- const counts = new Map();
4681
- for (const c of credentials) counts.set(c.provider || "", (counts.get(c.provider || "") ?? 0) + 1);
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
- const groups = useMemo(() => {
4686
- const q = search.trim().toLowerCase();
4687
- const map = new Map();
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
- if (store === null || store === undefined) return null;
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
- const saveKey = async () => {
4711
- if (account === null || keyDraft.trim() === "") return;
4712
- setNotice(null);
4713
- const result = await store.saveKey(account.accountId, keyDraft.trim());
4714
- if (result.ok) {
4715
- setKeyDraft("");
4716
- setNotice(t("qKeySaved"));
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
- const clearKey = async () => {
4720
- if (account === null) return;
4721
- const name = account.displayName || account.accountId;
4722
- if (typeof window !== "undefined" && typeof window.confirm === "function" && !window.confirm(fmt(t, "qKeyClearConfirm", { name }))) return;
4723
- setNotice(null);
4724
- const result = await store.clearKey(account.accountId);
4725
- if (result.ok) setNotice(t("qKeyCleared"));
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
- const errorText = account ? quotaErrorText(t, account) : null;
4729
- const refreshing = account?.refreshing === true || (snap.busy === "refresh" && snap.busyAccount === account?.accountId);
4730
- const shown = groups.reduce((n, [, list]) => n + list.length, 0);
6273
+ useEffect(() => {
6274
+ mounted.current = true;
6275
+ return () => {
6276
+ mounted.current = false;
6277
+ };
6278
+ }, []);
4731
6279
 
4732
- return h(
4733
- "div",
4734
- { className: "dcpa-q", "data-dsh-plugin": MODULE_ID },
4735
- scoped
4736
- ? null
4737
- : h(
4738
- "div",
4739
- { className: "dcpa-q-head" },
4740
- h("h3", { className: "dcpa-q-h" }, t("qTitle")),
4741
- h("p", { className: "dcpa-q-desc" }, t("qSubtitle"))
4742
- ),
4743
- scoped && account === null ? h("p", { className: "dcpa-q-msg" }, t("qPeekLoading")) : null,
4744
- !scoped && accounts.length > 1
4745
- ? h(
4746
- "div",
4747
- { className: "dcpa-q-seg", role: "tablist", "aria-label": t("qAccountsLabel") },
4748
- accounts.map((a) =>
4749
- h(
4750
- "button",
4751
- {
4752
- key: a.accountId,
4753
- type: "button",
4754
- role: "tab",
4755
- className: "dcpa-q-seg-tab",
4756
- "aria-selected": account?.accountId === a.accountId,
4757
- onClick: () => {
4758
- setActiveId(a.accountId);
4759
- setKeyDraft("");
4760
- setNotice(null);
4761
- setProviderFilter("all");
4762
- setSearch("");
4763
- },
4764
- },
4765
- a.displayName || a.accountId,
4766
- a.keyConfigured ? null : h("span", { className: "dcpa-q-seg-note" }, t("qKeyStatusUnset"))
4767
- )
4768
- )
4769
- )
4770
- : null,
4771
- snap.data !== null && accounts.length === 0 ? h("p", { className: "dcpa-q-msg" }, t("qNoAccounts")) : null,
4772
- account
4773
- ? h(
4774
- "div",
4775
- { className: "dcpa-q-row" },
4776
- h(
4777
- "div",
4778
- { className: "dcpa-q-row-main" },
4779
- h(
4780
- "div",
4781
- { className: "dcpa-q-title" },
4782
- h("label", { htmlFor: `dcpa-q-key-${account.accountId}` }, t("qKeyLabel")),
4783
- h(InfoTip, { t, text: fmt(t, "qKeyHint", { ref: account.keyRef ?? "" }) })
4784
- )
4785
- ),
4786
- h(
4787
- "div",
4788
- { className: "dcpa-q-actions" },
4789
- h(
4790
- "span",
4791
- { className: "dcpa-q-tag", "data-tone": account.keyConfigured ? "success" : undefined },
4792
- account.keyConfigured ? t("qKeyStatusSet") : t("qKeyStatusUnset")
4793
- )
4794
- )
4795
- )
4796
- : null,
4797
- account
4798
- ? h(
4799
- "div",
4800
- { className: "dcpa-q-form" },
4801
- h("input", {
4802
- id: `dcpa-q-key-${account.accountId}`,
4803
- className: "dcpa-q-input",
4804
- type: "password",
4805
- autoComplete: "off",
4806
- spellCheck: false,
4807
- value: keyDraft,
4808
- placeholder: account.keyConfigured ? t("qKeySetPlaceholder") : t("qKeyUnsetPlaceholder"),
4809
- onChange: (e) => setKeyDraft(e.target.value),
4810
- onKeyDown: (e) => {
4811
- if (e.key === "Enter") void saveKey();
4812
- },
4813
- }),
4814
- h(
4815
- "button",
4816
- { type: "button", className: "dcpa-q-btn", "data-variant": "primary", disabled: busy || keyDraft.trim() === "", onClick: () => void saveKey() },
4817
- snap.busy === "save" ? t("qKeySaving") : t("qKeySave")
4818
- ),
4819
- account.keyConfigured
4820
- ? h(
4821
- "button",
4822
- { type: "button", className: "dcpa-q-btn", "data-variant": "danger", disabled: busy, onClick: () => void clearKey() },
4823
- t("qKeyClear")
4824
- )
4825
- : null
4826
- )
4827
- : null,
4828
- account && account.keyConfigured
4829
- ? h(
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
- { className: "dcpa-q-row" },
4832
- h(
4833
- "div",
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
- { type: "button", className: "dcpa-q-btn", "data-variant": "outline", disabled: busy || refreshing, onClick: () => void store.refresh(account.accountId) },
4853
- t("qRefresh")
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
- : null,
4858
- errorText ? h("p", { className: "dcpa-q-msg", "data-tone": "error", role: "alert" }, errorText, credentials.length > 0 ? ` ${t("qStale")}` : "") : null,
4859
- snap.error ? h("p", { className: "dcpa-q-msg", "data-tone": "error", role: "alert" }, fmt(t, "qErrLoad", { error: snap.error })) : null,
4860
- notice ? h("p", { className: "dcpa-q-msg", "data-tone": "ok", role: "status" }, notice) : null,
4861
- account && account.keyConfigured && credentials.length === 0 && account.state === "ok"
4862
- ? h("p", { className: "dcpa-q-msg" }, t("qNoCredentials"))
4863
- : null,
4864
- account && account.keyConfigured && credentials.length > 0 ? h(QuotaSummary, { t, credentials }) : null,
4865
- account && account.keyConfigured && (providers.length > 1 || credentials.length > 8)
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
- { className: "dcpa-q-tools" },
4869
- providers.length > 1
4870
- ? [["all", credentials.length], ...providers].map(([key, count]) =>
4871
- h(
4872
- "button",
4873
- {
4874
- key: key || "_",
4875
- type: "button",
4876
- className: "dcpa-q-filter",
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
- account && account.keyConfigured && credentials.length > 0 ? h(ModelChannelMap, { t, account }) : null
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
- /** The CLIProxyAPI settings section: accounts, each with 连接 / 模型 / 额度 tabs. */
4913
- function CpaSectionPage({ t }) {
4914
- return h(CpaSettingsPanel, { t });
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
- /** Snapshot of the session's model directory (current or pending selection). */
4918
- function useSelection(directory) {
4919
- const [, setTick] = useState(0);
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
- if (!directory || typeof directory.subscribe !== "function") return undefined;
4922
- let alive = true;
4923
- const off = directory.subscribe(() => {
4924
- if (alive) setTick((n) => n + 1);
4925
- });
4926
- return () => {
4927
- alive = false;
4928
- off?.();
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
- }, [directory]);
4931
- const snapshot = directory && typeof directory.getSnapshot === "function" ? directory.getSnapshot() : null;
4932
- const current = snapshot?.pending ?? snapshot?.current ?? null;
4933
- return current && typeof current.provider === "string" && typeof current.model === "string" ? current : null;
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
- * Composer badge left of the model picker: signal bars + best remaining
4938
- * percentage of the credentials that serve the selected model on the
4939
- * selected model's own CPA account; click opens a peek list. Accounts
4940
- * without a management key show nothing.
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 QuotaBadge({ t, directory: directProp = null, resolveDirectory = null, store = sharedQuota, sessionId = null, placement = "composer", presence = composerBadges }) {
4943
- const shadowed = useBadgePlacement(placement, sessionId, presence);
4944
- const directory = useDirectory(directProp, shadowed ? null : resolveDirectory);
4945
- const selection = useSelection(shadowed ? null : directory);
4946
- const snap = useQuota(selection ? store : null);
4947
- const now = useNow(60 * 1000);
4948
- const [open, setOpen] = useState(false);
4949
- const [pos, setPos] = useState(null);
4950
- const wrapRef = useRef(null);
4951
- const buttonRef = useRef(null);
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
- const summary = selection ? summarizeModelQuota(snap.data, selection.provider, selection.model) : null;
4954
- const visible = !shadowed && summary !== null && summary.account.keyConfigured === true;
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 (!open) return undefined;
4958
- const place = () => {
4959
- const rect = buttonRef.current?.getBoundingClientRect?.();
4960
- if (!rect || typeof window === "undefined") return;
4961
- const width = Math.min(340, window.innerWidth - 32);
4962
- const left = Math.max(16, Math.min(rect.left, window.innerWidth - width - 16));
4963
- setPos({ left, bottom: Math.max(16, window.innerHeight - rect.top + 6), width });
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
- place();
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 (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false);
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", place);
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", place);
6974
+ window.removeEventListener("resize", close);
6975
+ window.removeEventListener("scroll", close, true);
4982
6976
  };
4983
- }, [open]);
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 peek = open
4998
- ? h(
4999
- "div",
5000
- {
5001
- className: "dcpa-q-pop",
5002
- role: "dialog",
5003
- "aria-label": label,
5004
- style: pos ? { left: `${pos.left}px`, bottom: `${pos.bottom}px`, width: `${pos.width}px` } : { visibility: "hidden" },
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
- h(
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-q-pop-head" },
5009
- h("div", { className: "dcpa-q-pop-model" }, selection.model),
5010
- h("div", { className: "dcpa-q-pop-sub" }, `${account.displayName || account.accountId} · ${fmt(t, "qPeekCreds", { n: usableCount })}`)
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
- { key: cred.id, className: "dcpa-q-pop-cred" },
5019
- h(
5020
- "div",
5021
- { className: "dcpa-q-pop-who" },
5022
- h("span", { className: "dcpa-q-tag" }, providerName(t, cred.provider)),
5023
- h("span", { className: "dcpa-q-name", title: cred.label || cred.id }, cred.label || cred.id),
5024
- index === 0 && cred.status === "active" && headroom !== null && entries.length > 1
5025
- ? h("span", { className: "dcpa-q-tag", "data-tone": "success" }, t("qPeekBest"))
5026
- : null,
5027
- CredentialTags({ t, cred })
5028
- ),
5029
- h(CredentialWindows, { t, cred, windows, now })
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-pop-foot" },
5035
- h("span", null, account.fetchedAt ? fmt(t, "qUpdated", { time: agoText(t, account.fetchedAt, now) }) : t("qPeekFooter")),
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-q-btn",
5041
- disabled: snap.busy !== null || account.refreshing === true,
5042
- onClick: () => void store?.refresh(account.accountId),
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
- refreshing ? t("qRefreshing") : t("qRefresh")
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
- "span",
5052
- { ref: wrapRef, className: "dcpa-q-wrap", "data-placement": placement, "data-dsh-plugin": MODULE_ID },
7343
+ "div",
7344
+ { className: "dcpa-chat", "data-dsh-plugin": MODULE_ID, "data-state": tone, "data-kind": kind },
5053
7345
  h(
5054
- "button",
5055
- {
5056
- ref: buttonRef,
5057
- type: "button",
5058
- className: "dcpa-q-badge",
5059
- "data-level": level,
5060
- "aria-label": label,
5061
- "aria-haspopup": "dialog",
5062
- "aria-expanded": open,
5063
- title: label,
5064
- onClick: () => setOpen((v) => !v),
5065
- },
5066
- h("span", { className: "dcpa-q-bars", "data-level": level, "aria-hidden": "true" }, h("i"), h("i"), h("i")),
5067
- h("span", { className: "dcpa-q-badge-pct" }, loadingFirst ? "…" : pctText(best))
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
- peek
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.