From 112c840f9ef7ce5b5f5eea7b1400894e4b81b2b0 Mon Sep 17 00:00:00 2001 From: "zhouwenhua.456" Date: Fri, 26 Jun 2026 20:38:16 +0800 Subject: [PATCH 1/3] feat: edit bot agent from dashboard --- src/bot-registry.ts | 7 + src/core/dashboard-ipc-server.ts | 63 ++++++++- src/dashboard.ts | 56 +++++++- src/dashboard/bot-payload.ts | 4 + src/dashboard/web/bot-defaults.ts | 164 +++++++++++++++++++++- src/dashboard/web/i18n.ts | 14 ++ test/dashboard-bot-defaults-cliid.test.ts | 14 +- test/dashboard-bot-payload.test.ts | 4 +- test/dashboard-ipc.test.ts | 56 +++++++- 9 files changed, 370 insertions(+), 12 deletions(-) diff --git a/src/bot-registry.ts b/src/bot-registry.ts index e0802227a..4532bffae 100644 --- a/src/bot-registry.ts +++ b/src/bot-registry.ts @@ -589,6 +589,13 @@ export interface BotState { const bots = new Map(); +export function __testOnly_resetBotRegistry(): void { + bots.clear(); + loadedConfigPath = undefined; + oncallChatCache = null; + brandLabelCache = null; +} + // Wire the i18n lookup so `localeForBot()` can resolve per-bot locale without // a hard import cycle between `i18n` and `bot-registry`. setBotLookup((id) => bots.get(id)); diff --git a/src/core/dashboard-ipc-server.ts b/src/core/dashboard-ipc-server.ts index f1efb17ea..7896e3592 100644 --- a/src/core/dashboard-ipc-server.ts +++ b/src/core/dashboard-ipc-server.ts @@ -23,7 +23,7 @@ import { config } from '../config.js'; import { computeSandboxDiff, applySandboxDiff } from '../services/sandbox-land.js'; import { buildSafeInsightConversation, buildSafeInsightOverview, buildSafeInsightReport, buildSafeInsightTurnDetail } from '../services/insight/report.js'; import type { InsightConversationRole, InsightDetail, InsightSeverity, SafeSpanTag } from '../services/insight/types.js'; -import { readRawConfig, findEntryIndex, requireConfigPath } from '../services/config-store.js'; +import { readRawConfig, findEntryIndex, requireConfigPath, rmwBotEntry } from '../services/config-store.js'; import { setDefaultLocale, localeForBot, t } from '../i18n/index.js'; import { isLocale, type Locale } from '../i18n/types.js'; import { readGlobalConfig } from '../global-config.js'; @@ -56,6 +56,7 @@ import { triggerSessionTurn } from './trigger-session.js'; import { triggerWorkflowFromEnvelope } from '../workflows/trigger-from-envelope.js'; import type { TriggerInput, TriggerResult } from '../workflows/trigger-run.js'; import { validateTriggerRequest } from '../services/trigger-types.js'; +import { resolveCliSelection, selectionKeyForBot } from '../setup/cli-selection.js'; // Workflow runner is wired by the daemon (it owns the heavy triggerWorkflowRun // deps). Until set, workflow-targeted triggers report not-implemented. @@ -1192,6 +1193,17 @@ ipcRoute('GET', '/api/bot-default-oncall', async (_req, res) => { const grantPrefs = grantPrefsStore.getBotGrantPrefs(cachedLarkAppId); let p2pMode: 'thread' | 'chat' = 'thread'; try { if (getBot(cachedLarkAppId).config.p2pMode === 'chat') p2pMode = 'chat'; } catch { /* default thread */ } + let cliId = ''; + let wrapperCli: string | null = null; + let model: string | null = null; + let agentSelectionKey = ''; + try { + const cfg = getBot(cachedLarkAppId).config; + cliId = cfg.cliId; + wrapperCli = typeof cfg.wrapperCli === 'string' && cfg.wrapperCli.trim() ? cfg.wrapperCli : null; + model = typeof cfg.model === 'string' && cfg.model.trim() ? cfg.model : null; + agentSelectionKey = selectionKeyForBot(cliId, wrapperCli ?? undefined); + } catch { /* no registered bot */ } let maxLiveWorkers: number | null = null; try { const m = getBot(cachedLarkAppId).config.maxLiveWorkers; @@ -1222,6 +1234,10 @@ ipcRoute('GET', '/api/bot-default-oncall', async (_req, res) => { jsonRes(res, 200, { larkAppId: cachedLarkAppId, botName: getBotName(), + cliId, + wrapperCli, + model, + agentSelectionKey, defaultOncall: defaultOncall ?? { enabled: false, workingDir: '', since: 0 }, defaultWorkingDir, autoboundChatCount: autoboundChats.length, @@ -1364,6 +1380,51 @@ ipcRoute('PUT', '/api/bot-brand-label', async (req, res) => { jsonRes(res, 200, { ok: true, brandLabel: r.brandLabel }); }); +// Per-bot agent launch settings. Body `{ cliId, model }` where `cliId` is the +// dashboard selection key (plain adapter id or a wrapper option such as +// `ttadk-x-codex`). Changes affect the next spawned CLI session. +ipcRoute('PUT', '/api/bot-agent', async (req, res) => { + if (!cachedLarkAppId) return jsonRes(res, 503, { error: 'larkAppId_not_set' }); + let body: { cliId?: unknown; model?: unknown }; + try { body = await readJsonBody<{ cliId?: unknown; model?: unknown }>(req); } + catch { return jsonRes(res, 400, { ok: false, error: 'bad_json' }); } + + const key = typeof body.cliId === 'string' && body.cliId.trim() ? body.cliId.trim() : ''; + if (!key) return jsonRes(res, 400, { ok: false, error: 'cli_required' }); + let selected: ReturnType; + try { + selected = resolveCliSelection(key); + } catch (err: any) { + return jsonRes(res, 400, { ok: false, error: 'invalid_cli', message: err?.message ?? String(err) }); + } + const model = typeof body.model === 'string' ? body.model.trim() : ''; + + const r = await rmwBotEntry(cachedLarkAppId, (entry) => { + entry.cliId = selected.cliId; + if (selected.wrapperCli) entry.wrapperCli = selected.wrapperCli; + else delete entry.wrapperCli; + if (model) entry.model = model; + else delete entry.model; + return { write: true, result: null }; + }); + if (!r.ok) return jsonRes(res, 400, { ok: false, error: r.reason }); + + const bot = getBot(cachedLarkAppId); + bot.config.cliId = selected.cliId; + if (selected.wrapperCli) bot.config.wrapperCli = selected.wrapperCli; + else bot.config.wrapperCli = undefined; + bot.config.model = model || undefined; + + const selectionKey = selectionKeyForBot(selected.cliId, selected.wrapperCli); + jsonRes(res, 200, { + ok: true, + cliId: selected.cliId, + wrapperCli: selected.wrapperCli ?? null, + model: model || null, + selectionKey, + }); +}); + // Per-bot 私聊单聊模式 p2pMode。Body `{ p2pMode: 'chat' | 'thread' }`: // • 'chat' → 私聊走扁平连续 chat-scope 会话 // • 'thread'(默认) → 清回每条 DM 独立 thread-scope 会话 diff --git a/src/dashboard.ts b/src/dashboard.ts index d4f37e43d..d4f33817b 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -549,8 +549,30 @@ function configuredCliIds(): Map { } } -function withConfiguredCliId(bot: T, ids: Map): T & { cliId?: string } { - return bot.cliId ? bot : { ...bot, cliId: ids.get(bot.larkAppId) }; +function configuredBotAgentFields(): Map { + try { + return new Map(loadBotConfigs().map(b => [b.larkAppId, { + cliId: b.cliId, + wrapperCli: b.wrapperCli, + model: b.model, + }])); + } catch { + return new Map(); + } +} + +function withConfiguredCliId( + bot: T, + ids: Map | Map, +): T & { cliId?: string; wrapperCli?: string; model?: string } { + const raw = ids.get(bot.larkAppId); + const fallback = typeof raw === 'string' ? { cliId: raw } : raw; + return { + ...bot, + cliId: bot.cliId || fallback?.cliId, + wrapperCli: bot.wrapperCli || fallback?.wrapperCli, + model: bot.model || fallback?.model, + }; } function liveBots(): { larkAppId: string; botName: string; cliId?: string }[] { @@ -1974,8 +1996,8 @@ const server = createServer(async (req, res) => { // PUT /api/bots/:appId/default-oncall — proxy to that bot's daemon if (req.method === 'GET' && url.pathname === '/api/bots') { - const cliIds = configuredCliIds(); - const onlineBots = [...registry.list()].map(b => withConfiguredCliId(b, cliIds)).sort((a, b) => a.botIndex - b.botIndex); + const agentFields = configuredBotAgentFields(); + const onlineBots = [...registry.list()].map(b => withConfiguredCliId(b, agentFields)).sort((a, b) => a.botIndex - b.botIndex); const out = await Promise.all(onlineBots.map(async d => { try { const r = await fetch(`http://127.0.0.1:${d.ipcPort}/api/bot-default-oncall`); @@ -1983,7 +2005,13 @@ const server = createServer(async (req, res) => { return botDefaultsPayload(d, undefined, `http_${r.status}`); } const j = await r.json() as any; - return botDefaultsPayload({ ...d, botName: d.botName ?? j.botName }, j); + return botDefaultsPayload({ + ...d, + botName: d.botName ?? j.botName, + cliId: j.cliId || d.cliId, + wrapperCli: j.wrapperCli || d.wrapperCli, + model: j.model || d.model, + }, j); } catch (e: any) { return botDefaultsPayload(d, undefined, e?.message ?? String(e)); } @@ -2026,6 +2054,24 @@ const server = createServer(async (req, res) => { return; } + // PUT /api/bots/:appId/agent — proxy to that bot's daemon. Body + // `{ cliId, model }`; cliId is the dashboard selection key. + let mBotAgent: RegExpMatchArray | null; + if (req.method === 'PUT' && (mBotAgent = url.pathname.match(/^\/api\/bots\/([^/]+)\/agent$/))) { + const appId = decodeURIComponent(mBotAgent[1]); + const chunks: Buffer[] = []; + for await (const c of req) chunks.push(c as Buffer); + const raw = Buffer.concat(chunks).toString('utf8') || '{}'; + const upstream = await proxyToDaemon(appId, `/api/bot-agent`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: raw, + }); + res.writeHead(upstream.status, { 'content-type': 'application/json' }); + res.end(await upstream.text()); + return; + } + // PUT /api/bots/:appId/skills — proxy to that bot's daemon. Body accepts // `{ action:'attach'|'detach', name }` or `{ action:'set', policy|null }`. let mBotSkills: RegExpMatchArray | null; diff --git a/src/dashboard/bot-payload.ts b/src/dashboard/bot-payload.ts index d6d65b7e0..1367cf4f0 100644 --- a/src/dashboard/bot-payload.ts +++ b/src/dashboard/bot-payload.ts @@ -5,6 +5,8 @@ export interface DashboardBotDescriptor { botName?: string | null; botAvatarUrl?: string; cliId?: string; + wrapperCli?: string; + model?: string; } export function botSummaryPayload(bot: DashboardBotDescriptor) { @@ -21,6 +23,8 @@ export function botDefaultsPayload(bot: DashboardBotDescriptor, j?: any, error?: larkAppId: bot.larkAppId, botName: bot.botName, ...(bot.cliId ? { cliId: bot.cliId } : {}), + ...(bot.wrapperCli ? { wrapperCli: bot.wrapperCli } : {}), + ...(bot.model ? { model: bot.model } : {}), online: true, }; if (error) return { ...base, error }; diff --git a/src/dashboard/web/bot-defaults.ts b/src/dashboard/web/bot-defaults.ts index d9ad899bd..2835d0421 100644 --- a/src/dashboard/web/bot-defaults.ts +++ b/src/dashboard/web/bot-defaults.ts @@ -8,6 +8,20 @@ import { botAvatarHtml, escapeHtml, loadNameMaps, loadingHtml, t } from './ui.js let cache: { bots: any[] } = { bots: [] }; let loadError: string | null = null; +type CliOption = { + id: string; + label: string; + gateway?: 'ttadk'; + acceptsModel?: boolean; +}; +let cliOptions: CliOption[] = [ + { id: 'claude-code', label: 'Claude' }, + { id: 'codex', label: 'Codex' }, + { id: 'traex', label: 'traex' }, +]; +let cliOptionsLoaded = false; +let ttadkModelDefault = 'glm-5.1'; +let ttadkModelSuggestions: string[] = []; // master-detail:左侧员工名册选中谁,右侧就渲染谁的档案 let selectedAppId: string | null = null; @@ -40,6 +54,79 @@ function cliIdOf(appId: string): string { return best?.cliId ?? ''; } +async function loadCliOptions(): Promise { + if (cliOptionsLoaded) return; + cliOptionsLoaded = true; + try { + const r = await fetch('/api/cli-options'); + const body = await r.json().catch(() => ({})); + if (r.ok && Array.isArray(body?.options)) { + cliOptions = body.options.filter((o: any): o is CliOption => + o && typeof o.id === 'string' && typeof o.label === 'string', + ); + if (typeof body.ttadkModelDefault === 'string' && body.ttadkModelDefault.trim()) { + ttadkModelDefault = body.ttadkModelDefault.trim(); + } + if (Array.isArray(body.ttadkModelSuggestions)) { + ttadkModelSuggestions = body.ttadkModelSuggestions.filter((s: unknown): s is string => typeof s === 'string'); + } + } + } catch { + // Keep the static fallback; saving still works for plain claude-code. + } +} + +function agentSelectionKey(bot: any, sessionFallback: string): string { + const explicit = typeof bot?.agentSelectionKey === 'string' && bot.agentSelectionKey ? bot.agentSelectionKey : ''; + if (explicit) return explicit; + const cli = displayCliId(bot, sessionFallback); + return cli || 'claude-code'; +} + +function selectedCliOption(key: string): CliOption | undefined { + return cliOptions.find(o => o.id === key); +} + +function modelSuggestionsForOption(opt: CliOption | undefined): string[] { + if (opt?.gateway === 'ttadk' && opt.acceptsModel !== false) return ttadkModelSuggestions; + return []; +} + +export function renderBotAgentSection(b: any, sessionFallback: string): string { + const key = agentSelectionKey(b, sessionFallback); + const optHtml = cliOptions + .map(o => ``) + .join(''); + const model = typeof b?.model === 'string' ? b.model : ''; + const suggestions = modelSuggestionsForOption(selectedCliOption(key)); + const disabled = selectedCliOption(key)?.gateway === 'ttadk' && selectedCliOption(key)?.acceptsModel === false; + return `
+

${t('botDefaults.sectionAgent')}

+
+ +
+
+ + ${t('botDefaults.agentHelp')} +
+ + +
+
+
`; +} + function pageHtml(): string { return `
@@ -106,7 +193,7 @@ export async function renderBotDefaultsPage(root: HTMLElement) { refreshBtn.disabled = true; try { botProfileRoleCache.clear(); - await loadBots(); + await Promise.all([loadBots(), loadCliOptions()]); rerender(); } finally { refreshBtn.disabled = false; } }; @@ -123,7 +210,7 @@ export async function renderBotDefaultsPage(root: HTMLElement) { // /api/bots 要逐 daemon 探活,慢——先亮 loading 占住右侧详情区。 listEl.innerHTML = loadingHtml(); - await loadBots(); + await Promise.all([loadBots(), loadCliOptions()]); function rerender() { const f = new FormData(form); @@ -210,6 +297,7 @@ export async function renderBotDefaultsPage(root: HTMLElement) {
+ ${renderBotAgentSection(b, cli)}

${t('botDefaults.sectionWorkingDir')}

@@ -769,6 +857,78 @@ export async function renderBotDefaultsPage(root: HTMLElement) { const statusEl = card.querySelector('[data-status]'); if (!wdModeSel || !input || !saveBtn || !statusEl) return; // error card + // ── Agent CLI / model (next-session) ───────────────────────────────── + const agentCliSel = card.querySelector('select[data-input=agentCliId]'); + const agentModelInput = card.querySelector('input[data-input=agentModel]'); + const agentSaveBtn = card.querySelector('button[data-action=save-agent]'); + const agentStatusEl = card.querySelector('[data-agent-status]'); + + function syncAgentModelField(): void { + if (!agentCliSel || !agentModelInput) return; + const list = card.querySelector(`#agent-model-suggestions-${CSS.escape(appId)}`); + const opt = selectedCliOption(agentCliSel.value); + const isTtadk = opt?.gateway === 'ttadk'; + const acceptsModel = isTtadk && opt.acceptsModel !== false; + if (isTtadk && !acceptsModel) { + if (list) list.innerHTML = ''; + agentModelInput.value = ''; + agentModelInput.disabled = true; + agentModelInput.placeholder = t('botOnboarding.modelTtadkCocoPlaceholder'); + return; + } + agentModelInput.disabled = false; + if (acceptsModel) { + if (list) list.innerHTML = ttadkModelSuggestions.map(m => ``).join(''); + agentModelInput.placeholder = t('botOnboarding.modelTtadkPlaceholder').replace('{model}', ttadkModelDefault); + if (!agentModelInput.value.trim()) agentModelInput.value = ttadkModelDefault; + } else { + if (list) list.innerHTML = ''; + agentModelInput.placeholder = t('botDefaults.agentModelPlaceholder'); + if (agentModelInput.value.trim() === ttadkModelDefault) agentModelInput.value = ''; + } + } + + if (agentCliSel && agentModelInput && agentSaveBtn && agentStatusEl) { + agentCliSel.addEventListener('change', syncAgentModelField); + agentSaveBtn.addEventListener('click', async () => { + agentStatusEl.textContent = ''; + agentStatusEl.className = 'oncall-status'; + agentSaveBtn.disabled = true; + agentCliSel.disabled = true; + agentModelInput.disabled = true; + try { + const r = await fetch(`/api/bots/${encodeURIComponent(appId)}/agent`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ cliId: agentCliSel.value, model: agentModelInput.value }), + }); + const body = await r.json().catch(() => ({})); + if (r.ok && body.ok) { + agentStatusEl.textContent = `✓ ${t('botDefaults.agentSaved')}`; + agentStatusEl.classList.add('hint-ok'); + const cached = cache.bots.find((bb: any) => bb.larkAppId === appId); + if (cached) { + cached.cliId = body.cliId; + cached.wrapperCli = body.wrapperCli ?? null; + cached.model = body.model ?? ''; + cached.agentSelectionKey = body.selectionKey ?? agentCliSel.value; + } + } else { + agentStatusEl.textContent = `✗ ${body.error ?? r.status}`; + agentStatusEl.classList.add('hint-warn-inline'); + } + } catch (e: any) { + agentStatusEl.textContent = `✗ ${e?.message ?? e}`; + agentStatusEl.classList.add('hint-warn-inline'); + } finally { + agentSaveBtn.disabled = false; + agentCliSel.disabled = false; + agentModelInput.disabled = false; + syncAgentModelField(); + } + }); + } + // 选「关闭」隐藏目录输入框;选其它则显示并聚焦(off 不需要目录)。 wdModeSel.addEventListener('change', () => { const off = wdModeSel.value === 'off'; diff --git a/src/dashboard/web/i18n.ts b/src/dashboard/web/i18n.ts index d34069162..81dc37df0 100644 --- a/src/dashboard/web/i18n.ts +++ b/src/dashboard/web/i18n.ts @@ -998,6 +998,13 @@ const zh: DashboardMessages = { 'botDefaults.refresh': '刷新', 'botDefaults.sectionOncall': '新群 Oncall', 'botDefaults.sectionBrand': '卡片签名', + 'botDefaults.sectionAgent': 'CLI / 模型', + 'botDefaults.agentCli': 'CLI', + 'botDefaults.agentModel': '模型', + 'botDefaults.agentModelPlaceholder': '留空使用该 CLI 默认模型', + 'botDefaults.agentHelp': '改动只影响下个新会话;运行中的会话需要 /restart 后才会换 CLI / 模型。', + 'botDefaults.agentSave': '保存 CLI / 模型', + 'botDefaults.agentSaved': '已保存(下个新会话生效)', 'botDefaults.warning': '开启后,没有 oncall binding 的群会在下次开新话题时自动绑定到该目录;手动绑定或手动解绑过的群不会被覆盖。', 'botDefaults.empty': '没有在线 bot。先 botmux restart 让 daemon 上线。', 'botDefaults.defaultOncall': '默认进入 oncall 模式', @@ -2350,6 +2357,13 @@ const en: DashboardMessages = { 'botDefaults.refresh': 'Refresh', 'botDefaults.sectionOncall': 'New-chat Oncall', 'botDefaults.sectionBrand': 'Card Signature', + 'botDefaults.sectionAgent': 'CLI / Model', + 'botDefaults.agentCli': 'CLI', + 'botDefaults.agentModel': 'Model', + 'botDefaults.agentModelPlaceholder': 'Leave empty for the CLI default model', + 'botDefaults.agentHelp': 'Changes affect only the next new session; running sessions need /restart to switch CLI / model.', + 'botDefaults.agentSave': 'Save CLI / Model', + 'botDefaults.agentSaved': 'Saved (takes effect next session)', 'botDefaults.warning': 'When enabled, chats without an oncall binding auto-bind to this directory on their next new topic. Manually bound or unbound chats are preserved.', 'botDefaults.empty': 'No bots online. Run botmux restart first.', 'botDefaults.defaultOncall': 'Default to oncall mode', diff --git a/test/dashboard-bot-defaults-cliid.test.ts b/test/dashboard-bot-defaults-cliid.test.ts index 2fedc394c..81624392b 100644 --- a/test/dashboard-bot-defaults-cliid.test.ts +++ b/test/dashboard-bot-defaults-cliid.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { displayCliId } from '../src/dashboard/web/bot-defaults.js'; +import { displayCliId, renderBotAgentSection } from '../src/dashboard/web/bot-defaults.js'; describe('bot defaults cli label', () => { it('prefers /api/bots cliId before session fallback', () => { @@ -7,4 +7,16 @@ describe('bot defaults cli label', () => { expect(displayCliId({ larkAppId: 'cli_traex' }, 'codex')).toBe('codex'); expect(displayCliId({ larkAppId: 'cli_traex', cliId: '' }, '')).toBe(''); }); + + it('renders an editable CLI and model section from /api/bots values', () => { + const html = renderBotAgentSection( + { larkAppId: 'cli_traex', cliId: 'traex', model: 'glm-5.1' }, + 'codex', + ); + expect(html).toContain('data-input="agentCliId"'); + expect(html).toContain('