@ran-sh/dsh-crew 0.3.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/.claude-plugin/marketplace.json +17 -0
- package/.claude-plugin/plugin.json +8 -0
- package/.mcp.json +8 -0
- package/LICENSE +21 -0
- package/README.de.md +359 -0
- package/README.es.md +359 -0
- package/README.fr.md +359 -0
- package/README.hi.md +359 -0
- package/README.id.md +359 -0
- package/README.ja.md +359 -0
- package/README.ko.md +359 -0
- package/README.md +360 -0
- package/README.pt.md +359 -0
- package/README.ru.md +359 -0
- package/README.th.md +359 -0
- package/README.tr.md +359 -0
- package/README.vi.md +359 -0
- package/README.zh-TW.md +359 -0
- package/README.zh.md +305 -0
- package/agents/ds-flash.md +26 -0
- package/agents/ds-pro.md +32 -0
- package/agents/ds-reviewer.md +23 -0
- package/agents/ds-worker.md +22 -0
- package/codex/agents/ds-flash.toml +30 -0
- package/codex/agents/ds-pro.toml +31 -0
- package/codex/agents/ds-reviewer.toml +28 -0
- package/codex/agents/ds-worker.toml +28 -0
- package/codex/prompts/dsh-config.md +3 -0
- package/codex/prompts/dsh-status.md +1 -0
- package/commands/config.md +11 -0
- package/commands/off.md +5 -0
- package/commands/on.md +5 -0
- package/commands/status.md +5 -0
- package/cordis.patch.yml +4 -0
- package/docs/images/dsh-crew-host.png +0 -0
- package/docs/images/dsh-crew-jobs.png +0 -0
- package/docs/images/dsh-crew-logo.png +0 -0
- package/docs/images/dsh-crew-overview.png +0 -0
- package/lib/client.js +2765 -0
- package/package.json +125 -0
- package/scripts/build-client.mjs +28 -0
- package/scripts/live-crew-smoke.mjs +39 -0
- package/scripts/live-policy-matrix.mjs +177 -0
- package/scripts/policy-probe.mjs +101 -0
- package/scripts/setup.mjs +294 -0
- package/scripts/smoke-real.mjs +110 -0
- package/scripts/smoke.mjs +78 -0
- package/scripts/verify-installer-fix.mjs +26 -0
- package/src/adaptive-routing.mjs +260 -0
- package/src/client/activation-summary.tsx +64 -0
- package/src/client/entry.tsx +236 -0
- package/src/client/index.tsx +1120 -0
- package/src/config-readiness.mjs +59 -0
- package/src/delivery.mjs +205 -0
- package/src/dsh-cli-runtime.mjs +251 -0
- package/src/failure-classification.mjs +172 -0
- package/src/hub/entry.mjs +98 -0
- package/src/hub/index.mjs +757 -0
- package/src/hub-client.mjs +132 -0
- package/src/hub-compatibility.mjs +49 -0
- package/src/i18n.mjs +19 -0
- package/src/install/cli.mjs +28 -0
- package/src/install/install-legacy.mjs +460 -0
- package/src/install/install.mjs +451 -0
- package/src/jobs.mjs +275 -0
- package/src/mcp-runtime.mjs +257 -0
- package/src/model-catalog.mjs +173 -0
- package/src/model-routing.mjs +391 -0
- package/src/multimodal.mjs +0 -0
- package/src/policy-legacy.mjs +830 -0
- package/src/policy.mjs +197 -0
- package/src/readiness-matrix.mjs +169 -0
- package/src/runtime-controls.mjs +90 -0
- package/src/runtime-identity.mjs +108 -0
- package/src/server.mjs +477 -0
- package/src/status-shard.mjs +52 -0
- package/src/structured-error-code.mjs +39 -0
- package/src/vision-route.mjs +138 -0
- package/src/workflow-runtime.mjs +567 -0
- package/src/workflow.mjs +160 -0
- package/src/workspace-audit.mjs +231 -0
- package/src/workspace-isolation.mjs +306 -0
- package/statusline/statusline.sh +14 -0
- package/statusline/worker-segment.sh +35 -0
- package/worker.cordis.yml +77 -0
|
@@ -0,0 +1,1120 @@
|
|
|
1
|
+
// Browser half of dsh-crew: integrations (install/update/restore),
|
|
2
|
+
// global worker configuration, and the live jobs table.
|
|
3
|
+
// Talks only to the plugin's own loopback routes.
|
|
4
|
+
|
|
5
|
+
import { useCallback, useEffect, useState, useSyncExternalStore } from 'react';
|
|
6
|
+
import { createPortal } from 'react-dom';
|
|
7
|
+
|
|
8
|
+
export const inject = ['slots', 'locale'];
|
|
9
|
+
|
|
10
|
+
const API = '/_dsh/dsh-crew';
|
|
11
|
+
|
|
12
|
+
const COPY = {
|
|
13
|
+
zh: {
|
|
14
|
+
label: 'DSH Crew',
|
|
15
|
+
title: 'DSH Crew',
|
|
16
|
+
intro: '把 Claude Code / Codex 的子任务派给本实例的 DSH agent,在宿主里显示为原生子代理、进度可见。配置派发默认值、执行方式,以及接入视觉与生图能力,一键装进宿主。',
|
|
17
|
+
integrations: '集成',
|
|
18
|
+
installed: '已安装', notInstalled: '未安装', hud: 'HUD 段',
|
|
19
|
+
install: '安装', update: '更新', restore: '还原',
|
|
20
|
+
confirmRestore: (name: string) => `确定从 ${name} 移除 dsh-crew 集成?(settings 会先备份)`,
|
|
21
|
+
globalConfig: '全局配置',
|
|
22
|
+
globalHint: '修改即时保存到 ~/.config/dsh-crew/config.json;CC / Codex 的新会话自动读取为默认值(会话内可用 /dsh-crew:config 临时覆盖)。',
|
|
23
|
+
orchestration: 'Agent 编排',
|
|
24
|
+
enableSubagents: '启用子 Agent',
|
|
25
|
+
enableSubagentsHint: '总开关。关闭后 dsh_run_worker / dsh_spawn_worker 在工具层拒绝派发(配置保留,重新开启即恢复)。',
|
|
26
|
+
subagentsKept: '配置已保留',
|
|
27
|
+
mainAgentMode: '主 Agent 模式',
|
|
28
|
+
mainAgentModeDesc: { 'direct-allowed': '直接允许 · 可自己做,也可委派', 'coordinator-first': '协调优先 · 先规划、拆分、委派,再检查验证', 'dispatcher-only': '仅派发 · 尽量只做规划、派发、审查与整合' },
|
|
29
|
+
mainAgentModeHint: '这是宿主路由指引,不是对宿主自身工具的硬限制。',
|
|
30
|
+
collaborationMode: '协作模式',
|
|
31
|
+
collaborationModeDesc: { 'flash-only': 'Flash Only · 只用 Flash', 'pro-only': 'Pro Only · 只用 Pro', balanced: 'Balanced · 双 Auto', 'review-pipeline': 'Review Pipeline · Flash 实现 + Pro 复审', custom: 'Custom · 完全手动' },
|
|
32
|
+
tierCardFlash: 'Flash', tierCardPro: 'Pro',
|
|
33
|
+
tierState: '状态',
|
|
34
|
+
tierStateDesc: { disabled: '禁用', manual: '手动(仅用户点名时可用)', auto: '自动(orchestrator 可自动委派)' },
|
|
35
|
+
stateManagedByPreset: '由协作模式管理',
|
|
36
|
+
statePresetHint: '切到 Custom 可单独配置各档状态。',
|
|
37
|
+
workerProvider: 'Worker Provider',
|
|
38
|
+
workerProviderDesc: { 'follow-dsh': '跟随 Harness · 使用各档独立的模型优先级', 'deepseek-official': 'DeepSeek Official · 使用旧版固定模型路由' },
|
|
39
|
+
workerProviderHint: '仅作用于 Hub 模式;Standalone 始终用 deepseek-official + DEEPSEEK_API_KEY。',
|
|
40
|
+
workerModels: 'Worker 模型', refreshHarnessModels: '刷新 Harness 模型', refreshingModels: '刷新中…',
|
|
41
|
+
modelPriority: '模型优先级', addPriorityModel: '+ 添加优先模型', modelSearch: '搜索 Provider 或 Model…',
|
|
42
|
+
harnessDefault: 'Harness 默认模型', providerUnavailable: 'Provider 不可用', notAdvertised: '当前未列出',
|
|
43
|
+
noPriority: '未配置优先项', modelPoolSummary: (p: number, m: number) => `Providers: ${p} · Models: ${m}`,
|
|
44
|
+
partialCatalog: '部分 Provider 读取失败', catalogFailed: '无法读取 Harness 模型目录', removePriority: '移除', moveUp: '上移', moveDown: '下移',
|
|
45
|
+
needsModelSelection: '多个 Provider 提供默认偏好模型,且 Harness Default 无法消歧;当前将回退到 Harness Default,请手动选择。',
|
|
46
|
+
responsibilities: '职责',
|
|
47
|
+
responsibilitiesHint: '路由指引,仅用于描述分工;只剩一个 Auto 档时它承担全部可委派工作。',
|
|
48
|
+
roleLabels: { implementation: '代码实现', simple_fix: '简单修复', tests: '测试', search_inspection: '搜索 / 检查', architecture: '架构', complex_debugging: '复杂调试', refactor: '重构', code_review: '代码审查' },
|
|
49
|
+
routing: '路由',
|
|
50
|
+
proReviewsFlash: 'Pro 复审 Flash 改动',
|
|
51
|
+
proReviewsFlashHint: 'Flash 成功后自动追加一次 Pro 复审(仅 blocking 派发)',
|
|
52
|
+
proReviewsLocked: 'Review Pipeline 模式固定开启',
|
|
53
|
+
enableCrewVision: '启用 Crew 视觉',
|
|
54
|
+
enableCrewVisionHint: '关闭后不注册 Crew 的 describe_image 与视觉 route(可与你的 dsh-vision 等共存)。',
|
|
55
|
+
enableImagegen: '启用生图',
|
|
56
|
+
enableImagegenHint: '关闭后不注册 Crew 的 generate_image。',
|
|
57
|
+
capRestartHint: '该开关改变工具注册,需重启 DSH 生效',
|
|
58
|
+
capDisabledNote: 'Crew 工具/route 已禁用(重启 DSH 后生效)',
|
|
59
|
+
tier: '默认档位', effort: '默认推理', mode: '执行模式', timeout: '默认超时(秒)', hubUrl: 'Hub 地址',
|
|
60
|
+
tierPolicy: '档位策略', escalate: '失败升档',
|
|
61
|
+
tierPolicyDesc: { auto: 'auto · orchestrator 自选', 'flash-only': '只用 flash', 'pro-only': '只用 pro' },
|
|
62
|
+
escalateHint: 'flash 失败自动用 pro 重试一次',
|
|
63
|
+
presetFlash: 'flash 模式', presetPro: 'pro 模式',
|
|
64
|
+
multimodal: '多模态',
|
|
65
|
+
visionProvider: '视觉 provider', visionModel: '视觉模型', imagegenProvider: '生图 provider',
|
|
66
|
+
customProviders: '自定义 Provider', addProvider: '+ 添加 Provider',
|
|
67
|
+
noCustomProviders: '暂无。添加后会出现在上方的视觉 / 生图 provider 选择里。',
|
|
68
|
+
providerName: '名称', modelsField: '模型列表(逗号分隔)',
|
|
69
|
+
providerType: '接入方式', typeApi: 'API · 兼容 OpenAI 接口', typeCli: 'CLI · 本地命令',
|
|
70
|
+
baseUrl: 'Base URL', apiKey: 'API Key', imagegenModel: '生图模型(留空=不提供生图)',
|
|
71
|
+
visionCmd: '视觉命令(可选)', imagegenCmd: '生图命令(可选)',
|
|
72
|
+
apiFormHint: '走 OpenAI 兼容接口:视觉调 {base}/chat/completions(图片以 base64 内联),生图调 {base}/images/generations。Key 保存在本机 ~/.config/dsh-crew/config.json,不会外发到第三方。',
|
|
73
|
+
cliFormHint: '命令经 bash 执行,占位符会被安全引用后代入。视觉: {image} {question} {model},stdout 即答案;生图: {prompt} {output} {size},命令须把图片写到 {output}。两条命令至少填一条。',
|
|
74
|
+
saveProvider: '保存', cancel: '取消', edit: '编辑', del: '删除',
|
|
75
|
+
testProvider: '连通测试', testing: '测试中…',
|
|
76
|
+
testTip: '按当前填写的内容实测:API 会检查可达性与鉴权并真发一次视觉请求;CLI 会检查可执行文件并真跑一次视觉命令。生图只校验配置,不实际出图。',
|
|
77
|
+
testPass: '连通正常', testFail: '连通失败',
|
|
78
|
+
staleHub: '本 DSH 实例还在跑旧版插件,缺少该接口 —— 重启 DSH 后再试',
|
|
79
|
+
emptyResponse: (path: string, status: number) => `${path} → HTTP ${status},响应为空`,
|
|
80
|
+
badJson: (path: string, body: string) => `${path} → 非 JSON 响应: ${body}`,
|
|
81
|
+
refreshModels: '重新从 CLI 获取模型列表',
|
|
82
|
+
presetDefault: (id?: string) => `default · 跟随 DSH${id ? ` (${id})` : ''}`,
|
|
83
|
+
progressTip: (t: string, turn: number, step: number, calls: number | string) => `耗时 ${t} · 第${turn}轮第${step}步 · ${calls} 次工具调用`,
|
|
84
|
+
confirmDeleteProvider: (n: string) => `删除自定义 provider「${n}」?`,
|
|
85
|
+
needName: '⚠ 名称必填', needCmd: '⚠ 视觉/生图命令至少填一条',
|
|
86
|
+
needBaseUrl: '⚠ Base URL 必填', needModels: '⚠ 模型列表至少填一个',
|
|
87
|
+
keySet: '已配置', keyPlaceholder: 'sk-…(留空则不发送 Authorization)',
|
|
88
|
+
addModelTip: '添加模型到列表', removeModelTip: '从列表移除当前模型', modelPlaceholder: '模型 id,回车确认',
|
|
89
|
+
capVision: '视觉', capImagegen: '生图',
|
|
90
|
+
groupDispatch: '派发', groupRuntime: '运行', groupMultimodal: '多模态',
|
|
91
|
+
groupWorkflow: '工作流 / 执行(v0.2)',
|
|
92
|
+
roleWorker: 'Worker 角色(执行)', roleReviewer: 'Reviewer 角色(独立审查)',
|
|
93
|
+
workerStateLabel: 'Worker 状态', reviewStateLabel: 'Reviewer 状态',
|
|
94
|
+
roleStateHint: 'auto:orchestrator 可自动派发;manual:仅显式点名;disabled:禁用。未显式设置时由协作模式推导。',
|
|
95
|
+
autoReview: '自动复审', autoReviewHint: 'worker 成功后自动运行一次 reviewer(独立模型策略,只读)',
|
|
96
|
+
isolation: '工作区隔离', isolationHint: 'worktree 为默认;非 Git 仓库时需显式 shared,否则任务以 NOT_GIT_REPOSITORY 失败。',
|
|
97
|
+
isolationDesc: { worktree: 'worktree · 每个 coding worker 独立临时 git worktree(主工作区不动)', shared: 'shared · 旧版就地执行' },
|
|
98
|
+
maxParallel: '最大并行', maxParallelHint: '同时运行的工作流上限(1–16);超出进入队列。',
|
|
99
|
+
legacyCompatibility: '↓ 以下为兼容配置(legacy,Flash/Pro 仍可编辑)',
|
|
100
|
+
cardDispatch: { t: '派发策略', d: 'orchestrator 未指定时的档位与推理强度,以及档位约束、失败升档与两档各自挂载的 Agent 预设。' },
|
|
101
|
+
cardRuntime: { t: '执行与连接', d: 'worker 会话跑在本实例内还是独立进程,单个任务的超时上限,以及 CC / Codex 探测本实例的地址。' },
|
|
102
|
+
cardMM: { t: '视觉与生图', d: '给纯文本的 DSH 模型借来眼睛和画笔:describe_image、会话贴图转写与 generate_image 都用这里的设置。' },
|
|
103
|
+
cardCustomProv: { t: '自定义 Provider', d: '接入自己的 API 或本地命令;保存后会出现在上面的视觉 / 生图 provider 选择里。' },
|
|
104
|
+
modeDesc: { auto: 'auto · 优先 hub', hub: 'hub · 必须 hub', standalone: 'standalone · 独立进程' },
|
|
105
|
+
save: '保存', saved: '已保存', jobs: 'Worker 任务', empty: '当前没有 worker 任务。',
|
|
106
|
+
col: { id: '任务', source: '来源', tier: '档位', status: '状态', progress: '进度', tokens: 'tokens ⇅', task: '内容' },
|
|
107
|
+
working: '处理中…',
|
|
108
|
+
tips: {
|
|
109
|
+
claude: {
|
|
110
|
+
install: '一键装进 Claude Code:注册本地 marketplace、claude plugin install、写入工具权限白名单、接线 claude-hud worker 段;修改 settings.json 前自动备份',
|
|
111
|
+
update: '重跑安装流程:刷新注册与权限、迁移旧格式配置、更新 HUD 接线;幂等,可放心点',
|
|
112
|
+
restore: '从 Claude Code 移除集成:清除 marketplace 注册、插件启用项与权限规则(settings 先备份);不删除插件源码与已跑任务',
|
|
113
|
+
},
|
|
114
|
+
codex: {
|
|
115
|
+
install: '把 ds-flash / ds-pro 角色和 /dsh-config、/dsh-status 命令复制到 ~/.codex/(MCP 路径按本机自动渲染,含 approve 审批与超时配置)',
|
|
116
|
+
update: '重新渲染并覆盖角色文件与命令(插件路径或配置变更后用);原文件先备份',
|
|
117
|
+
restore: '删除 ~/.codex/ 下的 ds-flash / ds-pro 角色与 dsh 命令文件(角色文件先备份)',
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
en: {
|
|
122
|
+
label: 'DSH Crew',
|
|
123
|
+
title: 'DSH Crew',
|
|
124
|
+
intro: 'Dispatch Claude Code / Codex subtasks to DSH agents within this instance, displayed as native subagents in the host with live progress. Configure dispatch defaults, execution mode, and vision/image generation (via subscription CLI or your own OpenAI API), then one-click install into the host.',
|
|
125
|
+
integrations: 'Integrations',
|
|
126
|
+
installed: 'installed', notInstalled: 'not installed', hud: 'HUD segment',
|
|
127
|
+
install: 'Install', update: 'Update', restore: 'Restore',
|
|
128
|
+
confirmRestore: (name: string) => `Remove the dsh-crew integration from ${name}? (settings are backed up first)`,
|
|
129
|
+
globalConfig: 'Global configuration',
|
|
130
|
+
globalHint: 'Changes save instantly to ~/.config/dsh-crew/config.json; new CC / Codex sessions pick them up as defaults (override per session with /dsh-crew:config).',
|
|
131
|
+
orchestration: 'Agent orchestration',
|
|
132
|
+
enableSubagents: 'Enable Subagents',
|
|
133
|
+
enableSubagentsHint: 'Master switch. When off, dsh_run_worker / dsh_spawn_worker refuse dispatch at the tool layer (the configuration is kept; re-enable to restore).',
|
|
134
|
+
subagentsKept: 'Configuration kept',
|
|
135
|
+
mainAgentMode: 'Main Agent Mode',
|
|
136
|
+
mainAgentModeDesc: { 'direct-allowed': 'Direct Allowed · implement directly or delegate', 'coordinator-first': 'Coordinator First · plan, decompose, delegate, verify', 'dispatcher-only': 'Dispatcher Only · plan, dispatch, review, integrate' },
|
|
137
|
+
mainAgentModeHint: 'Host routing guidance — not a hard restriction on the host\'s own tools.',
|
|
138
|
+
collaborationMode: 'Collaboration Mode',
|
|
139
|
+
collaborationModeDesc: { 'flash-only': 'Flash Only', 'pro-only': 'Pro Only', balanced: 'Balanced', 'review-pipeline': 'Review Pipeline · implement + review', custom: 'Custom' },
|
|
140
|
+
tierCardFlash: 'Flash', tierCardPro: 'Pro',
|
|
141
|
+
tierState: 'State',
|
|
142
|
+
tierStateDesc: { disabled: 'Disabled', manual: 'Manual (only when the user names this tier)', auto: 'Auto (the orchestrator may delegate to it)' },
|
|
143
|
+
stateManagedByPreset: 'managed by the collaboration mode',
|
|
144
|
+
statePresetHint: 'Switch to Custom to configure each tier state separately.',
|
|
145
|
+
workerProvider: 'Worker Provider',
|
|
146
|
+
workerProviderDesc: { 'follow-dsh': 'Follow Harness · use each tier\'s ordered model priority', 'deepseek-official': 'DeepSeek Official · use legacy fixed model routing' },
|
|
147
|
+
workerProviderHint: 'Applies to Hub workers only; Standalone always uses deepseek-official + DEEPSEEK_API_KEY.',
|
|
148
|
+
workerModels: 'Worker Models', refreshHarnessModels: 'Refresh Harness Models', refreshingModels: 'Refreshing…',
|
|
149
|
+
modelPriority: 'Model Priority', addPriorityModel: '+ Add Priority Model', modelSearch: 'Search Provider or Model…',
|
|
150
|
+
harnessDefault: 'Harness Default', providerUnavailable: 'Provider unavailable', notAdvertised: 'Currently not advertised',
|
|
151
|
+
noPriority: 'No priority models configured', modelPoolSummary: (p: number, m: number) => `Providers: ${p} · Models: ${m}`,
|
|
152
|
+
partialCatalog: 'Some providers failed to load', catalogFailed: 'Unable to read Harness model catalog', removePriority: 'Remove', moveUp: 'Move up', moveDown: 'Move down',
|
|
153
|
+
needsModelSelection: 'Multiple providers advertise the preferred model and Harness Default cannot disambiguate them. Falling back to Harness Default; choose a priority model explicitly.',
|
|
154
|
+
responsibilities: 'Responsibilities',
|
|
155
|
+
responsibilitiesHint: 'Routing guidance describing the split of work; with a single Auto tier left, that tier carries all delegatable coding work.',
|
|
156
|
+
roleLabels: { implementation: 'Code implementation', simple_fix: 'Simple fixes', tests: 'Tests', search_inspection: 'Search / inspection', architecture: 'Architecture', complex_debugging: 'Complex debugging', refactor: 'Refactoring', code_review: 'Code review' },
|
|
157
|
+
routing: 'Routing',
|
|
158
|
+
proReviewsFlash: 'Pro reviews Flash changes',
|
|
159
|
+
proReviewsFlashHint: 'Automatically follow a successful Flash run with one Pro review (blocking dispatch only)',
|
|
160
|
+
proReviewsLocked: 'always on in Review Pipeline mode',
|
|
161
|
+
enableCrewVision: 'Enable Crew Vision',
|
|
162
|
+
enableCrewVisionHint: 'When off, the Crew describe_image tool and vision route are not registered (coexist with your own dsh-vision or other plugins).',
|
|
163
|
+
enableImagegen: 'Enable Image Generation',
|
|
164
|
+
enableImagegenHint: 'When off, the Crew generate_image tool is not registered.',
|
|
165
|
+
capRestartHint: 'This switch changes tool registration and takes effect after a DSH restart',
|
|
166
|
+
capDisabledNote: 'Crew tool/route disabled (effective after DSH restart)',
|
|
167
|
+
tier: 'Default tier', effort: 'Default effort', mode: 'Mode', timeout: 'Timeout (s)', hubUrl: 'Hub URL',
|
|
168
|
+
tierPolicy: 'Tier policy', escalate: 'Escalate on failure',
|
|
169
|
+
tierPolicyDesc: { auto: 'auto · orchestrator picks', 'flash-only': 'flash only', 'pro-only': 'pro only' },
|
|
170
|
+
escalateHint: 'retry a failed flash run once on pro',
|
|
171
|
+
presetFlash: 'flash preset', presetPro: 'pro preset',
|
|
172
|
+
multimodal: 'Multimodal',
|
|
173
|
+
visionProvider: 'Vision provider', visionModel: 'Vision model', imagegenProvider: 'Image-gen provider',
|
|
174
|
+
customProviders: 'Custom providers', addProvider: '+ Add provider',
|
|
175
|
+
noCustomProviders: 'None yet. Added providers appear in the vision / image-gen selects above.',
|
|
176
|
+
providerName: 'Name', modelsField: 'Models (comma-separated)',
|
|
177
|
+
providerType: 'Connection', typeApi: 'API · OpenAI-compatible', typeCli: 'CLI · local command',
|
|
178
|
+
baseUrl: 'Base URL', apiKey: 'API key', imagegenModel: 'Image-gen model (empty = no image generation)',
|
|
179
|
+
visionCmd: 'Vision command (optional)', imagegenCmd: 'Image-gen command (optional)',
|
|
180
|
+
apiFormHint: 'OpenAI-compatible: vision calls {base}/chat/completions (image inlined as base64), image-gen calls {base}/images/generations. The key is stored locally in ~/.config/dsh-crew/config.json and never sent to third parties.',
|
|
181
|
+
cliFormHint: 'Commands run via bash with shell-quoted placeholders. Vision: {image} {question} {model}, stdout is the answer; image-gen: {prompt} {output} {size}, the command must write the image to {output}. Fill in at least one command.',
|
|
182
|
+
saveProvider: 'Save', cancel: 'Cancel', edit: 'Edit', del: 'Delete',
|
|
183
|
+
testProvider: 'Test', testing: 'Testing…',
|
|
184
|
+
testTip: 'Probes what is currently filled in: API checks reachability and auth then makes one real vision call; CLI checks the executable then runs the vision command once. Image generation is only validated, never actually run.',
|
|
185
|
+
testPass: 'Connection OK', testFail: 'Connection failed',
|
|
186
|
+
staleHub: 'This DSH instance is still running an older build of the plugin and lacks this route — restart DSH and retry',
|
|
187
|
+
emptyResponse: (path: string, status: number) => `${path} → HTTP ${status}, empty response`,
|
|
188
|
+
badJson: (path: string, body: string) => `${path} → non-JSON response: ${body}`,
|
|
189
|
+
refreshModels: 'Re-fetch the model list from the CLI',
|
|
190
|
+
presetDefault: (id?: string) => `default · follow DSH${id ? ` (${id})` : ''}`,
|
|
191
|
+
progressTip: (t: string, turn: number, step: number, calls: number | string) => `${t} elapsed · turn ${turn}, step ${step} · ${calls} tool calls`,
|
|
192
|
+
confirmDeleteProvider: (n: string) => `Delete custom provider "${n}"?`,
|
|
193
|
+
needName: '⚠ Name is required', needCmd: '⚠ Fill in at least one command',
|
|
194
|
+
needBaseUrl: '⚠ Base URL is required', needModels: '⚠ Add at least one model',
|
|
195
|
+
keySet: 'set', keyPlaceholder: 'sk-… (leave empty to send no Authorization header)',
|
|
196
|
+
addModelTip: 'Add a model to the list', removeModelTip: 'Remove the current model from the list', modelPlaceholder: 'model id, Enter to confirm',
|
|
197
|
+
capVision: 'vision', capImagegen: 'image-gen',
|
|
198
|
+
groupDispatch: 'Dispatch', groupRuntime: 'Runtime', groupMultimodal: 'Multimodal',
|
|
199
|
+
groupWorkflow: 'Workflow / Execution (v0.2)',
|
|
200
|
+
roleWorker: 'Worker role (execution)', roleReviewer: 'Reviewer role (independent review)',
|
|
201
|
+
workerStateLabel: 'Worker state', reviewStateLabel: 'Reviewer state',
|
|
202
|
+
roleStateHint: 'auto = the orchestrator may dispatch automatically; manual = only when explicitly named; disabled = off. When unset, derived from the collaboration mode.',
|
|
203
|
+
autoReview: 'Automatic review', autoReviewHint: 'Run one reviewer pass (independent model policy, read-only) after a successful worker run',
|
|
204
|
+
isolation: 'Workspace isolation', isolationHint: 'worktree is the default; non-git workspaces require explicit shared, otherwise jobs fail with NOT_GIT_REPOSITORY.',
|
|
205
|
+
isolationDesc: { worktree: 'worktree · each coding worker runs in its own temporary git worktree (primary untouched)', shared: 'shared · legacy in-place execution' },
|
|
206
|
+
maxParallel: 'Max parallel', maxParallelHint: 'Upper bound of concurrently running workflows (1–16); extra ones queue.',
|
|
207
|
+
legacyCompatibility: '↓ Legacy compatibility controls below (Flash/Pro still editable)',
|
|
208
|
+
cardDispatch: { t: 'Dispatch policy', d: 'Tier and effort used when the orchestrator names none, plus tier clamping, failure escalation and the Agent preset mounted per tier.' },
|
|
209
|
+
cardRuntime: { t: 'Execution & connection', d: 'Whether worker sessions run inside this instance or a separate process, the per-job timeout, and the address CC / Codex probes.' },
|
|
210
|
+
cardMM: { t: 'Vision & image generation', d: "Lends the harness's text-only models eyes and a brush: describe_image, pasted-image transcription and generate_image all use these settings." },
|
|
211
|
+
cardCustomProv: { t: 'Custom providers', d: 'Bring your own API or local command; saved providers appear in the vision / image-gen selects above.' },
|
|
212
|
+
modeDesc: { auto: 'auto · prefer hub', hub: 'hub · require hub', standalone: 'standalone' },
|
|
213
|
+
save: 'Save', saved: 'Saved', jobs: 'Worker jobs', empty: 'No worker jobs yet.',
|
|
214
|
+
col: { id: 'job', source: 'source', tier: 'tier', status: 'status', progress: 'progress', tokens: 'tokens ⇅', task: 'task' },
|
|
215
|
+
working: 'Working…',
|
|
216
|
+
tips: {
|
|
217
|
+
claude: {
|
|
218
|
+
install: 'One-click install into Claude Code: register local marketplace, claude plugin install, permission allowlist, claude-hud segment wiring; settings.json backed up first',
|
|
219
|
+
update: 'Re-run the installer: refresh registration & permissions, migrate legacy config, update HUD wiring; idempotent',
|
|
220
|
+
restore: 'Remove the integration from Claude Code: marketplace registration, plugin enablement and permission rules (settings backed up); plugin source and past jobs untouched',
|
|
221
|
+
},
|
|
222
|
+
codex: {
|
|
223
|
+
install: 'Copy ds-flash / ds-pro roles and /dsh-config, /dsh-status prompts into ~/.codex/ (MCP paths rendered for this machine, approve mode + timeout included)',
|
|
224
|
+
update: 'Re-render and overwrite role files and prompts (after path/config changes); originals backed up',
|
|
225
|
+
restore: 'Delete ds-flash / ds-pro roles and dsh prompts from ~/.codex/ (roles backed up first)',
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const S = {
|
|
232
|
+
// Margins are tuned against the page container's 8px flex gap: the gap alone
|
|
233
|
+
// sits below each heading, the margin only adds separation above it.
|
|
234
|
+
section: { margin: '8px 0 -2px', fontSize: 13, fontWeight: 600 as const, opacity: 0.9 },
|
|
235
|
+
// Small muted group label, matching the host's Agent-preset page (内置 / 自定义).
|
|
236
|
+
group: { margin: '4px 0 -3px', fontSize: 12, opacity: 0.55 },
|
|
237
|
+
// Card with a title + description header above its fields.
|
|
238
|
+
block: { border: '1px solid rgba(128,128,128,0.22)', borderRadius: 10, padding: '11px 14px 13px', display: 'flex', flexDirection: 'column' as const, gap: 10 },
|
|
239
|
+
blockGrid: { display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: '12px 14px', alignItems: 'end' as const },
|
|
240
|
+
settingTitle: { fontWeight: 600 as const, fontSize: 13 },
|
|
241
|
+
settingDesc: { fontSize: 11.5, opacity: 0.6, marginTop: 2, lineHeight: 1.5 },
|
|
242
|
+
card: { border: '1px solid rgba(128,128,128,0.22)', borderRadius: 8, padding: '10px 14px', display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' as const },
|
|
243
|
+
grid: {
|
|
244
|
+
border: '1px solid rgba(128,128,128,0.22)', borderRadius: 8, padding: '12px 14px',
|
|
245
|
+
display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: '12px 14px', alignItems: 'end' as const,
|
|
246
|
+
},
|
|
247
|
+
btn: { padding: '4px 12px', borderRadius: 6, border: '1px solid rgba(128,128,128,0.35)', background: 'transparent', color: 'inherit', cursor: 'pointer', fontSize: 12.5 },
|
|
248
|
+
chip: (on: boolean) => ({ fontSize: 11.5, padding: '1px 8px', borderRadius: 99, border: '1px solid', borderColor: on ? 'rgba(63,185,80,0.5)' : 'rgba(128,128,128,0.35)', color: on ? '#3fb950' : 'inherit', opacity: on ? 1 : 0.55 }),
|
|
249
|
+
field: { display: 'flex', flexDirection: 'column' as const, gap: 3 },
|
|
250
|
+
fieldLabel: { fontSize: 11, opacity: 0.6 },
|
|
251
|
+
input: { padding: '3px 8px', borderRadius: 5, border: '1px solid rgba(128,128,128,0.35)', background: 'transparent', color: 'inherit', fontSize: 12.5 },
|
|
252
|
+
cell: { padding: '5px 10px 5px 0', borderBottom: '1px solid rgba(128,128,128,0.16)', textAlign: 'left' as const, verticalAlign: 'top' as const },
|
|
253
|
+
tight: {
|
|
254
|
+
padding: '5px 8px 5px 0', borderBottom: '1px solid rgba(128,128,128,0.16)', textAlign: 'left' as const,
|
|
255
|
+
verticalAlign: 'top' as const, whiteSpace: 'nowrap' as const, overflow: 'hidden', textOverflow: 'ellipsis',
|
|
256
|
+
},
|
|
257
|
+
mono: { fontFamily: 'ui-monospace, Menlo, monospace', fontSize: 12, fontVariantNumeric: 'tabular-nums' as const },
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const VISION_MODELS: Record<string, string[]> = {
|
|
261
|
+
'claude-code': ['haiku', 'sonnet', 'opus'],
|
|
262
|
+
codex: ['default', 'gpt-5.4-mini', 'gpt-5.6'],
|
|
263
|
+
grok: ['default'],
|
|
264
|
+
agy: ['default'],
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
function CustomSelect({ value, options, onChange, minWidth }: {
|
|
268
|
+
value: string; options: Array<{ value: string; label?: string }>;
|
|
269
|
+
onChange: (v: string) => void; minWidth?: number;
|
|
270
|
+
}) {
|
|
271
|
+
const [open, setOpen] = useState<{ left: number; top: number; bottom: number; width: number; up: boolean } | null>(null);
|
|
272
|
+
useEffect(() => {
|
|
273
|
+
if (!open) return;
|
|
274
|
+
const close = () => setOpen(null);
|
|
275
|
+
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') close(); };
|
|
276
|
+
const t = setTimeout(() => document.addEventListener('mousedown', close), 0);
|
|
277
|
+
document.addEventListener('keydown', onKey);
|
|
278
|
+
return () => { clearTimeout(t); document.removeEventListener('mousedown', close); document.removeEventListener('keydown', onKey); };
|
|
279
|
+
}, [open]);
|
|
280
|
+
const current = options.find((o) => o.value === value);
|
|
281
|
+
return (<>
|
|
282
|
+
<button type="button"
|
|
283
|
+
style={{ ...S.input, display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer', minWidth: minWidth ?? 92, width: '100%', boxSizing: 'border-box' as const, justifyContent: 'space-between' }}
|
|
284
|
+
onClick={(e: any) => {
|
|
285
|
+
const r = e.currentTarget.getBoundingClientRect();
|
|
286
|
+
const up = window.innerHeight - r.bottom < 240;
|
|
287
|
+
setOpen({ left: r.left, top: r.bottom + 4, bottom: window.innerHeight - r.top + 4, width: Math.max(r.width, 130), up });
|
|
288
|
+
}}>
|
|
289
|
+
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{current?.label ?? value}</span>
|
|
290
|
+
<span style={{ opacity: 0.5, fontSize: 10 }}>▾</span>
|
|
291
|
+
</button>
|
|
292
|
+
{open && createPortal(
|
|
293
|
+
<div
|
|
294
|
+
style={{
|
|
295
|
+
position: 'fixed', left: open.left, ...(open.up ? { bottom: open.bottom } : { top: open.top }),
|
|
296
|
+
minWidth: open.width, zIndex: 9999, background: 'Canvas', color: 'CanvasText',
|
|
297
|
+
border: '1px solid rgba(128,128,128,0.3)', borderRadius: 8,
|
|
298
|
+
boxShadow: '0 8px 28px rgba(0,0,0,0.22)', padding: 4, maxHeight: 240, overflowY: 'auto',
|
|
299
|
+
}}
|
|
300
|
+
onMouseDown={(e: any) => e.stopPropagation()}>
|
|
301
|
+
{options.map((o) => (
|
|
302
|
+
<div key={o.value}
|
|
303
|
+
onClick={() => { onChange(o.value); setOpen(null); }}
|
|
304
|
+
style={{
|
|
305
|
+
padding: '5px 10px', borderRadius: 5, cursor: 'pointer', fontSize: 12.5, whiteSpace: 'nowrap',
|
|
306
|
+
background: o.value === value ? 'rgba(128,128,128,0.16)' : 'transparent',
|
|
307
|
+
display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'center',
|
|
308
|
+
}}
|
|
309
|
+
onMouseEnter={(e: any) => { e.currentTarget.style.background = 'rgba(128,128,128,0.24)'; }}
|
|
310
|
+
onMouseLeave={(e: any) => { e.currentTarget.style.background = o.value === value ? 'rgba(128,128,128,0.16)' : 'transparent'; }}>
|
|
311
|
+
<span>{o.label ?? o.value}</span>{o.value === value && <span style={{ opacity: 0.6, fontSize: 11 }}>✓</span>}
|
|
312
|
+
</div>
|
|
313
|
+
))}
|
|
314
|
+
</div>, document.body)}
|
|
315
|
+
</>);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function sourceLabel(source?: string) {
|
|
319
|
+
if (source === 'claude-code') return 'CC';
|
|
320
|
+
if (source === 'codex') return 'Codex';
|
|
321
|
+
if (source === 'api' || source === undefined) return 'API';
|
|
322
|
+
return source;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function elapsed(startedAt: string, endedAt?: string | null) {
|
|
326
|
+
const s = Math.max(0, Math.round(((endedAt ? +new Date(endedAt) : Date.now()) - +new Date(startedAt)) / 1000));
|
|
327
|
+
return s >= 60 ? `${Math.floor(s / 60)}m${s % 60}s` : `${s}s`;
|
|
328
|
+
}
|
|
329
|
+
function ktok(n: number) { return n >= 1000 ? `${Math.round(n / 100) / 10}k` : `${n}`; }
|
|
330
|
+
|
|
331
|
+
function WorkersPanel({ ctx }: { ctx: any }) {
|
|
332
|
+
const locale = useSyncExternalStore(
|
|
333
|
+
(notify: () => void) => ctx.on('locale/change', notify),
|
|
334
|
+
() => ctx.locale.getLocale().active,
|
|
335
|
+
() => ctx.locale.getLocale().active,
|
|
336
|
+
);
|
|
337
|
+
const copy = COPY[locale === 'zh' ? 'zh' : 'en'];
|
|
338
|
+
|
|
339
|
+
const [jobs, setJobs] = useState<any[]>([]);
|
|
340
|
+
const [hoverTask, setHoverTask] = useState<{ id: string; text: string; right: number; top: number; bottom: number; flip: boolean } | null>(null);
|
|
341
|
+
const [status, setStatus] = useState<any>(null);
|
|
342
|
+
const [config, setConfig] = useState<any>(null);
|
|
343
|
+
const [dynModels, setDynModels] = useState<Record<string, Array<{ value: string; label?: string }>>>({});
|
|
344
|
+
const [presetOptions, setPresetOptions] = useState<Array<{ value: string; label?: string }>>([{ value: 'default' }]);
|
|
345
|
+
const [notice, setNotice] = useState('');
|
|
346
|
+
const [busy, setBusy] = useState(false);
|
|
347
|
+
const [provForm, setProvForm] = useState<{
|
|
348
|
+
originalId?: string; name: string; type: 'api' | 'cli'; models: string;
|
|
349
|
+
base_url: string; api_key: string; imagegen_model: string;
|
|
350
|
+
vision_command: string; imagegen_command: string;
|
|
351
|
+
} | null>(null);
|
|
352
|
+
const [modelAdd, setModelAdd] = useState<string | null>(null);
|
|
353
|
+
const [modelCatalog, setModelCatalog] = useState<any>(null);
|
|
354
|
+
const [modelCatalogError, setModelCatalogError] = useState('');
|
|
355
|
+
const [modelCatalogBusy, setModelCatalogBusy] = useState(false);
|
|
356
|
+
const [modelPickerTier, setModelPickerTier] = useState<'flash' | 'pro' | null>(null);
|
|
357
|
+
const [modelQuery, setModelQuery] = useState('');
|
|
358
|
+
const [testResult, setTestResult] = useState<{ key: string; ok?: boolean; steps?: any[]; error?: string; busy: boolean } | null>(null);
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* The panel is served from disk on every load, but the hub's routes are
|
|
362
|
+
* registered at instance boot — after an upgrade the new UI can call a route
|
|
363
|
+
* the running instance does not have yet, which answers 404/405 with an empty
|
|
364
|
+
* body. Report that as "restart DSH" instead of a JSON parse error.
|
|
365
|
+
*/
|
|
366
|
+
const readJson = useCallback(async (res: Response, path: string) => {
|
|
367
|
+
const text = await res.text();
|
|
368
|
+
if (text === '') {
|
|
369
|
+
throw new Error(res.status === 404 || res.status === 405
|
|
370
|
+
? `${copy.staleHub}(${path} → HTTP ${res.status})`
|
|
371
|
+
: copy.emptyResponse(path, res.status));
|
|
372
|
+
}
|
|
373
|
+
try { return JSON.parse(text); } catch { throw new Error(copy.badJson(path, text.slice(0, 160))); }
|
|
374
|
+
}, [copy]);
|
|
375
|
+
/** Every request carries the active locale: the panel is the authority on it
|
|
376
|
+
* (DSH's setting may be unset), and the hub localizes its own strings from it. */
|
|
377
|
+
const withLang = useCallback((path: string) => `${API}${path}${path.includes('?') ? '&' : '?'}lang=${locale === 'zh' ? 'zh' : 'en'}`, [locale]);
|
|
378
|
+
const get = useCallback(async (path: string) => readJson(await fetch(withLang(path), { cache: 'no-store' }), path), [readJson, withLang]);
|
|
379
|
+
const post = useCallback(async (path: string, body: any) => readJson(await fetch(withLang(path), {
|
|
380
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
381
|
+
body: JSON.stringify({ ...body, lang: locale === 'zh' ? 'zh' : 'en' }),
|
|
382
|
+
}), path), [readJson, withLang, locale]);
|
|
383
|
+
|
|
384
|
+
const refreshAll = useCallback(async () => {
|
|
385
|
+
try {
|
|
386
|
+
const [j, s, c, pr] = await Promise.all([get('/jobs'), get('/install/status'), get('/config'), get('/presets')]);
|
|
387
|
+
if (j.ok) setJobs(j.jobs ?? []);
|
|
388
|
+
if (s.ok) setStatus(s.status);
|
|
389
|
+
if (c.ok) setConfig((prev: any) => prev ?? c.config);
|
|
390
|
+
if (pr.ok) setPresetOptions([
|
|
391
|
+
{ value: 'default', label: copy.presetDefault(pr.defaultId) },
|
|
392
|
+
...(pr.presets ?? []).map((x: any) => ({ value: x.id, label: x.name ?? x.id })),
|
|
393
|
+
]);
|
|
394
|
+
} catch { /* instance restarting */ }
|
|
395
|
+
}, [get]);
|
|
396
|
+
|
|
397
|
+
useEffect(() => {
|
|
398
|
+
void refreshAll();
|
|
399
|
+
const timer = setInterval(() => { void get('/jobs').then((j) => { if (j.ok) setJobs(j.jobs ?? []); }).catch(() => {}); }, 3000);
|
|
400
|
+
return () => clearInterval(timer);
|
|
401
|
+
}, [refreshAll, get]);
|
|
402
|
+
|
|
403
|
+
const refreshHarnessModels = useCallback(async () => {
|
|
404
|
+
setModelCatalogBusy(true);
|
|
405
|
+
setModelCatalogError('');
|
|
406
|
+
try {
|
|
407
|
+
const result = await get('/models');
|
|
408
|
+
if (!result.ok) throw new Error(result.error ?? copy.catalogFailed);
|
|
409
|
+
setModelCatalog(result);
|
|
410
|
+
} catch (error: any) {
|
|
411
|
+
setModelCatalogError(error?.message ?? copy.catalogFailed);
|
|
412
|
+
} finally {
|
|
413
|
+
setModelCatalogBusy(false);
|
|
414
|
+
}
|
|
415
|
+
}, [get, copy]);
|
|
416
|
+
|
|
417
|
+
useEffect(() => { void refreshHarnessModels(); }, [refreshHarnessModels]);
|
|
418
|
+
|
|
419
|
+
const act = useCallback(async (target: string, confirmName?: string) => {
|
|
420
|
+
if (confirmName && !window.confirm(copy.confirmRestore(confirmName))) return;
|
|
421
|
+
setBusy(true); setNotice(copy.working);
|
|
422
|
+
try {
|
|
423
|
+
const r = await post('/install', { target });
|
|
424
|
+
if (r.ok === false) throw new Error(r.error);
|
|
425
|
+
setNotice((r.actions ?? []).join(';'));
|
|
426
|
+
const s = await get('/install/status'); if (s.ok) setStatus(s.status);
|
|
427
|
+
} catch (e: any) { setNotice(String(e?.message ?? e)); } finally { setBusy(false); }
|
|
428
|
+
}, [post, get, copy]);
|
|
429
|
+
|
|
430
|
+
const applyPatch = useCallback((patch: Record<string, any>) => {
|
|
431
|
+
void post('/config', patch).then((r) => {
|
|
432
|
+
if (r.ok) { setConfig(r.config); setNotice(copy.saved); setTimeout(() => setNotice(''), 1500); }
|
|
433
|
+
}).catch(() => {});
|
|
434
|
+
}, [post, copy]);
|
|
435
|
+
/** Selects & checkboxes: apply immediately. */
|
|
436
|
+
const field = (key: string, value: any) => {
|
|
437
|
+
setConfig((c: any) => ({ ...c, [key]: value }));
|
|
438
|
+
applyPatch({ [key]: value });
|
|
439
|
+
};
|
|
440
|
+
/** Text/number inputs: track locally, apply on blur. */
|
|
441
|
+
const fieldLocal = (key: string, value: any) => setConfig((c: any) => ({ ...c, [key]: value }));
|
|
442
|
+
|
|
443
|
+
// --- custom providers & user-added models -------------------------------
|
|
444
|
+
const customProviders: any[] = config?.custom_providers ?? [];
|
|
445
|
+
const extraModels: Record<string, string[]> = config?.extra_models ?? {};
|
|
446
|
+
const RESERVED_IDS = ['claude-code', 'codex', 'grok', 'agy', 'off', 'custom', 'default'];
|
|
447
|
+
const provType = (p: any): 'api' | 'cli' => p.type ?? ((p.vision_command || p.imagegen_command) ? 'cli' : 'api');
|
|
448
|
+
const canSee = (p: any) => (provType(p) === 'cli' ? !!p.vision_command : !!p.base_url && (p.models?.length ?? 0) > 0);
|
|
449
|
+
const canDraw = (p: any) => (provType(p) === 'cli' ? !!p.imagegen_command : !!p.base_url && !!String(p.imagegen_model ?? '').trim());
|
|
450
|
+
const visionProviderOptions = [
|
|
451
|
+
{ value: 'claude-code' }, { value: 'codex' }, { value: 'grok' }, { value: 'agy' },
|
|
452
|
+
...customProviders.filter(canSee).map((p) => ({ value: p.id, label: p.name || p.id })),
|
|
453
|
+
{ value: 'off' },
|
|
454
|
+
];
|
|
455
|
+
const imagegenProviderOptions = [
|
|
456
|
+
{ value: 'codex' }, { value: 'agy', label: 'agy (nano banana)' }, { value: 'grok', label: 'grok (imagine)' },
|
|
457
|
+
...customProviders.filter(canDraw).map((p) => ({ value: p.id, label: p.name || p.id })),
|
|
458
|
+
{ value: 'off' },
|
|
459
|
+
];
|
|
460
|
+
const visionModelOptions = (() => {
|
|
461
|
+
if (!config) return [];
|
|
462
|
+
const p = config.vision_provider;
|
|
463
|
+
const custom = customProviders.find((c) => c.id === p);
|
|
464
|
+
const base: Array<{ value: string; label?: string }> = custom
|
|
465
|
+
? [{ value: 'default' }, ...(custom.models ?? []).map((m: string) => ({ value: m }))]
|
|
466
|
+
: dynModels[p] ?? (VISION_MODELS[p] ?? ['default']).map((m) => ({ value: m }));
|
|
467
|
+
const extras = (extraModels[p] ?? []).filter((m) => !base.some((o) => o.value === m)).map((m) => ({ value: m }));
|
|
468
|
+
return [...base, ...extras];
|
|
469
|
+
})();
|
|
470
|
+
const commitModelAdd = () => {
|
|
471
|
+
const m = (modelAdd ?? '').trim();
|
|
472
|
+
setModelAdd(null);
|
|
473
|
+
if (m === '' || !config) return;
|
|
474
|
+
const p = config.vision_provider;
|
|
475
|
+
const cur = extraModels[p] ?? [];
|
|
476
|
+
const nextExtra = { ...extraModels, [p]: cur.includes(m) ? cur : [...cur, m] };
|
|
477
|
+
setConfig((c: any) => ({ ...c, extra_models: nextExtra, vision_model: m }));
|
|
478
|
+
applyPatch({ extra_models: nextExtra, vision_model: m });
|
|
479
|
+
};
|
|
480
|
+
const removeCurrentModel = () => {
|
|
481
|
+
if (!config) return;
|
|
482
|
+
const p = config.vision_provider;
|
|
483
|
+
const m = config.vision_model;
|
|
484
|
+
const nextExtra = { ...extraModels, [p]: (extraModels[p] ?? []).filter((x) => x !== m) };
|
|
485
|
+
const fallback = visionModelOptions.find((o) => o.value !== m)?.value ?? 'default';
|
|
486
|
+
setConfig((c: any) => ({ ...c, extra_models: nextExtra, vision_model: fallback }));
|
|
487
|
+
applyPatch({ extra_models: nextExtra, vision_model: fallback });
|
|
488
|
+
};
|
|
489
|
+
const saveProviderForm = () => {
|
|
490
|
+
if (!provForm || !config) return;
|
|
491
|
+
const name = provForm.name.trim();
|
|
492
|
+
if (name === '') { setNotice(copy.needName); return; }
|
|
493
|
+
const models = provForm.models.split(/[,,\n]+/).map((s) => s.trim()).filter(Boolean);
|
|
494
|
+
const vision_command = provForm.vision_command.trim();
|
|
495
|
+
const imagegen_command = provForm.imagegen_command.trim();
|
|
496
|
+
if (provForm.type === 'cli') {
|
|
497
|
+
if (vision_command === '' && imagegen_command === '') { setNotice(copy.needCmd); return; }
|
|
498
|
+
} else {
|
|
499
|
+
if (provForm.base_url.trim() === '') { setNotice(copy.needBaseUrl); return; }
|
|
500
|
+
if (models.length === 0) { setNotice(copy.needModels); return; }
|
|
501
|
+
}
|
|
502
|
+
let id = provForm.originalId;
|
|
503
|
+
if (!id) {
|
|
504
|
+
const base = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'provider';
|
|
505
|
+
id = base;
|
|
506
|
+
let n = 2;
|
|
507
|
+
while (RESERVED_IDS.includes(id) || customProviders.some((p) => p.id === id)) id = `${base}-${n++}`;
|
|
508
|
+
}
|
|
509
|
+
const entry = provForm.type === 'api'
|
|
510
|
+
? {
|
|
511
|
+
id, name, type: 'api', models,
|
|
512
|
+
base_url: provForm.base_url.trim(),
|
|
513
|
+
api_key: provForm.api_key,
|
|
514
|
+
imagegen_model: provForm.imagegen_model.trim(),
|
|
515
|
+
}
|
|
516
|
+
: { id, name, type: 'cli', models, vision_command, imagegen_command };
|
|
517
|
+
const next = provForm.originalId
|
|
518
|
+
? customProviders.map((p) => (p.id === provForm.originalId ? entry : p))
|
|
519
|
+
: [...customProviders, entry];
|
|
520
|
+
field('custom_providers', next);
|
|
521
|
+
setProvForm(null);
|
|
522
|
+
};
|
|
523
|
+
const runProviderTest = (key: string, entry: any) => {
|
|
524
|
+
setTestResult({ key, busy: true });
|
|
525
|
+
void post('/provider-test', entry)
|
|
526
|
+
.then((r) => setTestResult(r.ok
|
|
527
|
+
? { key, busy: false, ok: r.result.ok, steps: r.result.steps }
|
|
528
|
+
: { key, busy: false, ok: false, error: r.error }))
|
|
529
|
+
.catch((e) => setTestResult({ key, busy: false, ok: false, error: String(e?.message ?? e) }));
|
|
530
|
+
};
|
|
531
|
+
/** Shared renderer for a test outcome under the button that triggered it. */
|
|
532
|
+
const testReport = (key: string) => {
|
|
533
|
+
if (!testResult || testResult.key !== key) return null;
|
|
534
|
+
if (testResult.busy) return <div style={{ fontSize: 11.5, opacity: 0.6 }}>{copy.testing}</div>;
|
|
535
|
+
return (
|
|
536
|
+
<div style={{ fontSize: 11.5, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
537
|
+
<span style={{ color: testResult.ok ? '#3fb950' : '#f85149' }}>
|
|
538
|
+
{testResult.ok ? `✓ ${copy.testPass}` : `✗ ${copy.testFail}`}
|
|
539
|
+
</span>
|
|
540
|
+
{testResult.error && <span style={{ opacity: 0.7, wordBreak: 'break-all' as const }}>{testResult.error}</span>}
|
|
541
|
+
{(testResult.steps ?? []).map((st: any, i: number) => (
|
|
542
|
+
<span key={i} style={{ opacity: 0.7, wordBreak: 'break-all' as const }}>
|
|
543
|
+
<span style={{ color: st.ok ? '#3fb950' : '#f85149' }}>{st.ok ? '✓' : '✗'}</span> {st.name}: {st.detail}
|
|
544
|
+
</span>
|
|
545
|
+
))}
|
|
546
|
+
</div>
|
|
547
|
+
);
|
|
548
|
+
};
|
|
549
|
+
const deleteProvider = (id: string) => {
|
|
550
|
+
if (!config) return;
|
|
551
|
+
const patch: any = { custom_providers: customProviders.filter((p) => p.id !== id) };
|
|
552
|
+
if (config.vision_provider === id) { patch.vision_provider = 'claude-code'; patch.vision_model = 'haiku'; }
|
|
553
|
+
if (config.imagegen_provider === id) patch.imagegen_provider = 'codex';
|
|
554
|
+
setConfig((c: any) => ({ ...c, ...patch }));
|
|
555
|
+
applyPatch(patch);
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
/** Card: title + one-line description header, then its fields. */
|
|
559
|
+
const block = (text: { t: string; d: string }, fields: any, gridded = true) => (
|
|
560
|
+
<div style={S.block}>
|
|
561
|
+
<div>
|
|
562
|
+
<div style={S.settingTitle}>{text.t}</div>
|
|
563
|
+
<div style={S.settingDesc}>{text.d}</div>
|
|
564
|
+
</div>
|
|
565
|
+
{gridded ? <div style={S.blockGrid}>{fields}</div> : fields}
|
|
566
|
+
</div>
|
|
567
|
+
);
|
|
568
|
+
|
|
569
|
+
const integrationRow = (name: string, installed: boolean, extra: any, installTarget: string, uninstallTarget: string, tips: any) => (
|
|
570
|
+
<div style={S.card}>
|
|
571
|
+
<span style={{ fontWeight: 600, fontSize: 13, minWidth: 92 }}>{name}</span>
|
|
572
|
+
<span style={S.chip(installed)}>{installed ? `● ${copy.installed}` : `○ ${copy.notInstalled}`}</span>
|
|
573
|
+
{extra}
|
|
574
|
+
<span style={{ flex: 1 }} />
|
|
575
|
+
{!installed && <button style={S.btn} title={tips.install} disabled={busy} onClick={() => { void act(installTarget); }}>{copy.install}</button>}
|
|
576
|
+
{installed && <button style={S.btn} title={tips.update} disabled={busy} onClick={() => { void act(installTarget); }}>{copy.update}</button>}
|
|
577
|
+
{installed && <button style={{ ...S.btn, opacity: 0.75 }} title={tips.restore} disabled={busy} onClick={() => { void act(uninstallTarget, name); }}>{copy.restore}</button>}
|
|
578
|
+
</div>
|
|
579
|
+
);
|
|
580
|
+
|
|
581
|
+
return (
|
|
582
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, fontSize: 13, lineHeight: 1.55 }}>
|
|
583
|
+
<div style={{ fontSize: 19, fontWeight: 600, marginBottom: -2 }}>{copy.title}</div>
|
|
584
|
+
<div style={{ opacity: 0.75 }}>{copy.intro}</div>
|
|
585
|
+
|
|
586
|
+
<div style={S.section}>{copy.integrations}</div>
|
|
587
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
588
|
+
{integrationRow('Claude Code', !!status?.claude?.installed,
|
|
589
|
+
status?.claude?.installed ? <span style={S.chip(!!status?.claude?.hud)}>{copy.hud}</span> : null,
|
|
590
|
+
'claude', 'claude-uninstall', (copy as any).tips.claude)}
|
|
591
|
+
{integrationRow('Codex', !!status?.codex?.installed, null, 'codex', 'codex-uninstall', (copy as any).tips.codex)}
|
|
592
|
+
</div>
|
|
593
|
+
|
|
594
|
+
<div style={S.section}>{copy.globalConfig}</div>
|
|
595
|
+
{config && (<>
|
|
596
|
+
{(() => {
|
|
597
|
+
const clampNum = (v: any) => {
|
|
598
|
+
const n = Number(v);
|
|
599
|
+
if (Number.isNaN(n)) return 3;
|
|
600
|
+
return Math.max(1, Math.min(16, n));
|
|
601
|
+
};
|
|
602
|
+
const mode = config.collaboration_mode ?? 'flash-only';
|
|
603
|
+
const nonCustom = mode !== 'custom';
|
|
604
|
+
const presetStates = { 'flash-only': { flash: 'auto', pro: 'disabled' }, 'pro-only': { flash: 'disabled', pro: 'auto' }, balanced: { flash: 'auto', pro: 'auto' }, 'review-pipeline': { flash: 'auto', pro: 'auto' }, custom: null } as Record<string, { flash: string; pro: string } | null>;
|
|
605
|
+
const states = nonCustom ? presetStates[mode] : null;
|
|
606
|
+
const effState = (tier: string) => states ? states[tier] : (tier === 'flash' ? config.flash_state : config.pro_state) ?? 'auto';
|
|
607
|
+
const roleOptions = ['implementation', 'simple_fix', 'tests', 'search_inspection', 'architecture', 'complex_debugging', 'refactor', 'code_review'] as const;
|
|
608
|
+
const roleKeys = (tier: string) => {
|
|
609
|
+
const arr = tier === 'flash' ? (config.flash_roles ?? []) : (config.pro_roles ?? []);
|
|
610
|
+
const set = new Set(arr);
|
|
611
|
+
return { arr: roleOptions.filter((r) => set.has(r)), set, save: (list: string[]) => field(tier === 'flash' ? 'flash_roles' : 'pro_roles', list) };
|
|
612
|
+
};
|
|
613
|
+
const catalogProviders: any[] = modelCatalog?.providers ?? [];
|
|
614
|
+
const refKey = (ref: any) => `${ref?.provider ?? ''}\0${ref?.model ?? ''}`;
|
|
615
|
+
const priorityFor = (tier: string): any[] => Array.isArray(config[`${tier}_model_priority`]) ? config[`${tier}_model_priority`] : [];
|
|
616
|
+
const savePriority = (tier: string, list: any[]) => {
|
|
617
|
+
const key = `${tier}_model_priority`;
|
|
618
|
+
setConfig((current: any) => ({ ...current, [key]: list, [`${tier}_model_priority_configured`]: true }));
|
|
619
|
+
applyPatch({ [key]: list });
|
|
620
|
+
};
|
|
621
|
+
const modelMeta = (ref: any) => {
|
|
622
|
+
const provider = catalogProviders.find((item) => item.id === ref.provider);
|
|
623
|
+
const model = provider?.models?.find((item: any) => item.id === ref.model);
|
|
624
|
+
return { provider, model };
|
|
625
|
+
};
|
|
626
|
+
const filteredProviders = catalogProviders.map((provider) => ({
|
|
627
|
+
...provider,
|
|
628
|
+
models: (provider.models ?? []).filter((model: any) => {
|
|
629
|
+
const query = modelQuery.trim().toLowerCase();
|
|
630
|
+
return !query || [provider.id, provider.name, model.id, model.name].some((value) => String(value ?? '').toLowerCase().includes(query));
|
|
631
|
+
}),
|
|
632
|
+
})).filter((provider) => provider.models.length > 0);
|
|
633
|
+
const modelPriorityPanel = (tier: 'flash' | 'pro') => {
|
|
634
|
+
const priority = priorityFor(tier);
|
|
635
|
+
const selected = new Set(priority.map(refKey));
|
|
636
|
+
const preferredId = tier === 'flash' ? 'deepseek-v4-flash' : 'deepseek-v4-pro';
|
|
637
|
+
const preferredProviders = catalogProviders.filter((provider) => (provider.models ?? []).some((model: any) => model.id === preferredId));
|
|
638
|
+
const ambiguousPreferred = priority.length === 0 && config[`${tier}_model_priority_configured`] !== true
|
|
639
|
+
&& preferredProviders.length > 1
|
|
640
|
+
&& !preferredProviders.some((provider) => provider.id === modelCatalog?.harness_default?.provider);
|
|
641
|
+
return (
|
|
642
|
+
<div style={{ gridColumn: '1 / -1', borderTop: '1px solid rgba(128,128,128,0.18)', paddingTop: 9 }}>
|
|
643
|
+
<div style={{ ...S.fieldLabel, marginBottom: 5 }}>{copy.modelPriority}</div>
|
|
644
|
+
{priority.length === 0 && <div style={{ fontSize: 12, opacity: 0.55, marginBottom: 6 }}>{copy.noPriority}</div>}
|
|
645
|
+
{ambiguousPreferred && <div style={{ fontSize: 11.5, color: '#c98735', marginBottom: 6 }}>{copy.needsModelSelection}</div>}
|
|
646
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
|
|
647
|
+
{priority.map((ref, index) => {
|
|
648
|
+
const meta = modelMeta(ref);
|
|
649
|
+
const warning = !meta.provider ? copy.providerUnavailable : !meta.model ? copy.notAdvertised : '';
|
|
650
|
+
return (
|
|
651
|
+
<div key={refKey(ref)} style={{ display: 'flex', gap: 7, alignItems: 'center', padding: '6px 8px', border: '1px solid rgba(128,128,128,0.2)', borderRadius: 6 }}>
|
|
652
|
+
<span style={{ opacity: 0.55, width: 18 }}>{index + 1}.</span>
|
|
653
|
+
<span style={{ flex: 1, minWidth: 0 }}>
|
|
654
|
+
<span style={{ display: 'block', fontSize: 12.5 }}>{meta.model?.name ?? ref.model}</span>
|
|
655
|
+
<span style={{ display: 'block', fontSize: 10.5, opacity: 0.58, fontFamily: 'monospace' }}>{ref.provider} / {ref.model}</span>
|
|
656
|
+
{warning && <span style={{ display: 'block', fontSize: 10.5, color: '#c98735' }}>{warning}</span>}
|
|
657
|
+
</span>
|
|
658
|
+
<button type="button" style={{ ...S.btn, padding: '2px 7px' }} title={copy.moveUp} disabled={index === 0}
|
|
659
|
+
onClick={() => { const next = [...priority]; [next[index - 1], next[index]] = [next[index], next[index - 1]]; savePriority(tier, next); }}>↑</button>
|
|
660
|
+
<button type="button" style={{ ...S.btn, padding: '2px 7px' }} title={copy.moveDown} disabled={index === priority.length - 1}
|
|
661
|
+
onClick={() => { const next = [...priority]; [next[index], next[index + 1]] = [next[index + 1], next[index]]; savePriority(tier, next); }}>↓</button>
|
|
662
|
+
<button type="button" style={{ ...S.btn, padding: '2px 7px' }} title={copy.removePriority}
|
|
663
|
+
onClick={() => savePriority(tier, priority.filter((_, itemIndex) => itemIndex !== index))}>×</button>
|
|
664
|
+
</div>
|
|
665
|
+
);
|
|
666
|
+
})}
|
|
667
|
+
</div>
|
|
668
|
+
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginTop: 7 }}>
|
|
669
|
+
<button type="button" style={S.btn} disabled={!modelCatalog || modelCatalogBusy}
|
|
670
|
+
onClick={() => { setModelPickerTier(modelPickerTier === tier ? null : tier); setModelQuery(''); }}>{copy.addPriorityModel}</button>
|
|
671
|
+
<span style={{ fontSize: 11, opacity: 0.58 }}>{copy.harnessDefault}: {modelCatalog?.harness_default ? `${modelCatalog.harness_default.provider} / ${modelCatalog.harness_default.model}` : '—'}</span>
|
|
672
|
+
</div>
|
|
673
|
+
{modelPickerTier === tier && (
|
|
674
|
+
<div style={{ marginTop: 8, border: '1px solid rgba(128,128,128,0.22)', borderRadius: 7, padding: 8, maxHeight: 260, overflow: 'auto' }}>
|
|
675
|
+
<input autoFocus value={modelQuery} placeholder={copy.modelSearch} onChange={(event) => setModelQuery(event.target.value)}
|
|
676
|
+
style={{ ...S.input, width: '100%', boxSizing: 'border-box' as const, marginBottom: 7 }} />
|
|
677
|
+
{filteredProviders.map((provider) => (
|
|
678
|
+
<div key={provider.id} style={{ marginBottom: 8 }}>
|
|
679
|
+
<div style={{ fontSize: 11.5, fontWeight: 600, marginBottom: 3 }}>{provider.name} <span style={{ opacity: 0.5, fontFamily: 'monospace' }}>{provider.id}</span></div>
|
|
680
|
+
{(provider.models ?? []).map((model: any) => {
|
|
681
|
+
const ref = { provider: provider.id, model: model.id };
|
|
682
|
+
const duplicate = selected.has(refKey(ref));
|
|
683
|
+
return <button key={model.id} type="button" disabled={duplicate} style={{ ...S.btn, display: 'block', width: '100%', textAlign: 'left', marginBottom: 3, opacity: duplicate ? 0.45 : 1 }}
|
|
684
|
+
onClick={() => { savePriority(tier, [...priority, ref]); setModelPickerTier(null); }}>{model.name} <span style={{ opacity: 0.55, fontFamily: 'monospace' }}>{model.id}</span></button>;
|
|
685
|
+
})}
|
|
686
|
+
</div>
|
|
687
|
+
))}
|
|
688
|
+
</div>
|
|
689
|
+
)}
|
|
690
|
+
</div>
|
|
691
|
+
);
|
|
692
|
+
};
|
|
693
|
+
const tierCard = (tier: string, card: { t: string; d: string }) => (
|
|
694
|
+
<div style={S.block}>
|
|
695
|
+
<div>
|
|
696
|
+
<div style={S.settingTitle}>{card.t}</div>
|
|
697
|
+
<div style={S.settingDesc}>{card.d}</div>
|
|
698
|
+
</div>
|
|
699
|
+
<div style={S.blockGrid}>
|
|
700
|
+
<label style={S.field}><span style={S.fieldLabel}>{copy.tierState}</span>
|
|
701
|
+
{nonCustom ? (
|
|
702
|
+
<div style={{ fontSize: 12.5, padding: '3px 8px', borderRadius: 5, border: '1px solid rgba(128,128,128,0.35)', opacity: 0.8 }}>
|
|
703
|
+
{effState(tier) === 'auto' ? copy.tierStateDesc.auto : copy.tierStateDesc.disabled}({copy.stateManagedByPreset})
|
|
704
|
+
</div>
|
|
705
|
+
) : (
|
|
706
|
+
<CustomSelect value={tier === 'flash' ? (config.flash_state ?? 'auto') : (config.pro_state ?? 'auto')}
|
|
707
|
+
onChange={(v) => field(tier === 'flash' ? 'flash_state' : 'pro_state', v)}
|
|
708
|
+
options={(['disabled', 'manual', 'auto'] as const).map((s) => ({ value: s, label: copy.tierStateDesc[s] }))} />
|
|
709
|
+
)}
|
|
710
|
+
</label>
|
|
711
|
+
<label style={S.field}><span style={S.fieldLabel}>{tier === 'flash' ? copy.presetFlash : copy.presetPro}</span>
|
|
712
|
+
<CustomSelect value={tier === 'flash' ? (config.preset_flash ?? 'default') : (config.preset_pro ?? 'default')}
|
|
713
|
+
onChange={(v) => field(tier === 'flash' ? 'preset_flash' : 'preset_pro', v)} options={presetOptions} /></label>
|
|
714
|
+
</div>
|
|
715
|
+
<div style={{ fontSize: 11, opacity: 0.6, marginTop: 2 }}>
|
|
716
|
+
{copy.statePresetHint}{' '}{copy.responsibilitiesHint}
|
|
717
|
+
</div>
|
|
718
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: '3px 14px' }}>
|
|
719
|
+
{roleOptions.map((r) => {
|
|
720
|
+
const k = roleKeys(tier);
|
|
721
|
+
const on = k.set.has(r);
|
|
722
|
+
return (
|
|
723
|
+
<label key={r} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12 }}>
|
|
724
|
+
<input type="checkbox" checked={on} onChange={() => {
|
|
725
|
+
k.save(on ? k.arr.filter((x) => x !== r) : [...k.arr, r]);
|
|
726
|
+
}} />
|
|
727
|
+
<span>{copy.roleLabels[r]}</span>
|
|
728
|
+
</label>
|
|
729
|
+
);
|
|
730
|
+
})}
|
|
731
|
+
</div>
|
|
732
|
+
{modelPriorityPanel(tier as 'flash' | 'pro')}
|
|
733
|
+
</div>
|
|
734
|
+
);
|
|
735
|
+
return (<>
|
|
736
|
+
<div style={S.group}>{copy.groupWorkflow}</div>
|
|
737
|
+
{block({ t: copy.roleWorker, d: copy.roleStateHint }, (<>
|
|
738
|
+
<label style={S.field}><span style={S.fieldLabel}>{copy.workerStateLabel}</span>
|
|
739
|
+
<CustomSelect value={config.worker_state ?? 'auto'}
|
|
740
|
+
onChange={(v) => field('worker_state', v)}
|
|
741
|
+
options={(['auto', 'manual', 'disabled'] as const).map((s) => ({ value: s, label: copy.tierStateDesc[s] }))} /></label>
|
|
742
|
+
<label style={S.field}><span style={S.fieldLabel}>{copy.reviewStateLabel}</span>
|
|
743
|
+
<CustomSelect value={config.review_state ?? 'auto'}
|
|
744
|
+
onChange={(v) => field('review_state', v)}
|
|
745
|
+
options={(['auto', 'manual', 'disabled'] as const).map((s) => ({ value: s, label: copy.tierStateDesc[s] }))} /></label>
|
|
746
|
+
</>))}
|
|
747
|
+
{block({ t: copy.autoReview, d: copy.autoReviewHint }, (<>
|
|
748
|
+
<label style={{ ...S.field, flexDirection: 'row' as const, alignItems: 'center', gap: 6 }}>
|
|
749
|
+
<input type="checkbox" checked={config.auto_review ?? !!config.pro_reviews_flash} onChange={(e) => field('auto_review', e.target.checked)} />
|
|
750
|
+
<span style={{ fontSize: 12.5 }}>{copy.autoReview}</span></label>
|
|
751
|
+
</>), false)}
|
|
752
|
+
{block({ t: copy.isolation, d: copy.isolationHint }, (<>
|
|
753
|
+
<label style={S.field}><span style={S.fieldLabel}>{copy.isolation}</span>
|
|
754
|
+
<CustomSelect value={config.isolation ?? 'worktree'}
|
|
755
|
+
onChange={(v) => field('isolation', v)}
|
|
756
|
+
options={(['worktree', 'shared'] as const).map((s) => ({ value: s, label: copy.isolationDesc[s] }))} /></label>
|
|
757
|
+
</>))}
|
|
758
|
+
{block({ t: copy.maxParallel, d: copy.maxParallelHint }, (<>
|
|
759
|
+
<label style={S.field}><span style={S.fieldLabel}>{copy.maxParallel}</span>
|
|
760
|
+
<input type="number" min={1} max={16} value={config.max_parallel ?? 3}
|
|
761
|
+
onChange={(e) => fieldLocal('max_parallel', clampNum(e.target.value))}
|
|
762
|
+
onBlur={() => field('max_parallel', clampNum(config.max_parallel))}
|
|
763
|
+
style={S.input} /></label>
|
|
764
|
+
</>))}
|
|
765
|
+
<div style={{ fontSize: 11, opacity: 0.6, marginTop: 8, marginBottom: 2 }}>{copy.legacyCompatibility}</div>
|
|
766
|
+
<div style={S.group}>{copy.orchestration}</div>
|
|
767
|
+
{block({ t: copy.orchestration, d: copy.enableSubagentsHint }, (<>
|
|
768
|
+
<label style={{ ...S.field, flexDirection: 'row' as const, alignItems: 'center', gap: 6 }} title={copy.enableSubagentsHint}>
|
|
769
|
+
<input type="checkbox" checked={!!config.subagents_enabled} onChange={(e) => field('subagents_enabled', e.target.checked)} />
|
|
770
|
+
<span style={{ fontSize: 12.5 }}>{config.subagents_enabled === false ? copy.subagentsKept : copy.enableSubagents}</span>
|
|
771
|
+
</label>
|
|
772
|
+
</>), false)}
|
|
773
|
+
{block({ t: copy.mainAgentMode, d: copy.mainAgentModeHint }, (<>
|
|
774
|
+
<label style={S.field}><span style={S.fieldLabel}>{copy.mainAgentMode}</span>
|
|
775
|
+
<CustomSelect value={config.main_agent_mode ?? 'direct-allowed'}
|
|
776
|
+
onChange={(v) => field('main_agent_mode', v)}
|
|
777
|
+
options={(['direct-allowed', 'coordinator-first', 'dispatcher-only'] as const).map((m) => ({ value: m, label: copy.mainAgentModeDesc[m] }))} /></label>
|
|
778
|
+
<label style={S.field}><span style={S.fieldLabel}>{copy.collaborationMode}</span>
|
|
779
|
+
<CustomSelect value={mode}
|
|
780
|
+
onChange={(v) => field('collaboration_mode', v)}
|
|
781
|
+
options={(['flash-only', 'pro-only', 'balanced', 'review-pipeline', 'custom'] as const).map((m) => ({ value: m, label: copy.collaborationModeDesc[m] }))} /></label>
|
|
782
|
+
<label style={S.field} title={copy.workerProviderHint}><span style={S.fieldLabel}>{copy.workerProvider}</span>
|
|
783
|
+
<CustomSelect value={config.worker_provider_mode ?? 'follow-dsh'}
|
|
784
|
+
onChange={(v) => field('worker_provider_mode', v)}
|
|
785
|
+
options={(['follow-dsh', 'deepseek-official'] as const).map((m) => ({ value: m, label: copy.workerProviderDesc[m] }))} /></label>
|
|
786
|
+
</>))}
|
|
787
|
+
{block({ t: copy.workerModels, d: modelCatalog ? copy.modelPoolSummary(modelCatalog.provider_count ?? 0, modelCatalog.model_count ?? 0) : copy.catalogFailed }, (<>
|
|
788
|
+
<button type="button" style={S.btn} disabled={modelCatalogBusy} onClick={() => { void refreshHarnessModels(); }}>
|
|
789
|
+
{modelCatalogBusy ? copy.refreshingModels : copy.refreshHarnessModels}
|
|
790
|
+
</button>
|
|
791
|
+
{modelCatalog?.partial && <span style={{ fontSize: 11.5, color: '#c98735' }}>{copy.partialCatalog}</span>}
|
|
792
|
+
{modelCatalogError && <span style={{ fontSize: 11.5, color: '#c55' }}>{modelCatalogError}</span>}
|
|
793
|
+
</>), false)}
|
|
794
|
+
{tierCard('flash', { t: copy.tierCardFlash, d: 'implementation · direct validation · Delivery Report' })}
|
|
795
|
+
{tierCard('pro', { t: copy.tierCardPro, d: 'manual advanced work · review pipeline' })}
|
|
796
|
+
<div style={S.group}>{copy.routing}</div>
|
|
797
|
+
{block({ t: copy.routing, d: '' }, (<>
|
|
798
|
+
<label style={{ ...S.field, flexDirection: 'row' as const, alignItems: 'center', gap: 6, paddingBottom: 5 }} title={copy.escalateHint}>
|
|
799
|
+
<input type="checkbox" checked={!!config.escalate_on_failure} onChange={(e) => field('escalate_on_failure', e.target.checked)} />
|
|
800
|
+
<span style={{ fontSize: 12 }}>{copy.escalate}</span></label>
|
|
801
|
+
<label style={{ ...S.field, flexDirection: 'row' as const, alignItems: 'center', gap: 6, paddingBottom: 5 }} title={mode === 'review-pipeline' ? copy.proReviewsLocked : copy.proReviewsFlashHint}>
|
|
802
|
+
<input type="checkbox" disabled={mode === 'review-pipeline'}
|
|
803
|
+
checked={mode === 'review-pipeline' ? true : !!config.pro_reviews_flash}
|
|
804
|
+
onChange={(e) => field('pro_reviews_flash', e.target.checked)} />
|
|
805
|
+
<span style={{ fontSize: 12 }}>{copy.proReviewsFlash}{mode === 'review-pipeline' ? `(${copy.proReviewsLocked})` : ''}</span></label>
|
|
806
|
+
</>))}
|
|
807
|
+
</>);
|
|
808
|
+
})()}
|
|
809
|
+
|
|
810
|
+
<div style={S.group}>{copy.groupDispatch}</div>
|
|
811
|
+
{block(copy.cardDispatch, (<>
|
|
812
|
+
<label style={S.field}><span style={S.fieldLabel}>{copy.tier}</span>
|
|
813
|
+
<CustomSelect value={config.default_tier} onChange={(v) => field('default_tier', v)}
|
|
814
|
+
options={[{ value: 'flash' }, { value: 'pro' }]} /></label>
|
|
815
|
+
<label style={S.field}><span style={S.fieldLabel}>{copy.effort}</span>
|
|
816
|
+
<CustomSelect value={config.default_effort} onChange={(v) => field('default_effort', v)}
|
|
817
|
+
options={[{ value: 'off' }, { value: 'high' }, { value: 'max' }]} /></label>
|
|
818
|
+
<label style={S.field}><span style={S.fieldLabel}>{copy.tierPolicy}</span>
|
|
819
|
+
<CustomSelect value={config.tier_policy} onChange={(v) => field('tier_policy', v)}
|
|
820
|
+
options={(['auto', 'flash-only', 'pro-only'] as const).map((p) => ({ value: p, label: copy.tierPolicyDesc[p] }))} /></label>
|
|
821
|
+
<label style={{ ...S.field, flexDirection: 'row' as const, alignItems: 'center', gap: 6, paddingBottom: 5 }} title={copy.escalateHint}>
|
|
822
|
+
<input type="checkbox" checked={!!config.escalate_on_failure} onChange={(e) => field('escalate_on_failure', e.target.checked)} />
|
|
823
|
+
<span style={{ fontSize: 12 }}>{copy.escalate}</span></label>
|
|
824
|
+
<label style={S.field}><span style={S.fieldLabel}>{copy.presetFlash}</span>
|
|
825
|
+
<CustomSelect value={config.preset_flash ?? 'default'} onChange={(v) => field('preset_flash', v)} options={presetOptions} /></label>
|
|
826
|
+
<label style={S.field}><span style={S.fieldLabel}>{copy.presetPro}</span>
|
|
827
|
+
<CustomSelect value={config.preset_pro ?? 'default'} onChange={(v) => field('preset_pro', v)} options={presetOptions} /></label>
|
|
828
|
+
</>))}
|
|
829
|
+
|
|
830
|
+
<div style={S.group}>{copy.groupRuntime}</div>
|
|
831
|
+
{block(copy.cardRuntime, (<>
|
|
832
|
+
<label style={S.field}><span style={S.fieldLabel}>{copy.mode}</span>
|
|
833
|
+
<CustomSelect value={config.mode} onChange={(v) => field('mode', v)}
|
|
834
|
+
options={(['auto', 'hub', 'standalone'] as const).map((m) => ({ value: m, label: copy.modeDesc[m] }))} /></label>
|
|
835
|
+
<label style={S.field}><span style={S.fieldLabel}>{copy.timeout}</span>
|
|
836
|
+
<input style={{ ...S.input, width: '100%', boxSizing: 'border-box' as const }} type="number" min={60} max={7200} value={config.default_timeout_seconds}
|
|
837
|
+
onChange={(e) => fieldLocal('default_timeout_seconds', Number(e.target.value))}
|
|
838
|
+
onBlur={() => applyPatch({ default_timeout_seconds: config.default_timeout_seconds })} /></label>
|
|
839
|
+
<label style={S.field}><span style={S.fieldLabel}>{copy.hubUrl}</span>
|
|
840
|
+
<input style={{ ...S.input, width: '100%', boxSizing: 'border-box' as const }} value={config.hub_url}
|
|
841
|
+
onChange={(e) => fieldLocal('hub_url', e.target.value)}
|
|
842
|
+
onBlur={() => applyPatch({ hub_url: config.hub_url })}
|
|
843
|
+
onKeyDown={(e: any) => { if (e.key === 'Enter') e.currentTarget.blur(); }} /></label>
|
|
844
|
+
</>))}
|
|
845
|
+
|
|
846
|
+
<div style={S.group}>{copy.groupMultimodal}</div>
|
|
847
|
+
{block(copy.cardMM, (<>
|
|
848
|
+
<label style={{ ...S.field, flexDirection: 'row' as const, alignItems: 'center', gap: 6, gridColumn: '1 / -1' }} title={copy.capRestartHint}>
|
|
849
|
+
<input type="checkbox" checked={!!config.vision_enabled} onChange={(e) => field('vision_enabled', e.target.checked)} />
|
|
850
|
+
<span style={{ fontSize: 12.5 }}>{copy.enableCrewVision}</span>
|
|
851
|
+
{config.vision_enabled === false && <span style={{ fontSize: 11, opacity: 0.55 }}>{copy.capDisabledNote}</span>}
|
|
852
|
+
</label>
|
|
853
|
+
<label style={S.field}><span style={S.fieldLabel}>{copy.visionProvider}</span>
|
|
854
|
+
<CustomSelect value={config.vision_provider}
|
|
855
|
+
onChange={(p) => {
|
|
856
|
+
const custom = customProviders.find((c) => c.id === p);
|
|
857
|
+
const m = custom ? (custom.models?.[0] ?? 'default') : (VISION_MODELS[p] ?? ['default'])[0];
|
|
858
|
+
setModelAdd(null);
|
|
859
|
+
setConfig((c: any) => ({ ...c, vision_provider: p, vision_model: m }));
|
|
860
|
+
applyPatch({ vision_provider: p, vision_model: m });
|
|
861
|
+
if ((p === 'grok' || p === 'agy') && !dynModels[p]) {
|
|
862
|
+
void get(`/vision-models?provider=${p}`).then((r) => {
|
|
863
|
+
if (r.ok) setDynModels((d) => ({ ...d, [p]: r.models }));
|
|
864
|
+
}).catch(() => {});
|
|
865
|
+
}
|
|
866
|
+
}}
|
|
867
|
+
options={visionProviderOptions} /></label>
|
|
868
|
+
<label style={S.field}><span style={S.fieldLabel}>{copy.visionModel}</span>
|
|
869
|
+
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
|
870
|
+
{modelAdd === null ? (
|
|
871
|
+
<CustomSelect value={config.vision_model} onChange={(v) => field('vision_model', v)}
|
|
872
|
+
options={visionModelOptions} />
|
|
873
|
+
) : (
|
|
874
|
+
<input autoFocus style={{ ...S.input, width: '100%', boxSizing: 'border-box' as const, ...S.mono }}
|
|
875
|
+
value={modelAdd} placeholder={copy.modelPlaceholder}
|
|
876
|
+
onChange={(e) => setModelAdd(e.target.value)}
|
|
877
|
+
onBlur={commitModelAdd}
|
|
878
|
+
onKeyDown={(e: any) => {
|
|
879
|
+
if (e.key === 'Enter') e.currentTarget.blur();
|
|
880
|
+
if (e.key === 'Escape') setModelAdd(null);
|
|
881
|
+
}} />
|
|
882
|
+
)}
|
|
883
|
+
{modelAdd === null && (config.vision_provider === 'grok' || config.vision_provider === 'agy') && (
|
|
884
|
+
<button type="button" title={copy.refreshModels} disabled={busy}
|
|
885
|
+
style={{ ...S.btn, padding: '3px 8px', flexShrink: 0 }}
|
|
886
|
+
onClick={() => {
|
|
887
|
+
const p = config.vision_provider;
|
|
888
|
+
setBusy(true);
|
|
889
|
+
void get(`/vision-models?provider=${p}&refresh=1`)
|
|
890
|
+
.then((r) => { if (r.ok) setDynModels((d) => ({ ...d, [p]: r.models })); })
|
|
891
|
+
.catch(() => {})
|
|
892
|
+
.finally(() => setBusy(false));
|
|
893
|
+
}}>↻</button>
|
|
894
|
+
)}
|
|
895
|
+
{modelAdd === null && config.vision_provider !== 'off' && (
|
|
896
|
+
<button type="button" title={copy.addModelTip} style={{ ...S.btn, padding: '3px 8px', flexShrink: 0 }}
|
|
897
|
+
onClick={() => setModelAdd('')}>+</button>
|
|
898
|
+
)}
|
|
899
|
+
{modelAdd === null && (extraModels[config.vision_provider] ?? []).includes(config.vision_model) && (
|
|
900
|
+
<button type="button" title={copy.removeModelTip} style={{ ...S.btn, padding: '3px 8px', flexShrink: 0 }}
|
|
901
|
+
onClick={removeCurrentModel}>−</button>
|
|
902
|
+
)}
|
|
903
|
+
</div></label>
|
|
904
|
+
<label style={{ ...S.field, flexDirection: 'row' as const, alignItems: 'center', gap: 6, gridColumn: '1 / -1' }} title={copy.capRestartHint}>
|
|
905
|
+
<input type="checkbox" checked={!!config.imagegen_enabled} onChange={(e) => field('imagegen_enabled', e.target.checked)} />
|
|
906
|
+
<span style={{ fontSize: 12.5 }}>{copy.enableImagegen}</span>
|
|
907
|
+
{config.imagegen_enabled === false && <span style={{ fontSize: 11, opacity: 0.55 }}>{copy.capDisabledNote}</span>}
|
|
908
|
+
</label>
|
|
909
|
+
<label style={S.field}><span style={S.fieldLabel}>{copy.imagegenProvider}</span>
|
|
910
|
+
<CustomSelect value={config.imagegen_provider} onChange={(v) => field('imagegen_provider', v)}
|
|
911
|
+
options={imagegenProviderOptions} /></label>
|
|
912
|
+
</>))}
|
|
913
|
+
|
|
914
|
+
{block(copy.cardCustomProv, (<>
|
|
915
|
+
{customProviders.length === 0 && provForm === null && (
|
|
916
|
+
<div style={{ fontSize: 12, opacity: 0.5 }}>{copy.noCustomProviders}</div>
|
|
917
|
+
)}
|
|
918
|
+
{customProviders.map((p) => (
|
|
919
|
+
<div key={p.id} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5 }}>
|
|
920
|
+
<span style={{ fontWeight: 500 }}>{p.name || p.id}</span>
|
|
921
|
+
<span style={{ ...S.chip(false), opacity: 0.7 }}>{provType(p) === 'api' ? 'API' : 'CLI'}</span>
|
|
922
|
+
{canSee(p) && <span style={S.chip(true)}>{copy.capVision}</span>}
|
|
923
|
+
{canDraw(p) && <span style={S.chip(true)}>{copy.capImagegen}</span>}
|
|
924
|
+
{provType(p) === 'api' && !!p.api_key && <span style={{ fontSize: 11.5, opacity: 0.5 }}>key {copy.keySet}</span>}
|
|
925
|
+
<span style={{ flex: 1 }} />
|
|
926
|
+
<button style={{ ...S.btn, padding: '2px 8px' }} title={copy.testTip}
|
|
927
|
+
disabled={!!testResult?.busy}
|
|
928
|
+
onClick={() => runProviderTest(`row:${p.id}`, p)}>
|
|
929
|
+
{testResult?.key === `row:${p.id}` && testResult.busy ? copy.testing : copy.testProvider}</button>
|
|
930
|
+
<button style={{ ...S.btn, padding: '2px 8px' }}
|
|
931
|
+
onClick={() => setProvForm({
|
|
932
|
+
originalId: p.id, name: p.name ?? p.id, type: provType(p),
|
|
933
|
+
models: (p.models ?? []).join(', '),
|
|
934
|
+
base_url: p.base_url ?? '', api_key: p.api_key ?? '', imagegen_model: p.imagegen_model ?? '',
|
|
935
|
+
vision_command: p.vision_command ?? '', imagegen_command: p.imagegen_command ?? '',
|
|
936
|
+
})}>{copy.edit}</button>
|
|
937
|
+
<button style={{ ...S.btn, padding: '2px 8px', opacity: 0.75 }}
|
|
938
|
+
onClick={() => { if (confirm(copy.confirmDeleteProvider(p.name || p.id))) deleteProvider(p.id); }}>{copy.del}</button>
|
|
939
|
+
</div>
|
|
940
|
+
))}
|
|
941
|
+
{customProviders.some((p) => testResult?.key === `row:${p.id}`) && testReport(testResult!.key)}
|
|
942
|
+
{provForm === null ? (
|
|
943
|
+
<div><button style={S.btn} onClick={() => setProvForm({
|
|
944
|
+
name: '', type: 'api', models: '', base_url: '', api_key: '', imagegen_model: '',
|
|
945
|
+
vision_command: '', imagegen_command: '',
|
|
946
|
+
})}>{copy.addProvider}</button></div>
|
|
947
|
+
) : (
|
|
948
|
+
<div style={{ borderTop: '1px solid rgba(128,128,128,0.16)', paddingTop: 10, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
|
949
|
+
<div style={S.blockGrid}>
|
|
950
|
+
<label style={S.field}><span style={S.fieldLabel}>{copy.providerName}</span>
|
|
951
|
+
<input autoFocus style={{ ...S.input, width: '100%', boxSizing: 'border-box' as const }}
|
|
952
|
+
value={provForm.name} onChange={(e) => setProvForm({ ...provForm, name: e.target.value })} /></label>
|
|
953
|
+
<label style={S.field}><span style={S.fieldLabel}>{copy.providerType}</span>
|
|
954
|
+
<CustomSelect value={provForm.type} onChange={(v) => setProvForm({ ...provForm, type: v as 'api' | 'cli' })}
|
|
955
|
+
options={[{ value: 'api', label: copy.typeApi }, { value: 'cli', label: copy.typeCli }]} /></label>
|
|
956
|
+
</div>
|
|
957
|
+
{provForm.type === 'api' ? (<>
|
|
958
|
+
<div style={S.blockGrid}>
|
|
959
|
+
<label style={S.field}><span style={S.fieldLabel}>{copy.baseUrl}</span>
|
|
960
|
+
<input style={{ ...S.input, width: '100%', boxSizing: 'border-box' as const, ...S.mono }}
|
|
961
|
+
value={provForm.base_url} placeholder="https://api.example.com/v1"
|
|
962
|
+
onChange={(e) => setProvForm({ ...provForm, base_url: e.target.value })} /></label>
|
|
963
|
+
<label style={S.field}><span style={S.fieldLabel}>{copy.apiKey}</span>
|
|
964
|
+
<input type="password" autoComplete="off" style={{ ...S.input, width: '100%', boxSizing: 'border-box' as const, ...S.mono }}
|
|
965
|
+
value={provForm.api_key} placeholder={copy.keyPlaceholder}
|
|
966
|
+
onChange={(e) => setProvForm({ ...provForm, api_key: e.target.value })} /></label>
|
|
967
|
+
<label style={S.field}><span style={S.fieldLabel}>{copy.modelsField}</span>
|
|
968
|
+
<input style={{ ...S.input, width: '100%', boxSizing: 'border-box' as const, ...S.mono }}
|
|
969
|
+
value={provForm.models} placeholder="gpt-4o-mini, qwen-vl-max"
|
|
970
|
+
onChange={(e) => setProvForm({ ...provForm, models: e.target.value })} /></label>
|
|
971
|
+
<label style={S.field}><span style={S.fieldLabel}>{copy.imagegenModel}</span>
|
|
972
|
+
<input style={{ ...S.input, width: '100%', boxSizing: 'border-box' as const, ...S.mono }}
|
|
973
|
+
value={provForm.imagegen_model} placeholder="dall-e-3"
|
|
974
|
+
onChange={(e) => setProvForm({ ...provForm, imagegen_model: e.target.value })} /></label>
|
|
975
|
+
</div>
|
|
976
|
+
<div style={{ fontSize: 11.5, opacity: 0.55 }}>{copy.apiFormHint}</div>
|
|
977
|
+
</>) : (<>
|
|
978
|
+
<label style={S.field}><span style={S.fieldLabel}>{copy.visionCmd}</span>
|
|
979
|
+
<input style={{ ...S.input, width: '100%', boxSizing: 'border-box' as const, ...S.mono }}
|
|
980
|
+
value={provForm.vision_command} placeholder="mycli see {image} --ask {question} --model {model}"
|
|
981
|
+
onChange={(e) => setProvForm({ ...provForm, vision_command: e.target.value })} /></label>
|
|
982
|
+
<label style={S.field}><span style={S.fieldLabel}>{copy.imagegenCmd}</span>
|
|
983
|
+
<input style={{ ...S.input, width: '100%', boxSizing: 'border-box' as const, ...S.mono }}
|
|
984
|
+
value={provForm.imagegen_command} placeholder="mycli gen {prompt} -o {output}"
|
|
985
|
+
onChange={(e) => setProvForm({ ...provForm, imagegen_command: e.target.value })} /></label>
|
|
986
|
+
<label style={S.field}><span style={S.fieldLabel}>{copy.modelsField}</span>
|
|
987
|
+
<input style={{ ...S.input, width: '100%', boxSizing: 'border-box' as const, ...S.mono }}
|
|
988
|
+
value={provForm.models} placeholder="model-a, model-b"
|
|
989
|
+
onChange={(e) => setProvForm({ ...provForm, models: e.target.value })} /></label>
|
|
990
|
+
<div style={{ fontSize: 11.5, opacity: 0.55 }}>{copy.cliFormHint}</div>
|
|
991
|
+
</>)}
|
|
992
|
+
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
|
993
|
+
<button style={S.btn} onClick={saveProviderForm}>{copy.saveProvider}</button>
|
|
994
|
+
<button style={S.btn} title={copy.testTip} disabled={!!testResult?.busy}
|
|
995
|
+
onClick={() => runProviderTest('form', {
|
|
996
|
+
id: provForm.originalId ?? 'draft', name: provForm.name || 'draft', type: provForm.type,
|
|
997
|
+
models: provForm.models.split(/[,,\n]+/).map((x) => x.trim()).filter(Boolean),
|
|
998
|
+
base_url: provForm.base_url.trim(), api_key: provForm.api_key,
|
|
999
|
+
imagegen_model: provForm.imagegen_model.trim(),
|
|
1000
|
+
vision_command: provForm.vision_command.trim(), imagegen_command: provForm.imagegen_command.trim(),
|
|
1001
|
+
})}>
|
|
1002
|
+
{testResult?.key === 'form' && testResult.busy ? copy.testing : copy.testProvider}</button>
|
|
1003
|
+
<button style={{ ...S.btn, opacity: 0.75 }} onClick={() => setProvForm(null)}>{copy.cancel}</button>
|
|
1004
|
+
</div>
|
|
1005
|
+
{testReport('form')}
|
|
1006
|
+
</div>
|
|
1007
|
+
)}
|
|
1008
|
+
</>), false)}
|
|
1009
|
+
</>)}
|
|
1010
|
+
<div style={{ fontSize: 11.5, opacity: 0.55, marginTop: 2 }}>{copy.globalHint}</div>
|
|
1011
|
+
|
|
1012
|
+
{notice !== '' && <div style={{ fontSize: 12, opacity: 0.8, whiteSpace: 'pre-wrap' }}>{notice}</div>}
|
|
1013
|
+
|
|
1014
|
+
<div style={S.section}>{copy.jobs}</div>
|
|
1015
|
+
{jobs.length === 0 ? (
|
|
1016
|
+
<div style={{ opacity: 0.55 }}>{copy.empty}</div>
|
|
1017
|
+
) : (
|
|
1018
|
+
<div style={{ overflowX: 'auto' }}>
|
|
1019
|
+
<table style={{ borderCollapse: 'collapse', width: '100%', tableLayout: 'fixed', minWidth: 760 }}>
|
|
1020
|
+
<colgroup>
|
|
1021
|
+
<col style={{ width: 64 }} /><col style={{ width: 58 }} /><col style={{ width: 72 }} />
|
|
1022
|
+
<col style={{ width: 40 }} /><col style={{ width: 100 }} /><col style={{ width: 84 }} />
|
|
1023
|
+
<col style={{ width: 340 }} />
|
|
1024
|
+
</colgroup>
|
|
1025
|
+
<thead><tr>
|
|
1026
|
+
{[copy.col.id, copy.col.source, copy.col.tier, copy.col.status, copy.col.progress, copy.col.tokens, copy.col.task].map((h) => (
|
|
1027
|
+
<th key={h} style={{ ...S.tight, fontSize: 11, opacity: 0.55, fontWeight: 500 }}>{h}</th>
|
|
1028
|
+
))}
|
|
1029
|
+
</tr></thead>
|
|
1030
|
+
<tbody>
|
|
1031
|
+
{jobs.map((job) => {
|
|
1032
|
+
const color = job.status === 'running' ? '#4a9eff' : job.status === 'done' ? '#3fb950' : '#f85149';
|
|
1033
|
+
return (
|
|
1034
|
+
<tr key={job.id}>
|
|
1035
|
+
<td style={{ ...S.tight, ...S.mono }} title={job.id}>{String(job.id).replace(/-[a-z0-9]+$/, '')}</td>
|
|
1036
|
+
<td style={S.tight}><span style={S.chip(job.source === 'claude-code' || job.source === 'codex')}>{sourceLabel(job.source)}</span></td>
|
|
1037
|
+
<td style={S.tight}>{job.tier}/{job.effort}</td>
|
|
1038
|
+
<td style={{ ...S.tight, textAlign: 'center' as const }} title={`${job.status}${job.currentTool ? ` · ${job.currentTool}` : ''}`}><span style={{ color, cursor: 'default' }}>●</span></td>
|
|
1039
|
+
<td style={{ ...S.tight, ...S.mono }} title={copy.progressTip(elapsed(job.startedAt, job.endedAt), job.turn, job.step, job.toolCalls ?? '-')}>{elapsed(job.startedAt, job.endedAt)} {job.turn}.{job.step} {job.toolCalls ?? '-'}t</td>
|
|
1040
|
+
<td style={{ ...S.tight, ...S.mono }}>{job.tokens ? `${ktok(job.tokens.input)}/${ktok(job.tokens.output)}` : '-'}</td>
|
|
1041
|
+
<td
|
|
1042
|
+
style={{ ...S.cell, position: 'relative' }}
|
|
1043
|
+
onMouseEnter={(e: any) => {
|
|
1044
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
1045
|
+
setHoverTask({
|
|
1046
|
+
id: job.id, text: job.task,
|
|
1047
|
+
right: Math.max(8, window.innerWidth - rect.right),
|
|
1048
|
+
top: rect.bottom + 4,
|
|
1049
|
+
bottom: window.innerHeight - rect.top + 4,
|
|
1050
|
+
flip: rect.top < 240,
|
|
1051
|
+
});
|
|
1052
|
+
}}
|
|
1053
|
+
onMouseLeave={() => setHoverTask(null)}
|
|
1054
|
+
>
|
|
1055
|
+
<div style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', lineHeight: 1.45, fontSize: 12.5 }}>
|
|
1056
|
+
{job.task}
|
|
1057
|
+
</div>
|
|
1058
|
+
</td>
|
|
1059
|
+
</tr>
|
|
1060
|
+
);
|
|
1061
|
+
})}
|
|
1062
|
+
</tbody>
|
|
1063
|
+
</table>
|
|
1064
|
+
</div>
|
|
1065
|
+
)}
|
|
1066
|
+
{hoverTask && createPortal(
|
|
1067
|
+
<div style={{
|
|
1068
|
+
position: 'fixed', right: hoverTask.right, zIndex: 9999,
|
|
1069
|
+
...(hoverTask.flip ? { top: hoverTask.top } : { bottom: hoverTask.bottom }),
|
|
1070
|
+
minWidth: 260, maxWidth: 420, maxHeight: 180, overflowY: 'auto',
|
|
1071
|
+
padding: '8px 12px', borderRadius: 8,
|
|
1072
|
+
border: '1px solid rgba(128,128,128,0.35)',
|
|
1073
|
+
background: 'Canvas', color: 'CanvasText',
|
|
1074
|
+
boxShadow: '0 8px 28px rgba(0,0,0,0.22)',
|
|
1075
|
+
whiteSpace: 'pre-wrap', fontSize: 12, lineHeight: 1.5,
|
|
1076
|
+
}}>{hoverTask.text}</div>,
|
|
1077
|
+
document.body,
|
|
1078
|
+
)}
|
|
1079
|
+
</div>
|
|
1080
|
+
);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
export function apply(ctx: any): void {
|
|
1084
|
+
let runningCount = 0;
|
|
1085
|
+
ctx.slots.inject('settings.section', () => {
|
|
1086
|
+
let dispose = register();
|
|
1087
|
+
function register() {
|
|
1088
|
+
return ctx.slots.register(
|
|
1089
|
+
{
|
|
1090
|
+
name: 'settings.section',
|
|
1091
|
+
id: 'dsh-crew',
|
|
1092
|
+
order: 65,
|
|
1093
|
+
label: () => {
|
|
1094
|
+
const base = COPY[ctx.locale.getLocale().active === 'zh' ? 'zh' : 'en'].label;
|
|
1095
|
+
if (runningCount <= 0) return base;
|
|
1096
|
+
// String-only slot labels: filled circled digits are the closest
|
|
1097
|
+
// thing to a badge the contract allows.
|
|
1098
|
+
const CIRCLED = ['❶', '❷', '❸', '❹', '❺', '❻', '❼', '❽', '❾', '❿'];
|
|
1099
|
+
return `${base} ${CIRCLED[runningCount - 1] ?? `(${runningCount})`}`;
|
|
1100
|
+
},
|
|
1101
|
+
},
|
|
1102
|
+
() => <WorkersPanel ctx={ctx} />,
|
|
1103
|
+
);
|
|
1104
|
+
}
|
|
1105
|
+
const poll = async () => {
|
|
1106
|
+
if (document.visibilityState !== 'visible') return;
|
|
1107
|
+
try {
|
|
1108
|
+
const r = await (await fetch(`${API}/jobs`, { cache: 'no-store' })).json();
|
|
1109
|
+
const n = r.ok ? (r.jobs ?? []).filter((j: any) => j.status === 'running').length : 0;
|
|
1110
|
+
if (n !== runningCount) {
|
|
1111
|
+
runningCount = n;
|
|
1112
|
+
dispose(); dispose = register(); // slots/changed → nav re-renders the label
|
|
1113
|
+
}
|
|
1114
|
+
} catch { /* hub restarting */ }
|
|
1115
|
+
};
|
|
1116
|
+
void poll();
|
|
1117
|
+
const timer = setInterval(() => { void poll(); }, 10_000);
|
|
1118
|
+
return () => { clearInterval(timer); dispose(); };
|
|
1119
|
+
});
|
|
1120
|
+
}
|