diff --git a/CHANGELOG.md b/CHANGELOG.md index 47dd42d..9ac8444 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased +## 2.0.9 - 2026-09-10 + +- Preserve tool evidence until the complete request reaches its context budget; add continuous file excerpts and byte-budgeted search pagination. +- Group Dashboard models by source URL and select one active credential per source while preserving each model's agent routes. +- Require remarks for new same-source duplicate models and show readable remarks instead of internal configuration IDs. +- Show configured idle gateways as connected, with real connection errors still reported. + +Release candidate validation and limitations: [Chinese release note](docs/releases/2.0.9-context-source-credentials_zh.md). + ## 2.0.8 - 2026-09-06 This is a small Dashboard workflow release on the 2.0 TypeScript runtime. diff --git a/README.md b/README.md index 98bc5d5..dd381cc 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ ant-code doctor ant-code ``` -`ant-code --version` should print `2.0.8`. If you previously linked a +`ant-code --version` should print `2.0.9`. If you previously linked a JavaScript install, run `npm link` again so the global command points at `src/cli/index.ts`. diff --git a/docs/releases/2.0.9-context-source-credentials_zh.md b/docs/releases/2.0.9-context-source-credentials_zh.md new file mode 100644 index 0000000..5fa682c --- /dev/null +++ b/docs/releases/2.0.9-context-source-credentials_zh.md @@ -0,0 +1,45 @@ +# Ant Code v2.0.9:保留任务证据、按来源选择模型与凭据 + +发布日期:2026-09-10 + +这是 2.0 TypeScript 运行时的小版本改进,面向跨文件编程、多步数据分析和 Dashboard 多模型使用。减少工具结果过早移除造成的重复读取,将来源凭据与模型配套路由分开管理,并修复权限确认框可见性。本说明是公开发布候选说明,不预先宣称 CI、tag 或 GitHub Release 已完成。 + +## 对用户工作流的直接变化 + +- TUI、Dashboard 和 CLI 主会话不再无条件把最近四条之外的工具输出替换成占位。完整请求达到配置预算才压缩,每缩减一条重新估算,足够即停止;超窗且无法缩减时仍在本地取消发送。 +- 文件读取以连续片段和原始行号展示,支持 `startLine`、`startColumn`、`maxLines`,返回续读位置。搜索和目录列表移除额外 40 条显示上限,按字节预算返回结果和 `nextOffset`。中文字符和长行续读不会在 UTF-8 边界产生半个字符。 +- Dashboard 模型选择器左侧按 URL 来源分组(保留端口和路径差异),右侧选择主模型方案。同来源新增同名模型时需填写模型备注;选择器显示模型 ID 与备注,旧配置或重复备注以配置编号区分,不再展示内部配置 ID。主模型切换保留其子智能体、视觉模型和思考设置。 +- 设置页可为每个 URL 单选一份生效凭据,按项目或全局范围保存。切换同来源模型不再自动换账户。存在多份 Key 时可沿用已有明确活动配置;无法确定或引用失效时需先选择凭据。 +- 已配置凭据的空闲 Dashboard 默认显示「本地网关已连接」;配置读取前显示「正在读取配置」。这是初始显示,不代表上游认证探测通过;事件连接离线、重连和异常状态仍会显示。 +- 权限确认框挂在页面根节点,避免被输入区裁切;连续请求排队,处理后更新等待状态,支持从等待活动打开确认框。 +- Windows 存储锁释放遇到短暂共享占用时进行有界重试,避免残留锁让后续任务超时;持续性错误仍会报告,不绕过锁所有权检查。 + +## 配置、数据与安全边界 + +- 新增通用配置 `lab.sourceCredentialSelections`,保存 URL 到已有配置 ID 的引用。Key 留在原有凭据存储,不通过分组接口暴露 Key 或其指纹。V2 模型配置也使用该通用配置字段。 +- 项目来源的凭据不能设为全局引用;项目设置可以覆盖继承的全局选择。运行中的任务保留原配置快照,空闲任务重新加载后采用新选择。不提供自动 Key 轮换;选中 Key 无模型权限时报告错误。 +- 权限模式 ID、session/transcript 格式和本地研究文件不迁移。不会同步或修改其他产品内嵌运行时。 +- 默认单次工具结果仍有 32,000 字节边界,历史/工具压缩仍有损;没有新增完整结果恢复缓存。Shell、网页、Git、子智能体短报告和图像像素卸载策略保持现有行为。 +- 自动化和模拟网关测试不能证明所有真实模型供应商、账户权限或私有研究数据的安全性与分析正确性。 + +## 升级与产物位置 + +需要 Node.js 22.18+。在源码安装目录更新后执行: + +```sh +git pull +npm ci +npm run verify:install +npm link +ant-code --version +``` + +版本输出应为 `2.0.9`。完整重启 TUI/Dashboard;浏览器强制刷新以加载更新的前端资源。已有配置与会话可继续使用,但旧会话已被压缩的正文不会自动恢复。首次使用多 Key 来源时检查设置页的生效凭据。无新增研究数据输出目录或需要搬迁的数据文件;审计产物仍由发布检查写入既有审计目录。 + +## 验证、限制与待完成门禁 + +- 开发阶段完整单元/集成 node:test 已通过;发布复验发现 Windows 锁释放共享占用,补充有界重试及故障注入回归。定向锁与任务存储测试 7 项通过,0 失败、0 跳过;与完整套件重叠,不累计为独立覆盖。 +- 正式仓完整 `verify:release` 通过:单元/集成 node:test 1,304 项通过,浏览器 37 项通过,均为 0 失败、0 跳过;两套独立计数。覆盖桌面/手机来源及凭据切换、旧会话模型路由、权限确认、无严重无障碍问题及 320–1440 像素布局。类型、语法、安装、模拟网关、资源一致性、发布封印和审计检查通过;用户已完成本轮本地验收,GitHub CI 仍待候选提交验证。 +- 分页针对当前文件重新执行,不是固定快照;文件变化后应重新搜索。`rg_search` 页大小包括上下文行;原始 ripgrep 输出到达自身上限时需缩小查询范围。 +- 当前配置窗口和 token 用量由本地估算管理,不保证与所有供应商的计数完全相同。视觉输入仍要求主模型或同来源视觉子模型具有视觉能力。 +- 公开候选最终结论以正式仓同一提交的 `verify:release`、跨仓 parity 和 GitHub CI 为准。tag 和 Release 完成情况在公开版本历史中另行记录。 diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 6175063..9055d1f 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -1,12 +1,12 @@ { "name": "@ant-code/cli", - "version": "2.0.8", + "version": "2.0.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ant-code/cli", - "version": "2.0.8", + "version": "2.0.9", "license": "AGPL-3.0-only", "dependencies": { "@vscode/ripgrep": "1.18.0", diff --git a/package-lock.json b/package-lock.json index 6175063..9055d1f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ant-code/cli", - "version": "2.0.8", + "version": "2.0.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ant-code/cli", - "version": "2.0.8", + "version": "2.0.9", "license": "AGPL-3.0-only", "dependencies": { "@vscode/ripgrep": "1.18.0", diff --git a/package.json b/package.json index 6d7567e..8ee1c77 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ant-code/cli", - "version": "2.0.8", + "version": "2.0.9", "description": "AGPL-licensed local coding agent with a terminal UI, local dashboard, tool permissions, skills, MCP integration, and model gateway adapters.", "type": "module", "private": true, diff --git a/scripts/verify-gateway-compat.ts b/scripts/verify-gateway-compat.ts index 9ceb627..457b443 100644 --- a/scripts/verify-gateway-compat.ts +++ b/scripts/verify-gateway-compat.ts @@ -54,6 +54,7 @@ async function verifyMockGateway() { mode: "mock", env: { ...process.env, + LAB_MODEL_GATEWAY_PROTOCOL: "lab-agent-gateway", LAB_MODEL_GATEWAY_URL: `${baseUrl}/v1/chat`, LAB_MODEL_GATEWAY_HEALTH_URL: `${baseUrl}/health`, LAB_AGENT_MODEL: "compatibility-mock", diff --git a/src/config-v2/runtime-selection.ts b/src/config-v2/runtime-selection.ts index a99824b..7c3587e 100644 --- a/src/config-v2/runtime-selection.ts +++ b/src/config-v2/runtime-selection.ts @@ -1,4 +1,5 @@ import { applyModelContextBudget, contextTokensForConfig } from "../config/context-budget.ts"; +import { applySourceCredentials } from "../config/source-credentials.ts"; import { projectLegacyRuntimeConfig } from "./legacy-projection.ts"; const UNRESOLVED_CODE = "SESSION_MODEL_SELECTION_UNRESOLVED"; @@ -282,7 +283,7 @@ export function applyRuntimeModelSelection( status: "resolved" as const, source: validated.source, selection: validated.selection, - config: nextConfig + config: applySourceCredentials(nextConfig) }; } @@ -524,7 +525,7 @@ function materializeRuntimeProfileSelection(config: RuntimeConfig, profile: Gate } }; applyModelContextBudget(nextConfig, config, contextTokensForConfig(nextConfig)); - return nextConfig; + return applySourceCredentials(nextConfig); } /** diff --git a/src/config/defaults.ts b/src/config/defaults.ts index 4603dda..14406b5 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -217,6 +217,9 @@ export type LabAgentConfig = Mutable & { lab: Mutable & { configPath: string | null; gatewayApiKeyDisabled?: boolean; + sourceCredentialSelections?: Record; + resolvedSourceCredentials?: Record; + sourceCredentialSelectionRequired?: boolean; activeGatewayProfile?: string; gatewayProfiles?: Array; sources?: { diff --git a/src/config/load-config.ts b/src/config/load-config.ts index 5920c80..1ce7186 100644 --- a/src/config/load-config.ts +++ b/src/config/load-config.ts @@ -1,4 +1,5 @@ import fs from "node:fs/promises"; +import { applySourceCredentials } from "./source-credentials.ts"; import path from "node:path"; import { createHash } from "node:crypto"; import { fileURLToPath } from "node:url"; @@ -209,6 +210,7 @@ export async function loadConfig(options: { cwd?: string; env?: NodeJS.ProcessEn gatewayMaxResponseBytes: parseOptionalInteger(env.LAB_MODEL_GATEWAY_MAX_RESPONSE_BYTES, integerOr(hardenedLab.gatewayMaxResponseBytes, DEFAULT_GATEWAY_MAX_RESPONSE_BYTES)), activeGatewayProfile: activeProfile?.id ?? "", gatewayProfiles: resolvedProfiles, + sourceCredentialSelections: isPlainObject(hardenedLab.sourceCredentialSelections) ? hardenedLab.sourceCredentialSelections : {}, configPath: lab ? labConfigReadPath : explicitLabConfigPath ? labConfigPath : null }; validateLabConfig(finalLab); @@ -256,7 +258,7 @@ export async function loadConfig(options: { cwd?: string; env?: NodeJS.ProcessEn provenance: undefined, resolved: null }) as LabAgentConfig["configV2"]; - return { + return applySourceCredentials({ ...hardened, lab: labWithSources, defaultModelAlias: typeof hardened.modelAlias === "string" ? hardened.modelAlias : "", @@ -266,7 +268,7 @@ export async function loadConfig(options: { cwd?: string; env?: NodeJS.ProcessEn globalConfigPath: labConfigPath, configSources, configV2: configV2State - } as LabAgentConfig; + } as LabAgentConfig); } /** diff --git a/src/config/source-credentials.ts b/src/config/source-credentials.ts new file mode 100644 index 0000000..3fbe165 --- /dev/null +++ b/src/config/source-credentials.ts @@ -0,0 +1,41 @@ +type RecordValue = Record; + +// URLs are compared exactly, including port and API path. Never group by host. +export function sourceCredentialState(config: RecordValue) { + const lab = config.lab ?? {}; + const profiles: RecordValue[] = Array.isArray(lab.gatewayProfiles) ? lab.gatewayProfiles : []; + const groups = new Map(); + for (const profile of profiles) { + const url = String(profile.gatewayUrl || ""); + if (!url) continue; + groups.set(url, [...(groups.get(url) ?? []), profile]); + } + const result: Record = {}; + for (const [url, members] of groups) { + const explicit = lab.sourceCredentialSelections?.[url]; + const retained = lab.resolvedSourceCredentials?.[url]; + const requested = explicit ?? retained; + if (requested !== undefined) { + result[url] = members.find((p) => p.id === requested)?.id ?? null; + continue; + } + const keys = new Set(members.map((p) => JSON.stringify([p.gatewayApiKeyDisabled === true, p.gatewayApiKey || ""]))); + const active = members.find((p) => p.id === lab.activeGatewayProfile); + result[url] = keys.size === 1 ? members[0].id : active?.id ?? null; + } + return result; +} + +export function applySourceCredentials(config: T): T { + const lab = config.lab ?? {}; + const resolved = sourceCredentialState(config); + const url = String(lab.gatewayUrl || ""); + if (!(url in resolved)) return config; + const selected = (lab.gatewayProfiles ?? []).find((p: RecordValue) => p.id === resolved[url] && p.gatewayUrl === url); + return { ...config, lab: { + ...lab, resolvedSourceCredentials: resolved, + sourceCredentialSelectionRequired: !selected, + gatewayApiKey: selected?.gatewayApiKeyDisabled === true ? null : selected?.gatewayApiKey ?? null, + gatewayApiKeyDisabled: !selected || selected.gatewayApiKeyDisabled === true + } }; +} diff --git a/src/core/inflight-compaction.ts b/src/core/inflight-compaction.ts index 7593f7c..fb9f678 100644 --- a/src/core/inflight-compaction.ts +++ b/src/core/inflight-compaction.ts @@ -24,6 +24,7 @@ export function compactInFlightToolMessages(messages: Array boolean; } = {}) { const maxTokens = positiveInteger(options.maxTokens) ?? null; const beforeBytes = estimateMessagesBytes(messages); @@ -31,7 +32,8 @@ export function compactInFlightToolMessages(messages: Array shouldCompactMore(messages, triggerTokens, options.force === true)); + if (!needsCompaction()) { return result(false, beforeBytes, beforeBytes, beforeTokens, beforeTokens, 0, triggerTokens); } @@ -42,23 +44,21 @@ export function compactInFlightToolMessages(messages: Array retentionPriority(messages[a]) - retentionPriority(messages[b]) || a - b)) { + if (!needsCompaction()) break; + compactedTools += compactToolMessage(messages[index], maxToolTextChars) ? 1 : 0; } if (!pruneStale || options.force === true) { const oversizedChars = Math.max(maxToolTextChars, oversizedRecentChars); - while (shouldCompactMore(messages, triggerTokens, options.force === true)) { - const candidate = largestUncompactedTool(messages, toolIndexes, oversizedChars, triggerTokens); + const remainingIndexes = [...toolIndexes]; + while (needsCompaction()) { + const candidate = largestUncompactedTool(messages, remainingIndexes, oversizedChars, options.needsCompaction ? 1 : triggerTokens); if (candidate == null) { break; } - if (!compactToolMessage(messages[candidate], maxToolTextChars)) { - break; - } - compactedTools += 1; + remainingIndexes.splice(remainingIndexes.indexOf(candidate), 1); + if (compactToolMessage(messages[candidate], maxToolTextChars)) compactedTools += 1; } } @@ -67,6 +67,16 @@ export function compactInFlightToolMessages(messages: Array 0, beforeBytes, afterBytes, beforeTokens, afterTokens, compactedTools, triggerTokens); } +function retentionPriority(message: Record) { + const text = extractText(message.content); + const parsed = parseJson(text); + if (parsed?.ok === false || /\bok=false\b|\bexitCode=[1-9]|\berror=/m.test(text)) return 3; + if (["write_file", "edit_file", "list_files", "glob", "rg_files"].includes(String(message.name))) return 0; + // Successful shell output may contain scientific results, so keep its + // evidence alongside source reads rather than treating it all as log noise. + return 2; +} + function collectToolIndexes(messages: Array>, currentTurnOnly: boolean): number[] { let start = 0; if (currentTurnOnly) { @@ -101,56 +111,10 @@ function compactToolMessage(message: Record | undefined, maxToo return true; } -function stubStaleToolMessage(message: Record | undefined) { - if (!message) { - return false; - } - const text = extractText(message.content); - if (!text || isReducedToolText(text)) { - return false; - } - const placeholder = formatStaleToolPlaceholder(message.name, text); - if (!placeholder || placeholder.length >= text.length) { - return false; - } - message.content = [{ type: "text", text: placeholder }]; - return true; -} - export function isReducedToolText(text: string) { return text.includes(COMPACTED_TOOL_MARKER) || text.includes(STALE_TOOL_MARKER); } -function formatStaleToolPlaceholder(toolName: unknown, text: string) { - const parsed = parseJson(text); - const record = parsed && typeof parsed === "object" && !Array.isArray(parsed) - ? parsed as Record - : {}; - const resultValue = record.result && typeof record.result === "object" && !Array.isArray(record.result) - ? record.result as Record - : {}; - const locators = [ - resultValue.path ? `path=${cleanInline(resultValue.path)}` : "", - resultValue.finalUrl || resultValue.url ? `url=${cleanInline(resultValue.finalUrl ?? resultValue.url)}` : "", - Number.isFinite(Number(resultValue.bytes)) ? `bytes=${resultValue.bytes}` : "", - Number.isFinite(Number(resultValue.bytesRead)) ? `bytes=${resultValue.bytesRead}` : "" - ].filter(Boolean); - const header = firstViewHeader(text); - const ok = record.ok === true || /\bok=true\b/.test(header); - const pathFromHeader = /\bpath=([^\s]+)/.exec(header)?.[1]; - return [ - STALE_TOOL_MARKER, - `tool=${toolName ?? "unknown"} ok=${ok}`, - locators.length ? locators.join(" ") : (pathFromHeader ? `path=${pathFromHeader}` : header), - "需要时再读或再跑同一工具。" - ].filter(Boolean).join("\n"); -} - -function firstViewHeader(text: string) { - const first = String(text ?? "").split(/\r?\n/).find((line) => line.trim()); - return first && first.length <= 240 ? first.trim() : ""; -} - function shouldCompactMore(messages: Array>, triggerTokens: number | null, force: boolean) { if (!triggerTokens) { return force; @@ -177,7 +141,8 @@ function largestUncompactedTool( if (triggerTokens && text.length <= oversizedChars && estimateTokensFromBytes(estimateMessagesBytes(messages)) < triggerTokens) { continue; } - if (text.length > bestLength) { + if (bestIndex === null || retentionPriority(messages[index]) < retentionPriority(messages[bestIndex]) || + (retentionPriority(messages[index]) === retentionPriority(messages[bestIndex]) && text.length > bestLength)) { bestIndex = index; bestLength = text.length; } diff --git a/src/core/session-health.ts b/src/core/session-health.ts index 6132e14..2cc8a9c 100644 --- a/src/core/session-health.ts +++ b/src/core/session-health.ts @@ -448,7 +448,6 @@ export async function preparePromptBudgetForGateway(input: PromptBudgetInput) { ) ); - messages = pruneStaleInflightForGateway(input, messages); let estimate = estimateOf(messages); if (!needsCompaction(estimate)) { return { messages, estimate, blocked: false }; @@ -541,20 +540,6 @@ async function compactHistoryForGateway( return nextMessages; } -function pruneStaleInflightForGateway(input: PromptBudgetInput, messages: SessionMessage[]) { - const inflight = compactInFlightToolMessages(messages as Array>, { - maxTokens: input.session.contextWindow?.maxTokens, - keepRecentTools: input.session.config.context?.inFlightKeepRecentTools ?? undefined, - pruneStale: true, - currentTurnOnly: true - }); - if (!inflight.compacted) { - return messages; - } - syncCompactedToolResults(input.toolResults, messages); - return messages; -} - async function compactInflightForGateway( input: PromptBudgetInput, messages: SessionMessage[], @@ -564,7 +549,14 @@ async function compactInflightForGateway( maxTokens: input.session.contextWindow?.maxTokens, triggerRatio: boundedContextRatio(input.session.config.context?.inFlightCompactRatio, DEFAULT_IN_FLIGHT_COMPACT_RATIO), keepRecentTools: input.session.config.context?.inFlightKeepRecentTools ?? undefined, - force + force, + needsCompaction: () => { + syncCompactedToolResults(input.toolResults, messages); + return promptEstimateNeedsCompaction(estimatePromptPayload({ + model: input.session.model, messages, tools: input.session.context.tools, + toolResults: input.toolResults, gatewayProtocol: sessionGatewayProtocol(input.session) + }), input.session.contextWindow, input.session.config.context?.promptCompactRatio); + } }); if (!inflight.compacted) { return messages; diff --git a/src/dashboard/public/app-core.ts b/src/dashboard/public/app-core.ts index 646b320..8c83140 100644 --- a/src/dashboard/public/app-core.ts +++ b/src/dashboard/public/app-core.ts @@ -580,6 +580,7 @@ export type DashboardUiState = { }; goalSubmitting: boolean; pendingApproval: DashboardApproval | null; + approvalQueue: DashboardApproval[]; approvalSubmitting: boolean; pendingQuestion: DashboardPendingQuestion | null; questionSubmitting: boolean; @@ -748,6 +749,7 @@ export const state: DashboardUiState = { }, goalSubmitting: false, pendingApproval: null, + approvalQueue: [], approvalSubmitting: false, pendingQuestion: null, questionSubmitting: false, diff --git a/src/dashboard/public/app-ui3.ts b/src/dashboard/public/app-ui3.ts index 52d09e4..82584e0 100644 --- a/src/dashboard/public/app-ui3.ts +++ b/src/dashboard/public/app-ui3.ts @@ -3,7 +3,7 @@ import { hydrateRichContent } from "./rich-renderers.ts"; import { visibleTranscriptRole } from "./transcript.ts"; import { MANUAL_AGENT_MODEL_VALUE, state, els, MODE_DESCRIPTIONS, LOCAL_FILE_EXTENSIONS, FILE_REFERENCE_PATTERN, TRANSCRIPT_DOM_LIMIT, EVENT_STALE_AFTER_MS, EVENT_CONNECT_TIMEOUT_MS, EVENT_RECONNECT_MAX_ATTEMPTS, DASHBOARD_REQUEST_TIMEOUT_MS, DASHBOARD_API_VERSION, DASHBOARD_LIFECYCLE_TIMEOUT_MS, DASHBOARD_SHUTDOWN_TIMEOUT_MS, DASHBOARD_INTERRUPT_TIMEOUT_MS, MAX_IMAGE_ATTACHMENTS, MAX_IMAGE_ATTACHMENT_BYTES, CURRENT_SESSION_STORAGE_KEY, DASHBOARD_CLIENT_STORAGE_KEY, PREVIEW_WIDTH_STORAGE_KEY, PREVIEW_WIDTH_DEFAULT, PREVIEW_WIDTH_MIN, PREVIEW_WIDTH_MAX, PREVIEW_WORKSPACE_MIN , emptySessionStatus, emptyBackgroundSubagent } from "./app-core.ts"; import type { DashboardConfigSource, DashboardReasoningEffort, DashboardTurnChangeStats, DashboardScopedDefaultSelection, DashboardVisionAgent, DashboardReasoningDiscovery, DashboardReasoningCapabilityCandidate, DashboardGatewayProbeModel, DashboardGatewayProbeResult, DashboardLifecycleActivity, DashboardSettings, DashboardFile, DashboardTableSheet, DashboardTablePreview, DashboardLightboxItem, DashboardQuestionChoice, DashboardPendingQuestion, DashboardApproval, DashboardGatewayTransport, DashboardScopedRequest, DashboardFetchOptions, DashboardModelSource, DashboardSessionStatus, DashboardApiResult, DashboardActivity, DashboardModelOption, DashboardGatewayProfile, DashboardGatewayConfig, DashboardModelSelection, DashboardPendingGuide, DashboardStreamEvent, DashboardUiState , DashboardSessionSummary } from "./app-core.ts"; -import { eventTargetOf, eventElement, isPlainObject, modelSourceOf, errorMessageOf, init, bootstrapDashboard, observeRunStatus, updateRunStatusTone, bindEvents, normalizedResponsiveView, composerHeightFor, previewWidthBounds, clampedPreviewWidth, permissionIndexForKey, focusTrapTarget, shouldFollowTranscript, scheduleAnimationFrameOnce, cancelScheduledAnimationFrame, appendPlainDraftDelta, renderFinalAssistantBody, selectTranscriptNodesToRemove, responsiveLayoutMode, restorePreviewWidth, setPreviewWidth, syncPreviewResizeHandle, beginPreviewResize, updatePreviewResize, finishPreviewResize, handlePreviewResizeKeydown, setResponsiveView, syncResponsiveNavigation, setResponsiveSurfaceInert, handleResponsiveFileNavigation, syncVisualViewport, resizePromptInput, handlePermissionModeKeydown, requestPermissionMode, defaultGoalMaxAutoContinues, emptyGoalSnapshot, applyGoalSnapshot, renderGoalControls, renderGoalStatusBar, requestGoalMode, showGoalConfirm, hideGoalConfirm, showGoalTextPanel, hideGoalTextPanel, enableGoalWithObjective, submitGoalAction, adoptGoalRunResult, showPermissionConfirm, hidePermissionConfirm, updateContextActions, announceStatus, modalFocusableElements, activateModal, collectModalBackground, focusModalInitialTarget, deactivateModal, restoreModalAttribute, handleGlobalKeydown, closeActiveModal, loadTrust, loadSessions, restoreInitialSession, latestBackgroundSessionId, initialSessionId, rememberCurrentSession, renderSessions, sessionMeta, sessionStatusView, toggleSidebar, setSidebarCollapsed, sessionsNeedRefresh, scheduleSessionsRefresh, handleSessionAction, openSession, restoreBackgroundSnapshot, deleteSession, setSessionsRefreshState, copySessionId, newTask, rememberNewTaskModelState, restoreNewTaskModelState, refreshNewTaskModelState, addAttachmentFiles, readImageAttachment, renderAttachmentStrip, attachmentPayload, clearAttachments, sendPrompt, stableTurnRequest, dashboardRequestId, dashboardClientId, statusUrl, interruptTurn, guideTurn, cancelQueuedTurn, renderTranscriptWindowMarker, captureTranscriptViewportAnchor, restoreTranscriptViewportAnchor, transcriptNodeTop, restoreTranscriptNodeAnchor, resetTranscriptWindow, appendAssistantDraft, scheduleDraftRender, renderAssistantDraft, appendActivity, appendContextBoundary, contextBoundaryText, handleActivity, isBackgroundSubagentActivity, handleBackgroundSubagentActivity, clearBackgroundSubagentStatus, reconcileBackgroundSubagentSnapshot, backgroundSubagentDisplayStatus, backgroundSubagentVisible, updateLiveActivity, removeLiveActivity, setLiveTitle, toggleLiveStatusDetails, updateLiveStatus, liveStatusTitle, primaryLiveActivity, gatewayRetryChipText, renderBackgroundSubagentStatus, backgroundSubagentCompactLabel, backgroundSubagentTitle, backgroundSubagentMeta, backgroundSubagentCancellable, resetLiveStatus, backgroundSubagentCounts, idleRunStatus, applyIdleRunStatus, updateRunStatusForBackground, updateSessionStatus, updateTurnChangeStats, resetTurnChangeStats, normalizeChangeStats, renderComposerStatus, modelStatusHtml, unresolvedModelStatusHtml, handleModelStatusActivate, handleModelStatusKeydown, toggleModelPanel, hideModelPanel, showSettingsWorkspace, refreshSettingsConfiguration, hideSettingsWorkspace, showModelConfigPanel, hideModelConfigPanel, renderModelPanel, modelCapabilityLabels, handleModelPanelClick, handleModelPanelChange, renderSettingsView, syncSettingsRail, modelSettingsHtml, transcriptSettingsHtml, transcriptRetentionOptionsHtml, networkSettingsHtml, networkModeOptionHtml, agentSettingsHtml, reliabilitySettingsHtml, settingsSectionHeading, settingsToggleHtml, managedFieldHtml, settingsDisabled, settingsFormActions, settingsFeedbackHtml, settingsGatewayProfileHtml, gatewayProfileReadonlyLabel, providerModelKey, scopedDefaultModelLabel, settingsModelHtml, handleSettingsRailClick, initializeSettingsFormTracking, handleSettingsFormChange, settingsControlValue, changedSettingsFields, canonicalSettingsField, setSettingsFormSaving, renderSettingsFeedbackInPlace, saveSettingsConfig, handleSettingsClick, protocolDisplayName, agentModelPickerHtml, renderModelConfigPanel, handleModelConfigPanelClick, handleModelConfigInput, handleModelConfigChange, markModelConfigCredentialChanged, markModelConfigEndpointChanged, handleModelConfigModelIdChanged, markReasoningCapabilityManual, clearReasoningCapabilityControls, syncGatewayUrlHint, gatewayUrlPlaceholder, gatewayUrlHint, syncReasoningDefaultOptions, probeGateway, isCurrentModelConfigRequest, currentGatewayProbeResult, currentGatewayCatalogModels, modelConfigGatewayProfile, modelConfigEndpointChanged, modelConfigAgentModelsSnapshot, initializeAgentModelPickerSnapshot, syncAgentModelPickersForEndpoint, uniqueAgentModelCandidates, appendAgentModelOptions, renderAgentModelPickers, updateAgentModelPickerManualStatus, handleAgentModelSelection, renderGatewayProbeResult, applyProbedModel, applyGatewayDiscoveredModel, applySuggestedGatewayUrl, gatewayCredentialAction, normalizeGatewayProbeModels, normalizeReasoningDiscovery, reasoningCapabilityCandidate, applyReasoningCapabilityCandidate, ensureReasoningEffortOptions, applyPendingReasoningCapabilities, reasoningCapabilityIsActionable, renderReasoningCapabilityStatus, reasoningCapabilityStatusText, reasoningDiscoveryStatusText, probeModelCapabilities, setFormControlsSaving, setModelConfigFormSaving, renderModelConfigFailure, clearModelConfigFailure, manualAgentModelIds, saveModelConfig, saveDefaultModelSelection, switchModel, handleReasoningEffortChange, switchReasoningEffort, deleteGatewayProfile, deleteModel, updateConfigRevisions, normalizeScopedDefaultSelection, configScope, configMutationMetadata, isConfigRevisionConflict, configRevisionConflictMessage, refreshConfigRevisionsAfterConflict, normalizeGatewayConfig, normalizeDashboardSettings, mergeGatewayConfig, normalizeGatewayProfiles, normalizeConfigSource, normalizeModels, normalizeModelSource, normalizeReasoningEfforts, normalizedReasoningEffort, isDisabledReasoningEffort, configuredReasoningEffort, reasoningEffortFallbackLabel, localizedReasoningEffortLabel, reasoningEffortCatalog, reasoningEffortLabel, resolveAtomicModelSelection, currentModelSelection, currentSessionNeedsModelSelection, currentGatewayProfile, gatewayProfileById, settingsInspectedGatewayProfile, modelSourceLabel, markCurrentModel, currentModelInfo, modelDisplayName, normalizeAgentModelTiers, normalizeVisionAgent, firstVisionModelId, hasAgentModelTiers, agentModelTiersSummary, gatewaySummary, modelSaveTargetLabel, gatewaySourceNote, environmentGatewayDefaultNote, sourceBadge, sourceLabel, formatContextUsage, firstFiniteNumber, formatTokenCount, trimNumber, nonNegativeInteger, collapseCompletedActivities, clearAssistantDrafts, collapseAssistantDrafts, isMeaningfulCompletedActivity, isDuplicateDraftText, normalizeComparableText, showApproval, resolveApproval, hideApproval, showQuestion, revealInteractionPanel, renderQuestionPanel, reviewQuestionConversation, returnToQuestion, activateQuestionReviewBackground, deactivateQuestionReviewBackground, questionChoiceButton, toggleQuestionChoice, submitQuestion, cancelQuestion, finishQuestionSubmission, hideQuestion, showTrustPanel, renderTrustPanel, confirmTrust, renderQueuePanel, renderQueueItem, setPendingGuide, clearPendingGuide, syncPendingGuideFromQueue, renderGuideFeedback, guideCopy, guideSource, guideTurnFromQueue, guideButtonText, guideButtonDisabled, guideButtonVisible, syncGuideButton, shouldKeepGuideFeedback, isInterruptError, updateSendButton, showContextConfirm, hideContextConfirm, runContextAction, contextActionRequestOptions, contextSummaryLine, compactResultLine, rememberQuestionDraft, questionResolutionText, showShutdownPanel, hideShutdownPanel, shutdownDashboard, lockClosedDashboard, normalizeLifecycleActivity, shutdownRequestBody, shutdownResultIsClosed, lifecycleActivitySummary, renderShutdownActivity, renderFiles, currentImageFiles, openFile, renderOfficePreview, officePreviewMeta, officePreviewBodyHtml, renderTablePreview, normalizeTablePreview, tablePreviewMeta, renderCompactTableHtml, renderExpandedTableHtml, renderTableHtml, tableTruncationNote, maxVisibleColumns, columnLabel, renderSheetPreviewHtml, renderSheetCellHtml, resetPreview, fencedDataForFile, dataLanguageForExtension, showImageLightbox, showTableLightbox, renderLightboxImage, bindTableLightboxControls, moveLightbox, hideLightbox, setPermissionMode, clearTranscript, cancelTranscriptAnimationFrames, clearAssistantDraftTimers, hideEmptyState, showError, showNotice, renderBootstrapLoading, renderBootstrapFailure, dashboardPayloadError, bootstrapFailurePresentation, clearBootstrapStatus, scrollTranscript, isTranscriptNearBottom, syncTranscriptFollowState, followTranscript, updateTranscriptJump, beginScopedRequest, isCurrentScopedRequest, finishScopedRequest, cancelScopedRequest, isAbortError, getJson, postJson, deleteJson, dashboardFetch, responseJson, dashboardJsonHeaders, dashboardCsrfToken, messageText, messageDisplayText, userMessageDisplayText, userTranscriptDisplayText, transcriptMessageAttachments, normalizeAttachmentMetadata, imageAttachmentLine, renderMessageText, renderLinkedText, bindRichContent, linkifyFileTextNodes, replaceFileReferences, isLikelyLocalFileReference, resolveDisplayFilePath, normalizeFileReferencePath, parentDirectory, filePreviewUrl, rawFileUrl, apiFileUrl, imagePreviewUrl, isSafeInlineBitmapUrl, normalizeRelativePath, isWorkspaceRelativeToBase, copyCodeBlock, previewText, formatNumber, formatBytes, escapeHtml, escapeAttribute, formatTime, formatRelativeTime } from "./app-barrel.ts"; +import { eventTargetOf, eventElement, isPlainObject, modelSourceOf, errorMessageOf, init, bootstrapDashboard, observeRunStatus, updateRunStatusTone, bindEvents, normalizedResponsiveView, composerHeightFor, previewWidthBounds, clampedPreviewWidth, permissionIndexForKey, focusTrapTarget, shouldFollowTranscript, scheduleAnimationFrameOnce, cancelScheduledAnimationFrame, appendPlainDraftDelta, renderFinalAssistantBody, selectTranscriptNodesToRemove, responsiveLayoutMode, restorePreviewWidth, setPreviewWidth, syncPreviewResizeHandle, beginPreviewResize, updatePreviewResize, finishPreviewResize, handlePreviewResizeKeydown, setResponsiveView, syncResponsiveNavigation, setResponsiveSurfaceInert, handleResponsiveFileNavigation, syncVisualViewport, resizePromptInput, handlePermissionModeKeydown, requestPermissionMode, defaultGoalMaxAutoContinues, emptyGoalSnapshot, applyGoalSnapshot, renderGoalControls, renderGoalStatusBar, requestGoalMode, showGoalConfirm, hideGoalConfirm, showGoalTextPanel, hideGoalTextPanel, enableGoalWithObjective, submitGoalAction, adoptGoalRunResult, showPermissionConfirm, hidePermissionConfirm, updateContextActions, announceStatus, modalFocusableElements, activateModal, collectModalBackground, focusModalInitialTarget, deactivateModal, restoreModalAttribute, handleGlobalKeydown, closeActiveModal, loadTrust, loadSessions, restoreInitialSession, latestBackgroundSessionId, initialSessionId, rememberCurrentSession, renderSessions, sessionMeta, sessionStatusView, toggleSidebar, setSidebarCollapsed, sessionsNeedRefresh, scheduleSessionsRefresh, handleSessionAction, openSession, restoreBackgroundSnapshot, deleteSession, setSessionsRefreshState, copySessionId, newTask, rememberNewTaskModelState, restoreNewTaskModelState, refreshNewTaskModelState, addAttachmentFiles, readImageAttachment, renderAttachmentStrip, attachmentPayload, clearAttachments, sendPrompt, stableTurnRequest, dashboardRequestId, dashboardClientId, statusUrl, interruptTurn, guideTurn, cancelQueuedTurn, renderTranscriptWindowMarker, captureTranscriptViewportAnchor, restoreTranscriptViewportAnchor, transcriptNodeTop, restoreTranscriptNodeAnchor, resetTranscriptWindow, appendAssistantDraft, scheduleDraftRender, renderAssistantDraft, appendActivity, appendContextBoundary, contextBoundaryText, handleActivity, isBackgroundSubagentActivity, handleBackgroundSubagentActivity, clearBackgroundSubagentStatus, reconcileBackgroundSubagentSnapshot, backgroundSubagentDisplayStatus, backgroundSubagentVisible, updateLiveActivity, removeLiveActivity, setLiveTitle, toggleLiveStatusDetails, updateLiveStatus, liveStatusTitle, primaryLiveActivity, gatewayRetryChipText, renderBackgroundSubagentStatus, backgroundSubagentCompactLabel, backgroundSubagentTitle, backgroundSubagentMeta, backgroundSubagentCancellable, resetLiveStatus, backgroundSubagentCounts, idleRunStatus, applyIdleRunStatus, updateRunStatusForBackground, updateSessionStatus, updateTurnChangeStats, resetTurnChangeStats, normalizeChangeStats, renderComposerStatus, modelStatusHtml, unresolvedModelStatusHtml, handleModelStatusActivate, handleModelStatusKeydown, toggleModelPanel, hideModelPanel, showSettingsWorkspace, refreshSettingsConfiguration, hideSettingsWorkspace, showModelConfigPanel, hideModelConfigPanel, renderModelPanel, modelCapabilityLabels, handleModelPanelClick, handleModelPanelChange, renderSettingsView, syncSettingsRail, modelSettingsHtml, transcriptSettingsHtml, transcriptRetentionOptionsHtml, networkSettingsHtml, networkModeOptionHtml, agentSettingsHtml, reliabilitySettingsHtml, settingsSectionHeading, settingsToggleHtml, managedFieldHtml, settingsDisabled, settingsFormActions, settingsFeedbackHtml, settingsGatewayProfileHtml, gatewayProfileReadonlyLabel, providerModelKey, scopedDefaultModelLabel, settingsModelHtml, handleSettingsRailClick, initializeSettingsFormTracking, handleSettingsFormChange, settingsControlValue, changedSettingsFields, canonicalSettingsField, setSettingsFormSaving, renderSettingsFeedbackInPlace, saveSettingsConfig, handleSettingsClick, protocolDisplayName, agentModelPickerHtml, renderModelConfigPanel, handleModelConfigPanelClick, handleModelConfigInput, handleModelConfigChange, markModelConfigCredentialChanged, markModelConfigEndpointChanged, handleModelConfigModelIdChanged, markReasoningCapabilityManual, clearReasoningCapabilityControls, syncGatewayUrlHint, gatewayUrlPlaceholder, gatewayUrlHint, syncReasoningDefaultOptions, probeGateway, isCurrentModelConfigRequest, currentGatewayProbeResult, currentGatewayCatalogModels, modelConfigGatewayProfile, modelConfigEndpointChanged, modelConfigAgentModelsSnapshot, initializeAgentModelPickerSnapshot, syncAgentModelPickersForEndpoint, uniqueAgentModelCandidates, appendAgentModelOptions, renderAgentModelPickers, updateAgentModelPickerManualStatus, handleAgentModelSelection, renderGatewayProbeResult, applyProbedModel, applyGatewayDiscoveredModel, applySuggestedGatewayUrl, gatewayCredentialAction, normalizeGatewayProbeModels, normalizeReasoningDiscovery, reasoningCapabilityCandidate, applyReasoningCapabilityCandidate, ensureReasoningEffortOptions, applyPendingReasoningCapabilities, reasoningCapabilityIsActionable, renderReasoningCapabilityStatus, reasoningCapabilityStatusText, reasoningDiscoveryStatusText, probeModelCapabilities, setFormControlsSaving, setModelConfigFormSaving, renderModelConfigFailure, clearModelConfigFailure, manualAgentModelIds, saveModelConfig, saveDefaultModelSelection, switchModel, handleReasoningEffortChange, switchReasoningEffort, deleteGatewayProfile, deleteModel, updateConfigRevisions, normalizeScopedDefaultSelection, configScope, configMutationMetadata, isConfigRevisionConflict, configRevisionConflictMessage, refreshConfigRevisionsAfterConflict, normalizeGatewayConfig, normalizeDashboardSettings, mergeGatewayConfig, normalizeGatewayProfiles, normalizeConfigSource, normalizeModels, normalizeModelSource, normalizeReasoningEfforts, normalizedReasoningEffort, isDisabledReasoningEffort, configuredReasoningEffort, reasoningEffortFallbackLabel, localizedReasoningEffortLabel, reasoningEffortCatalog, reasoningEffortLabel, resolveAtomicModelSelection, currentModelSelection, currentSessionNeedsModelSelection, currentGatewayProfile, gatewayProfileById, settingsInspectedGatewayProfile, modelSourceLabel, markCurrentModel, currentModelInfo, modelDisplayName, normalizeAgentModelTiers, normalizeVisionAgent, firstVisionModelId, hasAgentModelTiers, agentModelTiersSummary, gatewaySummary, modelSaveTargetLabel, gatewaySourceNote, environmentGatewayDefaultNote, sourceBadge, sourceLabel, formatContextUsage, firstFiniteNumber, formatTokenCount, trimNumber, nonNegativeInteger, collapseCompletedActivities, clearAssistantDrafts, collapseAssistantDrafts, isMeaningfulCompletedActivity, isDuplicateDraftText, normalizeComparableText, showApproval, resolveApproval, hideApproval, clearPermissionWaitActivity, showQuestion, revealInteractionPanel, renderQuestionPanel, reviewQuestionConversation, returnToQuestion, activateQuestionReviewBackground, deactivateQuestionReviewBackground, questionChoiceButton, toggleQuestionChoice, submitQuestion, cancelQuestion, finishQuestionSubmission, hideQuestion, showTrustPanel, renderTrustPanel, confirmTrust, renderQueuePanel, renderQueueItem, setPendingGuide, clearPendingGuide, syncPendingGuideFromQueue, renderGuideFeedback, guideCopy, guideSource, guideTurnFromQueue, guideButtonText, guideButtonDisabled, guideButtonVisible, syncGuideButton, shouldKeepGuideFeedback, isInterruptError, updateSendButton, showContextConfirm, hideContextConfirm, runContextAction, contextActionRequestOptions, contextSummaryLine, compactResultLine, rememberQuestionDraft, questionResolutionText, showShutdownPanel, hideShutdownPanel, shutdownDashboard, lockClosedDashboard, normalizeLifecycleActivity, shutdownRequestBody, shutdownResultIsClosed, lifecycleActivitySummary, renderShutdownActivity, renderFiles, currentImageFiles, openFile, renderOfficePreview, officePreviewMeta, officePreviewBodyHtml, renderTablePreview, normalizeTablePreview, tablePreviewMeta, renderCompactTableHtml, renderExpandedTableHtml, renderTableHtml, tableTruncationNote, maxVisibleColumns, columnLabel, renderSheetPreviewHtml, renderSheetCellHtml, resetPreview, fencedDataForFile, dataLanguageForExtension, showImageLightbox, showTableLightbox, renderLightboxImage, bindTableLightboxControls, moveLightbox, hideLightbox, setPermissionMode, clearTranscript, cancelTranscriptAnimationFrames, clearAssistantDraftTimers, hideEmptyState, showError, showNotice, renderBootstrapLoading, renderBootstrapFailure, dashboardPayloadError, bootstrapFailurePresentation, clearBootstrapStatus, scrollTranscript, isTranscriptNearBottom, syncTranscriptFollowState, followTranscript, updateTranscriptJump, beginScopedRequest, isCurrentScopedRequest, finishScopedRequest, cancelScopedRequest, isAbortError, getJson, postJson, deleteJson, dashboardFetch, responseJson, dashboardJsonHeaders, dashboardCsrfToken, messageText, messageDisplayText, userMessageDisplayText, userTranscriptDisplayText, transcriptMessageAttachments, normalizeAttachmentMetadata, imageAttachmentLine, renderMessageText, renderLinkedText, bindRichContent, linkifyFileTextNodes, replaceFileReferences, isLikelyLocalFileReference, resolveDisplayFilePath, normalizeFileReferencePath, parentDirectory, filePreviewUrl, rawFileUrl, apiFileUrl, imagePreviewUrl, isSafeInlineBitmapUrl, normalizeRelativePath, isWorkspaceRelativeToBase, copyCodeBlock, previewText, formatNumber, formatBytes, escapeHtml, escapeAttribute, formatTime, formatRelativeTime } from "./app-barrel.ts"; export async function cancelBackgroundSubagent(groupId: unknown, taskId: unknown) { const key = backgroundCancelKey(groupId, taskId); if (!state.currentSessionId || !key || state.backgroundCancelling.has(key)) { @@ -239,9 +239,12 @@ export function setConnectionState(next: string) { error: "本地网关异常", stale: "本地网关连接过期" }; - const label = labels[next] ?? labels.idle; - els.connectionStatus.dataset.state = next; - els.connectionStatus.title = `仅此电脑可打开,文件访问按当前权限执行。${label}。点击重新连接`; + const configured = state.gatewayConfig?.apiKeyConfigured === true + || (state.gatewayProfiles ?? []).some((profile) => profile.apiKeyConfigured === true && profile.credentialSelectionRequired !== true); + const displayState = next === "idle" && configured ? "connected" : next; + const label = labels[displayState] ?? labels.idle; + els.connectionStatus.dataset.state = displayState; + els.connectionStatus.title = `仅此电脑可打开,文件访问按当前权限执行。${label}。${next === "idle" && configured ? "凭据已配置,尚未验证上游请求。" : ""}点击重新连接`; els.connectionStatus.setAttribute("aria-label", `${label},点击重新连接`); const text = els.connectionStatus.querySelector(".connection-label"); if (text) text.textContent = label; @@ -520,7 +523,14 @@ export function handleDashboardEvent(event: DashboardStreamEvent) { return; } if (event.type === "approval_resolved") { - hideApproval(); + hideApproval({ approvalId: event.approvalId }); + if (state.pendingApproval) { + els.runStatus.textContent = "等待确认"; + setLiveTitle("等待权限确认"); + updateSendButton(); + return; + } + clearPermissionWaitActivity(); if (event.interrupted && state.pendingGuide) { els.runStatus.textContent = "引导中"; setLiveTitle("引导已接管,等待当前轮次收束"); @@ -995,4 +1005,3 @@ export function isProtectedTranscriptNode(node: Element) { || node === state.transcriptWindow.newerNode || node.classList.contains("draft-message"); } - diff --git a/src/dashboard/public/app-ui4.ts b/src/dashboard/public/app-ui4.ts index 6c7b80e..7f1cc56 100644 --- a/src/dashboard/public/app-ui4.ts +++ b/src/dashboard/public/app-ui4.ts @@ -1,4 +1,5 @@ import { renderMarkdown } from "./markdown.ts"; +import { modelConnectionGroups, modelPickerLabel } from "./model-groups.ts"; import { hydrateRichContent } from "./rich-renderers.ts"; import { visibleTranscriptRole } from "./transcript.ts"; import { MANUAL_AGENT_MODEL_VALUE, state, els, MODE_DESCRIPTIONS, LOCAL_FILE_EXTENSIONS, FILE_REFERENCE_PATTERN, TRANSCRIPT_DOM_LIMIT, EVENT_STALE_AFTER_MS, EVENT_CONNECT_TIMEOUT_MS, EVENT_RECONNECT_MAX_ATTEMPTS, DASHBOARD_REQUEST_TIMEOUT_MS, DASHBOARD_API_VERSION, DASHBOARD_LIFECYCLE_TIMEOUT_MS, DASHBOARD_SHUTDOWN_TIMEOUT_MS, DASHBOARD_INTERRUPT_TIMEOUT_MS, MAX_IMAGE_ATTACHMENTS, MAX_IMAGE_ATTACHMENT_BYTES, CURRENT_SESSION_STORAGE_KEY, DASHBOARD_CLIENT_STORAGE_KEY, PREVIEW_WIDTH_STORAGE_KEY, PREVIEW_WIDTH_DEFAULT, PREVIEW_WIDTH_MIN, PREVIEW_WIDTH_MAX, PREVIEW_WORKSPACE_MIN , emptySessionStatus, emptyBackgroundSubagent } from "./app-core.ts"; @@ -355,6 +356,10 @@ export function setLiveTitle(title: string) { } export function toggleLiveStatusDetails() { + if (state.pendingApproval) { + showApproval(state.pendingApproval); + return; + } if (state.backgroundSubagents.size === 0) { return; } @@ -368,15 +373,19 @@ export function updateLiveStatus() { if (background.length === 0) { state.liveStatusExpanded = false; } - const visible = state.running || active.length > 0 || background.length > 0 || state.liveTitle; + const waitingApproval = Boolean(state.pendingApproval); + const visible = state.running || active.length > 0 || background.length > 0 || waitingApproval || state.liveTitle; els.liveStatus.classList.toggle("hidden", !visible); els.liveStatus.classList.toggle("has-background-subagents", background.length > 0); + els.liveStatus.classList.toggle("has-pending-approval", waitingApproval); els.liveStatus.classList.toggle("expanded", state.liveStatusExpanded && background.length > 0); - els.activityToggle.disabled = background.length === 0; + els.activityToggle.disabled = background.length === 0 && !waitingApproval; els.activityToggle.setAttribute("aria-expanded", String(state.liveStatusExpanded && background.length > 0)); - els.activityToggle.setAttribute("aria-label", background.length > 0 - ? `${state.liveStatusExpanded ? "收起" : "展开"}后台活动详情` - : "当前活动"); + els.activityToggle.setAttribute("aria-label", waitingApproval + ? "打开权限确认" + : background.length > 0 + ? `${state.liveStatusExpanded ? "收起" : "展开"}后台活动详情` + : "当前活动"); if (!visible) { els.liveTitle.textContent = ""; els.liveSubtasks.innerHTML = ""; @@ -402,6 +411,11 @@ export function updateLiveStatus() { } export function liveStatusTitle(primary: DashboardActivity | undefined, subtasks: DashboardActivity[], background: DashboardActivity[]) { + if (state.pendingApproval) { + return state.approvalQueue.length > 0 + ? `等待权限确认 · 还有 ${state.approvalQueue.length} 个排队` + : "等待权限确认"; + } if (primary?.rawType === "gateway_retry") { return "网关响应异常,正在自动重试"; } @@ -676,6 +690,7 @@ export function normalizeChangeStats(stats: DashboardTurnChangeStats | Record group.profiles.some((profile) => profile.id === activeProfileId)); + const models = activeGroup + ? activeGroup.profiles.flatMap((profile) => (profile.models ?? []).map((model) => ({ ...model, switchProfileId: profile.id, switchReady: profile.ready !== false }))) + : (profiles.length === 0 ? state.models ?? [] : []).map((model) => ({ ...model, switchProfileId: "", switchReady: true })); els.modelPanel.innerHTML = `
模型
@@ -960,14 +979,14 @@ export function renderModelPanel() { 模型来源
diff --git a/src/dashboard/public/app-ui5.ts b/src/dashboard/public/app-ui5.ts index 5c28056..634a24a 100644 --- a/src/dashboard/public/app-ui5.ts +++ b/src/dashboard/public/app-ui5.ts @@ -1,4 +1,5 @@ import { renderMarkdown } from "./markdown.ts"; +import { modelConnectionGroups } from "./model-groups.ts"; import { hydrateRichContent } from "./rich-renderers.ts"; import { visibleTranscriptRole } from "./transcript.ts"; import { MANUAL_AGENT_MODEL_VALUE, state, els, MODE_DESCRIPTIONS, LOCAL_FILE_EXTENSIONS, FILE_REFERENCE_PATTERN, TRANSCRIPT_DOM_LIMIT, EVENT_STALE_AFTER_MS, EVENT_CONNECT_TIMEOUT_MS, EVENT_RECONNECT_MAX_ATTEMPTS, DASHBOARD_REQUEST_TIMEOUT_MS, DASHBOARD_API_VERSION, DASHBOARD_LIFECYCLE_TIMEOUT_MS, DASHBOARD_SHUTDOWN_TIMEOUT_MS, DASHBOARD_INTERRUPT_TIMEOUT_MS, MAX_IMAGE_ATTACHMENTS, MAX_IMAGE_ATTACHMENT_BYTES, CURRENT_SESSION_STORAGE_KEY, DASHBOARD_CLIENT_STORAGE_KEY, PREVIEW_WIDTH_STORAGE_KEY, PREVIEW_WIDTH_DEFAULT, PREVIEW_WIDTH_MIN, PREVIEW_WIDTH_MAX, PREVIEW_WORKSPACE_MIN , emptySessionStatus, emptyBackgroundSubagent } from "./app-core.ts"; @@ -29,7 +30,7 @@ export async function handleModelPanelChange(event: Event) { const modelId = profile?.modelAlias || profile?.models?.[0]?.id || ""; await switchModel(modelId, { profileId: profile?.id || "", keepPanelOpen: true }); } else if (select.dataset.action === "switch-model") { - await switchModel(select.value, { profileId: currentModelSelection().profile?.id || "" }); + await switchModel(select.value, { profileId: select.selectedOptions[0]?.dataset.profileId || currentModelSelection().profile?.id || "" }); } } @@ -92,7 +93,7 @@ export function modelSettingsHtml() { ${escapeHtml(inspectedProfile?.gatewayUrl || "未配置网关")}
- ${profiles.map((profile) => settingsGatewayProfileHtml(profile)).join("") || `
尚未保存模型来源
`} + ${modelConnectionGroups(profiles).map((group) => `

${escapeHtml(group.label)}

${group.profiles[0].credentialSelectionRequired ? `

请选择生效凭据

` : ""}${group.profiles.map((profile) => settingsGatewayProfileHtml(profile)).join("")}
`).join("") || `
尚未保存模型来源
`}
@@ -316,6 +317,7 @@ export function settingsGatewayProfileHtml(profile: DashboardGatewayProfile) { : `${protocolDisplayName(profile.gatewayProtocol)} · ${profile.apiKeyConfigured ? "Key 已配置" : "无 Key"} · ${count} 模型`)}
+ @@ -546,6 +548,19 @@ export async function handleSettingsClick(event: Event) { state.modelDefaultScope = configScope(action.dataset.scope, "project"); state.settingsFeedback = null; renderSettingsView(); + } else if (action.dataset.action === "select-source-credential") { + state.settingsRefreshing = true; + try { + const result = await postJson("/api/settings-config", { section: "source-credential", settings: { profileId: action.dataset.profileId }, saveTarget: state.modelDefaultScope, sessionId: state.currentSessionId || undefined }); + if (!result.ok) throw new Error(result.error || "切换凭据失败"); + state.settingsRefreshing = false; + await refreshSettingsConfiguration(); + } catch (error) { + state.settingsFeedback = { tone: "error", message: errorMessageOf(error) }; + } finally { + state.settingsRefreshing = false; + renderSettingsView(); + } } else if (action.dataset.action === "inspect-profile") { state.settingsProviderId = action.dataset.profileId || ""; state.deleteConfirmModelKey = ""; @@ -764,8 +779,8 @@ export function renderModelConfigPanel() {
@@ -4246,6 +4299,7 @@ function settingsGatewayProfileHtml5(profile) { ${escapeHtml(profile.ready === false ? `${protocolDisplayName5(profile.gatewayProtocol)} · 配置不完整` : `${protocolDisplayName5(profile.gatewayProtocol)} · ${profile.apiKeyConfigured ? "Key 已配置" : "无 Key"} · ${count} 模型`)}
+ @@ -4438,6 +4492,19 @@ async function handleSettingsClick(event) { state.modelDefaultScope = configScope5(action.dataset.scope, "project"); state.settingsFeedback = null; renderSettingsView4(); + } else if (action.dataset.action === "select-source-credential") { + state.settingsRefreshing = true; + try { + const result = await postJson("/api/settings-config", { section: "source-credential", settings: { profileId: action.dataset.profileId }, saveTarget: state.modelDefaultScope, sessionId: state.currentSessionId || void 0 }); + if (!result.ok) throw new Error(result.error || "切换凭据失败"); + state.settingsRefreshing = false; + await refreshSettingsConfiguration4(); + } catch (error) { + state.settingsFeedback = { tone: "error", message: errorMessageOf(error) }; + } finally { + state.settingsRefreshing = false; + renderSettingsView4(); + } } else if (action.dataset.action === "inspect-profile") { state.settingsProviderId = action.dataset.profileId || ""; state.deleteConfirmModelKey = ""; @@ -4627,8 +4694,8 @@ function renderModelConfigPanel4() {
- + diff --git a/src/dashboard/public/model-groups.ts b/src/dashboard/public/model-groups.ts new file mode 100644 index 0000000..07b726a --- /dev/null +++ b/src/dashboard/public/model-groups.ts @@ -0,0 +1,32 @@ +import type { DashboardGatewayProfile } from "./app-core.ts"; + +export function modelPickerLabel(model: { id: string; label?: string; switchProfileId: string }, models: Array<{ id: string; label?: string; switchProfileId: string }>) { + const remark = model.label && model.label !== model.id ? model.label : ""; + const label = remark ? `${model.id} (${remark})` : model.id; + const duplicates = models.filter((other) => other.id === model.id && (other.label || other.id) === (model.label || model.id)); + if (duplicates.length < 2) return label; + return `${label} (配置 ${duplicates.findIndex((other) => other.switchProfileId === model.switchProfileId) + 1})`; +} + +export function modelConnectionGroups(profiles: DashboardGatewayProfile[]) { + const groups = new Map(); + for (const profile of profiles) { + const id = String(profile.connectionGroupId || profile.id); + let group = groups.get(id); + if (!group) { + group = { id, label: profile.gatewayUrl || profile.label || profile.id, profiles: [] }; + groups.set(id, group); + } + group.profiles.push(profile); + } + const result = [...groups.values()]; + const counts = new Map(); + for (const group of result) counts.set(group.label, (counts.get(group.label) || 0) + 1); + for (const group of result) { + if ((counts.get(group.label) || 0) > 1) { + const profile = group.profiles[0]; + group.label += ` (${profile.gatewayUrl || profile.id}; ${profile.id})`; + } + } + return result; +} diff --git a/src/dashboard/public/styles.css b/src/dashboard/public/styles.css index 017287a..b2d0e97 100644 --- a/src/dashboard/public/styles.css +++ b/src/dashboard/public/styles.css @@ -2048,10 +2048,15 @@ button { padding: 9px 14px; } -.live-status.has-background-subagents { +.live-status.has-background-subagents, +.live-status.has-pending-approval { cursor: pointer; } +.live-status.has-pending-approval { + border-color: rgba(246, 200, 111, 0.55); +} + .live-status.has-background-subagents:focus-visible { border-color: rgba(246, 200, 111, 0.5); outline: 2px solid rgba(246, 200, 111, 0.18); @@ -2266,6 +2271,25 @@ button { margin: 0 auto 12px; max-width: 860px; padding: 14px 16px; + position: sticky; + bottom: 0; + z-index: 6; +} + +body > .approval-panel:not(.hidden), +.approval-panel.modal-interaction { + bottom: auto; + left: 50%; + margin: 0; + max-height: min(82dvh, 720px); + max-width: min(680px, calc(100vw - 32px)); + overflow: auto; + position: fixed; + right: auto; + top: 50%; + transform: translate(-50%, -50%); + width: min(680px, calc(100vw - 32px)); + z-index: 100; } .trust-panel, diff --git a/src/dashboard/runtime/public-config.ts b/src/dashboard/runtime/public-config.ts index 795fd3b..ef34357 100644 --- a/src/dashboard/runtime/public-config.ts +++ b/src/dashboard/runtime/public-config.ts @@ -1,4 +1,5 @@ import fs from "node:fs/promises"; +import { sourceCredentialState } from "../../config/source-credentials.ts"; import path from "node:path"; import { createHash, createHmac, randomBytes, type Hash } from "node:crypto"; import { isDeepStrictEqual } from "node:util"; @@ -218,7 +219,13 @@ export function publicDashboardSettings(config: LabAgentConfig, env: NodeJS.Proc export function publicGatewayProfiles(config: LabAgentConfig) { const active = activeGatewayProfileId(config); + // Compare credentials only on the server. Expose a representative profile + // ID, never the credential or a credential-derived fingerprint. + const groups = new Map(); + const credentialSelections = sourceCredentialState(config); return gatewayProfilesFromConfig(config).map((profile) => { + const connection = String(profile.gatewayUrl || profile.id); + if (!groups.has(connection)) groups.set(connection, profile.id); const owner = gatewayProfileOwner(config, profile.id); const ownerScope = String(owner?.type ?? "").trim(); const profileConfig = { @@ -238,6 +245,9 @@ export function publicGatewayProfiles(config: LabAgentConfig) { )); return { id: profile.id, + connectionGroupId: groups.get(connection), + activeCredentialProfileId: credentialSelections[profile.gatewayUrl] ?? "", + credentialSelectionRequired: !credentialSelections[profile.gatewayUrl], label: profile.label || profile.id, gatewayUrl: publicGatewayUrl(profile.gatewayUrl), gatewayHealthUrl: publicGatewayUrl(profile.gatewayHealthUrl), diff --git a/src/dashboard/runtime/session-model.ts b/src/dashboard/runtime/session-model.ts index 19353a1..29d648c 100644 --- a/src/dashboard/runtime/session-model.ts +++ b/src/dashboard/runtime/session-model.ts @@ -1,4 +1,5 @@ import fs from "node:fs/promises"; +import { applySourceCredentials } from "../../config/source-credentials.ts"; import path from "node:path"; import { createHash, createHmac, randomBytes, type Hash } from "node:crypto"; import { isDeepStrictEqual } from "node:util"; @@ -722,7 +723,7 @@ export function configForGatewayProfileSelection(config: LabAgentConfig, profile return config; } const agents = isPlainObject(profile.agents) ? profile.agents : {}; - return { + return applySourceCredentials({ ...config, modelAlias, defaultModelAlias: modelAlias, @@ -738,7 +739,7 @@ export function configForGatewayProfileSelection(config: LabAgentConfig, profile gatewayApiKey: profile.gatewayApiKey, gatewayApiKeyDisabled: profile.gatewayApiKeyDisabled === true } - } as LabAgentConfig; + } as LabAgentConfig); } /** @param {Map>} selections @param {unknown} clientId @param {Record} fallback */ diff --git a/src/dashboard/runtime/settings.ts b/src/dashboard/runtime/settings.ts index 4d8e7a1..b214e09 100644 --- a/src/dashboard/runtime/settings.ts +++ b/src/dashboard/runtime/settings.ts @@ -1,4 +1,5 @@ import fs from "node:fs/promises"; +import { gatewayProfileOwner } from "./public-config.ts"; import path from "node:path"; import { createHash, createHmac, randomBytes, type Hash } from "node:crypto"; import { isDeepStrictEqual } from "node:util"; @@ -96,6 +97,14 @@ export function normalizeDashboardSettingsInput(input: DashboardRequestInput, co const section = String(input.section ?? input.category ?? "").trim().toLowerCase(); const saveTarget = normalizeModelConfigSaveTarget(input.saveTarget ?? input.scope ?? input.target ?? "project"); const values = isPlainObject(input.settings) ? input.settings : input; + if (section === "source-credential") { + const profile = gatewayProfilesFromConfig(config).find((item) => item.id === String(values.profileId ?? "")); + if (!profile || !parseConfigUrl(profile.gatewayUrl)) return { ok: false, status: 400, error: "凭据所属来源不存在" }; + if (saveTarget === "global" && gatewayProfileOwner(config, profile.id)?.type === "project") { + return { ok: false, status: 400, error: "项目凭据只能设为当前项目的生效凭据" }; + } + return { ok: true, section, saveTarget, changedFields: ["profileId"], values: { profileId: profile.id, url: profile.gatewayUrl } }; + } if (![ "transcript", "network", @@ -225,6 +234,13 @@ export function buildDashboardSettingsConfig(targetConfig: Record 160 || /[\r\n\t\0]/.test(label)) { + return { ok: false, status: 400, error: "模型备注最多 160 个字符,不能包含换行或控制字符" }; + } + const editingProfileId = String(input.providerId ?? input.profileId ?? input.gatewayProfileId ?? "").trim(); + const createsProvider = isPlainObject(config.configV2) && config.configV2.enabled === true; + const duplicateModels = gatewayProfilesFromConfig(config).filter((profile) => ( + profile.id !== editingProfileId + && (createsProvider || Boolean(editingProfileId) || profile.gatewayProtocol !== gatewayProtocol) + && profile.gatewayUrl === gatewayUrl + && profile.models.some((model) => model.id === modelId) + )); + if (duplicateModels.length > 0 && (!label || label === modelId)) { + return { ok: false, status: 400, error: "同一模型来源下已存在同名主模型,请填写模型备注(例如:科研分析、代码开发)后再保存" }; + } const contextTokens = positiveIntegerOrNull(input.contextTokens); const modalities = normalizeModelInputModalities(input); const agentModelTiersProvided = Object.prototype.hasOwnProperty.call(input, "agentModelTiers") diff --git a/src/model-gateway/client.ts b/src/model-gateway/client.ts index 162ce3a..ee80d2e 100644 --- a/src/model-gateway/client.ts +++ b/src/model-gateway/client.ts @@ -89,6 +89,9 @@ export function createLabModelGateway(config: import("../config/load-config.ts") * @param {{ messages: Array>; tools?: Array>; toolResults?: Array>; sessionId?: string; stream?: boolean; signal?: AbortSignal; onEvent?: (event: Record) => void | Promise }} request */ async sendChat(request: { messages: Array>; tools?: Array>; toolResults?: Array>; sessionId?: string; stream?: boolean; signal?: AbortSignal; onEvent?: (event: Record) => void | Promise }): Promise { + if (config.lab.sourceCredentialSelectionRequired) { + throw new Error("请先在设置页为当前 URL 来源选择生效凭据;未发送模型请求。"); + } if (!config.lab.gatewayUrl) { return { ok: false, diff --git a/src/storage/durable-file.ts b/src/storage/durable-file.ts index 5b1e74c..8900f5e 100644 --- a/src/storage/durable-file.ts +++ b/src/storage/durable-file.ts @@ -223,14 +223,17 @@ async function acquireFileMutationLock(filePath: string, options: { timeoutMs?: } async function releaseOwnedLock(lockPath: string, token: string) { - const owner = await readLockOwner(lockPath); + // A transient Windows sharing violation must not strand a lock we own. + const owner = await retryLockRelease(() => fs.readFile(path.join(lockPath, "owner.json"), "utf8")) + .then((text) => JSON.parse(text) as { token?: unknown }) + .catch((error) => { if (errorCode(error) === "ENOENT") return null; throw error; }); if (owner?.token !== token) { return; } const released = `${lockPath}.released.${process.pid}.${randomBytes(8).toString("hex")}`; try { - await fs.rename(lockPath, released); - await fs.rm(released, { recursive: true, force: true }); + await retryLockRelease(() => fs.rename(lockPath, released)); + await fs.rm(released, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 }); } catch (error) { if (errorCode(error) !== "ENOENT") { throw error; @@ -238,6 +241,17 @@ async function releaseOwnedLock(lockPath: string, token: string) { } } +async function retryLockRelease(operation: () => Promise): Promise { + for (let attempt = 0; ; attempt += 1) { + try { + return await operation(); + } catch (error) { + if (attempt >= 5 || !["EPERM", "EACCES", "EBUSY"].includes(errorCode(error) ?? "")) throw error; + await delay(20 * (attempt + 1)); + } + } +} + async function isAbandonedLock(lockPath: string, staleMs: number) { const stat = await fs.stat(lockPath).catch(() => null); if (!stat || Date.now() - stat.mtimeMs <= staleMs) { diff --git a/src/tools/definitions.ts b/src/tools/definitions.ts index 7f31260..59d8f92 100644 --- a/src/tools/definitions.ts +++ b/src/tools/definitions.ts @@ -1,7 +1,7 @@ export const BUILT_IN_TOOLS = Object.freeze([ { name: "read_file", - description: "Read a UTF-8 text file inside the active workspace, or an approved/full-access local path. Returns the full file unless maxBytes is explicitly supplied.", + description: "Read a UTF-8 text file inside the active workspace, or an approved/full-access local path. Model output is a bounded continuous excerpt. Continue with returned nextStartLine/nextStartColumn as startLine/startColumn; maxLines defaults to 200 for range reads. Reuse retained results unless the file changed or another range is needed.", risk: "read", supportsAbort: false, inputSchema: { @@ -9,7 +9,10 @@ export const BUILT_IN_TOOLS = Object.freeze([ required: ["path"], properties: { path: { type: "string" }, - maxBytes: { type: "number" } + maxBytes: { type: "number" }, + startLine: { type: "integer", minimum: 1 }, + startColumn: { type: "integer", minimum: 1, description: "1-based UTF-16 column on startLine; use the returned continuation value for long lines." }, + maxLines: { type: "integer", minimum: 1 } } } }, @@ -22,7 +25,8 @@ export const BUILT_IN_TOOLS = Object.freeze([ type: "object", properties: { path: { type: "string" }, - maxEntries: { type: "number" } + maxEntries: { type: "number" }, + offset: { type: "integer", minimum: 0, description: "Continue with the returned nextOffset. Results reflect current directory contents." } } } }, @@ -37,7 +41,8 @@ export const BUILT_IN_TOOLS = Object.freeze([ properties: { pattern: { type: "string" }, path: { type: "string" }, - maxMatches: { type: "number" } + maxMatches: { type: "number" }, + offset: { type: "integer", minimum: 0, description: "Continue with the returned nextOffset; reruns against current files." } } } }, @@ -52,7 +57,8 @@ export const BUILT_IN_TOOLS = Object.freeze([ properties: { pattern: { type: "string" }, path: { type: "string" }, - maxMatches: { type: "number" } + maxMatches: { type: "number" }, + offset: { type: "integer", minimum: 0, description: "Continue with the returned nextOffset; reruns against current files." } } } }, @@ -75,9 +81,10 @@ export const BUILT_IN_TOOLS = Object.freeze([ multiline: { type: "boolean" }, hidden: { type: "boolean" }, noIgnore: { type: "boolean" }, + offset: { type: "integer", minimum: 0, description: "Skip this many result rows, including context rows. Continue with nextOffset." }, beforeContext: { type: "number" }, afterContext: { type: "number" }, - maxResults: { type: "number" }, + maxResults: { type: "number", description: "Maximum returned rows, including context rows; default 100." }, timeoutMs: { type: "number" } } } @@ -91,6 +98,7 @@ export const BUILT_IN_TOOLS = Object.freeze([ type: "object", properties: { path: { type: "string" }, + offset: { type: "integer", minimum: 0, description: "Continue with nextOffset." }, glob: { type: "array" }, hidden: { type: "boolean" }, noIgnore: { type: "boolean" }, @@ -109,6 +117,7 @@ export const BUILT_IN_TOOLS = Object.freeze([ required: ["pattern"], properties: { pattern: { type: "string" }, + offset: { type: "integer", minimum: 0, description: "Continue with nextOffset." }, path: { type: "string" }, glob: { type: "array" }, ignoreCase: { type: "boolean" }, diff --git a/src/tools/file-tools.ts b/src/tools/file-tools.ts index 41b526f..b2a9357 100644 --- a/src/tools/file-tools.ts +++ b/src/tools/file-tools.ts @@ -19,7 +19,7 @@ const BINARY_EXTENSIONS = new Set([ /** * @param {{ cwd: string; path: string; maxBytes?: number; policy?: Record }} input */ -export async function readFileTool(input: { cwd: string; path: string; maxBytes?: number; policy?: Record }) { +export async function readFileTool(input: { cwd: string; path: string; maxBytes?: number; startLine?: number; maxLines?: number; startColumn?: number; policy?: Record }) { const filePath = await resolveWorkspacePath(input.cwd, input.path, { allowOutsideWorkspace: canUseOutsideWorkspace(input.policy) }); await fs.access(filePath).catch((error: unknown) => { if (isNotFoundError(error)) { @@ -28,6 +28,55 @@ export async function readFileTool(input: { cwd: string; path: string; maxBytes? throw error; }); const explicitMaxBytes = positiveIntegerOrNull(input.maxBytes); + if (input.startLine !== undefined || input.maxLines !== undefined || input.startColumn !== undefined) { + const startLine = positiveIntegerOrNull(input.startLine) ?? 1; + const startColumn = positiveIntegerOrNull(input.startColumn) ?? 1; + const maxLines = positiveIntegerOrNull(input.maxLines) ?? 200; + const maxBytes = explicitMaxBytes ?? 32_000; + const selected: string[] = []; + let lineNumber = 0; + let bytesRead = 0; + let nextStartLine: number | null = null; + let nextStartColumn: number | null = null; + for await (const line of readLines(filePath)) { + lineNumber += 1; + if (lineNumber < startLine) continue; + if (selected.length >= maxLines) { + nextStartLine = lineNumber; + nextStartColumn = 1; + break; + } + const column = lineNumber === startLine ? startColumn : 1; + if (column > line.length + 1 || (column > 1 && /[\uDC00-\uDFFF]/.test(line.charAt(column - 1)))) { + throw new Error("Invalid startColumn; use the returned nextStartColumn on an unchanged file."); + } + const source = line.slice(column - 1); + const separatorBytes = selected.length ? 1 : 0; + const remaining = maxBytes - bytesRead - separatorBytes; + if (remaining <= 0) { + nextStartLine = lineNumber; + nextStartColumn = column; + break; + } + const decoder = new TextDecoder("utf-8"); + const excerpt = decoder.decode(Buffer.from(source).subarray(0, remaining), { stream: true }); + if (!excerpt && source && selected.length === 0) { + throw new Error("maxBytes is too small to read one UTF-8 character; increase maxBytes."); + } + selected.push(excerpt); + bytesRead += separatorBytes + Buffer.byteLength(excerpt); + if (excerpt.length < source.length) { + nextStartLine = lineNumber; + nextStartColumn = column + excerpt.length; + break; + } + } + return { + path: toDisplayPath(input.cwd, filePath), startLine, startColumn, + bytesRead, content: selected.join("\n"), + truncated: nextStartLine !== null, nextStartLine, nextStartColumn + }; + } if (!explicitMaxBytes) { const content = await fs.readFile(filePath, "utf8"); return { @@ -42,11 +91,15 @@ export async function readFileTool(input: { cwd: string; path: string; maxBytes? const buffer = Buffer.alloc(explicitMaxBytes); const result = await handle.read(buffer, 0, explicitMaxBytes, 0); const stat = await handle.stat(); + const content = new TextDecoder("utf-8").decode(buffer.subarray(0, result.bytesRead), { stream: true }); + if (!content && result.bytesRead > 0) { + throw new Error("maxBytes is too small to read one UTF-8 character; increase maxBytes."); + } return { path: toDisplayPath(input.cwd, filePath), bytesRead: result.bytesRead, truncated: stat.size > result.bytesRead, - content: buffer.subarray(0, result.bytesRead).toString("utf8") + content }; } finally { await handle.close(); @@ -56,7 +109,7 @@ export async function readFileTool(input: { cwd: string; path: string; maxBytes? /** * @param {{ cwd: string; path?: string; policy?: Record }} input */ -export async function listFilesTool(input: { cwd: string; path?: string; maxEntries?: number; policy?: Record }) { +export async function listFilesTool(input: { cwd: string; path?: string; maxEntries?: number; offset?: number; policy?: Record }) { const dirPath = await resolveWorkspacePath(input.cwd, input.path ?? ".", { allowOutsideWorkspace: canUseOutsideWorkspace(input.policy) }); const entries = await fs.readdir(dirPath, { withFileTypes: true }).catch((error: unknown) => { if (isNotFoundError(error)) { @@ -69,47 +122,57 @@ export async function listFilesTool(input: { cwd: string; path?: string; maxEntr type: entry.isDirectory() ? "directory" : entry.isFile() ? "file" : "other" })); const maxEntries = positiveIntegerOrNull(input.maxEntries) ?? DEFAULT_FILE_LIST_MAX_ENTRIES; + mapped.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0); + const offset = positiveIntegerOrNull(input.offset) ?? 0; return { path: toDisplayPath(input.cwd, dirPath), - entries: mapped.slice(0, maxEntries), + entries: mapped.slice(offset, offset + maxEntries), + offset, + nextOffset: mapped.length > offset + maxEntries ? offset + maxEntries : null, total: mapped.length, - truncated: mapped.length > maxEntries + truncated: mapped.length > offset + maxEntries }; } /** * @param {{ cwd: string; pattern: string; path?: string; maxMatches?: number; policy?: Record }} input */ -export async function globTool(input: { cwd: string; pattern: string; path?: string; maxMatches?: number; policy?: Record }) { +export async function globTool(input: { cwd: string; pattern: string; path?: string; maxMatches?: number; offset?: number; policy?: Record }) { const root = await resolveWorkspacePath(input.cwd, input.path ?? ".", { allowOutsideWorkspace: canUseOutsideWorkspace(input.policy) }); const maxMatches = positiveIntegerOrNull(input.maxMatches) ?? DEFAULT_FILE_SEARCH_MAX_MATCHES; const regex = globToRegex(input.pattern); const matches: string[] = []; + const offset = positiveIntegerOrNull(input.offset) ?? 0; + let seen = 0; for await (const filePath of walkPaths(root)) { const relativeToRoot = toPosix(path.relative(root, filePath)); const relativeToCwd = toDisplayPath(input.cwd, filePath); if (regex.test(relativeToRoot) || regex.test(relativeToCwd)) { + if (seen++ < offset) continue; matches.push(relativeToCwd); - if (maxMatches && matches.length >= maxMatches) { + if (matches.length > maxMatches) { break; } } } - return { matches, truncated: Boolean(maxMatches && matches.length >= maxMatches) }; + const truncated = matches.length > maxMatches; + return { matches: matches.slice(0, maxMatches), offset, nextOffset: truncated ? offset + maxMatches : null, truncated }; } /** * @param {{ cwd: string; pattern: string; path?: string; maxMatches?: number; policy?: Record }} input */ -export async function grepTool(input: { cwd: string; pattern: string; path?: string; maxMatches?: number; policy?: Record }) { +export async function grepTool(input: { cwd: string; pattern: string; path?: string; maxMatches?: number; offset?: number; policy?: Record }) { const root = await resolveWorkspacePath(input.cwd, input.path ?? ".", { allowOutsideWorkspace: canUseOutsideWorkspace(input.policy) }); const maxMatches = positiveIntegerOrNull(input.maxMatches) ?? DEFAULT_FILE_SEARCH_MAX_MATCHES; const matches: Array<{ path: string; line: number; text: string }> = []; + const offset = positiveIntegerOrNull(input.offset) ?? 0; + let seen = 0; for await (const filePath of walkTextFiles(root)) { - if (maxMatches && matches.length >= maxMatches) { + if (matches.length > maxMatches) { break; } let lineNumber = 0; @@ -119,12 +182,13 @@ export async function grepTool(input: { cwd: string; pattern: string; path?: str if (!line.includes(input.pattern)) { continue; } + if (seen++ < offset) continue; matches.push({ path: toDisplayPath(input.cwd, filePath), line: lineNumber, text: line }); - if (maxMatches && matches.length >= maxMatches) { + if (matches.length > maxMatches) { break; } } @@ -133,7 +197,8 @@ export async function grepTool(input: { cwd: string; pattern: string; path?: str } } - return { matches, truncated: Boolean(maxMatches && matches.length >= maxMatches) }; + const truncated = matches.length > maxMatches; + return { matches: matches.slice(0, maxMatches), offset, nextOffset: truncated ? offset + maxMatches : null, truncated }; } /** @@ -329,6 +394,7 @@ async function* walkPaths(root: string): AsyncGenerator { return; } const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []); + entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0); for (const entry of entries) { const fullPath = path.join(root, entry.name); if (entry.isDirectory()) { @@ -370,11 +436,18 @@ async function hasBinaryHeader(fileName: string) { } } -function readLines(fileName: string) { - return createInterface({ - input: createReadStream(fileName, { encoding: "utf8" }), +async function* readLines(fileName: string) { + const stream = createReadStream(fileName, { encoding: "utf8" }); + const lines = createInterface({ + input: stream, crlfDelay: Infinity }); + try { + yield* lines; + } finally { + lines.close(); + stream.destroy(); + } } /** diff --git a/src/tools/result-view.ts b/src/tools/result-view.ts index 5eed4d6..7e7de8d 100644 --- a/src/tools/result-view.ts +++ b/src/tools/result-view.ts @@ -1,9 +1,7 @@ import { capToolResultText, DEFAULT_TOOL_RESULT_MAX_BYTES, type SerializedToolResult, type ToolResultValue } from "./result.ts"; -const READ_FILE_VIEW_CHARS = 12_000; const SEARCH_VIEW_MATCHES = 40; const SEARCH_LINE_CHARS = 200; -const LIST_VIEW_ENTRIES = 40; const SHELL_HEAD_CHARS = 4_000; const SHELL_TAIL_CHARS = 4_000; const FETCH_EXCERPT_CHARS = 8_000; @@ -23,7 +21,7 @@ export function formatToolResultForModel( execution: ToolResultValue, options: { maxBytes?: number; evidence?: Array<{ id?: string; name?: string; bytes?: number }> } = {} ): SerializedToolResult { - const view = renderToolResultView(String(name ?? "unknown"), execution, options.evidence); + const view = renderToolResultView(String(name ?? "unknown"), execution, options.evidence, options.maxBytes); return capToolResultText(view.text, { maxBytes: options.maxBytes ?? DEFAULT_TOOL_RESULT_MAX_BYTES, truncated: view.truncated @@ -33,7 +31,8 @@ export function formatToolResultForModel( export function renderToolResultView( name: string, execution: ToolResultValue, - evidence: Array<{ id?: string; name?: string; bytes?: number }> = [] + evidence: Array<{ id?: string; name?: string; bytes?: number }> = [], + maxBytes: number = DEFAULT_TOOL_RESULT_MAX_BYTES ): ViewDraft { const result = asRecord(execution?.result); const lines = [ @@ -41,7 +40,8 @@ export function renderToolResultView( ...locatorLines(name, execution, result), ...evidenceLines(evidence) ]; - const body = bodyForTool(name, execution, result); + const bodyBudget = Math.max(0, maxBytes - Buffer.byteLength(lines.join("\n"), "utf8") - 512 - Buffer.byteLength(stringField(result.systemReminder), "utf8")); + const body = bodyForTool(name, execution, result, bodyBudget); if (body.text) { lines.push(body.text); } @@ -58,21 +58,21 @@ export function renderToolResultView( }; } -function bodyForTool(name: string, execution: ToolResultValue, result: Record): ViewDraft { +function bodyForTool(name: string, execution: ToolResultValue, result: Record, budget: number): ViewDraft { if (name === "read_file") { - return formatReadFile(result); + return formatReadFile(result, budget); } if (name === "grep" || name === "rg_search" || name === "rg_files_with_matches") { - return formatSearch(result); + return formatSearch(result, budget); } if (name === "rg_count") { return formatRgCount(result); } if (name === "glob" || name === "rg_files") { - return formatPathList(result, "matches", SEARCH_VIEW_MATCHES); + return formatPathList(result, "matches", budget); } if (name === "list_files") { - return formatListFiles(result); + return formatListFiles(result, budget); } if (name === "powershell" || name === "bash") { return formatShell(result); @@ -186,18 +186,45 @@ function evidenceLines(evidence: Array<{ id?: string; name?: string; bytes?: num )); } -function formatReadFile(result: Record): ViewDraft { +function formatReadFile(result: Record, budget: number): ViewDraft { const content = String(result.content ?? ""); const bytes = Number.isFinite(Number(result.bytesRead)) ? Number(result.bytesRead) : Buffer.byteLength(content, "utf8"); - const excerpt = headTail(numberLines(content), READ_FILE_VIEW_CHARS / 2, READ_FILE_VIEW_CHARS / 2); + const startLine = Math.max(1, Number(result.startLine) || 1); + const startColumn = Math.max(1, Number(result.startColumn) || 1); + const sourceLines = content ? content.split(/\r\n|\n|\r/) : []; + const shown: string[] = []; + let remaining = Math.max(0, budget - 180); + let nextStartLine: number | null = null; + let nextStartColumn: number | null = null; + for (const [index, line] of sourceLines.entries()) { + const prefix = `${startLine + index}: `; + const column = index === 0 ? startColumn : 1; + const lineBudget = Math.max(0, remaining - Buffer.byteLength(prefix) - 1); + const excerpt = new TextDecoder("utf-8").decode(Buffer.from(line).subarray(0, lineBudget), { stream: true }); + if (remaining < Buffer.byteLength(prefix) + 1 || excerpt.length < line.length) { + if (excerpt) shown.push(prefix + excerpt); + nextStartLine = startLine + index; + nextStartColumn = column + excerpt.length; + break; + } + shown.push(prefix + line); + remaining -= Buffer.byteLength(prefix + line) + 1; + } + if (nextStartLine === null && result.truncated === true) { + nextStartLine = Number(result.nextStartLine) || startLine + Math.max(0, sourceLines.length - 1); + nextStartColumn = Number(result.nextStartColumn) || (sourceLines.at(-1)?.length ?? 0) + (sourceLines.length <= 1 ? startColumn : 1); + } + const truncated = nextStartLine !== null; return { text: [ - `bytes=${bytes}${result.truncated === true || excerpt.truncated ? " truncated=true" : ""}`, - excerpt.text + `bytes=${bytes} startLine=${startLine} startColumn=${startColumn}${truncated ? " truncated=true" : ""}`, + truncated && shown.length ? `nextStartLine=${nextStartLine} nextStartColumn=${nextStartColumn}` : "", + truncated && !shown.length ? "No content fits the model output budget; increase maxToolResultBytes. Repeating this range will not advance." : "", + shown.join("\n") ].filter(Boolean).join("\n"), - truncated: excerpt.truncated || result.truncated === true + truncated }; } @@ -245,20 +272,14 @@ function formatTodoList(execution: ToolResultValue): ViewDraft { return { text: lines.join("\n"), truncated }; } -function formatSearch(result: Record): ViewDraft { - const matches = asArray(result.matches); - const shown = matches.slice(0, SEARCH_VIEW_MATCHES); - const truncated = result.truncated === true || matches.length > shown.length; - const lines = [ - `matches=${matches.length}${truncated ? " truncated=true" : ""}`, - ...shown.map((item) => formatSearchMatch(item)) - ]; - return { text: lines.join("\n"), truncated }; +function formatSearch(result: Record, budget: number): ViewDraft { + const matches = asArray(result.matches ?? result.files); + return formatPage(result, matches.map(formatSearchMatch), "matches", budget); } function formatSearchMatch(item: unknown): string { if (typeof item === "string") { - return `- ${truncateClean(item, SEARCH_LINE_CHARS)}`; + return `- ${item}`; } const record = asRecord(item); const pathValue = stringField(record.path) || stringField(record.file); @@ -267,34 +288,42 @@ function formatSearchMatch(item: unknown): string { return `- ${pathValue || "?"}${line != null ? `:${line}` : ""}${text ? ` ${truncateClean(text, SEARCH_LINE_CHARS)}` : ""}`; } -function formatPathList(result: Record, key: string, limit: number): ViewDraft { +function formatPathList(result: Record, key: string, budget: number): ViewDraft { const matches = asArray(result[key] ?? result.files ?? result.matches).map((item) => ( typeof item === "string" ? item : stringField(asRecord(item).path) || JSON.stringify(item) )); - const shown = matches.slice(0, limit); - const truncated = result.truncated === true || matches.length > shown.length; - return { - text: [ - `${key}=${matches.length}${truncated ? " truncated=true" : ""}`, - ...shown.map((item) => `- ${truncateClean(item, SEARCH_LINE_CHARS)}`) - ].join("\n"), - truncated - }; + return formatPage(result, matches.map((item) => `- ${item}`), key, budget); } -function formatListFiles(result: Record): ViewDraft { +function formatListFiles(result: Record, budget: number): ViewDraft { const entries = asArray(result.entries); - const shown = entries.slice(0, LIST_VIEW_ENTRIES); - const truncated = result.truncated === true || entries.length > shown.length; - const total = Number.isFinite(Number(result.total)) ? Number(result.total) : entries.length; + return formatPage(result, entries.map((item) => { + const record = asRecord(item); + return `- ${record.type ?? "file"} ${stringField(record.name) || "?"}`; + }), "entries", budget); +} + +function formatPage(result: Record, rows: string[], label: string, budget: number): ViewDraft { + const shown: string[] = []; + let used = 0; + for (const row of rows) { + const bytes = Buffer.byteLength(row) + 1; + if (used + bytes > Math.max(0, budget - 180)) break; + shown.push(row); + used += bytes; + } + const offset = Number(result.offset) || 0; + const truncated = shown.length < rows.length || result.truncated === true; + const nextOffset = shown.length === 0 && rows.length > 0 ? null : shown.length < rows.length ? offset + shown.length : result.nextOffset; return { text: [ - `entries=${shown.length}/${total}${truncated ? " truncated=true" : ""}`, - ...shown.map((item) => { - const record = asRecord(item); - return `- ${record.type ?? "file"} ${stringField(record.name) || "?"}`; - }) - ].join("\n"), + `${label}=${rows.length} shown=${shown.length} offset=${offset}${truncated ? " truncated=true" : ""}`, + result.total !== undefined ? `total=${result.total}` : "", + nextOffset != null ? `nextOffset=${nextOffset}` : "", + shown.length === 0 && rows.length > 0 ? "No row fits the output budget; increase maxToolResultBytes or narrow the scope. Repeating this offset will not advance." : "", + truncated && nextOffset == null ? "Source output incomplete; narrow the search scope." : "", + ...shown + ].filter(Boolean).join("\n"), truncated }; } @@ -481,13 +510,6 @@ function isImageLike(value: Record): boolean { return data.length > 200 && /^[A-Za-z0-9+/=\s]+$/.test(data) && Boolean(value.width || value.height || value.mimeType); } -function numberLines(content: string): string { - if (!content) { - return ""; - } - return content.split(/\r?\n/).map((line, index) => `${index + 1}: ${line}`).join("\n"); -} - function headTail(text: string, headChars: number, tailChars: number): ViewDraft { const value = String(text ?? ""); if (!value) { diff --git a/src/tools/rg-tools.ts b/src/tools/rg-tools.ts index 8169bdd..cdaa449 100644 --- a/src/tools/rg-tools.ts +++ b/src/tools/rg-tools.ts @@ -94,10 +94,13 @@ export async function rgSearchTool(input: RgToolInput) { if (!result.ok) { return result; } - const parsed = parseJsonSearchOutput(input.cwd, result.stdout, maxResults); + const offset = positiveInteger(input.offset, 0); + const parsed = parseJsonSearchOutput(input.cwd, result.stdout, maxResults, offset); return { command: result.command, matches: parsed.matches, + offset, + nextOffset: parsed.truncated ? offset + parsed.matches.length : null, stats: parsed.stats, warning: result.warning, partialFailure: result.partialFailure, @@ -115,7 +118,7 @@ export async function rgFilesTool(input: RgToolInput) { if (!result.ok) { return result; } - return lineListResult(input.cwd, result, maxResults, "files"); + return lineListResult(input.cwd, result, maxResults, "files", positiveInteger(input.offset, 0)); } export async function rgFilesWithMatchesTool(input: RgToolInput) { @@ -126,7 +129,7 @@ export async function rgFilesWithMatchesTool(input: RgToolInput) { if (!result.ok) { return result; } - return lineListResult(input.cwd, result, maxResults, "files"); + return lineListResult(input.cwd, result, maxResults, "files", positiveInteger(input.offset, 0)); } export async function rgCountTool(input: RgToolInput) { @@ -175,7 +178,7 @@ export async function rgCountTool(input: RgToolInput) { } function commonRgArgs(input: Record) { - const args: string[] = []; + const args: string[] = ["--sort", "path"]; if (input.ignoreCase === true) args.push("--ignore-case"); if (input.caseSensitive === true) args.push("--case-sensitive"); if (input.fixedStrings === true) args.push("--fixed-strings"); @@ -194,7 +197,7 @@ function commonRgArgs(input: Record) { } function fileRgArgs(input: Record) { - const args: string[] = []; + const args: string[] = ["--sort", "path"]; if (input.hidden === true) args.push("--hidden"); if (input.noIgnore === true) args.push("--no-ignore"); for (const glob of normalizeStringArray(input.glob)) { @@ -217,10 +220,11 @@ export function windowsReservedDeviceGlobArgs(platform: unknown = process.platfo return args; } -function parseJsonSearchOutput(cwd: string, stdout: string, maxResults: number) { +function parseJsonSearchOutput(cwd: string, stdout: string, maxResults: number, offset: number) { const matches: RgMatch[] = []; const stats: unknown[] = []; let truncated = false; + let seen = 0; for (const line of stdout.split(/\r?\n/)) { if (!line.trim()) continue; let event: Record; @@ -230,7 +234,8 @@ function parseJsonSearchOutput(cwd: string, stdout: string, maxResults: number) continue; } if (event.type === "match" || event.type === "context") { - if (event.type === "match" && matches.filter((item) => item.type === "match").length >= maxResults) { + if (seen++ < offset) continue; + if (matches.length >= maxResults) { truncated = true; continue; } @@ -260,15 +265,17 @@ function parseJsonSearchOutput(cwd: string, stdout: string, maxResults: number) return { matches, stats: stats[0] ?? {}, truncated }; } -function lineListResult(cwd: string, result: Extract, maxResults: number, key: string) { +function lineListResult(cwd: string, result: Extract, maxResults: number, key: string, offset: number) { const lines = result.stdout.split(/\r?\n/).filter(Boolean).map((item) => toDisplayPath(cwd, item)); - const values = lines.slice(0, maxResults); + const values = lines.slice(offset, offset + maxResults); return { command: result.command, [key]: values, + offset, + nextOffset: lines.length > offset + values.length ? offset + values.length : null, warning: result.warning, partialFailure: result.partialFailure, - truncated: result.truncated || lines.length > values.length, + truncated: result.truncated || lines.length > offset + values.length, timedOut: result.timedOut, durationMs: result.durationMs }; diff --git a/src/version.ts b/src/version.ts index 2a1adf7..d5ab6dd 100644 --- a/src/version.ts +++ b/src/version.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; -const FALLBACK_VERSION = "2.0.8"; +const FALLBACK_VERSION = "2.0.9"; /** * @param {NodeJS.ProcessEnv} [env] diff --git a/tests/browser/dashboard-browser.test.ts b/tests/browser/dashboard-browser.test.ts index b44feb3..7fde96a 100644 --- a/tests/browser/dashboard-browser.test.ts +++ b/tests/browser/dashboard-browser.test.ts @@ -1571,6 +1571,73 @@ test("archived gateway failures show the upstream reason after reopening", async } }); +test("model picker groups connections and switches the original provider on desktop and mobile", async () => { + const originalStatus = runtime.status; + const originalSwitch = runtime.switchModel; + const originalSettings = runtime.settingsConfig; + const originalSave = runtime.saveSettingsConfig; + const base = await originalStatus(); + let current = "local-a"; + const profiles = ["local-a", "local-b", "local-c"].map((id, index) => ({ + id, label: "127.0.0.1", connectionGroupId: index < 2 ? "local-a" : "local-c", + gatewayUrl: index < 2 ? "http://127.0.0.1:9000/v1" : "http://127.0.0.1:9001/v1", gatewayProtocol: "openai-chat", ready: true, + activeCredentialProfileId: index < 2 ? "local-a" : "local-c", + modelAlias: `model-${index}`, models: [{ ...browserModel(), id: `model-${index}`, label: `Model ${index}`, source: { id, profileId: id, label: "127.0.0.1" } }] + })); + const status = () => { + const profile = profiles.find((item) => item.id === current); + return { ...base, sessionStatus: { ...base.sessionStatus, model: profile.modelAlias, providerId: current, selectionResolved: true }, + gatewayConfig: { ...base.gatewayConfig, activeProfileId: current }, + gatewayProfiles: profiles.map((item) => ({ ...item, current: item.id === current })), models: profile.models }; + }; + runtime.status = async () => status(); + let credential = "local-a"; + runtime.settingsConfig = async () => ({ ...(await originalSettings.call(runtime)), gatewayProfiles: status().gatewayProfiles.map((p) => ({ ...p, activeCredentialProfileId: p.id === "local-c" ? "local-c" : credential })) }); + runtime.saveSettingsConfig = async (body) => { + assert.equal(body.section, "source-credential"); + assert.equal(body.settings.profileId, "local-b"); + assert.equal(body.saveTarget, "project"); + credential = "local-b"; + return { ok: true }; + }; + runtime.switchModel = async (body) => { + assert.equal(body.providerId, "local-b"); + assert.equal(body.modelId, "model-1"); + current = body.providerId; + return { ok: true, ...status() }; + }; + try { + for (const viewport of [{ width: 1280, height: 900 }, { width: 390, height: 844 }]) { + current = "local-a"; + credential = "local-a"; + await withDashboardPage(viewport, async (page) => { + await page.locator("#model-status-toggle").click(); + const panel = page.locator("#model-panel"); + const sources = panel.locator("select[data-action='switch-source'] option"); + assert.equal(await sources.count(), 2); + assert.equal(new Set(await sources.allTextContents()).size, 2); + const models = panel.locator("select[data-action='switch-model']"); + assert.deepEqual(await models.locator("option").allTextContents(), ["model-0 (Model 0)", "model-1 (Model 1)"]); + await models.selectOption("model-1"); + await waitUntil(() => current === "local-b"); + await page.waitForFunction(() => document.querySelector("#model-status")?.textContent?.includes("Model 1")); + await page.locator("#settings-button").click(); + await page.waitForFunction(() => document.querySelector("#settings-content")?.getAttribute("aria-busy") !== "true"); + const key = page.locator("input[data-action='select-source-credential'][data-profile-id='local-b']"); + await key.check(); + await waitUntil(() => credential === "local-b"); + await page.waitForFunction(() => document.querySelector("#settings-content")?.getAttribute("aria-busy") !== "true"); + assert.equal(await key.isChecked(), true); + }); + } + } finally { + runtime.status = originalStatus; + runtime.switchModel = originalSwitch; + runtime.settingsConfig = originalSettings; + runtime.saveSettingsConfig = originalSave; + } +}); + test("legacy sessions keep provider, model list, and reasoning effort atomic", async () => { const originalStatus = runtime.status; const originalSwitchModel = runtime.switchModel; @@ -1687,11 +1754,11 @@ test("legacy sessions keep provider, model list, and reasoning effort atomic", a await page.locator("#model-status-toggle").click(); const panel = page.locator("#model-panel"); const sourceSelect = panel.locator("select[data-action='switch-source']"); - assert.deepEqual(await sourceSelect.locator("option").allTextContents(), ["Grok", "DeepSeek"]); + assert.deepEqual(await sourceSelect.locator("option").allTextContents(), ["https://grok.test/v1/responses", "https://deepseek.test/v1/chat/completions"]); await sourceSelect.selectOption("deepseek"); await page.waitForFunction(() => /DeepSeek\s*·\s*DeepSeek V4 Pro/.test(document.querySelector("#model-status")?.textContent ?? "")); assert.equal(await page.locator("#reasoning-effort-select option[value='max']").count(), 1); - assert.deepEqual(await sourceSelect.locator("option").allTextContents(), ["Grok", "DeepSeek"]); + assert.deepEqual(await sourceSelect.locator("option").allTextContents(), ["https://grok.test/v1/responses", "https://deepseek.test/v1/chat/completions"]); await page.locator("#settings-button").click(); await page.waitForFunction(() => document.querySelector("#settings-content")?.getAttribute("aria-busy") !== "true"); @@ -1712,7 +1779,7 @@ test("legacy sessions keep provider, model list, and reasoning effort atomic", a assert.equal(await panel.locator("select[data-action='switch-model']").inputValue(), "glm-5.2"); assert.deepEqual( await panel.locator("select[data-action='switch-model'] option").allTextContents(), - ["DeepSeek V4 Pro", "GLM 5.2", "Shared model"] + ["deepseek-v4-pro (DeepSeek V4 Pro)", "glm-5.2 (GLM 5.2)", "shared-model (Shared model)"] ); runtime.reasoningEffortCalls.length = 0; @@ -1785,7 +1852,7 @@ test("legacy sessions keep provider, model list, and reasoning effort atomic", a const sourceSelect = panel.locator("select[data-action='switch-source']"); const modelSelect = panel.locator("select[data-action='switch-model']"); assert.equal(await sourceSelect.inputValue(), ""); - assert.deepEqual(await sourceSelect.locator("option").allTextContents(), ["请选择模型来源", "Grok", "DeepSeek"]); + assert.deepEqual(await sourceSelect.locator("option").allTextContents(), ["请选择模型来源", "https://grok.test/v1/responses", "https://deepseek.test/v1/chat/completions"]); assert.equal(await modelSelect.isDisabled(), true); assert.deepEqual(await modelSelect.locator("option").allTextContents(), ["请先选择模型来源"]); @@ -2017,6 +2084,121 @@ test("question review preserves the draft while allowing transcript-only inspect } }); +test("permission approval dialog stays on screen and does not leave a stuck wait bar", async () => { + runtime.activeSessionIds.add("session-b"); + try { + await withDashboardPage({ width: 1280, height: 900 }, async (page) => { + await page.locator(".thread-open", { hasText: "Session B" }).click(); + await page.waitForFunction(() => globalThis.__dashboardEventSources?.length > 0); + await page.setViewportSize({ width: 390, height: 844 }); + await page.waitForFunction(() => document.body.dataset.dashboardView === "conversation"); + await page.evaluate(() => { + const source = globalThis.__dashboardEventSources.at(-1); + source.emit("dashboard", { + sequence: 1, + id: "approval-1", + type: "approval_required", + approval: { + id: "approval-1", + toolName: "web_search", + reason: "子智能体需要搜索 GitHub 项目", + preview: ["q=ant-code"] + }, + activity: { + id: "activity-approval-1", + title: "等待权限确认", + detail: "web_search", + status: "waiting", + source: "permission" + } + }); + source.emit("dashboard", { + sequence: 2, + id: "approval-2", + type: "approval_required", + approval: { + id: "approval-2", + toolName: "web_fetch", + reason: "子智能体需要打开搜索结果", + preview: ["https://github.com/example"] + }, + activity: { + id: "activity-approval-2", + title: "等待权限确认", + detail: "web_fetch", + status: "waiting", + source: "permission" + } + }); + }); + + const panel = page.locator("#approval-panel"); + await panel.locator("button[data-action='allow-once']").waitFor(); + const first = await panel.evaluate((node) => { + const bounds = node.getBoundingClientRect(); + return { + parent: node.parentElement?.tagName, + hidden: node.classList.contains("hidden"), + modal: node.classList.contains("modal-interaction"), + title: node.querySelector("#approval-title")?.textContent ?? "", + left: bounds.left, + top: bounds.top, + right: bounds.right, + bottom: bounds.bottom + }; + }); + assert.equal(first.parent, "BODY"); + assert.equal(first.hidden, false); + assert.equal(first.modal, true); + assert.match(first.title, /web_search/); + assert.match(first.title, /还有 1 个排队/); + assert.ok(first.left >= 0 && first.top >= 0, `approval dialog clipped at origin: ${JSON.stringify(first)}`); + assert.ok(first.right <= 390 && first.bottom <= 844, `approval dialog outside mobile viewport: ${JSON.stringify(first)}`); + assert.equal(await page.locator("#live-title").textContent(), "等待权限确认 · 还有 1 个排队"); + + await page.evaluate(() => { + globalThis.__dashboardEventSources.at(-1).emit("dashboard", { + sequence: 3, + id: "approval-resolved-1", + type: "approval_resolved", + approvalId: "approval-1", + allowed: true + }); + }); + await page.waitForFunction(() => document.querySelector("#approval-title")?.textContent?.includes("web_fetch")); + assert.match(await panel.locator("#approval-title").textContent(), /web_fetch/); + assert.equal(await page.locator("#live-title").textContent(), "等待权限确认"); + + await page.evaluate(() => { + globalThis.__dashboardEventSources.at(-1).emit("dashboard", { + sequence: 4, + id: "approval-resolved-2", + type: "approval_resolved", + approvalId: "approval-2", + allowed: true + }); + }); + await panel.waitFor({ state: "hidden" }); + const after = await page.evaluate(() => { + const panel = document.querySelector("#approval-panel"); + return { + parentClass: panel?.parentElement?.className ?? "", + previousId: panel?.previousElementSibling?.id ?? "", + liveTitle: document.querySelector("#live-title")?.textContent ?? "", + waiting: document.querySelector("#live-status")?.classList.contains("has-pending-approval") + }; + }); + assert.match(after.parentClass, /composer-shell/); + assert.equal(after.previousId, "live-status"); + assert.equal(after.waiting, false); + assert.notEqual(after.liveTitle, "等待权限确认"); + await assertNoPageOverflow(page, "mobile approval dialog"); + }, { fakeEventSource: true }); + } finally { + runtime.activeSessionIds.delete("session-b"); + } +}); + test("dashboard has no serious or critical axe violations", async () => { await withDashboardPage({ width: 1280, height: 900 }, async (page) => { await page.evaluate(axeSource); diff --git a/tests/unit/context-evidence.test.ts b/tests/unit/context-evidence.test.ts new file mode 100644 index 0000000..cf25289 --- /dev/null +++ b/tests/unit/context-evidence.test.ts @@ -0,0 +1,136 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { readFileTool, grepTool, globTool, listFilesTool } from "../../src/tools/file-tools.ts"; +import { rgSearchTool, rgFilesTool, rgFilesWithMatchesTool } from "../../src/tools/rg-tools.ts"; +import { formatToolResultForModel } from "../../src/tools/result-view.ts"; +import { compactInFlightToolMessages } from "../../src/core/inflight-compaction.ts"; + +test("source pages preserve Unicode and continue from the model-visible boundary", async (t) => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "ant-evidence-")); + t.after(() => fs.rm(cwd, { recursive: true, force: true })); + const source = "\u4e2d\u6587\ud83d\ude80abc".repeat(2000); + await fs.writeFile(path.join(cwd, "source.txt"), source); + let startColumn = 1; + let recovered = ""; + for (let page = 0; page < 100; page++) { + const result = await readFileTool({ cwd, path: "source.txt", startLine: 1, startColumn, maxBytes: 4000 }); + const view = formatToolResultForModel("read_file", { ok: true, result }, { maxBytes: 2000 }); + assert.ok(view.bytes <= 2000); + assert.doesNotMatch(view.content, /\ufffd/); + const chunk = /^1: (.*)$/m.exec(view.content)?.[1] ?? ""; + assert.ok(chunk.length > 0); + recovered += chunk; + const next = /nextStartLine=(\d+) nextStartColumn=(\d+)/.exec(view.content); + if (!next) break; + assert.equal(Number(next[1]), 1); + assert.equal(Number(next[2]), startColumn + chunk.length); + startColumn = Number(next[2]); + } + assert.equal(recovered, source); +}); + +test("range reads preserve original line numbers, CRLF and exact end-of-file", async (t) => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "ant-evidence-")); + t.after(() => fs.rm(cwd, { recursive: true, force: true })); + await fs.writeFile(path.join(cwd, "source.txt"), "alpha\r\nbeta\r\ngamma\r\ndelta"); + const first = await readFileTool({ cwd, path: "source.txt", startLine: 2, maxLines: 2 }); + assert.equal(first.content, "beta\ngamma"); + assert.equal(first.nextStartLine, 4); + const view = formatToolResultForModel("read_file", { ok: true, result: first }); + assert.match(view.content, /2: beta\n3: gamma/); + const last = await readFileTool({ cwd, path: "source.txt", startLine: 4, maxLines: 1 }); + assert.equal(last.content, "delta"); + assert.equal(last.truncated, false); + const past = await readFileTool({ cwd, path: "source.txt", startLine: 8 }); + assert.equal(past.content, ""); + assert.equal(past.truncated, false); +}); + +test("byte-limited Unicode reads do not fabricate a replacement character or loop", async (t) => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "ant-evidence-")); + t.after(() => fs.rm(cwd, { recursive: true, force: true })); + await fs.writeFile(path.join(cwd, "source.txt"), "\u4e2d\u6587-tail"); + const first = await readFileTool({ cwd, path: "source.txt", maxBytes: 5 }); + assert.equal(first.content, "\u4e2d"); + const view = formatToolResultForModel("read_file", { ok: true, result: first }); + assert.match(view.content, /nextStartLine=1 nextStartColumn=2/); + const next = await readFileTool({ cwd, path: "source.txt", startLine: 1, startColumn: 2 }); + assert.equal(first.content + next.content, "\u4e2d\u6587-tail"); + await assert.rejects(readFileTool({ cwd, path: "source.txt", startLine: 1, maxBytes: 1 }), /too small/); +}); + +test("all search tools page beyond 40 results without omissions or duplicates", async (t) => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "ant-evidence-")); + t.after(() => fs.rm(cwd, { recursive: true, force: true })); + const names = Array.from({ length: 85 }, (_, i) => `file-${String(i).padStart(3, "0")}.txt`); + await Promise.all(names.map((name) => fs.writeFile(path.join(cwd, name), "needle\n"))); + const tools = [ + ["grep", (offset: number) => grepTool({ cwd, pattern: "needle", maxMatches: 60, offset }), "matches"], + ["glob", (offset: number) => globTool({ cwd, pattern: "*.txt", maxMatches: 60, offset }), "matches"], + ["list_files", (offset: number) => listFilesTool({ cwd, maxEntries: 60, offset }), "entries"], + ["rg_search", (offset: number) => rgSearchTool({ cwd, pattern: "needle", maxResults: 60, offset }), "matches"], + ["rg_files", (offset: number) => rgFilesTool({ cwd, maxResults: 60, offset }), "files"], + ["rg_files_with_matches", (offset: number) => rgFilesWithMatchesTool({ cwd, pattern: "needle", maxResults: 60, offset }), "files"] + ] as const; + for (const [name, run, key] of tools) { + const collected: string[] = []; + let offset = 0; + for (let page = 0; page < 20; page++) { + const result = await run(offset) as Record; + assert.notEqual(result.ok, false, `${name}: ${JSON.stringify(result.error)}`); + const view = formatToolResultForModel(name, { ok: true, result }, { maxBytes: 1200 }); + const shown = Number(/shown=(\d+)/.exec(view.content)?.[1]); + assert.ok(shown > 0, name); + collected.push(...result[key].slice(0, shown).map((row: any) => typeof row === "string" ? row : row.path ?? row.name)); + const next = /nextOffset=(\d+)/.exec(view.content); + if (!next) break; + assert.ok(Number(next[1]) > offset, name); + offset = Number(next[1]); + } + assert.deepEqual(collected, names, name); + } +}); + +test("search context rows page with consistent offsets", async (t) => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "ant-evidence-")); + t.after(() => fs.rm(cwd, { recursive: true, force: true })); + await fs.writeFile(path.join(cwd, "source.txt"), "before\nneedle\nafter\n"); + const first = await rgSearchTool({ cwd, pattern: "needle", beforeContext: 1, afterContext: 1, maxResults: 2 }); + assert.equal(first.nextOffset, 2); + const last = await rgSearchTool({ cwd, pattern: "needle", beforeContext: 1, afterContext: 1, maxResults: 2, offset: 2 }); + assert.deepEqual([...first.matches, ...last.matches].map((row) => row.text), ["before", "needle", "after"]); + assert.equal(last.nextOffset, null); +}); + +test("full-request pressure stops compaction as soon as one result makes room", () => { + const messages = ["read_file", "list_files", "bash", "read_file", "read_file", "read_file"].map((name) => ({ + role: "tool", name, content: [{ type: "text", text: `ok=true tool=${name}\n${"evidence ".repeat(400)}` }] + })); + const original = structuredClone(messages); + const result = compactInFlightToolMessages(messages, { + force: true, maxTokens: 1_000_000, + needsCompaction: () => !messages[1].content[0].text.startsWith("[compacted tool result]") + }); + assert.equal(result.compactedTools, 1); + assert.deepEqual(messages[0], original[0]); + assert.deepEqual(messages.slice(2), original.slice(2)); +}); + +test("tiny model budgets report no progress without returning a looping cursor", () => { + const view = formatToolResultForModel("glob", { ok: true, result: { offset: 0, matches: ["x".repeat(1000)] } }, { maxBytes: 800 }); + assert.match(view.content, /will not advance/); + assert.doesNotMatch(view.content, /nextOffset=/); +}); + +test("a short receipt cannot prevent reduction of a larger recent result", () => { + const messages = [ + { role: "tool", name: "write_file", content: [{ type: "text", text: "ok=true tool=write_file" }] }, + { role: "tool", name: "read_file", content: [{ type: "text", text: "source ".repeat(3000) }] } + ]; + const result = compactInFlightToolMessages(messages, { force: true, needsCompaction: () => messages[1].content[0].text.length > 2000 }); + assert.equal(result.compactedTools, 1); + assert.match(messages[1].content[0].text, /\[compacted tool result\]/); +}); diff --git a/tests/unit/dashboard-model-groups.test.ts b/tests/unit/dashboard-model-groups.test.ts new file mode 100644 index 0000000..c933cdb --- /dev/null +++ b/tests/unit/dashboard-model-groups.test.ts @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { publicGatewayProfiles } from "../../src/dashboard/runtime/public-config.ts"; +import { modelConnectionGroups, modelPickerLabel } from "../../src/dashboard/public/model-groups.ts"; +import { normalizeModelConfigInput } from "../../src/dashboard/runtime/settings.ts"; + +test("model remarks distinguish routes without showing internal IDs", () => { + const models = [ + { id: "deepseek", label: "科研分析", switchProfileId: "provider-private-a" }, + { id: "deepseek", label: "代码开发", switchProfileId: "provider-private-b" } + ]; + assert.equal(modelPickerLabel(models[0], models), "deepseek (科研分析)"); + models[1].label = "科研分析"; + assert.equal(modelPickerLabel(models[1], models), "deepseek (科研分析) (配置 2)"); + models.forEach((model) => { model.label = model.id; }); + assert.equal(modelPickerLabel(models[0], models), "deepseek (配置 1)"); +}); + +test("same-source duplicate models require a remark but edits exclude their own profile", () => { + const gatewayUrl = "http://127.0.0.1:9000/v1/chat/completions"; + const config = { lab: { gatewayProfiles: [{ + id: "existing", gatewayUrl, gatewayProtocol: "openai-chat", modelAlias: "deepseek", + models: [{ id: "deepseek", label: "deepseek" }] + }] } }; + const input = { gatewayUrl, modelId: "deepseek", gatewayProtocol: "openai-chat", profileId: "new-profile" }; + assert.equal(normalizeModelConfigInput(input, config).ok, false); + assert.equal(normalizeModelConfigInput({ ...input, label: "deepseek" }, config).ok, false); + const named = normalizeModelConfigInput({ ...input, label: "科研分析" }, config); + assert.equal(named.ok, true); + if (named.ok) assert.equal(named.model.label, "科研分析"); + assert.equal(normalizeModelConfigInput({ ...input, profileId: "existing" }, config).ok, true); + assert.equal(normalizeModelConfigInput({ ...input, profileId: "" }, config).ok, true); + assert.equal(normalizeModelConfigInput({ ...input, profileId: "" }, { ...config, configV2: { enabled: true } }).ok, false); + assert.equal(normalizeModelConfigInput({ ...input, gatewayUrl: "http://127.0.0.1:9001/v1/chat/completions" }, config).ok, true); +}); + +test("model picker groups URLs regardless of key or protocol without exposing credentials", () => { + const profile = (id: string, key: string, protocol = "openai-chat") => ({ + id, label: "127.0.0.1", gatewayUrl: "http://127.0.0.1:9000/v1", gatewayProtocol: protocol, + gatewayApiKey: key, modelAlias: id, models: { [id]: { upstreamModel: id } } + }); + const profiles = publicGatewayProfiles({ lab: { gatewayProfiles: [ + profile("a", "fake-key-one"), profile("b", "fake-key-one"), + profile("c", "fake-key-two"), profile("d", "fake-key-one", "openai-responses") + ] } } as any); + assert.equal(profiles[0].connectionGroupId, profiles[1].connectionGroupId); + assert.equal(profiles[0].connectionGroupId, profiles[2].connectionGroupId); + assert.equal(profiles[0].connectionGroupId, profiles[3].connectionGroupId); + assert.doesNotMatch(JSON.stringify(profiles), /fake-key/); + const groups = modelConnectionGroups(profiles); + assert.equal(groups.length, 1); + assert.equal(groups[0].label, "http://127.0.0.1:9000/v1"); + assert.deepEqual(groups[0].profiles.map((item) => item.id), ["a", "b", "c", "d"]); +}); + +test("older servers do not group profiles solely by a matching public URL", () => { + assert.equal(modelConnectionGroups([ + { id: "a", label: "local", gatewayUrl: "http://127.0.0.1" }, + { id: "b", label: "local", gatewayUrl: "http://127.0.0.1" } + ]).length, 2); +}); diff --git a/tests/unit/dashboard-ui.test.ts b/tests/unit/dashboard-ui.test.ts index 37da46a..8739031 100644 --- a/tests/unit/dashboard-ui.test.ts +++ b/tests/unit/dashboard-ui.test.ts @@ -25,6 +25,26 @@ if (typeof globalThis.document === "undefined") { }; } +test("configured idle gateway shows connected without masking event failures", async () => { + const app = await loadAppExports(["state", "els", "setConnectionState"]); + const label = { textContent: "" }; + const node = { dataset: {}, title: "", querySelector: () => label, setAttribute() {} }; + app.els.connectionStatus = node; + app.state.gatewayConfig = { apiKeyConfigured: true }; + app.setConnectionState("idle"); + assert.equal(label.textContent, "本地网关已连接"); + assert.equal(app.state.connectionState, "idle"); + assert.equal(node.dataset.state, "connected"); + app.setConnectionState("error"); + assert.equal(label.textContent, "本地网关异常"); + app.setConnectionState("offline"); + assert.equal(label.textContent, "本地网关离线"); + app.state.gatewayConfig = { apiKeyConfigured: false }; + app.state.gatewayProfiles = []; + app.setConnectionState("idle"); + assert.equal(label.textContent, "本地网关未连接"); +}); + async function publicTsSource() { const dir = path.resolve("src/dashboard/public"); const names = (await fs.readdir(dir)).filter((name) => name === "app-core.ts" || /^app-ui\d+\.ts$/.test(name)); @@ -482,12 +502,19 @@ test("dashboard composer controls keep confirmations reviewable and critical sta assert.match(app, /