diff --git a/.cursorignore b/.cursorignore new file mode 100644 index 0000000..d9e47db --- /dev/null +++ b/.cursorignore @@ -0,0 +1,36 @@ +# Cursor privacy hardening — added 2026-09-21 +# Files listed here are excluded from Cursor AI features (context, codebase +# indexing, tab sync, agent). Keep secrets and local-only artifacts here. + +# --- Secrets / credentials --- +.env +.env.* +*.env +*.key +*.pem +*.p12 +*.pfx +*.keystore +id_rsa +id_ed25519 +known_hosts +.ssh/ +.aws/ +.azure/ +.netrc +*credentials* +*secret* +*token* + +# --- Local-only artifacts --- +.zcode/ +.cursor/ +.git/ +node_modules/ +vendor/local-agent/.venv/ +.venv/ +venv/ +__pycache__/ +*.sqlite +*.sqlite3 +*.db \ No newline at end of file diff --git a/docs/agent-protocol.md b/docs/agent-protocol.md index 5d72bdd..fbf6720 100644 --- a/docs/agent-protocol.md +++ b/docs/agent-protocol.md @@ -149,7 +149,7 @@ type ∈ { file_proposal_start, file_proposal_delta, file_proposal, document_pro | 字段 | 说明 | |---|---| | `type` | `"preparing"` | -| `stage` | `"streaming"`(assist / chat 两条路径);`"mcp"`(MCP 工具发现之前) | +| `stage` | `"streaming"`(assist / chat 两条路径);`"mcp"`(MCP 工具发现之前);`"compact"`(上下文超长触发运行时压缩,重试前) | 语义:首 token 前的等待提示。TS 类型声明里还允许 `'summarize'` / `'build_messages'`,但运行时当前不发。 @@ -602,7 +602,7 @@ client_context 非空 AND ( | 工具 | 参数 | |---|---| -| `auto_annotate` | `user_request: string(≥1)`、`paths: string[]?`、`all_files: bool?`、`scope_hint: string?`、`write_mode: string?`、`conf_threshold: float? (0..1)`、`iou_threshold: float? (0..1)`、`model_id: string?`、`include_classes: string[]?`、`exclude_classes: string[]?`、`use_vision_mapping: bool?` | +| `auto_annotate` | `user_request: string(≥1)`、`paths: string[]?`、`all_files: bool?`、`scope_hint: string?`、`write_mode: string?`、`conf_threshold: float? (0..1)`、`iou_threshold: float? (0..1)`、`model_id: string?`、`include_classes: string[]?`、`exclude_classes: string[]?`、`use_vision_mapping: bool?`、`unique_labels_per_box: bool?` | | `mutate_annotation` | `user_request: string(≥1)`、`paths: string[]?`、`annotation_ids: string[]?` | | `start_terminal_command` | `command: string`、`args: string[]?`、`timeout_ms: int? (1..600000)` | @@ -1213,6 +1213,10 @@ work_mode === "editor" → 工作区助手(task + 文件 | 视觉映射重试次数 | 1 | | 标签池预检模式 | `"auto"` | | 标签池预检最小额外标签数 | 2 | +| 逐框视觉映射候选标签上限 | 300(`LR_AGENT_VISION_CANDIDATE_LIMIT`) | +| 标签池 preflight 候选上限 | 500(`LR_AGENT_PREFLIGHT_CANDIDATE_LIMIT`) | +| mutation 规划标签名上限 | 300(`LR_AGENT_MUTATION_LABEL_NAME_LIMIT`) | +| 标签唯一性策略 | `"allow"`(`LR_AGENT_LABEL_UNIQUENESS=allow\|enforce`) | | 标注变更功能开关 | true | **代码里硬编码、未走配置的编排参数**(不要漏): diff --git a/src/main/agent/annotate/labelPool.ts b/src/main/agent/annotate/labelPool.ts index 0d19b4d..3062bfc 100644 --- a/src/main/agent/annotate/labelPool.ts +++ b/src/main/agent/annotate/labelPool.ts @@ -79,11 +79,13 @@ export async function preflightLabelPool( ): Promise<[string[], boolean]> { const validIds = new Set(params.candidates.map((c) => String(c.id ?? ''))); - // 只送前 80 个,且保留原值(不转字符串) - const payload = params.candidates.slice(0, 80).map((c) => ({ - id: c.id, - name: c.name, - })); + // 只送前 N 个(由设置控制,默认 500;过小会让尾部标签永远选不到),且保留原值(不转字符串) + const payload = params.candidates + .slice(0, params.settings.annotationPreflightCandidateLimit) + .map((c) => ({ + id: c.id, + name: c.name, + })); const userText = `用户请求:${params.userRequest}\n` + diff --git a/src/main/agent/annotate/mapLabels.ts b/src/main/agent/annotate/mapLabels.ts index 1230cb2..b21ef7d 100644 --- a/src/main/agent/annotate/mapLabels.ts +++ b/src/main/agent/annotate/mapLabels.ts @@ -17,7 +17,6 @@ import type { ChatMessage, LlmClient } from '../llm/client'; import { extractJsonObject, pythonJsonDumps } from './common'; import { heuristicMapBoxes } from './heuristic'; -import { labelsRequireVisionMapping } from './visionPolicy'; import { resolveEffectiveLabelCandidates, type LabelPoolResult, @@ -41,8 +40,8 @@ export const CROP_VISION_SYSTEM = `你是视觉标注助手。你会收到一张 /** 裁剪后送 LLM 的最长边。 */ const CROP_MAX_EDGE = 768; -/** 送 LLM 的候选标签上限。 */ -const VISION_CANDIDATE_LIMIT = 40; +/** 送 LLM 的候选标签上限的兜底值(实际取 settings.annotationVisionCandidateLimit)。 */ +const VISION_CANDIDATE_LIMIT_FALLBACK = 300; /** 单框视觉映射结果。 */ export interface BoxMapping { @@ -120,6 +119,7 @@ interface VisionMapBoxParams { scopeNote: string; retryNote: string; reservedLabelIds: string[] | null; + candidateLimit: number; signal?: AbortSignal; } @@ -141,7 +141,7 @@ async function visionMapBoxWithCrop( `用户请求:${params.userRequest}\n意图:${params.intentSummary}\n` + `box_index=${params.boxIndex} 检测类名:${params.box.class_name || params.box.detection_label || ''}\n` + `${params.scopeNote}${retryBlock}${reservedBlock}\n\nlabel_candidates:\n` + - `${pythonJsonDumps(params.candidates.slice(0, VISION_CANDIDATE_LIMIT))}`; + `${pythonJsonDumps(params.candidates.slice(0, params.candidateLimit))}`; const messages: ChatMessage[] = [ { role: 'system', content: CROP_VISION_SYSTEM }, @@ -191,6 +191,7 @@ async function visionMapRegionsConcurrent( intentSummary: string; scopeNote: string; concurrency: number; + candidateLimit: number; regionOverrides?: Map; signal?: AbortSignal; }, @@ -217,6 +218,7 @@ async function visionMapRegionsConcurrent( scopeNote: options.scopeNote, retryNote: extra?.retryNote ?? '', reservedLabelIds: extra?.reservedLabelIds ?? null, + candidateLimit: options.candidateLimit, signal: options.signal, }); }), @@ -287,6 +289,7 @@ interface ValidationRetryParams { intentSummary: string; scopeNote: string; concurrency: number; + candidateLimit: number; instanceLabels: boolean; maxRetries: number; validate: boolean; @@ -305,6 +308,7 @@ async function visionMapWithValidationRetry( intentSummary: params.intentSummary, scopeNote: params.scopeNote, concurrency: params.concurrency, + candidateLimit: params.candidateLimit, signal: params.signal, }); @@ -333,7 +337,11 @@ async function visionMapWithValidationRetry( const byIndex = mappingByIndex(mappings); for (const boxIndex of retryIndices) { - const used = usedLabelIds(mappings, { excludeBox: boxIndex }); + // 仅在「每框标签唯一」策略下才排除已被其它框占用的 label_id; + // 类别型/细粒度标签允许同类多实例,否则会把重复实例逼成空标签。 + const used = params.instanceLabels + ? usedLabelIds(mappings, { excludeBox: boxIndex }) + : new Set(); const currentLid = String(byIndex.get(boxIndex)?.label_id ?? ''); const boxCandidates = candidatesForRetryBox( params.candidates, @@ -344,7 +352,7 @@ async function visionMapWithValidationRetry( overrides.set(boxIndex, { candidates: boxCandidates, retryNote: formatIssuesForRetry(boxIssues), - reservedLabelIds: [...used].sort(), + reservedLabelIds: params.instanceLabels ? [...used].sort() : undefined, }); } @@ -360,6 +368,7 @@ async function visionMapWithValidationRetry( intentSummary: params.intentSummary, scopeNote: params.scopeNote, concurrency: 1, + candidateLimit: params.candidateLimit, regionOverrides: overrides, signal: params.signal, }); @@ -385,6 +394,8 @@ export interface MapUnifiedParams { ocrText: string; scope: AnnotationScope | Record | null; labelStrategy: string; + /** 标签唯一性策略;undefined 时取 settings.annotationLabelUniqueness */ + labelUniqueness?: 'allow' | 'enforce'; singleLabelId: string | null; imageAbsolutePath: string; imageBase64: string; @@ -496,7 +507,13 @@ export async function mapDetectionBoxesToLabelsUnified( }); const regions: CropRegion[] = []; - if (probed.ok && probed.width != null && probed.height != null) { + // 空裁剪集必须显式失败:否则会以 `ok: true` + 空 mappings 返回, + // 上游只能报出「mappings 为空」,把真正的原因(多为图片无法解码)盖掉。 + let cropFailure = ''; + + if (!probed.ok || probed.width == null || probed.height == null) { + cropFailure = `无法读取图片尺寸:${probed.error ?? '未知原因'}`; + } else { const usable: Array<{ box: NormalizedBox; rect: { left: number; top: number; right: number; bottom: number }; @@ -517,28 +534,54 @@ export async function mapDetectionBoxesToLabelsUnified( }); } - const cropped = await params.imageService.cropBatch({ - absolutePath: params.imageAbsolutePath || undefined, - base64: params.imageBase64 || undefined, - boxes: usable.map((u) => u.rect), - maxEdge: CROP_MAX_EDGE, - quality: settings.annotationLlmImageJpegQuality, - }); + if (usable.length === 0) { + cropFailure = '检测框宽高或坐标非法,无法生成裁剪区域'; + } else { + const cropped = await params.imageService.cropBatch({ + absolutePath: params.imageAbsolutePath || undefined, + base64: params.imageBase64 || undefined, + boxes: usable.map((u) => u.rect), + maxEdge: CROP_MAX_EDGE, + quality: settings.annotationLlmImageJpegQuality, + }); - if (cropped.ok && cropped.images) { - cropped.images.forEach((dataUrl, index) => { - const entry = usable[index]; - if (!entry || !dataUrl) return; - regions.push({ - box_index: entry.box.box_index, - data_url: dataUrl, - box: entry.box, + if (!cropped.ok) { + cropFailure = `裁剪图片失败:${cropped.error ?? '未知原因'}`; + } else { + (cropped.images ?? []).forEach((dataUrl, index) => { + const entry = usable[index]; + if (!entry || !dataUrl) return; + regions.push({ + box_index: entry.box.box_index, + data_url: dataUrl, + box: entry.box, + }); }); - }); + if (regions.length === 0) cropFailure = '裁剪结果为空图'; + } } } - const instanceLabels = labelsRequireVisionMapping(params.labelCandidates); + if (regions.length === 0) { + return { + ok: false, + error: 'crop_unavailable', + method: 'vision_crop', + hint: `${cropFailure || '未能为任何检测框生成裁剪区域'};无裁剪图则逐框视觉映射不可用`, + }; + } + + // 唯一性策略:显式参数优先,否则取系统设置。 + // 注意:不再用 labelsRequireVisionMapping 推断「实例标签」——它只表示 + // 「检测类名与项目标签无交集、必须靠视觉判断」,对细粒度类别标签同样成立, + // 若据此判定每框唯一,会把同类多实例(如本图 3 盒奥利奥)全部逼成空标签。 + const enforceUniqueLabels = + (params.labelUniqueness ?? settings.annotationLabelUniqueness) === + 'enforce'; + const instanceLabels = enforceUniqueLabels; + const candidateLimit = + settings.annotationVisionCandidateLimit ?? + VISION_CANDIDATE_LIMIT_FALLBACK; const { mappings, retryRounds } = await visionMapWithValidationRetry({ llm: params.llm, regions, @@ -548,6 +591,7 @@ export async function mapDetectionBoxesToLabelsUnified( intentSummary: params.intentSummary, scopeNote, concurrency, + candidateLimit, instanceLabels, maxRetries: settings.annotationVisionMapMaxRetries, validate: settings.annotationVisionMapValidate, diff --git a/src/main/agent/annotate/mapLabelsCrop.test.ts b/src/main/agent/annotate/mapLabelsCrop.test.ts new file mode 100644 index 0000000..726ed7d --- /dev/null +++ b/src/main/agent/annotate/mapLabelsCrop.test.ts @@ -0,0 +1,225 @@ +/** + * 逐框视觉映射的裁剪失败契约。 + * + * 回归背景:`cropImageBatch` 失败时,这里原先仍返回 `ok: true` + 空 `mappings`, + * 上游只能报出「mappings 为空」,把「图片解不出像素」这类真因盖掉 + * (现场:`.jpg` 后缀的 WebP 文件,检测正常出框、裁剪全失败)。 + */ + +import { describe, expect, it } from '@jest/globals'; +import { DEFAULT_AGENT_SETTINGS } from '../config'; +import type { LlmClient } from '../llm/client'; +import type { ImageService } from '../services/imageService'; +import { mapDetectionBoxesToLabelsUnified } from './mapLabels'; + +const LABELS = [ + { id: 'L1', name: '斯蒂芬库里' }, + { id: 'L2', name: '勒布朗詹姆斯' }, +]; + +function boxes(count: number): Array> { + return Array.from({ length: count }, (_item, i) => ({ + box_index: i, + x: 0.1, + y: 0.2, + width: 0.3, + height: 0.4, + class_name: 'person', + confidence: 0.9, + })); +} + +function imageServiceStub(crop: ImageService['cropBatch']): ImageService { + return { + canEncode: true, + probe: async () => ({ ok: true, width: 1200, height: 675, format: 'WEBP' }), + toJpegDataUrl: async () => ({ ok: false, error: 'not_used' }), + cropBatch: crop, + }; +} + +/** 只在真正发起视觉调用时才被调用的桩:裁剪失败路径不应碰它。 */ +function llmStub(onCall?: () => void): LlmClient { + return { + completeChat: async () => { + onCall?.(); + return { + content: '{"label_id":"L1","reason":"stub"}', + reasoning: '', + toolCalls: [], + }; + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any as LlmClient; +} + +/** 映射结果里本测试关心的字段。 */ +interface MapOutcome { + ok?: boolean; + error?: string; + method?: string; + hint?: string; + mappings?: Array<{ box_index: number; label_id: string }>; +} + +async function runMap(params: { + imageService: ImageService; + llm?: LlmClient | null; + boxList?: Array>; +}): Promise { + return (await mapDetectionBoxesToLabelsUnified({ + llm: params.llm ?? llmStub(), + userRequest: '标注库里', + intentSummary: '标注球员', + labelCandidates: LABELS, + boxes: params.boxList ?? boxes(2), + useVision: true, + ocrText: '', + scope: null, + labelStrategy: 'per_box', + singleLabelId: null, + imageAbsolutePath: 'C:\\imgs\\4.jpg', + imageBase64: '', + mimeType: 'image/jpeg', + settings: { + ...DEFAULT_AGENT_SETTINGS, + annotationLabelPoolPreflight: 'off', + annotationVisionMapValidate: false, + }, + imageService: params.imageService, + imageAvailable: true, + })) as MapOutcome; +} + +describe('mapDetectionBoxesToLabelsUnified:裁剪失败必须显式失败', () => { + it('裁剪整批失败时返回 crop_unavailable 而非空 mappings', async () => { + const result = await runMap({ + imageService: imageServiceStub(async () => ({ + ok: false, + error: '无法解码图片,无法裁剪。', + })), + }); + + expect(result.ok).toBe(false); + expect(result.error).toBe('crop_unavailable'); + expect(result.method).toBe('vision_crop'); + expect(result.mappings).toBeUndefined(); + expect(String(result.hint)).toContain('无法解码图片,无法裁剪。'); + }); + + it('裁剪成功但产出空串时同样返回 crop_unavailable', async () => { + const result = await runMap({ + imageService: imageServiceStub(async () => ({ + ok: true, + images: ['', ''], + })), + }); + + expect(result.ok).toBe(false); + expect(result.error).toBe('crop_unavailable'); + expect(String(result.hint)).toContain('裁剪结果为空图'); + }); + + it('尺寸探测失败时给出探测原因', async () => { + const broken: ImageService = { + ...imageServiceStub(async () => ({ ok: true, images: [] })), + probe: async () => ({ ok: false, error: '文件不存在:C:\\imgs\\4.jpg' }), + }; + const result = await runMap({ imageService: broken }); + + expect(result.ok).toBe(false); + expect(result.error).toBe('crop_unavailable'); + expect(String(result.hint)).toContain('文件不存在'); + }); + + it('检测框宽高非法时给出框原因(且不调用解码)', async () => { + let cropped = false; + const result = await runMap({ + imageService: imageServiceStub(async () => { + cropped = true; + return { ok: true, images: ['data:image/jpeg;base64,AAAA'] }; + }), + boxList: [ + { box_index: 0, x: 0.1, y: 0.1, width: 0, height: 0.2 }, + { box_index: 1, x: 0.1, y: 0.1, width: 0.2, height: 0 }, + ], + }); + + expect(result.ok).toBe(false); + expect(result.error).toBe('crop_unavailable'); + expect(String(result.hint)).toContain('检测框宽高或坐标非法'); + expect(cropped).toBe(false); + }); + + it('裁剪可用时照常返回逐框映射', async () => { + let llmCalls = 0; + const result = await runMap({ + imageService: imageServiceStub(async () => ({ + ok: true, + images: ['data:image/jpeg;base64,AAAA', 'data:image/jpeg;base64,BBBB'], + })), + llm: llmStub(() => { + llmCalls += 1; + }), + }); + + expect(result.ok).toBe(true); + expect(result.method).toBe('vision_crop'); + expect(result.mappings).toHaveLength(2); + expect(result.mappings?.[0]).toMatchObject({ + box_index: 0, + label_id: 'L1', + }); + expect(llmCalls).toBe(2); + }); +}); + +describe('mapDetectionBoxesToLabelsUnified:标签唯一性策略', () => { + const cropOk = imageServiceStub(async () => ({ + ok: true, + images: ['data:image/jpeg;base64,AAAA', 'data:image/jpeg;base64,BBBB'], + })); + + async function runWith(uniqueness: 'allow' | 'enforce') { + let calls = 0; + const result = (await mapDetectionBoxesToLabelsUnified({ + llm: llmStub(() => { + calls += 1; + }), + userRequest: '补标签', + intentSummary: '补标签', + labelCandidates: LABELS, + boxes: boxes(2), + useVision: true, + ocrText: '', + scope: null, + labelStrategy: 'map_each_box_to_label', + labelUniqueness: uniqueness, + singleLabelId: null, + imageAbsolutePath: 'C:\\imgs\\4.jpg', + imageBase64: '', + mimeType: 'image/jpeg', + settings: { + ...DEFAULT_AGENT_SETTINGS, + annotationLabelPoolPreflight: 'off', + annotationVisionMapValidate: true, + annotationVisionMapMaxRetries: 1, + }, + imageService: cropOk, + imageAvailable: true, + })) as MapOutcome; + return { result, calls }; + } + + it('allow:同类多实例共用标签,不因重复触发重试', async () => { + const { result, calls } = await runWith('allow'); + expect(result.ok).toBe(true); + expect(result.mappings?.map((m) => m.label_id)).toEqual(['L1', 'L1']); + expect(calls).toBe(2); + }); + + it('enforce:重复标签视为问题并触发重试', async () => { + const { calls } = await runWith('enforce'); + expect(calls).toBeGreaterThan(2); + }); +}); diff --git a/src/main/agent/annotate/mutation.ts b/src/main/agent/annotate/mutation.ts index efa160b..e7da059 100644 --- a/src/main/agent/annotate/mutation.ts +++ b/src/main/agent/annotate/mutation.ts @@ -64,7 +64,9 @@ text_classification、classification、span_ner、instruction、preference、con } 规则: -- patch_label:改已有标签(含多边形/分类空 labelId 补标)。必须给出 new_label_name(项目标签名之一) +- patch_label:改已有标签(含多边形/分类空 labelId 补标)。new_label_name 必须是项目标签名之一; + 若需要「按图像内容逐框自动判定标签」(用户说"给这些框补标签 / 标注这些框"但未指定具体标签名), + **省略 new_label_name**:系统会对目标框逐框视觉判定,判定不出的框保持留空。 - patch_geometry:改几何。 bbox 填 x/y/width/height(0–1,左上角);rotated_bbox 与 keypoint 骨架填 cx/cy/width/height(0–1,中心点),可带 angle(度); keypoint 改关键点填 keypoints 整表替换(长度与骨架模板一致,visibility 0=不可见 1=遮挡 2=可见); @@ -278,6 +280,8 @@ export interface PrepareMutationParams { labelNames: string[]; selectedAnnotationIds?: string[]; conversationTranscript?: string; + /** 送 LLM 的标签名上限(默认 300)。 */ + labelNameLimit?: number; signal?: AbortSignal; } @@ -301,7 +305,9 @@ export async function prepareMutationAnnotation( parent: c.parent, })), ); - const labels = params.labelNames.slice(0, 50).join(', ') || '(无)'; + const labels = + params.labelNames.slice(0, params.labelNameLimit ?? 300).join(', ') || + '(无)'; const selectedIds = (params.selectedAnnotationIds ?? []).join(', ') || '(无)'; const transcript = diff --git a/src/main/agent/annotate/schemas.ts b/src/main/agent/annotate/schemas.ts index 2c6b5b2..94a64c0 100644 --- a/src/main/agent/annotate/schemas.ts +++ b/src/main/agent/annotate/schemas.ts @@ -73,6 +73,7 @@ export const MapDetectionBoxesRequestSchema = AnnotationLlmBase.extend({ boxes: z.array(z.record(z.string(), z.unknown())).default([]), use_vision: z.boolean().default(false), label_strategy: z.string().default('map_each_box_to_label'), + label_uniqueness: z.enum(['allow', 'enforce']).nullish(), single_label_id: z.string().nullish(), annotation_scope: z.record(z.string(), z.unknown()).default({}), ocr_text: z.string().default(''), diff --git a/src/main/agent/config.ts b/src/main/agent/config.ts index 37d5c47..0e70c9d 100644 --- a/src/main/agent/config.ts +++ b/src/main/agent/config.ts @@ -52,6 +52,52 @@ export interface AgentSettings { annotationLabelPoolPreflight: 'off' | 'auto' | 'always'; /** 标签池预检最小额外标签数。 */ annotationLabelPoolPreflightMinExtra: number; + /** 逐框视觉映射送的候选标签上限(细粒度大标签集需调高,否则尾部标签永远选不到)。 */ + annotationVisionCandidateLimit: number; + /** 标签池整图 preflight 送的候选标签上限。 */ + annotationPreflightCandidateLimit: number; + /** mutation 规划送 LLM 的标签名上限。 */ + annotationMutationLabelNameLimit: number; + /** + * 标签唯一性策略。 + * - allow:允许同一标签分配给多个框(类别型/细粒度标签,默认) + * - enforce:每框标签唯一(球员名等实例型标签) + */ + annotationLabelUniqueness: 'allow' | 'enforce'; + /** + * 主循环每轮显式下发的 `max_tokens`(0 表示不下发,沿用 provider 默认)。 + * + * 显式下发是「输出截断恢复」的前提:只有知道上限,`finish_reason=length` + * 时才谈得上「升档重试」。 + */ + chatMaxOutputTokens: number; + /** 首轮静默升档后的 `max_tokens`(仅当本轮尚无任何输出时使用)。 */ + chatMaxOutputTokensBumped: number; + /** 输出截断后的续写次数上限(超过即正常收尾)。 */ + outputRecoveryMaxAttempts: number; + /** 是否启用「上下文超长(HTTP 4xx)→ 运行时压缩后重试」。 */ + reactiveCompactEnabled: boolean; + /** 运行时压缩时保留的末尾轮数(每轮按 2 条消息计)。 */ + reactiveCompactKeepTurns: number; + /** + * 可重取工具结果的字符上限;超过则折叠为预览 + 重取提示(0 表示关闭)。 + * + * 只读类工具(read/grep/glob/list)结果通常可以再次获取,故可安全截断。 + */ + toolResultPreviewBytes: number; + /** 折叠大结果时保留的预览字符数。 */ + toolResultPreviewChars: number; + /** + * 循环内上下文预算(tokens);0 表示关闭循环内 microcompact。 + * + * 默认关闭以保持既有行为;开启后,单轮工具结果累积超预算时会清理旧的 + * **可重取**工具结果内容(本地操作、零 API 开销)。 + */ + contextTokenBudget: number; + /** 触发 microcompact 的预算占比。 */ + contextCompactRatio: number; + /** microcompact 保留的最近工具结果条数(更旧的才可清理)。 */ + microCompactKeepRecentToolResults: number; } export const DEFAULT_AGENT_SETTINGS: AgentSettings = { @@ -76,6 +122,22 @@ export const DEFAULT_AGENT_SETTINGS: AgentSettings = { annotationVisionMapMaxRetries: 1, annotationLabelPoolPreflight: 'auto', annotationLabelPoolPreflightMinExtra: 2, + annotationVisionCandidateLimit: 300, + annotationPreflightCandidateLimit: 500, + annotationMutationLabelNameLimit: 300, + annotationLabelUniqueness: 'allow', + // 0 = 不下发 max_tokens,保持与既有行为完全一致(provider 默认)。 + // 显式设置后才启用「静默升档」;截断续写恢复不依赖它。 + chatMaxOutputTokens: 0, + chatMaxOutputTokensBumped: 65536, + outputRecoveryMaxAttempts: 3, + reactiveCompactEnabled: true, + reactiveCompactKeepTurns: 4, + toolResultPreviewBytes: 50_000, + toolResultPreviewChars: 2_000, + contextTokenBudget: 0, + contextCompactRatio: 0.85, + microCompactKeepRecentToolResults: 5, }; /** 运行时可覆盖的配置(环境变量 → 覆盖默认值),便于对拍与调试。 */ @@ -86,6 +148,16 @@ export function loadAgentSettings(): AgentSettings { const parsed = Number(raw); return Number.isFinite(parsed) ? parsed : fallback; }; + const bool = (key: string, fallback: boolean): boolean => { + const raw = process.env[key]; + if (raw == null || raw === '') return fallback; + return /^(1|true|yes|on)$/i.test(raw); + }; + const labelUniqueness: AgentSettings['annotationLabelUniqueness'] = + (process.env.LR_AGENT_LABEL_UNIQUENESS ?? '').trim().toLowerCase() === + 'enforce' + ? 'enforce' + : DEFAULT_AGENT_SETTINGS.annotationLabelUniqueness; return { ...DEFAULT_AGENT_SETTINGS, maxToolRounds: num( @@ -112,5 +184,58 @@ export function loadAgentSettings(): AgentSettings { 'LR_AGENT_LIST_DIR_MAX_ENTRIES', DEFAULT_AGENT_SETTINGS.listDirMaxEntries, ), + chatMaxOutputTokens: num( + 'LR_AGENT_CHAT_MAX_OUTPUT_TOKENS', + DEFAULT_AGENT_SETTINGS.chatMaxOutputTokens, + ), + chatMaxOutputTokensBumped: num( + 'LR_AGENT_CHAT_MAX_OUTPUT_TOKENS_BUMPED', + DEFAULT_AGENT_SETTINGS.chatMaxOutputTokensBumped, + ), + outputRecoveryMaxAttempts: num( + 'LR_AGENT_OUTPUT_RECOVERY_MAX_ATTEMPTS', + DEFAULT_AGENT_SETTINGS.outputRecoveryMaxAttempts, + ), + reactiveCompactEnabled: bool( + 'LR_AGENT_REACTIVE_COMPACT', + DEFAULT_AGENT_SETTINGS.reactiveCompactEnabled, + ), + reactiveCompactKeepTurns: num( + 'LR_AGENT_REACTIVE_COMPACT_KEEP_TURNS', + DEFAULT_AGENT_SETTINGS.reactiveCompactKeepTurns, + ), + toolResultPreviewBytes: num( + 'LR_AGENT_TOOL_RESULT_PREVIEW_BYTES', + DEFAULT_AGENT_SETTINGS.toolResultPreviewBytes, + ), + toolResultPreviewChars: num( + 'LR_AGENT_TOOL_RESULT_PREVIEW_CHARS', + DEFAULT_AGENT_SETTINGS.toolResultPreviewChars, + ), + contextTokenBudget: num( + 'LR_AGENT_CONTEXT_TOKEN_BUDGET', + DEFAULT_AGENT_SETTINGS.contextTokenBudget, + ), + contextCompactRatio: num( + 'LR_AGENT_CONTEXT_COMPACT_RATIO', + DEFAULT_AGENT_SETTINGS.contextCompactRatio, + ), + microCompactKeepRecentToolResults: num( + 'LR_AGENT_MICRO_COMPACT_KEEP_RECENT', + DEFAULT_AGENT_SETTINGS.microCompactKeepRecentToolResults, + ), + annotationVisionCandidateLimit: num( + 'LR_AGENT_VISION_CANDIDATE_LIMIT', + DEFAULT_AGENT_SETTINGS.annotationVisionCandidateLimit, + ), + annotationPreflightCandidateLimit: num( + 'LR_AGENT_PREFLIGHT_CANDIDATE_LIMIT', + DEFAULT_AGENT_SETTINGS.annotationPreflightCandidateLimit, + ), + annotationMutationLabelNameLimit: num( + 'LR_AGENT_MUTATION_LABEL_NAME_LIMIT', + DEFAULT_AGENT_SETTINGS.annotationMutationLabelNameLimit, + ), + annotationLabelUniqueness: labelUniqueness, }; } diff --git a/src/main/agent/context/microCompact.test.ts b/src/main/agent/context/microCompact.test.ts new file mode 100644 index 0000000..5b1a191 --- /dev/null +++ b/src/main/agent/context/microCompact.test.ts @@ -0,0 +1,68 @@ +/** + * @jest-environment node + */ +import { describe, expect, it } from '@jest/globals'; +import { + microCompactMessages, + MICRO_COMPACT_PLACEHOLDER, +} from './microCompact'; +import type { ChatMessage } from '../llm/client'; + +function history(): ChatMessage[] { + return [ + { role: 'user', content: '读文件' }, + { + role: 'assistant', + content: '', + toolCalls: [ + { id: 'c1', name: 'read_workspace_file', args: { relative_path: 'a' } }, + ], + }, + { role: 'tool', content: 'A'.repeat(1000), toolCallId: 'c1' }, + { + role: 'assistant', + content: '', + toolCalls: [ + { id: 'c2', name: 'auto_annotate', args: { user_request: 'x' } }, + ], + }, + { role: 'tool', content: 'B'.repeat(1000), toolCallId: 'c2' }, + ]; +} + +describe('microCompactMessages', () => { + it('只清理较旧的可重取结果,保留最近 N 条', () => { + const messages = history(); + const replaced = microCompactMessages(messages, { keepRecent: 1 }); + // toolIndexes=[2,4],keepRecent=1 → 只清理索引 2(读文件) + expect(replaced).toBe(1); + expect(messages[2].content).toBe(MICRO_COMPACT_PLACEHOLDER); + // 最近的 auto_annotate 结果与调用配对保持不动 + expect(messages[4].content).toBe('B'.repeat(1000)); + expect(messages[1].toolCalls?.[0].id).toBe('c1'); + }); + + it('不可重取的工具结果不被清理', () => { + const messages: ChatMessage[] = [ + { + role: 'assistant', + content: '', + toolCalls: [{ id: 'c1', name: 'auto_annotate', args: {} }], + }, + { role: 'tool', content: 'C'.repeat(1000), toolCallId: 'c1' }, + ]; + expect(microCompactMessages(messages, { keepRecent: 0 })).toBe(0); + expect(messages[1].content).toBe('C'.repeat(1000)); + }); + + it('幂等:已是占位符则不重复替换计数', () => { + const messages = history(); + microCompactMessages(messages, { keepRecent: 1 }); + expect(microCompactMessages(messages, { keepRecent: 1 })).toBe(0); + }); + + it('keepRecent 大于结果数时不清理', () => { + const messages = history(); + expect(microCompactMessages(messages, { keepRecent: 5 })).toBe(0); + }); +}); diff --git a/src/main/agent/context/microCompact.ts b/src/main/agent/context/microCompact.ts new file mode 100644 index 0000000..6c127ae --- /dev/null +++ b/src/main/agent/context/microCompact.ts @@ -0,0 +1,64 @@ +/** + * 循环内 microcompact:本地、零 API 的上下文瘦身。 + * + * 当单轮工具结果累积逼近预算时,把**较旧的、可重取的**工具结果内容替换为占位符, + * 只保留最近 N 条。完整的 `tool_call / tool_result` 配对**始终保留**(消息还在, + * 只是内容变短),因此不会破坏 API 协议。 + * + * 绝不清理: + * - 不可重取的结果(子代理结论、客户端工具结果等); + * - 最近 N 条工具结果(通常是当前任务正在用的)。 + * + * 需要回原始内容时,模型可重新调用对应只读工具(见 `RE_RETRIEVABLE_TOOLS`)。 + */ + +import type { ChatMessage } from '../llm/client'; +import { RE_RETRIEVABLE_TOOLS } from '../tools/resultOffload'; + +/** 被清理的工具结果占位文本。 */ +export const MICRO_COMPACT_PLACEHOLDER = + '[较早的工具结果已清理以节省上下文。如仍需该内容,请重新调用对应工具获取。]'; + +/** + * 清理旧的可重取工具结果,返回被清理的条数(原地修改)。 + * + * @param keepRecent 保留最近多少条工具结果不清理 + */ +export function microCompactMessages( + messages: ChatMessage[], + options: { keepRecent: number }, +): number { + // tool_call_id → 工具名(用于判断是否可重取) + const nameByCallId = new Map(); + for (const message of messages) { + if (message.role !== 'assistant') continue; + for (const call of message.toolCalls ?? []) { + if (call.id) nameByCallId.set(call.id, call.name); + } + } + + const toolIndexes: number[] = []; + messages.forEach((message, index) => { + if (message.role === 'tool') toolIndexes.push(index); + }); + + const keep = Math.max(0, Math.floor(options.keepRecent)); + const cutoff = toolIndexes.length - keep; + let replaced = 0; + + for (let k = 0; k < cutoff; k += 1) { + const index = toolIndexes[k]; + const message = messages[index]; + const name = message.toolCallId + ? nameByCallId.get(message.toolCallId) + : undefined; + if (!name || !RE_RETRIEVABLE_TOOLS.has(name)) continue; + if (message.content === MICRO_COMPACT_PLACEHOLDER) continue; + // 只清理字符串内容;多模态等保持原样 + if (typeof message.content !== 'string') continue; + messages[index] = { ...message, content: MICRO_COMPACT_PLACEHOLDER }; + replaced += 1; + } + + return replaced; +} diff --git a/src/main/agent/context/orphanRepair.test.ts b/src/main/agent/context/orphanRepair.test.ts new file mode 100644 index 0000000..7655ee4 --- /dev/null +++ b/src/main/agent/context/orphanRepair.test.ts @@ -0,0 +1,69 @@ +/** + * @jest-environment node + */ +import { describe, expect, it } from '@jest/globals'; +import { repairOrphanToolCalls } from './orphanRepair'; +import type { ChatMessage } from '../llm/client'; + +function assistantWithCall( + id: string, + name = 'read_workspace_file', +): ChatMessage { + return { + role: 'assistant', + content: '', + toolCalls: [{ id, name, args: { relative_path: 'a.md' } }], + }; +} + +describe('repairOrphanToolCalls', () => { + it('已配对的调用不补', () => { + const messages: ChatMessage[] = [ + { role: 'user', content: 'hi' }, + assistantWithCall('c1'), + { role: 'tool', content: '结果', toolCallId: 'c1' }, + ]; + const before = messages.length; + repairOrphanToolCalls(messages); + expect(messages.length).toBe(before); + }); + + it('孤立调用补一条合成 tool_result', () => { + const messages: ChatMessage[] = [ + { role: 'user', content: 'hi' }, + assistantWithCall('c1'), + assistantWithCall('c2'), + { role: 'tool', content: '结果', toolCallId: 'c1' }, + ]; + repairOrphanToolCalls(messages); + const repaired = messages.at(-1); + expect(repaired?.role).toBe('tool'); + expect(repaired?.toolCallId).toBe('c2'); + expect(String(repaired?.content)).toContain('未执行'); + }); + + it('孤立调用位于历史中段时,合成结果紧跟其 assistant(而非追加到尾部)', () => { + const messages: ChatMessage[] = [ + { role: 'user', content: 'u0' }, + assistantWithCall('c1'), + { role: 'user', content: 'u1' }, + ]; + repairOrphanToolCalls(messages); + expect(messages).toHaveLength(4); + expect(messages[1].role).toBe('assistant'); + expect(messages[2].role).toBe('tool'); + expect(messages[2].toolCallId).toBe('c1'); + expect(messages[3].role).toBe('user'); + }); + + it('幂等:重复调用不重复补', () => { + const messages: ChatMessage[] = [ + { role: 'user', content: 'hi' }, + assistantWithCall('c1'), + ]; + repairOrphanToolCalls(messages); + const afterFirst = messages.length; + repairOrphanToolCalls(messages); + expect(messages.length).toBe(afterFirst); + }); +}); diff --git a/src/main/agent/context/orphanRepair.ts b/src/main/agent/context/orphanRepair.ts new file mode 100644 index 0000000..90ed9c4 --- /dev/null +++ b/src/main/agent/context/orphanRepair.ts @@ -0,0 +1,85 @@ +/** + * 孤立 `tool_use` 修复。 + * + * OpenAI / Anthropic 协议都有同一条死规定:**每个 assistant 发起的 `tool_call` + * 必须有一条对应的 `tool` 消息**。若历史里存在「已声明但无人应答」的调用, + * 下一次请求会被 API 直接拒收(`tool_call_id` 不匹配)。 + * + * 何时会出现孤立调用: + * 1. 模型流完 `tool_call` 后、执行前用户取消 / 网络中断; + * 2. `tool_pending` 之后客户端 resume,但结果列表里漏了某个调用; + * 3. 主循环在工具执行途中被 abort。 + * + * 处理方式对齐 Claude Code 的 `yieldMissingToolResultBlocks`:**合成一条明确标注 + * 为错误的 `tool_result`**,而不是删除调用。这样既不破坏配对,又能让模型知道 + * 「上个工具没跑」,从而自行决定重试或继续。 + * + * 幂等:对已修复的数组再次调用不会重复补。 + */ + +import type { ChatMessage } from '../llm/client'; +import { buildToolResult, formatToolResultForDisplay } from '../tools/result'; + +/** 合成结果的状态与文案。 */ +export const ORPHAN_TOOL_STATUS = 'not_executed'; +export const ORPHAN_TOOL_SUMMARY = + '该工具未执行(会话中断或结果缺失)。如需继续,请重新发起调用。'; + +/** 构造一条合成的错误 tool_result 消息。 */ +function syntheticToolMessage(id: string, name: string): ChatMessage { + const display = formatToolResultForDisplay( + buildToolResult({ + ok: false, + tool: name, + status: ORPHAN_TOOL_STATUS, + summary: ORPHAN_TOOL_SUMMARY, + }), + ); + return { role: 'tool', content: display, toolCallId: id }; +} + +/** + * 统计并补齐「已声明但未应答」的 tool_call。原地修改并返回同一数组。 + * + * 关键:合成消息必须**紧跟声明它的 assistant 消息**(在其已有的 tool 结果之后), + * 而不是追加到数组尾部——否则当孤立调用位于历史中段(后面还有 user 消息)时, + * `tool` 与其 `tool_calls` 之间会隔着 user 消息,API 仍会拒收。 + */ +export function repairOrphanToolCalls(messages: ChatMessage[]): ChatMessage[] { + const answered = new Set(); + for (const message of messages) { + if (message.role === 'tool' && message.toolCallId) { + answered.add(message.toolCallId); + } + } + + // 按「声明它的 assistant 下标」归组孤立调用 + const orphansByAssistant = new Map< + number, + Array<{ id: string; name: string }> + >(); + messages.forEach((message, index) => { + if (message.role !== 'assistant') return; + for (const call of message.toolCalls ?? []) { + if (!call.id || answered.has(call.id)) continue; + const list = orphansByAssistant.get(index) ?? []; + list.push({ id: call.id, name: call.name }); + orphansByAssistant.set(index, list); + } + }); + if (orphansByAssistant.size === 0) return messages; + + // 从后往前插入,避免前面的 splice 影响后面的下标 + const assistantIndexes = [...orphansByAssistant.keys()].sort((a, b) => b - a); + for (const assistantIndex of assistantIndexes) { + // 插到该 assistant 已直接跟随的 tool 结果之后 + let pos = assistantIndex + 1; + while (pos < messages.length && messages[pos].role === 'tool') pos += 1; + const synthetic = (orphansByAssistant.get(assistantIndex) ?? []).map( + (orphan) => syntheticToolMessage(orphan.id, orphan.name), + ); + messages.splice(pos, 0, ...synthetic); + } + + return messages; +} diff --git a/src/main/agent/context/snapshot.ts b/src/main/agent/context/snapshot.ts index 1c3926b..1a8c393 100644 --- a/src/main/agent/context/snapshot.ts +++ b/src/main/agent/context/snapshot.ts @@ -56,6 +56,7 @@ export const ANNOTATION_CALL_GUIDE = `【标注调用纪律】(auto_annotate) - model_id 仅在用户点名检测模型时填(可用模型见 describe_annotation_project)。 - include_classes / exclude_classes 仅在用户说“只标 X / 不要 Y”时填,类名以检测模型输出为准。 - use_vision_mapping 留空由系统按标签情况决定;标签为实例/细粒度(球员名等)时填 true。 +- unique_labels_per_box 仅在用户明确要求「每个框标签唯一/不允许重复标签」(如球员名一人一标签)时填 true;默认留空,同类多实例(如同一商品多瓶)允许共用同一标签。 - 所有参数以用户本轮原话为依据,禁止凭猜测补值。`; export const ANNOTATION_SCOPE_HINT = diff --git a/src/main/agent/context/systemPrompt.ts b/src/main/agent/context/systemPrompt.ts index a0fa2c3..d51c047 100644 --- a/src/main/agent/context/systemPrompt.ts +++ b/src/main/agent/context/systemPrompt.ts @@ -170,7 +170,10 @@ export function buildChatMessages(params: { if (params.contextSummary) { out.push({ role: 'system', - content: `【此前对话摘要】\n${params.contextSummary}`, + content: + '本会话是从此前一段对话延续而来(因上下文预算压缩)。' + + '以下摘要概述此前内容;请据此继续,不要从头开始,也不要重复已完成的操作。\n' + + `【此前对话摘要】\n${params.contextSummary}`, }); } out.push(...params.messages); diff --git a/src/main/agent/context/tokenEstimate.test.ts b/src/main/agent/context/tokenEstimate.test.ts new file mode 100644 index 0000000..8b08e0e --- /dev/null +++ b/src/main/agent/context/tokenEstimate.test.ts @@ -0,0 +1,38 @@ +/** + * @jest-environment node + */ +import { describe, expect, it } from '@jest/globals'; +import { estimateMessagesTokens } from './tokenEstimate'; +import type { ChatMessage } from '../llm/client'; + +describe('estimateMessagesTokens', () => { + it('按 chars/3 粗略估算,并计入 toolCalls', () => { + const messages: ChatMessage[] = [ + { role: 'user', content: 'abcdef' }, + { + role: 'assistant', + content: '', + toolCalls: [{ id: 'c1', name: 'read_workspace_file', args: { a: 1 } }], + }, + ]; + // 6 chars + 名称/参数 chars,再 /3 上取整 + expect(estimateMessagesTokens(messages)).toBeGreaterThanOrEqual(2); + }); + + it('多模态文本部件计入,图片按等效字符估算', () => { + const messages: ChatMessage[] = [ + { + role: 'user', + content: [ + { type: 'text', text: 'abc' }, + { type: 'image_url', image_url: { url: 'data:...' } }, + ], + }, + ]; + expect(estimateMessagesTokens(messages)).toBeGreaterThan(1000); + }); + + it('空消息为 0', () => { + expect(estimateMessagesTokens([])).toBe(0); + }); +}); diff --git a/src/main/agent/context/tokenEstimate.ts b/src/main/agent/context/tokenEstimate.ts new file mode 100644 index 0000000..3d2d9bc --- /dev/null +++ b/src/main/agent/context/tokenEstimate.ts @@ -0,0 +1,38 @@ +/** + * 运行时的消息 token 估算(粗略,中英混合按 chars/3 折算)。 + * + * 与渲染层 `contextPreparer.estimateTokens` 保持同一启发式,避免两侧口径不一致。 + * 仅用于「是否触发循环内 microcompact」的决策,不追求精确。 + */ + +import type { ChatMessage, MessageContent } from '../llm/client'; + +/** 单张图片的等效字符开销(保守估计,避免图片型历史被严重低估)。 */ +const IMAGE_EQUIV_CHARS = 3_000; + +function contentChars(content: MessageContent): number { + if (typeof content === 'string') return content.length; + let total = 0; + for (const part of content) { + if (part.type === 'text') total += part.text.length; + else total += IMAGE_EQUIV_CHARS; + } + return total; +} + +/** 估算一组消息的总 token 数。 */ +export function estimateMessagesTokens(messages: ChatMessage[]): number { + let chars = 0; + for (const message of messages) { + chars += contentChars(message.content); + for (const call of message.toolCalls ?? []) { + chars += call.name.length; + try { + chars += JSON.stringify(call.args ?? {}).length; + } catch { + /* 循环引用等异常忽略,不影响估算 */ + } + } + } + return Math.ceil(chars / 3); +} diff --git a/src/main/agent/handlers/annotation.ts b/src/main/agent/handlers/annotation.ts index 90b628a..64154c9 100644 --- a/src/main/agent/handlers/annotation.ts +++ b/src/main/agent/handlers/annotation.ts @@ -141,6 +141,7 @@ export function createMutationPrepareHandler(deps: RuntimeDeps): RouteHandler { labelNames: labelNamesOf(body.project), selectedAnnotationIds: body.selected_annotation_ids, conversationTranscript: body.conversation_transcript, + labelNameLimit: deps.settings.annotationMutationLabelNameLimit, }); return { @@ -262,6 +263,7 @@ export function createMapDetectionBoxesHandler( ocrText: body.ocr_text, scope: body.annotation_scope, labelStrategy: body.label_strategy, + labelUniqueness: body.label_uniqueness ?? undefined, singleLabelId: body.single_label_id ?? null, imageAbsolutePath: body.image_absolute_path, imageBase64: body.image_base64, diff --git a/src/main/agent/llm/client.ts b/src/main/agent/llm/client.ts index d1859e8..663780a 100644 --- a/src/main/agent/llm/client.ts +++ b/src/main/agent/llm/client.ts @@ -96,17 +96,37 @@ const DEFAULT_TEMPERATURE = 0.7; const DEFAULT_MAX_RETRIES = 2; const DEFAULT_RETRY_BASE_DELAY_MS = 300; +/** + * 判定错误体是否表示「上下文超长」。 + * + * 不同 provider 的措辞差异很大(OpenAI `context_length_exceeded`、 + * Anthropic `prompt is too long`、DeepSeek `maximum context length` 等), + * 因此按关键词宽匹配;仅在上层确实要「压缩后重试」时才使用,误判的代价 + * 是压缩一次而非崩溃,可接受。 + */ +export function isContextLengthExceededBody(body: string): boolean { + return /context[_ ]?length|maximum context|context window|prompt is too long|prompt too long|too many tokens|reduce the length|exceeds? the (maximum )?context|输入.{0,4}(过长|超长)|上下文.{0,4}(过长|超长)/i.test( + body, + ); +} + /** 调用失败时抛出,携带 HTTP 状态与响应体片段以便上层分流。 */ export class LlmRequestError extends Error { readonly status: number; readonly body: string; + /** 是否为「上下文超长」类错误(触发运行时压缩重试的依据)。 */ + readonly isContextLengthExceeded: boolean; + constructor(status: number, body: string) { super(`LLM 请求失败 HTTP ${status}: ${body.slice(0, 500)}`); this.name = 'LlmRequestError'; this.status = status; this.body = body; + this.isContextLengthExceeded = + (status === 400 || status === 413 || status === 422) && + isContextLengthExceededBody(body); } } diff --git a/src/main/agent/loop/assistLoop.test.ts b/src/main/agent/loop/assistLoop.test.ts index ffef01d..b7cf64f 100644 --- a/src/main/agent/loop/assistLoop.test.ts +++ b/src/main/agent/loop/assistLoop.test.ts @@ -5,7 +5,12 @@ * jsdom 环境不提供这些。 */ import { describe, expect, it } from '@jest/globals'; -import { LlmClient, LlmRequestError, type ToolSpec } from '../llm/client'; +import { + LlmClient, + LlmRequestError, + type ChatMessage, + type ToolSpec, +} from '../llm/client'; import { buildTools, toToolSpec } from '../tools/registry'; import { DEFAULT_AGENT_SETTINGS } from '../config'; import { createStandaloneImageService } from '../services/imageService'; @@ -25,14 +30,33 @@ function makeFetch( toolCalls?: Array<{ id: string; name: string; args: unknown }>; /** 非流式响应(tool_choice="any" 路径)。 */ nonStream?: boolean; + /** 该轮直接返回 HTTP 错误(用于触发上下文超长路径)。 */ + error?: { status: number; body: string }; + /** 流式响应结束时的 finish_reason(默认按是否有 toolCalls 推断)。 */ + finishReason?: string; }>, -): { fetchImpl: typeof fetch; callCount: () => number } { + hooks: { onRequest?: (body: Record) => void } = {}, +): { + fetchImpl: typeof fetch; + callCount: () => number; + requestBodies: () => Array>; +} { let index = 0; + const requestBodies: Array> = []; const impl = (async (_url: string, init?: RequestInit) => { - const body = JSON.parse(String(init?.body ?? '{}')) as { stream?: boolean }; + const parsedBody = JSON.parse(String(init?.body ?? '{}')) as { + stream?: boolean; + }; + requestBodies.push(parsedBody as Record); + hooks.onRequest?.(parsedBody as Record); + const body = parsedBody; const turn = turns[Math.min(index, turns.length - 1)]; if (index < turns.length) index += 1; + if (turn.error) { + return new Response(turn.error.body, { status: turn.error.status }); + } + if (body.stream === false) { return new Response( JSON.stringify({ @@ -98,7 +122,9 @@ function makeFetch( choices: [ { delta: {}, - finish_reason: turn.toolCalls?.length ? 'tool_calls' : 'stop', + finish_reason: + turn.finishReason ?? + (turn.toolCalls?.length ? 'tool_calls' : 'stop'), }, ], })}\n\n`, @@ -111,7 +137,11 @@ function makeFetch( }); }) as unknown as typeof fetch; - return { fetchImpl: impl, callCount: () => index }; + return { + fetchImpl: impl, + callCount: () => index, + requestBodies: () => requestBodies, + }; } async function collect( @@ -488,6 +518,256 @@ describe('阶段门禁与工具集', () => { }); }); +describe('Assist 循环:2.2 循环内 microcompact', () => { + function longHistory(): ChatMessage[] { + return [ + { role: 'user', content: '读文件' }, + { + role: 'assistant', + content: '', + toolCalls: [ + { + id: 'c1', + name: 'read_workspace_file', + args: { relative_path: 'a' }, + }, + ], + }, + { role: 'tool', content: 'A'.repeat(500), toolCallId: 'c1' }, + { role: 'user', content: '继续' }, + ]; + } + + it('超预算时清理旧的可重取工具结果', async () => { + const { fetchImpl, requestBodies } = makeFetch([{ text: '完成' }]); + await collect( + streamAssist({ + ...baseParams, + settings: { + ...DEFAULT_AGENT_SETTINGS, + contextTokenBudget: 10, + contextCompactRatio: 0.5, + microCompactKeepRecentToolResults: 0, + }, + llm: makeClient(fetchImpl), + messages: longHistory(), + toolSpecs: [], + toolSet: new Set(), + }), + ); + expect(JSON.stringify(requestBodies()[0])).toContain( + '较早的工具结果已清理', + ); + }); + + it('默认(budget=0)不触发', async () => { + const { fetchImpl, requestBodies } = makeFetch([{ text: '完成' }]); + await collect( + streamAssist({ + ...baseParams, + llm: makeClient(fetchImpl), + messages: longHistory(), + toolSpecs: [], + toolSet: new Set(), + }), + ); + expect(JSON.stringify(requestBodies()[0])).not.toContain( + '较早的工具结果已清理', + ); + }); +}); + +describe('Assist 循环:3 流式并行只读工具', () => { + it('同轮多个只读工具:结果正确且事件仍按序串行', async () => { + const { fetchImpl } = makeFetch([ + { + text: '并行查阅。', + toolCalls: [ + { id: 'p1', name: 'get_lr_agent_help', args: { topic: '模型' } }, + { id: 'p2', name: 'get_lr_agent_help', args: { topic: '标注' } }, + ], + }, + { text: '完成' }, + ]); + + const events = await collect( + streamAssist({ + ...baseParams, + llm: makeClient(fetchImpl), + messages: [{ role: 'user', content: '帮我查两件事' }], + toolSpecs: allSpecs, + toolSet: LIGHT_TOOL_SET, + }), + ); + + const results = events.filter((e) => e.type === 'tool_result'); + expect(results).toHaveLength(2); + // 事件顺序仍与工具调用顺序一致 + const starts = events.filter((e) => e.type === 'tool_start'); + expect(starts.map((e) => e.toolCallId)).toEqual(['p1', 'p2']); + expect(results.map((e) => e.toolCallId)).toEqual(['p1', 'p2']); + // 无错误 + expect(events.some((e) => e.type === 'error')).toBe(false); + }); +}); + +describe('Assist 循环:1.3 孤立 tool_use 修复', () => { + it('声明但未应答的 tool_call 会被补齐合成 tool_result', async () => { + const { fetchImpl, requestBodies } = makeFetch([{ text: '完成' }]); + + await collect( + streamAssist({ + ...baseParams, + llm: makeClient(fetchImpl), + messages: [ + { role: 'user', content: '读文件' }, + { + role: 'assistant', + content: '', + toolCalls: [ + { + id: 'orphan-1', + name: 'read_workspace_file', + args: { relative_path: 'README.md' }, + }, + ], + }, + ], + toolSpecs: allSpecs, + toolSet: LIGHT_TOOL_SET, + }), + ); + + const sent = requestBodies()[0].messages as Array<{ + role: string; + tool_call_id?: string; + }>; + const repaired = sent.find( + (m) => m.role === 'tool' && m.tool_call_id === 'orphan-1', + ); + expect(repaired).toBeDefined(); + expect(JSON.stringify(requestBodies()[0])).toContain('未执行'); + }); +}); + +describe('Assist 循环:1.2 上下文超长 → 运行时压缩重试', () => { + it('400 超长后压缩一次并重试成功', async () => { + const { fetchImpl, callCount, requestBodies } = makeFetch([ + { + error: { + status: 400, + body: '{"error":{"message":"This model maximum context length is 8192 tokens"}}', + }, + }, + { nonStream: true, text: '这是历史摘要。' }, + { text: '恢复后的回答' }, + ]); + + // 需要足够的轮次才能挤出完整对话(keepTurns 默认 4 → 8 条) + const messages = Array.from({ length: 10 }, (_, i) => ({ + role: 'user' as const, + content: `消息${i}`, + })); + + const events = await collect( + streamAssist({ + ...baseParams, + llm: makeClient(fetchImpl), + messages, + toolSpecs: [], + toolSet: new Set(), + }), + ); + + expect( + events.filter((e) => e.type === 'preparing').map((e) => e.stage), + ).toEqual(['streaming', 'compact']); + expect( + events.some( + (e) => e.type === 'text_delta' && e.content === '恢复后的回答', + ), + ).toBe(true); + // 首次(超长) + 压缩 + 重试 + expect(callCount()).toBe(3); + expect(JSON.stringify(requestBodies()[2])).toContain('此前对话摘要'); + }); +}); + +describe('Assist 循环:1.1 输出截断恢复', () => { + it('finish_reason=length 时注入续写提示并继续', async () => { + const { fetchImpl, requestBodies } = makeFetch([ + { text: '前半段', finishReason: 'length' }, + { text: '后半段' }, + ]); + + const events = await collect( + streamAssist({ + ...baseParams, + llm: makeClient(fetchImpl), + messages: [{ role: 'user', content: '写报告' }], + toolSpecs: [], + toolSet: new Set(), + }), + ); + + const texts = events + .filter((e) => e.type === 'text_delta') + .map((e) => e.content); + expect(texts).toEqual(['前半段', '后半段']); + expect(JSON.stringify(requestBodies()[1])).toContain( + '输出因长度上限被截断', + ); + }); + + it('无任何输出时静默升档重试 max_tokens', async () => { + const { fetchImpl, callCount, requestBodies } = makeFetch([ + { text: '', finishReason: 'length' }, + { text: '升档后的回答' }, + ]); + + const settings = { + ...DEFAULT_AGENT_SETTINGS, + chatMaxOutputTokens: 8192, + chatMaxOutputTokensBumped: 65536, + }; + + const events = await collect( + streamAssist({ + ...baseParams, + settings, + llm: makeClient(fetchImpl), + messages: [{ role: 'user', content: '写长文' }], + toolSpecs: [], + toolSet: new Set(), + }), + ); + + expect(callCount()).toBe(2); + expect(requestBodies()[0].max_tokens).toBe(8192); + expect(requestBodies()[1].max_tokens).toBe(65536); + const texts = events + .filter((e) => e.type === 'text_delta') + .map((e) => e.content); + expect(texts).toEqual(['升档后的回答']); + }); + + it('默认不下发 max_tokens(保持既有请求体不变)', async () => { + const { fetchImpl, requestBodies } = makeFetch([{ text: '正常回答' }]); + + await collect( + streamAssist({ + ...baseParams, + llm: makeClient(fetchImpl), + messages: [{ role: 'user', content: '你好' }], + toolSpecs: [], + toolSet: new Set(), + }), + ); + + expect('max_tokens' in requestBodies()[0]).toBe(false); + }); +}); + describe('LLM 客户端:网络失败重试', () => { /** 构造一个合法的 SSE 流式响应(单段文本)。 */ function streamResponse(text: string): Response { diff --git a/src/main/agent/loop/assistLoop.ts b/src/main/agent/loop/assistLoop.ts index da095d3..a2b2a93 100644 --- a/src/main/agent/loop/assistLoop.ts +++ b/src/main/agent/loop/assistLoop.ts @@ -16,12 +16,19 @@ */ import { + LlmRequestError, ToolCallAccumulator, type ChatMessage, type ChatToolCall, type LlmClient, type ToolSpec, } from '../llm/client'; +import { reactiveCompact } from './compact'; +import { StreamingToolExecutor } from './streamingToolExecutor'; +import { repairOrphanToolCalls } from '../context/orphanRepair'; +import { estimateMessagesTokens } from '../context/tokenEstimate'; +import { microCompactMessages } from '../context/microCompact'; +import { maybePreviewLargeResult } from '../tools/resultOffload'; import { DONE_FRAME, sse, @@ -100,6 +107,38 @@ const COALESCED_SUMMARY = const ALREADY_COMPLETED_SUMMARY = '该工具本轮已执行,请勿重复调用。请总结,勿重跑标注工具。'; +/** 输出截断后的「续写」提示(对齐 Claude Code 的 nudge 思路)。 */ +const OUTPUT_TRUNCATION_NUDGE = + '输出因长度上限被截断。请直接从断点继续:不要道歉、不要复述已写内容、不要回顾此前过程,把剩余工作拆成更小的块。'; + +/** + * 跨轮的可变循环状态。 + * + * 所有「计数器 / 防重复标志」集中一处,避免散落的闭包变量难以调试 + * (对齐 Claude Code 显式 State 的做法)。 + */ +interface LoopState { + /** 输出截断后续写次数。 */ + outputRecoveryCount: number; + /** 是否已用过一次「静默升档」。 */ + maxTokensBumped: boolean; + /** 本请求是否已触发过运行时压缩(同一请求只压一次)。 */ + hasAttemptedReactiveCompact: boolean; + /** 本请求是否已触发过循环内 microcompact(只清理一次)。 */ + microCompacted: boolean; +} + +/** 本轮应下发的 `max_tokens`;配置为 0 时不下发(沿用 provider 默认)。 */ +function resolveChatMaxTokens( + settings: AgentSettings, + state: LoopState, +): number | undefined { + if (settings.chatMaxOutputTokens <= 0) return undefined; + return state.maxTokensBumped + ? settings.chatMaxOutputTokensBumped + : settings.chatMaxOutputTokens; +} + /** * 未知工具的失败结果。 * @@ -291,40 +330,157 @@ async function* streamAssistInner( const visionState = { bootstrapped: false }; let budgetExhausted = true; + const loopState: LoopState = { + outputRecoveryCount: 0, + maxTokensBumped: false, + hasAttemptedReactiveCompact: false, + microCompacted: false, + }; for (let roundIdx = 0; roundIdx <= settings.maxToolRounds; roundIdx += 1) { if (isCancelled()) return; + // 1.3 修复孤立 tool_use:历史里「已声明但未应答」的调用会让 API 直接拒收 + repairOrphanToolCalls(messages); + + // 2.2 循环内上下文治理:超预算时本地清理旧的可重取工具结果(零 API、只清一次) + if ( + settings.contextTokenBudget > 0 && + !loopState.microCompacted && + estimateMessagesTokens(messages) > + settings.contextTokenBudget * settings.contextCompactRatio + ) { + microCompactMessages(messages, { + keepRecent: settings.microCompactKeepRecentToolResults, + }); + loopState.microCompacted = true; + } + const accumulator = new ToolCallAccumulator(); // 拦截器必须**每轮新建**:tc_index 每轮从 0 重新编号 const interceptor = new ProposalStreamInterceptor(params.clientContext); + // 流式并行执行器同样每轮新建(index 每轮重置);只提前跑只读白名单工具 + const executor = new StreamingToolExecutor({ + isEligible: (name) => + PARALLEL_SYNC_TOOLS.has(name) && + typeof tools.get(name)?.execute === 'function', + run: (call) => + invokeTool( + { + toolCallId: call.id || `early-${roundIdx}-${call.index}`, + name: call.name, + arguments: call.args, + source: 'api', + }, + tools, + params.toolSet, + toolContext, + ), + }); let pendingText = ''; let gatheredAny = false; + let finishReason: string | null = null; - // ── 阶段 A:单次 astream ──────────────────────────────── - for await (const delta of llm.streamChat({ - messages, - tools: params.toolSpecs, - toolChoice: 'auto', - signal, - })) { - if (isCancelled()) return; - if (delta.content) { - pendingText += delta.content; - yield sse.textDelta(delta.content); - gatheredAny = true; - } - if (delta.reasoning) { - yield sse.reasoningDelta(delta.reasoning); - gatheredAny = true; + // ── 阶段 A:单次 astream(上下文超长时压缩后重试)──────── + try { + for await (const delta of llm.streamChat({ + messages, + tools: params.toolSpecs, + toolChoice: 'auto', + signal, + maxTokens: resolveChatMaxTokens(settings, loopState), + })) { + if (isCancelled()) return; + if (delta.content) { + pendingText += delta.content; + yield sse.textDelta(delta.content); + gatheredAny = true; + } + if (delta.reasoning) { + yield sse.reasoningDelta(delta.reasoning); + gatheredAny = true; + } + if (delta.toolCallChunks?.length) { + accumulator.push(delta.toolCallChunks); + // 只读工具的参数一旦完整就立刻后台开跑(与模型生成时间重叠) + executor.onChunk(delta.toolCallChunks); + gatheredAny = true; + // 流式拦截:在 chunk 期间实时发出提案 start / delta 事件 + for (const event of interceptor.onChunk(delta.toolCallChunks)) { + yield event; + } + } + if (delta.finishReason != null) finishReason = delta.finishReason; } - if (delta.toolCallChunks?.length) { - accumulator.push(delta.toolCallChunks); - gatheredAny = true; - // 流式拦截:在 chunk 期间实时发出提案 start / delta 事件 - for (const event of interceptor.onChunk(delta.toolCallChunks)) { - yield event; + } catch (err) { + // 1.2 上下文超长:压缩一次后重试该轮(超长不在既有场景中,基线不受影响) + if ( + err instanceof LlmRequestError && + err.isContextLengthExceeded && + settings.reactiveCompactEnabled && + !loopState.hasAttemptedReactiveCompact + ) { + loopState.hasAttemptedReactiveCompact = true; + console.warn('[agentRuntime] 上下文超长,尝试运行时压缩后重试'); + yield sse.preparing('compact'); + let compacted: ChatMessage[]; + try { + compacted = await reactiveCompact({ + messages, + llm, + settings, + signal, + }); + } catch (compactErr) { + // 压缩本身失败时保留**原始**超长错误(它才是根因),避免掩盖故障 + console.warn( + '[agentRuntime] 运行时压缩失败:', + describeError(compactErr), + ); + throw err; } + messages.length = 0; + messages.push(...compacted); + roundIdx -= 1; // 重试同一轮 + continue; + } + throw err; + } + + // ── 1.1 输出截断恢复 ─────────────────────────────────── + if (finishReason === 'length') { + const partialCalls = accumulator.resolve(); + const incompleteToolCall = partialCalls.some( + (call) => !call.name || Object.keys(call.args).length === 0, + ); + + // 尚无任何输出时,优先静默升档重试(不会造成重复内容) + if ( + !gatheredAny && + !loopState.maxTokensBumped && + settings.chatMaxOutputTokens > 0 && + settings.chatMaxOutputTokensBumped > settings.chatMaxOutputTokens + ) { + loopState.maxTokensBumped = true; + roundIdx -= 1; + continue; + } + + // 否则注入续写提示,让模型从断点继续 + if ( + loopState.outputRecoveryCount < settings.outputRecoveryMaxAttempts && + (pendingText.length > 0 || partialCalls.length > 0) + ) { + loopState.outputRecoveryCount += 1; + messages.push({ role: 'assistant', content: pendingText }); + messages.push({ + role: 'user', + content: incompleteToolCall + ? `${OUTPUT_TRUNCATION_NUDGE}\n\n注意:上一次的工具调用参数被截断且不完整,请重新完整地发起该调用。` + : OUTPUT_TRUNCATION_NUDGE, + }); + budgetExhausted = true; + continue; } } @@ -334,6 +490,9 @@ async function* streamAssistInner( break; } + // 收尾:启动尚未提前执行的合格调用(单个/最后一个工具在此起步) + executor.startPending(); + const apiToolCalls = accumulator.resolve(); const outcome: RoundOutcomeHolder = { kind: 'finished' }; @@ -357,6 +516,7 @@ async function* streamAssistInner( interceptor, visionState, settings, + earlyResult: (call) => executor.getResult(call.name, call.arguments), }); if (outcome.kind === 'pending') { @@ -421,6 +581,8 @@ interface ExecuteRoundParams { /** 「本请求是否已处理过视觉图」——由调用方持有,跨轮次共享。 */ visionState: { bootstrapped: boolean }; settings: AgentSettings; + /** 取回流式期间已提前执行的结果;未命中时返回 undefined。 */ + earlyResult?: (call: ResolvedToolCall) => Promise | undefined; } /** @@ -451,6 +613,7 @@ async function* executeRound( isCancelled, signal, interceptor, + earlyResult, } = params; const { settings } = toolContext; @@ -654,8 +817,9 @@ async function* executeRound( ); if (parallelCalls.length > 0) { const results = await Promise.all( - parallelCalls.map((call) => - invokeTool(call, tools, toolSet, toolContext), + parallelCalls.map( + (call) => + earlyResult?.(call) ?? invokeTool(call, tools, toolSet, toolContext), ), ); parallelCalls.forEach((call, index) => { @@ -1001,6 +1165,13 @@ async function* streamToolExecution( display = formatToolResultForDisplay(raw); } + // 2.1 大结果预览化:只读可重取工具结果过大时折叠为预览 + 重取提示 + display = maybePreviewLargeResult({ + toolName: call.name, + result: display, + settings: toolContext.settings, + }); + yield sse.toolResult(call.toolCallId, display); messages.push({ role: 'tool', diff --git a/src/main/agent/loop/compact.test.ts b/src/main/agent/loop/compact.test.ts new file mode 100644 index 0000000..e35bdb7 --- /dev/null +++ b/src/main/agent/loop/compact.test.ts @@ -0,0 +1,84 @@ +/** + * @jest-environment node + */ +import { describe, expect, it } from '@jest/globals'; +import { reactiveCompact } from './compact'; +import { DEFAULT_AGENT_SETTINGS } from '../config'; +import type { ChatMessage, LlmClient } from '../llm/client'; + +function stubLlm(summary = '结构化摘要'): LlmClient { + return { + completeChat: async () => ({ + content: summary, + reasoning: '', + toolCalls: [], + }), + } as unknown as LlmClient; +} + +describe('reactiveCompact', () => { + it('保留 system 块并把旧对话摘进 system,切分点落在 user 边界', async () => { + const conversation: ChatMessage[] = Array.from({ length: 6 }, (_, i) => ({ + role: 'user' as const, + content: `消息${i}`, + })); + const messages: ChatMessage[] = [ + { role: 'system', content: 'SYS' }, + ...conversation, + ]; + + const out = await reactiveCompact({ + messages, + llm: stubLlm('历史要点'), + settings: { ...DEFAULT_AGENT_SETTINGS, reactiveCompactKeepTurns: 2 }, + }); + + expect(out[0].role).toBe('system'); + expect(String(out[0].content)).toContain('SYS'); + expect(String(out[0].content)).toContain('历史要点'); + // keepTurns=2 → 保留 4 条对话,均以 user 开头 + expect(out.length).toBe(1 + 4); + expect(out[1].role).toBe('user'); + }); + + it('朴素切点落在轮内时向后找 user 边界,不因首个 user 而放弃压缩', async () => { + // 朴素 cut=1 落在 assistant 上;向后应找到 index 7 的 user + const conversation: ChatMessage[] = [ + { role: 'user', content: 'u0' }, + { + role: 'assistant', + content: '', + toolCalls: [{ id: 'c1', name: 'read_workspace_file', args: {} }], + }, + { role: 'tool', content: 'A', toolCallId: 'c1' }, + { role: 'tool', content: 'B', toolCallId: 'c1' }, + { role: 'tool', content: 'C', toolCallId: 'c1' }, + { role: 'tool', content: 'D', toolCallId: 'c1' }, + { role: 'tool', content: 'E', toolCallId: 'c1' }, + { role: 'user', content: 'u7' }, + { role: 'assistant', content: 'a8' }, + ]; + + const out = await reactiveCompact({ + messages: conversation, + llm: stubLlm('摘要X'), + settings: { ...DEFAULT_AGENT_SETTINGS, reactiveCompactKeepTurns: 4 }, + }); + + // 保留 [u7, a8] + 前插的一条 system 摘要 + expect(out).toHaveLength(3); + expect(out[0].role).toBe('system'); + expect(out[1]).toMatchObject({ role: 'user', content: 'u7' }); + expect(out[2]).toMatchObject({ role: 'assistant', content: 'a8' }); + }); + + it('没有可挤出的完整轮次时原样返回', async () => { + const messages: ChatMessage[] = [{ role: 'user', content: '只有一条' }]; + const out = await reactiveCompact({ + messages, + llm: stubLlm(), + settings: DEFAULT_AGENT_SETTINGS, + }); + expect(out).toBe(messages); + }); +}); diff --git a/src/main/agent/loop/compact.ts b/src/main/agent/loop/compact.ts new file mode 100644 index 0000000..932621a --- /dev/null +++ b/src/main/agent/loop/compact.ts @@ -0,0 +1,136 @@ +/** + * 运行时「被动压缩」(reactive compact)。 + * + * 仅在**上游以 4xx 拒绝请求(上下文超长)**时触发:把窗口内较旧的对话 + * 摘成一段结构化摘要,替换其占位,再重试原请求。正常请求完全不走这里, + * 因此对既有协议基线零影响。 + * + * 与渲染层的 `contextSummarizer` 的分工: + * - 渲染层是「主动、按预算」的常规压缩; + * - 这里是「被动、按报错」的兜底,只负责把请求救回来。 + * + * 关键约束:**绝不破坏 tool_call / tool_result 配对**。切分点只落在 + * `user` 消息边界上,保证被保留的尾部自身是一段合法的对话历史。 + */ + +import type { ChatMessage, LlmClient, MessageContent } from '../llm/client'; +import type { AgentSettings } from '../config'; +import { AGENT_SUMMARY_SYSTEM_PROMPT } from '../../../shared/agentSummaryPrompt'; + +/** 被挤出内容送给摘要器时的字符上限(超过则保留靠后的、更相关的部分)。 */ +const MAX_EVICTED_CHARS = 80_000; + +const SUMMARY_HEADER = '【此前对话摘要(因上下文超长触发压缩)】'; + +function contentToText(content: MessageContent): string { + if (typeof content === 'string') return content; + return content + .map((part) => (part.type === 'text' ? part.text : '[图片]')) + .join(''); +} + +/** 把被挤出的消息渲染成带角色标注的 transcript。 */ +function formatEvicted(messages: ChatMessage[]): string { + const lines: string[] = []; + for (const message of messages) { + if (message.role === 'tool') { + lines.push(`[工具结果] ${contentToText(message.content)}`); + continue; + } + if (message.role === 'assistant') { + const text = contentToText(message.content); + const calls = (message.toolCalls ?? []).map((c) => c.name).join(', '); + lines.push(`[助手] ${text}${calls ? ` (调用工具: ${calls})` : ''}`); + continue; + } + if (message.role === 'user') { + lines.push(`[用户] ${contentToText(message.content)}`); + continue; + } + lines.push(`[系统] ${contentToText(message.content)}`); + } + let transcript = lines.join('\n'); + if (transcript.length > MAX_EVICTED_CHARS) { + transcript = `(更早内容已省略)\n${transcript.slice(-MAX_EVICTED_CHARS)}`; + } + return transcript; +} + +/** + * 执行一次被动压缩,返回新的消息数组(不改动入参)。 + * + * @throws 摘要请求失败 / 返回空内容时抛出,由调用方决定是否放弃重试。 + */ +export async function reactiveCompact(params: { + messages: ChatMessage[]; + llm: LlmClient; + settings: AgentSettings; + signal?: AbortSignal; +}): Promise { + const { messages } = params; + + // 1) 保留开头的 system 块(系统提示词 + 渲染层摘要),它们不参与挤出。 + let head = 0; + while (head < messages.length && messages[head].role === 'system') head += 1; + const headMessages = messages.slice(0, head); + const conversation = messages.slice(head); + + // 2) 在对话区找一个 user 边界作切分点,只保留末尾若干完整轮次。 + // + // 先从「保留末尾 keepCount 条」的朴素切点出发;若该点落在某轮内部 + // (assistant/tool 中间),**向后**找最近的下一个 user 边界——这样被保留的 + // 尾部仍以完整轮次开始。只有当后方不存在 user 边界时,才回退向前找。 + // 两头都无(或切完无内容)则视为不可压缩。 + const keepCount = Math.max(2, params.settings.reactiveCompactKeepTurns) * 2; + let cut = Math.max(0, conversation.length - keepCount); + if (cut > 0 && conversation[cut]?.role !== 'user') { + let forward = cut; + while ( + forward < conversation.length && + conversation[forward].role !== 'user' + ) { + forward += 1; + } + if (forward < conversation.length) { + cut = forward; + } else { + while (cut > 0 && conversation[cut].role !== 'user') cut -= 1; + } + } + if (cut <= 0 || cut >= conversation.length) return messages; // 无可挤出的完整轮次 + + const evicted = conversation.slice(0, cut); + const kept = conversation.slice(cut); + if (evicted.length === 0) return messages; + + const transcript = formatEvicted(evicted); + + const turn = await params.llm.completeChat({ + messages: [ + { role: 'system', content: AGENT_SUMMARY_SYSTEM_PROMPT }, + { role: 'user', content: transcript }, + ], + temperature: 0.2, + maxTokens: 2048, + signal: params.signal, + }); + const summary = turn.content.trim(); + if (!summary) throw new Error('压缩摘要返回空内容'); + + // 3) 把摘要并入最后一条 system 消息(保持消息序列合法,避免中途插入 system)。 + const header = `\n\n${SUMMARY_HEADER}\n${summary}`; + const outHead = headMessages.slice(); + if (outHead.length > 0) { + const last = outHead[outHead.length - 1]; + const content = + typeof last.content === 'string' + ? `${last.content}${header}` + : // 保留既有部件(含图片),仅在末尾追加文本部件 + [...last.content, { type: 'text' as const, text: header }]; + outHead[outHead.length - 1] = { ...last, content }; + } else { + outHead.push({ role: 'system', content: `${SUMMARY_HEADER}\n${summary}` }); + } + + return [...outHead, ...kept]; +} diff --git a/src/main/agent/loop/streamingToolExecutor.test.ts b/src/main/agent/loop/streamingToolExecutor.test.ts new file mode 100644 index 0000000..3275256 --- /dev/null +++ b/src/main/agent/loop/streamingToolExecutor.test.ts @@ -0,0 +1,137 @@ +/** + * @jest-environment node + */ +import { describe, expect, it } from '@jest/globals'; +import { StreamingToolExecutor } from './streamingToolExecutor'; + +interface RunCall { + index: number; + id: string; + name: string; + args: Record; +} + +function makeExecutor(opts: { + eligible?: (name: string) => boolean; + record?: RunCall[]; +}): StreamingToolExecutor { + return new StreamingToolExecutor({ + isEligible: opts.eligible ?? (() => true), + run: async (call) => { + opts.record?.push(call); + return `r:${call.name}`; + }, + }); +} + +describe('StreamingToolExecutor', () => { + it('后一个 index 出现时,前一个调用的参数视为完整并立即开跑', () => { + const record: RunCall[] = []; + const executor = makeExecutor({ record }); + + executor.onChunk([ + { + index: 0, + id: 'a', + name: 'read_workspace_file', + argsFragment: '{"relative_path":"a.md"}', + }, + ]); + expect(record).toHaveLength(0); + + executor.onChunk([ + { + index: 1, + id: 'b', + name: 'grep_workspace', + argsFragment: '{"pattern":"x"}', + }, + ]); + expect(record).toHaveLength(1); + expect(record[0].name).toBe('read_workspace_file'); + expect(record[0].args).toEqual({ relative_path: 'a.md' }); + }); + + it('未命中白名单的工具不提前执行', () => { + const record: RunCall[] = []; + const executor = makeExecutor({ + eligible: (name) => name !== 'write_workspace_file', + record, + }); + + executor.onChunk([ + { + index: 0, + name: 'write_workspace_file', + argsFragment: '{"relative_path":"a.md"}', + }, + { index: 1, name: 'grep_workspace', argsFragment: '{"pattern":"x"}' }, + ]); + executor.startPending(); + + expect(record.map((c) => c.name)).toEqual(['grep_workspace']); + }); + + it('参数 JSON 不完整时不提前执行,收尾也不补跑', () => { + const record: RunCall[] = []; + const executor = makeExecutor({ record }); + + executor.onChunk([ + { index: 0, name: 'grep_workspace', argsFragment: '{"pattern":"x"' }, + ]); + executor.onChunk([ + { + index: 1, + name: 'glob_workspace', + argsFragment: '{"glob_pattern":"*.ts"}', + }, + ]); + executor.startPending(); + + expect(record.map((c) => c.name)).toEqual(['glob_workspace']); + }); + + it('单个调用在 startPending 时才起步,getResult 可取回', async () => { + const record: RunCall[] = []; + const executor = makeExecutor({ record }); + + executor.onChunk([ + { + index: 0, + id: 'solo', + name: 'read_workspace_file', + argsFragment: '{"relative_path":"b.md"}', + }, + ]); + expect(record).toHaveLength(0); + + executor.startPending(); + expect(record).toHaveLength(1); + + const result = executor.getResult('read_workspace_file', { + relative_path: 'b.md', + }); + expect(result).toBeDefined(); + await expect(result).resolves.toBe('r:read_workspace_file'); + }); + + it('同名同参只执行一次(结果键去重)', () => { + const record: RunCall[] = []; + const executor = makeExecutor({ record }); + + executor.onChunk([ + { index: 0, name: 'grep_workspace', argsFragment: '{"pattern":"dup"}' }, + { index: 1, name: 'grep_workspace', argsFragment: '{"pattern":"dup"}' }, + ]); + executor.startPending(); + + expect(record).toHaveLength(1); + }); + + it('未提前执行时 getResult 返回 undefined', () => { + const executor = makeExecutor({}); + expect( + executor.getResult('grep_workspace', { pattern: 'x' }), + ).toBeUndefined(); + }); +}); diff --git a/src/main/agent/loop/streamingToolExecutor.ts b/src/main/agent/loop/streamingToolExecutor.ts new file mode 100644 index 0000000..9524ba2 --- /dev/null +++ b/src/main/agent/loop/streamingToolExecutor.ts @@ -0,0 +1,133 @@ +/** + * 流式并行工具执行器。 + * + * 目标:在阶段 A(模型仍在流式吐字)时,**一旦某个只读工具的参数完整就立刻开跑**, + * 从而把「模型生成时间」与「工具执行时间」重叠,压缩首字后到结果的延迟。 + * + * 与 Claude Code `StreamingToolExecutor` 的差异(OpenAI 兼容协议的限制): + * - Anthropic 有 `content_block_stop`,能精确知道某个 tool_use 块结束; + * - OpenAI 兼容流没有该信号,只能靠「**下一个 tool_call index 出现**」推断 + * 上一个调用参数已完整,流结束时再收尾。 + * - 因此:同轮多个工具能真正重叠;单个(或最后一个)工具只能等到流结束才起步, + * 与改动前的行为一致。 + * + * 安全边界:只有调用方判定为 `eligible` 的工具才会被提前执行。调用方应只放行 + * **只读、无副作用、且不会被本轮五道过滤改变**的工具(即 `PARALLEL_SYNC_TOOLS`)。 + * 即便因截断/压缩导致本轮被丢弃,提前执行也只读,不产生副作用。 + * + * 结果按 `(name, args)` 的规范化键缓存:stage B 用同一键取回,取不到则回退现场执行。 + * 键取自同一份原始 JSON 解析,键序稳定,因此可可靠命中。 + */ + +/** 流式 tool_call 分片(与 `LlmDelta.toolCallChunks` 同构)。 */ +export interface ToolCallChunk { + index: number; + id?: string; + name?: string; + argsFragment?: string; +} + +interface AccumEntry { + id: string; + name: string; + argsRaw: string; +} + +/** 结果缓存键:同名同参视为同一调用(只读工具下安全且节省重复执行)。 */ +export function toolResultKey( + name: string, + args: Record, +): string { + return `${name}\u0000${JSON.stringify(args)}`; +} + +export interface StreamingToolExecutorOptions { + /** 是否允许提前执行该工具。 */ + isEligible: (name: string) => boolean; + /** 实际执行(应复用主循环的 invokeTool,保证错误语义一致)。 */ + run: (call: { + index: number; + id: string; + name: string; + args: Record; + }) => Promise; +} + +export class StreamingToolExecutor { + private readonly byIndex = new Map(); + + private readonly startedIndexes = new Set(); + + private readonly results = new Map>(); + + private maxIndex = -1; + + constructor(private readonly options: StreamingToolExecutorOptions) {} + + /** 喂入一批流式分片;自动把「已被更高 index 取代」的调用启动。 */ + onChunk(chunks: ToolCallChunk[]): void { + for (const chunk of chunks) { + const entry = this.byIndex.get(chunk.index) ?? { + id: '', + name: '', + argsRaw: '', + }; + if (chunk.id && !entry.id) entry.id = chunk.id; + if (chunk.name && !entry.name) entry.name = chunk.name; + if (chunk.argsFragment) entry.argsRaw += chunk.argsFragment; + this.byIndex.set(chunk.index, entry); + if (chunk.index > this.maxIndex) this.maxIndex = chunk.index; + } + for (const index of this.byIndex.keys()) { + if (index < this.maxIndex) this.tryStart(index); + } + } + + /** 流结束时的收尾:启动所有尚未启动的合格调用(含最后一个/单个调用)。 */ + startPending(): void { + for (const index of this.byIndex.keys()) this.tryStart(index); + } + + /** 取回提前执行的结果;未提前执行时返回 undefined,由调用方回退执行。 */ + getResult( + name: string, + args: Record, + ): Promise | undefined { + return this.results.get(toolResultKey(name, args)); + } + + /** 已启动提前执行的调用数(测试/调试用)。 */ + get startedCount(): number { + return this.startedIndexes.size; + } + + private tryStart(index: number): void { + if (this.startedIndexes.has(index)) return; + const entry = this.byIndex.get(index); + if (!entry || !entry.name || !this.options.isEligible(entry.name)) return; + + // 参数必须是**完整合法**的 JSON 对象;半截 JSON 一律不提前执行。 + let args: unknown; + try { + args = JSON.parse(entry.argsRaw); + } catch { + return; + } + if (!args || typeof args !== 'object' || Array.isArray(args)) return; + + this.startedIndexes.add(index); + const parsedArgs = args as Record; + const key = toolResultKey(entry.name, parsedArgs); + if (this.results.has(key)) return; + const promise = this.options.run({ + index, + id: entry.id, + name: entry.name, + args: parsedArgs, + }); + // 本轮可能因截断/压缩被丢弃而不消费该结果:挂一个空 catch 避免 + // 未来若 run 出现 rejection 时产生 unhandledRejection(当前 run 不抛)。 + promise.catch(() => {}); + this.results.set(key, promise); + } +} diff --git a/src/main/agent/tools/registry.ts b/src/main/agent/tools/registry.ts index 00c471a..ac29f9b 100644 --- a/src/main/agent/tools/registry.ts +++ b/src/main/agent/tools/registry.ts @@ -192,6 +192,12 @@ const AutoAnnotateArgs = z.object({ .boolean() .nullish() .describe('是否用视觉模型把检测框映射到项目标签;留空由系统决定'), + unique_labels_per_box: z + .boolean() + .nullish() + .describe( + '是否要求每个框的标签唯一(实例型标签,如球员名)。用户未明确要求时留空,同类多实例默认允许。', + ), }); const MutateAnnotationArgs = z.object({ diff --git a/src/main/agent/tools/resultOffload.test.ts b/src/main/agent/tools/resultOffload.test.ts new file mode 100644 index 0000000..890278f --- /dev/null +++ b/src/main/agent/tools/resultOffload.test.ts @@ -0,0 +1,60 @@ +/** + * @jest-environment node + */ +import { describe, expect, it } from '@jest/globals'; +import { maybePreviewLargeResult } from './resultOffload'; +import { DEFAULT_AGENT_SETTINGS } from '../config'; + +const settings = { + ...DEFAULT_AGENT_SETTINGS, + toolResultPreviewBytes: 100, + toolResultPreviewChars: 20, +}; + +describe('maybePreviewLargeResult', () => { + it('小于阈值时原样返回', () => { + const result = 'x'.repeat(50); + expect( + maybePreviewLargeResult({ + toolName: 'read_workspace_file', + result, + settings, + }), + ).toBe(result); + }); + + it('可重取工具超阈值时折叠为预览 + 重取提示', () => { + const result = 'x'.repeat(500); + const out = maybePreviewLargeResult({ + toolName: 'read_workspace_file', + result, + settings, + }); + expect(out).not.toBe(result); + expect(out.startsWith('x'.repeat(20))).toBe(true); + expect(out).toContain('结果过大已截断'); + expect(out).toContain('offset / limit'); + }); + + it('不可重取工具即使很大也不折叠', () => { + const result = 'y'.repeat(500); + expect( + maybePreviewLargeResult({ + toolName: 'auto_annotate', + result, + settings, + }), + ).toBe(result); + }); + + it('阈值为 0 时关闭', () => { + const result = 'z'.repeat(500); + expect( + maybePreviewLargeResult({ + toolName: 'grep_workspace', + result, + settings: { ...settings, toolResultPreviewBytes: 0 }, + }), + ).toBe(result); + }); +}); diff --git a/src/main/agent/tools/resultOffload.ts b/src/main/agent/tools/resultOffload.ts new file mode 100644 index 0000000..e2c4f13 --- /dev/null +++ b/src/main/agent/tools/resultOffload.ts @@ -0,0 +1,66 @@ +/** + * 大工具结果的预览化(上下文治理第 1 层:大结果不整段进上下文)。 + * + * 对于**可重取**的只读工具(read / grep / glob / list / document),结果过大时 + * 只保留前若干字符 + 重取提示——模型需要时可换更窄的参数再次获取。 + * + * 与 Claude Code「大结果落盘」的差异:本项目只读工具本身支持 `offset/limit` + * 与 pattern 收窄,重取成本低,因此无需落盘 + 专用读回工具,直接折叠更简单。 + * 完整内容也不会丢失——它仍可从磁盘/标注库重新读出。 + * + * 仅对纯文本输出的可重取工具生效;JSON 结构化结果(如 read_file_annotation) + * 不在此列,避免截断破坏结构。 + */ + +import type { AgentSettings } from '../config'; + +/** 可重取、纯文本输出的只读工具。 */ +export const RE_RETRIEVABLE_TOOLS: ReadonlySet = new Set([ + 'read_workspace_file', + 'read_document_file', + 'grep_workspace', + 'glob_workspace', + 'list_workspace_directory', +]); + +/** 重取提示(按工具特点给出更具体的做法)。 */ +function reRetrieveHint(toolName: string, originalLength: number): string { + const byTool: Record = { + read_workspace_file: '用 offset / limit 分段读取,或只看需要的那一段', + read_document_file: '减少 max_pages,或指定页码范围', + grep_workspace: '收紧 pattern,或降低 max_results', + glob_workspace: '收窄 glob_pattern', + list_workspace_directory: '进入具体子目录后再列', + }; + const hint = byTool[toolName] ?? '缩小范围后重新获取'; + return ( + `\n\n[结果过大已截断:原始约 ${originalLength} 字符。` + + `如需完整内容,请${hint}。]` + ); +} + +/** + * 必要时把结果折叠为预览;不需要时原样返回。 + * + * @param toolName 工具名(决定是否可折叠) + * @param result 已格式化的结果文本 + */ +export function maybePreviewLargeResult(params: { + toolName: string; + result: string; + settings: AgentSettings; +}): string { + const { toolName, result, settings } = params; + if (settings.toolResultPreviewBytes <= 0) return result; + if (!RE_RETRIEVABLE_TOOLS.has(toolName)) return result; + if (result.length <= settings.toolResultPreviewBytes) return result; + + // 预览长度不得超过阈值,否则「截断后反而整段返回」并附加误导性提示 + const previewChars = Math.min( + Math.max(0, settings.toolResultPreviewChars), + settings.toolResultPreviewBytes, + ); + return ( + result.slice(0, previewChars) + reRetrieveHint(toolName, result.length) + ); +} diff --git a/src/main/agentImage/handlers.test.ts b/src/main/agentImage/handlers.test.ts new file mode 100644 index 0000000..636da48 --- /dev/null +++ b/src/main/agentImage/handlers.test.ts @@ -0,0 +1,86 @@ +/** + * 附图注入回退路径的 MIME 判定。 + * + * 回归背景:`nativeImage` 解不出的格式走「原字节直传」,但 MIME 原先按**后缀**取, + * 于是「WebP 存成 .jpg」这类文件会被声明成 `image/jpeg` 而字节其实是 WebP, + * 多模态接口要么拒绝要么解错。文件头才是字节的真身。 + * + * 只覆盖 `isEmpty()` 回退分支:能解码的分支属于 `nativeImage` 自身能力。 + */ + +import { afterAll, beforeAll, describe, expect, it, jest } from '@jest/globals'; +import fs from 'fs-extra'; +import os from 'os'; +import path from 'path'; + +jest.mock('electron', () => ({ + nativeImage: { + // 一律视为解不出像素,逼出回退分支 + createFromBuffer: () => ({ isEmpty: () => true }), + }, +})); + +// eslint-disable-next-line import/first +import { imageToJpegDataUrl } from './handlers'; + +const WEBP_BYTES = Buffer.from([ + 0x52, 0x49, 0x46, 0x46, 0xb0, 0xb7, 0x01, 0x00, 0x57, 0x45, 0x42, 0x50, 0x56, + 0x50, 0x38, 0x20, 0xa4, 0xb7, 0x01, 0x00, 0x30, 0xfe, 0x06, 0x9d, 0x01, 0x2a, + 0xb0, 0x04, 0xa3, 0x02, +]); + +let tmpDir = ''; + +function write(name: string, bytes: Buffer): string { + const file = path.join(tmpDir, name); + fs.writeFileSync(file, bytes); + return file; +} + +beforeAll(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lr-img-handlers-')); +}); + +afterAll(() => { + fs.removeSync(tmpDir); +}); + +describe('imageToJpegDataUrl:回退分支的 MIME 以文件头为准', () => { + it('WebP 字节配 .jpg 后缀时声明 image/webp', () => { + const result = imageToJpegDataUrl({ + absolutePath: write('4.jpg', WEBP_BYTES), + maxEdge: 1280, + quality: 85, + }); + + expect(result.ok).toBe(true); + expect(result.dataUrl?.startsWith('data:image/webp;base64,')).toBe(true); + expect(result.format).toBe('WEBP'); + // 尺寸来自文件头(VP8 有损:14 位宽高) + expect(result.width).toBe(1200); + expect(result.height).toBe(675); + }); + + it('文件头无法识别时退回后缀', () => { + const result = imageToJpegDataUrl({ + absolutePath: write('mystery.png', Buffer.from('not an image')), + maxEdge: 1280, + quality: 85, + }); + + expect(result.ok).toBe(true); + expect(result.dataUrl?.startsWith('data:image/png;base64,')).toBe(true); + }); + + it('文件头与后缀都识别不出时退化为 octet-stream', () => { + const result = imageToJpegDataUrl({ + absolutePath: write('mystery.bin', Buffer.from('not an image')), + maxEdge: 1280, + quality: 85, + }); + + expect( + result.dataUrl?.startsWith('data:application/octet-stream;base64,'), + ).toBe(true); + }); +}); diff --git a/src/main/agentImage/handlers.ts b/src/main/agentImage/handlers.ts index fcb19a1..d7eb8c6 100644 --- a/src/main/agentImage/handlers.ts +++ b/src/main/agentImage/handlers.ts @@ -224,10 +224,13 @@ export function imageToJpegDataUrl(params: { const suffix = path .extname((params.absolutePath ?? '').trim()) .toLowerCase(); + // 文件头描述的是字节本身,后缀可能说谎(WebP 存成 `.jpg`)。 + // data URL 的 MIME 要描述字节,因此文件头优先,无法识别时才退回后缀。 + const byHeader = formatToMime(header?.format); const mime = - mimeFromSuffix(suffix) !== 'application/octet-stream' - ? mimeFromSuffix(suffix) - : formatToMime(header?.format); + byHeader !== 'application/octet-stream' + ? byHeader + : mimeFromSuffix(suffix); return { ok: true, dataUrl: toDataUrl(mime, loaded.bytes), diff --git a/src/main/agentImage/imageOps.test.ts b/src/main/agentImage/imageOps.test.ts index 2d3f91b..6471245 100644 --- a/src/main/agentImage/imageOps.test.ts +++ b/src/main/agentImage/imageOps.test.ts @@ -78,6 +78,21 @@ function webpBytes(width: number, height: number): Buffer { return buf; } +/** 构造有损 WebP(VP8)的帧头。 */ +function webpLossyBytes(width: number, height: number): Buffer { + const buf = Buffer.alloc(40); + buf.write('RIFF', 0, 'ascii'); + buf.writeUInt32LE(32, 4); + buf.write('WEBP', 8, 'ascii'); + buf.write('VP8 ', 12, 'ascii'); + buf.writeUInt32LE(20, 16); + // 帧标签 3 字节 + 起始码 3 字节,其后为 14 位宽高(小端) + buf.writeUInt32LE(0x9d012a, 23); + buf.writeUInt16LE(width & 0x3fff, 26); + buf.writeUInt16LE(height & 0x3fff, 28); + return buf; +} + /** 构造 BMP 头。 */ function bmpBytes(width: number, height: number): Buffer { const buf = Buffer.alloc(30); @@ -142,6 +157,14 @@ describe('parseImageHeader:文件头解析', () => { }); }); + it('WebP(VP8 有损)读出宽高与格式', () => { + expect(parseImageHeader(webpLossyBytes(1200, 675))).toEqual({ + width: 1200, + height: 675, + format: 'WEBP', + }); + }); + it('BMP 读出宽高(高度取绝对值)', () => { expect(parseImageHeader(bmpBytes(64, 48))).toEqual({ width: 64, diff --git a/src/main/preload.ts b/src/main/preload.ts index c1dd857..518a7b8 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -173,6 +173,7 @@ const electronHandler = { | 'checkpointPath' | 'configPath' | 'detectionMode' + | 'promptClasses' | 'keypointBackend' | 'keypointTemplateIds' | 'auxiliaryPaths' diff --git a/src/main/pretrainedModels/pretrainedModelHandlers.ts b/src/main/pretrainedModels/pretrainedModelHandlers.ts index 1a66268..dbce7ca 100644 --- a/src/main/pretrainedModels/pretrainedModelHandlers.ts +++ b/src/main/pretrainedModels/pretrainedModelHandlers.ts @@ -29,6 +29,7 @@ export default function registerPretrainedModelHandlers(): void { | 'checkpointPath' | 'configPath' | 'detectionMode' + | 'promptClasses' | 'keypointBackend' | 'keypointTemplateIds' | 'auxiliaryPaths' diff --git a/src/main/pretrainedModels/pretrainedModelStore.ts b/src/main/pretrainedModels/pretrainedModelStore.ts index f3e223c..020ff38 100644 --- a/src/main/pretrainedModels/pretrainedModelStore.ts +++ b/src/main/pretrainedModels/pretrainedModelStore.ts @@ -365,6 +365,7 @@ export async function validatePretrainedModel( | 'checkpointPath' | 'configPath' | 'detectionMode' + | 'promptClasses' | 'keypointBackend' | 'keypointTemplateIds' | 'auxiliaryPaths' @@ -392,6 +393,12 @@ export async function validatePretrainedModel( if (!checkpointPath.toLowerCase().endsWith('.pt')) { warnings.push('权重文件扩展名不是 .pt'); } + if ( + model.detectionMode === 'open_vocab' && + !(model.promptClasses ?? []).some((item) => item.trim()) + ) { + errors.push('开放词表检测需要至少一个提示词 (promptClasses)'); + } // 检测模式写错会让模型在标注界面静默消失(详见 describeDetectionModeMismatch)。 const mismatch = describeDetectionModeMismatch({ detectionMode: model.detectionMode, diff --git a/src/renderer/components/agent/AgentSubagentTabView.css b/src/renderer/components/agent/AgentSubagentTabView.css index dcb8c58..4036859 100644 --- a/src/renderer/components/agent/AgentSubagentTabView.css +++ b/src/renderer/components/agent/AgentSubagentTabView.css @@ -47,7 +47,9 @@ .agent-subagent-tab-body { flex: 1; min-height: 0; - overflow: auto; +} + +.agent-subagent-tab-body-scroll { padding: 12px 16px 20px; } diff --git a/src/renderer/components/agent/AgentSubagentTabView.tsx b/src/renderer/components/agent/AgentSubagentTabView.tsx index 9d48613..a17b2b0 100644 --- a/src/renderer/components/agent/AgentSubagentTabView.tsx +++ b/src/renderer/components/agent/AgentSubagentTabView.tsx @@ -13,6 +13,7 @@ import { buildAssistantRenderSegments, type AssistantRenderSegment, } from './explorationRenderUtils'; +import OverlayVerticalScrollArea from '../OverlayVerticalScrollArea'; import './AgentMessageItem.css'; import './AgentSubagentTabView.css'; @@ -112,7 +113,11 @@ export default function AgentSubagentTabView({ -
+ {emptyRunning ? (
正在查阅工作区…
) : failed && transcript.length === 0 ? ( @@ -143,7 +148,7 @@ export default function AgentSubagentTabView({ ) : null}
)} - + ); } diff --git a/src/renderer/components/annotation/AnnotationDrawLabelPicker.css b/src/renderer/components/annotation/AnnotationDrawLabelPicker.css index aae8e8b..90ea25c 100644 --- a/src/renderer/components/annotation/AnnotationDrawLabelPicker.css +++ b/src/renderer/components/annotation/AnnotationDrawLabelPicker.css @@ -49,25 +49,6 @@ font-size: 16px; } -.annotation-right-label-more-menu { - position: absolute; - top: calc(100% + 4px); - right: 0; - z-index: 30; - display: flex; - flex-direction: column; - gap: 4px; - min-width: 120px; - max-width: 220px; - max-height: 220px; - overflow-y: auto; - padding: 4px; - border: 1px solid var(--vscode-menu-border, #454545); - border-radius: 4px; - background: var(--vscode-menu-background, #252526); - box-shadow: 0 4px 12px var(--lr-menu-shadow, rgba(0, 0, 0, 0.4)); -} - .annotation-right-label-btn--menu { width: 100%; text-align: left; diff --git a/src/renderer/components/annotation/AnnotationLabelOverflowMenuPortal.css b/src/renderer/components/annotation/AnnotationLabelOverflowMenuPortal.css index 43b98bd..9bbe1cd 100644 --- a/src/renderer/components/annotation/AnnotationLabelOverflowMenuPortal.css +++ b/src/renderer/components/annotation/AnnotationLabelOverflowMenuPortal.css @@ -1,19 +1,34 @@ .annotation-label-overflow-menu-portal { position: fixed; z-index: 1200; - display: flex; - flex-direction: column; - gap: 4px; min-width: 120px; max-width: 220px; - max-height: min(220px, calc(100vh - 16px)); - overflow-y: auto; - padding: 4px; + overflow: hidden; + padding: 0; margin: 0; border: 1px solid var(--vscode-menu-border, #454545); border-radius: 4px; background: var(--vscode-menu-background, #252526); box-shadow: 0 4px 12px var(--lr-menu-shadow, rgba(0, 0, 0, 0.4)); + + /* portal 到 body,拿不到侧栏上的这套变量,需在此声明 */ + --agent-overlay-scrollbar-size: 6px; + --agent-overlay-scrollbar-inset: 3px; + --agent-overlay-scrollbar-thumb: rgba(255, 255, 255, 0.22); + --agent-overlay-scrollbar-thumb-hover: rgba(255, 255, 255, 0.36); +} + +[data-theme='light'] .annotation-label-overflow-menu-portal { + --agent-overlay-scrollbar-thumb: rgba(0, 0, 0, 0.18); + --agent-overlay-scrollbar-thumb-hover: rgba(0, 0, 0, 0.3); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.14); +} + +.annotation-label-overflow-menu-list { + display: flex; + flex-direction: column; + gap: 4px; + padding: 4px; } .annotation-label-overflow-menu-portal[style*='visibility: hidden'] { @@ -24,4 +39,4 @@ .annotation-label-overflow-menu-portal .image-annotation-toolbar-chip--menu { width: 100%; text-align: left; -} +} \ No newline at end of file diff --git a/src/renderer/components/annotation/AnnotationLabelOverflowMenuPortal.test.tsx b/src/renderer/components/annotation/AnnotationLabelOverflowMenuPortal.test.tsx new file mode 100644 index 0000000..9d8af2c --- /dev/null +++ b/src/renderer/components/annotation/AnnotationLabelOverflowMenuPortal.test.tsx @@ -0,0 +1,71 @@ +import { afterEach, describe, expect, it, jest } from '@jest/globals'; +import { cleanup, fireEvent, render } from '@testing-library/react'; +import AnnotationLabelOverflowMenuPortal from './AnnotationLabelOverflowMenuPortal'; + +function makeLabels(count: number) { + return Array.from({ length: count }, (_, index) => ({ + id: `label-${index}`, + name: `label_${index}`, + color: '#F44336', + })); +} + +function setup(count: number) { + const anchor = document.createElement('button'); + document.body.appendChild(anchor); + const onSelect = jest.fn(); + const onClose = jest.fn(); + + render( + , + ); + + const menu = document.body.querySelector( + '.annotation-label-overflow-menu-portal', + ) as HTMLElement; + return { anchor, onSelect, onClose, menu }; +} + +afterEach(() => { + cleanup(); + document.body.innerHTML = ''; +}); + +describe('AnnotationLabelOverflowMenuPortal', () => { + it('使用悬浮滚动区渲染全部标签', () => { + const { menu } = setup(40); + expect(menu).toBeTruthy(); + expect(menu.querySelector('.overlay-vertical-scroll-area')).toBeTruthy(); + expect(menu.querySelectorAll('[role="option"]')).toHaveLength(40); + }); + + it('菜单内部滚动不关闭菜单(回归:原 bug 一滚就关)', () => { + const { menu, onClose } = setup(40); + + fireEvent.scroll( + menu.querySelector('.overlay-vertical-scroll-area') as HTMLElement, + ); + expect(onClose).not.toHaveBeenCalled(); + + fireEvent.scroll(menu.querySelector('[role="option"]') as HTMLElement); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('外部容器滚动仍会关闭菜单', () => { + const { onClose } = setup(40); + const outside = document.createElement('div'); + document.body.appendChild(outside); + + fireEvent.scroll(outside); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/renderer/components/annotation/AnnotationLabelOverflowMenuPortal.tsx b/src/renderer/components/annotation/AnnotationLabelOverflowMenuPortal.tsx index 3f599da..c3aea83 100644 --- a/src/renderer/components/annotation/AnnotationLabelOverflowMenuPortal.tsx +++ b/src/renderer/components/annotation/AnnotationLabelOverflowMenuPortal.tsx @@ -11,6 +11,7 @@ import { computeFloatingMenuPosition, type FloatingMenuPosition, } from '../../utils/annotationMenuPosition'; +import OverlayVerticalScrollArea from '../OverlayVerticalScrollArea'; import './AnnotationLabelOverflowMenuPortal.css'; interface LabelOption { @@ -74,17 +75,24 @@ export default function AnnotationLabelOverflowMenuPortal({ if (event.key === 'Escape') onClose(); }; + // 内部滚动(菜单自身溢出时)不应关闭菜单;只有外部容器滚动才关闭。 + const onScroll = (event: Event) => { + const target = event.target as Node | null; + if (target && menuRef.current?.contains(target)) return; + onClose(); + }; + const onDismiss = () => onClose(); document.addEventListener('mousedown', onPointerDown); document.addEventListener('keydown', onKeyDown); - window.addEventListener('scroll', onDismiss, true); + window.addEventListener('scroll', onScroll, true); window.addEventListener('resize', onDismiss); return () => { document.removeEventListener('mousedown', onPointerDown); document.removeEventListener('keydown', onKeyDown); - window.removeEventListener('scroll', onDismiss, true); + window.removeEventListener('scroll', onScroll, true); window.removeEventListener('resize', onDismiss); }; }, [anchorEl, onClose]); @@ -111,24 +119,32 @@ export default function AnnotationLabelOverflowMenuPortal({ onMouseDown={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()} > - {labels.map((lab) => { - const active = lab.id === activeLabelId; - return ( - - ); - })} + + {labels.map((lab) => { + const active = lab.id === activeLabelId; + return ( + + ); + })} + , document.body, ); diff --git a/src/renderer/components/annotation/ExportAnnotationWizard.css b/src/renderer/components/annotation/ExportAnnotationWizard.css index 5b1e48f..f4be856 100644 --- a/src/renderer/components/annotation/ExportAnnotationWizard.css +++ b/src/renderer/components/annotation/ExportAnnotationWizard.css @@ -92,17 +92,18 @@ } .export-annotation-format-list { - display: flex; - flex-direction: column; - gap: 6px; - max-height: 220px; - overflow: auto; padding: 4px; border-radius: 4px; border: 1px solid var(--vscode-input-border, #3c3c3c); background: var(--vscode-input-background, #3c3c3c); } +.export-annotation-format-list-inner { + display: flex; + flex-direction: column; + gap: 6px; +} + .export-annotation-format-option { display: flex; flex-direction: column; diff --git a/src/renderer/components/annotation/ExportAnnotationWizard.tsx b/src/renderer/components/annotation/ExportAnnotationWizard.tsx index ad6222b..a4b80cc 100644 --- a/src/renderer/components/annotation/ExportAnnotationWizard.tsx +++ b/src/renderer/components/annotation/ExportAnnotationWizard.tsx @@ -17,6 +17,7 @@ import { import { useAnnotation } from '../../context/AnnotationContext'; import { useAnnotationWorkspace } from '../../context/AnnotationWorkspaceContext'; import { exportAnnotationProject } from '../../services/annotationExportService'; +import OverlayVerticalScrollArea from '../OverlayVerticalScrollArea'; import './ExportAnnotationWizard.css'; interface ExportAnnotationWizardProps { @@ -209,25 +210,31 @@ export default function ExportAnnotationWizard({
导出格式
- {formatOptions.map((option) => ( - - ))} + + {formatOptions.map((option) => ( + + ))} +
diff --git a/src/renderer/components/annotation/ImageCaptionEditor.css b/src/renderer/components/annotation/ImageCaptionEditor.css index d413bec..87f64b3 100644 --- a/src/renderer/components/annotation/ImageCaptionEditor.css +++ b/src/renderer/components/annotation/ImageCaptionEditor.css @@ -160,7 +160,6 @@ body.is-resizing-row .image-caption-drawer { /* ── Tab 内容区 ── */ .image-caption-drawer-body { flex: 1; - overflow-y: auto; min-height: 0; } diff --git a/src/renderer/components/annotation/ImageCaptionEditor.tsx b/src/renderer/components/annotation/ImageCaptionEditor.tsx index 69cc126..4fd7b8a 100644 --- a/src/renderer/components/annotation/ImageCaptionEditor.tsx +++ b/src/renderer/components/annotation/ImageCaptionEditor.tsx @@ -10,6 +10,7 @@ import { useAnnotation } from '../../context/AnnotationContext'; import { useAnnotationWorkspace } from '../../context/AnnotationWorkspaceContext'; import type { CaptionGranularity } from '../../types/annotationDocument'; import ImageAnnotationToolbar from './ImageAnnotationToolbar'; +import OverlayVerticalScrollArea from '../OverlayVerticalScrollArea'; import './ImageCaptionEditor.css'; interface ImageCaptionEditorProps { @@ -248,7 +249,11 @@ export default function ImageCaptionEditor({ {/* Tab 内容区 */} -
+ {activeTab === 'input' ? (
@@ -366,7 +371,7 @@ export default function ImageCaptionEditor({ )}
)} -
+
diff --git a/src/renderer/components/annotation/ImageClassificationEditor.css b/src/renderer/components/annotation/ImageClassificationEditor.css index 04b15e3..3678943 100644 --- a/src/renderer/components/annotation/ImageClassificationEditor.css +++ b/src/renderer/components/annotation/ImageClassificationEditor.css @@ -34,11 +34,9 @@ .image-classification-panel { width: 320px; min-width: 280px; - display: flex; - flex-direction: column; border-left: 1px solid var(--vscode-panel-border, #3c3c3c); background: var(--vscode-editor-background, #1e1e1e); - overflow-y: auto; + overflow: hidden; } .image-classification-panel-section { diff --git a/src/renderer/components/annotation/ImageClassificationEditor.tsx b/src/renderer/components/annotation/ImageClassificationEditor.tsx index 87fc31b..617408e 100644 --- a/src/renderer/components/annotation/ImageClassificationEditor.tsx +++ b/src/renderer/components/annotation/ImageClassificationEditor.tsx @@ -3,6 +3,7 @@ import { VscodeButton } from '@vscode-elements/react-elements'; import { useAnnotation } from '../../context/AnnotationContext'; import { useAnnotationWorkspace } from '../../context/AnnotationWorkspaceContext'; import ImageAnnotationToolbar from './ImageAnnotationToolbar'; +import OverlayVerticalScrollArea from '../OverlayVerticalScrollArea'; import './ImageClassificationEditor.css'; interface ImageClassificationEditorProps { @@ -75,7 +76,11 @@ export default function ImageClassificationEditor({ /> -
+

已分配标签

{classificationAnnotations.length === 0 ? ( @@ -184,7 +189,7 @@ export default function ImageClassificationEditor({

)} -
+ ); diff --git a/src/renderer/components/annotation/ImportLabelsDialog.css b/src/renderer/components/annotation/ImportLabelsDialog.css new file mode 100644 index 0000000..ad778ef --- /dev/null +++ b/src/renderer/components/annotation/ImportLabelsDialog.css @@ -0,0 +1,99 @@ +.import-labels-dialog { + width: min(520px, calc(100vw - 32px)); + max-height: calc(100vh - 64px); + display: flex; + flex-direction: column; + padding: 20px; + border-radius: 6px; + background: var(--vscode-editorWidget-background, #252526); + border: 1px solid var(--vscode-widget-border, #454545); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45); +} + +.import-labels-title { + margin: 0 0 8px; + font-size: var(--lr-ui-font-large); + font-weight: 600; + color: var(--vscode-foreground, #cccccc); +} + +.import-labels-file { + margin: 0 0 12px; + font-size: var(--lr-ui-font-caption); + color: var(--vscode-descriptionForeground, #9d9d9d); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.import-labels-summary { + margin: 0 0 12px; + font-size: var(--lr-ui-font-meta); + color: var(--vscode-foreground, #cccccc); +} + +.import-labels-summary strong { + color: var(--vscode-textLink-foreground, #3794ff); +} + +.import-labels-invalid { + margin: 0 0 12px; + padding: 8px 10px; + border-radius: 4px; + background: var(--vscode-inputValidation-warningBackground, rgba(255, 152, 0, 0.12)); + font-size: var(--lr-ui-font-caption); + color: var(--vscode-descriptionForeground, #9d9d9d); +} + +.import-labels-invalid-list { + margin: 0; + padding: 0; + list-style: none; +} + +.import-labels-invalid-list li { + line-height: 1.5; +} + +.import-labels-list { + flex: 1; + min-height: 60px; + margin: 0 0 16px; + border-radius: 4px; + background: var(--vscode-input-background, #3c3c3c); +} + +.import-labels-list-inner { + padding: 10px; + display: flex; + flex-wrap: wrap; + gap: 6px 8px; + align-content: flex-start; +} + +.import-labels-item { + display: flex; +} + +.import-labels-chip { + display: inline-block; + max-width: 220px; + padding: 2px 8px; + border-radius: 10px; + font-size: var(--lr-ui-font-caption); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.import-labels-empty { + margin: 0 0 16px; + font-size: var(--lr-ui-font-meta); + color: var(--vscode-descriptionForeground, #9d9d9d); +} + +.import-labels-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} \ No newline at end of file diff --git a/src/renderer/components/annotation/ImportLabelsDialog.tsx b/src/renderer/components/annotation/ImportLabelsDialog.tsx new file mode 100644 index 0000000..54b0556 --- /dev/null +++ b/src/renderer/components/annotation/ImportLabelsDialog.tsx @@ -0,0 +1,107 @@ +import { VscodeButton } from '@vscode-elements/react-elements'; +import ModalMotion from '../../motion/ModalMotion'; +import { getLabelChipStyle } from '../../utils/labelColor'; +import type { LabelImportPlan } from '../../utils/labelImport'; +import OverlayVerticalScrollArea from '../OverlayVerticalScrollArea'; +import './ImportLabelsDialog.css'; + +interface ImportLabelsDialogProps { + open: boolean; + plan: LabelImportPlan | null; + fileName: string; + onCancel: () => void; + onConfirm: () => void; +} + +const MAX_VISIBLE_INVALID = 6; + +export default function ImportLabelsDialog({ + open, + plan, + fileName, + onCancel, + onConfirm, +}: ImportLabelsDialogProps) { + const importedCount = plan?.imported.length ?? 0; + const duplicateCount = plan?.duplicateNames.length ?? 0; + const invalidCount = plan?.invalidMessages.length ?? 0; + const visibleInvalid = + plan?.invalidMessages.slice(0, MAX_VISIBLE_INVALID) ?? []; + const hiddenInvalid = invalidCount - visibleInvalid.length; + + return ( + +

+ 导入标签 +

+ {fileName ? ( +

+ {fileName} +

+ ) : null} + +

+ 将导入 {importedCount} 个 + {duplicateCount > 0 ? ` · 跳过重复 ${duplicateCount} 个` : ''} + {invalidCount > 0 ? ` · 忽略无效 ${invalidCount} 项` : ''} +

+ + {visibleInvalid.length > 0 ? ( + +
    + {visibleInvalid.map((message) => ( +
  • {message}
  • + ))} + {hiddenInvalid > 0 ?
  • …另有 {hiddenInvalid} 项被忽略
  • : null} +
+
+ ) : null} + + {importedCount > 0 ? ( + + {plan?.imported.map((label) => ( + + + {label.name} + + + ))} + + ) : ( +

没有可导入的新标签。

+ )} + +
+ + 取消 + + + 确认导入 + +
+
+ ); +} diff --git a/src/renderer/components/annotation/LabelEditor.css b/src/renderer/components/annotation/LabelEditor.css index 023abbc..4e356eb 100644 --- a/src/renderer/components/annotation/LabelEditor.css +++ b/src/renderer/components/annotation/LabelEditor.css @@ -11,6 +11,13 @@ gap: 8px; } +.label-editor-header-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + .label-editor-title { font-size: var(--lr-ui-font-meta); font-weight: 600; @@ -46,6 +53,10 @@ text-overflow: ellipsis; } +.label-editor-list-scroll-content { + padding-right: 8px; +} + .label-editor-list { list-style: none; margin: 0; diff --git a/src/renderer/components/annotation/LabelEditor.test.tsx b/src/renderer/components/annotation/LabelEditor.test.tsx new file mode 100644 index 0000000..f0c758e --- /dev/null +++ b/src/renderer/components/annotation/LabelEditor.test.tsx @@ -0,0 +1,40 @@ +import { describe, expect, it } from '@jest/globals'; +import { render } from '@testing-library/react'; +import { ToastProvider } from '../../context/ToastContext'; +import type { LabelDefinition } from '../../types/annotation'; +import LabelEditor from './LabelEditor'; + +function makeLabels(count: number): LabelDefinition[] { + return Array.from({ length: count }, (_, index) => ({ + id: `label-${index}`, + name: `label_${index}`, + color: '#F44336', + })); +} + +describe('LabelEditor 滚动', () => { + it('长标签列表使用悬浮滚动区(回归:不再用原生滚动条)', () => { + const { container } = render( + + {}} /> + , + ); + + expect( + container.querySelector('.overlay-vertical-scroll-area'), + ).toBeTruthy(); + expect(container.querySelector('.label-editor-list')).toBeTruthy(); + expect(container.querySelectorAll('.label-editor-row')).toHaveLength(200); + }); + + it('空标签时展示提示文案', () => { + const { container, getByText } = render( + + {}} emptyHint="暂无标签" /> + , + ); + + expect(getByText('暂无标签')).toBeTruthy(); + expect(container.querySelector('.overlay-vertical-scroll-area')).toBeNull(); + }); +}); diff --git a/src/renderer/components/annotation/LabelEditor.tsx b/src/renderer/components/annotation/LabelEditor.tsx index 527ec48..af92825 100644 --- a/src/renderer/components/annotation/LabelEditor.tsx +++ b/src/renderer/components/annotation/LabelEditor.tsx @@ -1,10 +1,19 @@ import { VscodeButton, VscodeIcon } from '@vscode-elements/react-elements'; -import { useRef } from 'react'; +import { useRef, useState } from 'react'; import { LABEL_COLOR_PRESETS, LabelDefinition } from '../../types/annotation'; import { findTemplatesByDefaultLabel, formatKeypointLabelRenameWarning, } from '../../types/keypointTemplate'; +import { basename } from '../../types/file'; +import { useToast } from '../../context/ToastContext'; +import { + isTruncatedReadFileResult, + LabelImportPlan, + planLabelImport, +} from '../../utils/labelImport'; +import ImportLabelsDialog from './ImportLabelsDialog'; +import OverlayVerticalScrollArea from '../OverlayVerticalScrollArea'; import './LabelEditor.css'; interface LabelEditorProps { @@ -26,6 +35,9 @@ export default function LabelEditor({ emptyHint, }: LabelEditorProps) { const nameAtFocusRef = useRef>(new Map()); + const { showToast } = useToast(); + const [importPlan, setImportPlan] = useState(null); + const [importFileName, setImportFileName] = useState(''); const addLabel = () => { onChange([ @@ -38,6 +50,47 @@ export default function LabelEditor({ ]); }; + const openImportDialog = async () => { + const path = await window.electron.dialog.openFile({ + title: '导入标签 JSON', + filters: [{ name: 'JSON', extensions: ['json'] }], + }); + if (!path) return; + + const text = await window.electron.fileSystem.readFile(path); + if (text === null) { + showToast('读取文件失败。', { type: 'error' }); + return; + } + if (isTruncatedReadFileResult(text)) { + showToast('文件过大,无法完整读取标签 JSON。', { type: 'error' }); + return; + } + + const plan = planLabelImport(text, labels); + if (plan.fatalError) { + showToast(plan.fatalError, { type: 'error' }); + return; + } + + setImportFileName(basename(path)); + setImportPlan(plan); + }; + + const closeImportDialog = () => { + setImportPlan(null); + setImportFileName(''); + }; + + const confirmImport = () => { + if (!importPlan) return; + onChange([...labels, ...importPlan.imported]); + showToast(`已导入 ${importPlan.imported.length} 个标签。`, { + type: 'success', + }); + closeImportDialog(); + }; + const updateLabel = (id: string, patch: Partial) => { onChange( labels.map((label) => (label.id === id ? { ...label, ...patch } : label)), @@ -93,9 +146,19 @@ export default function LabelEditor({ {keypointMode ? '模板关联标签' : '标签定义'} - - 添加标签 - +
+ + 批量导入 + + + 添加标签 + +
{keypointMode && labels.length > 0 ? ( @@ -107,62 +170,78 @@ export default function LabelEditor({ {labels.length === 0 ? (

{emptyHint ?? defaultEmptyHint}

) : ( -
    - {labels.map((label) => { - const linked = keypointMode - ? findTemplatesByDefaultLabel(label.name) - : []; - return ( -
  • - - updateLabel(label.id, { color: event.target.value }) - } - aria-label="标签颜色" - /> -
    + +
      + {labels.map((label) => { + const linked = keypointMode + ? findTemplatesByDefaultLabel(label.name) + : []; + return ( +
    • handleNameFocus(label.id, label.name)} + type="color" + className="label-editor-color" + value={label.color} onChange={(event) => - updateLabel(label.id, { name: event.target.value }) - } - onBlur={(event) => - handleNameBlur(label.id, event.target.value) + updateLabel(label.id, { color: event.target.value }) } + aria-label="标签颜色" /> - {linked.length > 0 ? ( - t.name).join('、')} - > - {linked.map((t) => t.name).join(' · ')} - - ) : null} -
    - -
  • - ); - })} -
+
+ handleNameFocus(label.id, label.name)} + onChange={(event) => + updateLabel(label.id, { name: event.target.value }) + } + onBlur={(event) => + handleNameBlur(label.id, event.target.value) + } + /> + {linked.length > 0 ? ( + t.name).join('、')} + > + {linked.map((t) => t.name).join(' · ')} + + ) : null} +
+ + + ); + })} + + )} + + ); } diff --git a/src/renderer/components/annotation/PreAnnotToolbarSection.tsx b/src/renderer/components/annotation/PreAnnotToolbarSection.tsx index 4efe4e2..f86f2a5 100644 --- a/src/renderer/components/annotation/PreAnnotToolbarSection.tsx +++ b/src/renderer/components/annotation/PreAnnotToolbarSection.tsx @@ -20,6 +20,7 @@ import { isDetectResult, isPolygonResult, isPoseResult, + resolvePreAnnotDetectKind, } from '../../../shared/preAnnotTypes'; import { describeNoEligibleModelReason, @@ -168,7 +169,7 @@ export default function PreAnnotToolbarSection({ setRunning(true); try { - const kind = mode === 'rotated_bbox' ? 'yolo_obb' : 'yolo_detect'; + const kind = resolvePreAnnotDetectKind(selectedModel); const response = await runPreAnnot(kind, imagePath, selectedModel); const result = assertPreAnnotResult(response, isDetectResult, '检测'); diff --git a/src/renderer/components/annotation/TextClassificationEditor.css b/src/renderer/components/annotation/TextClassificationEditor.css index 35208f3..3d5be6a 100644 --- a/src/renderer/components/annotation/TextClassificationEditor.css +++ b/src/renderer/components/annotation/TextClassificationEditor.css @@ -75,11 +75,9 @@ .text-classification-labels-panel { width: 280px; - display: flex; - flex-direction: column; - overflow: auto; flex-shrink: 0; background: var(--vscode-editor-background); + overflow: hidden; } .text-classification-section { diff --git a/src/renderer/components/annotation/TextClassificationEditor.tsx b/src/renderer/components/annotation/TextClassificationEditor.tsx index 23a20f8..70e89be 100644 --- a/src/renderer/components/annotation/TextClassificationEditor.tsx +++ b/src/renderer/components/annotation/TextClassificationEditor.tsx @@ -3,6 +3,7 @@ import { VscodeButton, VscodeIcon } from '@vscode-elements/react-elements'; import { useAnnotation } from '../../context/AnnotationContext'; import { useAnnotationWorkspace } from '../../context/AnnotationWorkspaceContext'; import { getLabelChipStyle } from '../../utils/labelColor'; +import OverlayVerticalScrollArea from '../OverlayVerticalScrollArea'; import './TextClassificationEditor.css'; export default function TextClassificationEditor() { @@ -93,7 +94,11 @@ export default function TextClassificationEditor() { {/* 分类面板 */} -
+ {/* 已分配标签 */}

已分配标签

@@ -196,7 +201,7 @@ export default function TextClassificationEditor() {

)} -
+ ); diff --git a/src/renderer/components/annotation/WorkspaceMemoryPanel.css b/src/renderer/components/annotation/WorkspaceMemoryPanel.css index 39c04f7..e6bb215 100644 --- a/src/renderer/components/annotation/WorkspaceMemoryPanel.css +++ b/src/renderer/components/annotation/WorkspaceMemoryPanel.css @@ -63,11 +63,13 @@ .workspace-memory-panel-list { flex: 1; min-height: 160px; - max-height: min(420px, calc(100vh - 280px)); - overflow: auto; margin-bottom: 16px; } +.workspace-memory-panel-list-inner { + min-height: 160px; +} + .workspace-memory-panel-empty { margin: 24px 0; text-align: center; diff --git a/src/renderer/components/annotation/WorkspaceMemoryPanel.tsx b/src/renderer/components/annotation/WorkspaceMemoryPanel.tsx index 86f662a..010d258 100644 --- a/src/renderer/components/annotation/WorkspaceMemoryPanel.tsx +++ b/src/renderer/components/annotation/WorkspaceMemoryPanel.tsx @@ -17,6 +17,7 @@ import { syncWorkspaceFactMemory } from '../../services/workspaceFactMemory'; import type { AnnotationProject } from '../../types/annotation'; import WorkspaceMemoryOpenSplit from './WorkspaceMemoryOpenSplit'; import WorkspaceMemoryToggle from './WorkspaceMemoryToggle'; +import OverlayVerticalScrollArea from '../OverlayVerticalScrollArea'; import './WorkspaceMemoryPanel.css'; interface WorkspaceMemoryPanelProps { @@ -142,51 +143,57 @@ export default function WorkspaceMemoryPanel({ /> -
- {loading ? ( -

正在读取记忆文件…

- ) : entries.length === 0 ? ( -
- -

尚未生成记忆文件

- - 打开开关后,确认标注或保存时系统会更新进度与已标文件;Agent - 可另记偏好 - -
- ) : ( -
    - {entries.map((entry) => ( -
  • -
    - -
    -
    -
    - {entry.title} + +
    + {loading ? ( +

    正在读取记忆文件…

    + ) : entries.length === 0 ? ( +
    + +

    尚未生成记忆文件

    + + 打开开关后,确认标注或保存时系统会更新进度与已标文件;Agent + 可另记偏好 + +
    + ) : ( +
      + {entries.map((entry) => ( +
    • +
      +
      - {entry.excerpt ? ( -

      - {entry.excerpt} -

      - ) : null} -
      - {entry.relativePath} +
      +
      + {entry.title} +
      + {entry.excerpt ? ( +

      + {entry.excerpt} +

      + ) : null} +
      + {entry.relativePath} +
      -
      - { - void handleOpenFile(entry, target); - }} - /> -
    • - ))} -
    - )} -
    + { + void handleOpenFile(entry, target); + }} + /> +
  • + ))} +
+ )} +
+
{progress.lines.length > 0 && ( -
-          {progress.lines.join('\n')}
-        
+ +
+            {progress.lines.join('\n')}
+          
+
)} {progress.stage === 'done' && (
diff --git a/src/renderer/components/pretrainedModels/PretrainedModelFormModal.tsx b/src/renderer/components/pretrainedModels/PretrainedModelFormModal.tsx index 5a4a2b2..7731535 100644 --- a/src/renderer/components/pretrainedModels/PretrainedModelFormModal.tsx +++ b/src/renderer/components/pretrainedModels/PretrainedModelFormModal.tsx @@ -8,6 +8,7 @@ import { KeypointBackend, KEYPOINT_BACKEND_LABELS, KEYPOINT_BACKEND_PRESETS, + defaultParamsForDetectionMode, defaultParamsForType, type ObjectDetectionMode, } from '../../types/pretrainedModel'; @@ -48,10 +49,12 @@ export default function PretrainedModelFormModal({ const [validationMessages, setValidationMessages] = useState([]); const [submitting, setSubmitting] = useState(false); const [scanning, setScanning] = useState(false); + const [promptText, setPromptText] = useState(''); useEffect(() => { if (!open) return; setForm(initial); + setPromptText((initial.promptClasses ?? []).join('\n')); setError(null); setValidationMessages([]); setSubmitting(false); @@ -71,6 +74,19 @@ export default function PretrainedModelFormModal({ })); }; + const handleDetectionModeChange = (mode: ObjectDetectionMode) => { + const defaults = defaultParamsForDetectionMode(mode); + setForm((prev) => ({ + ...prev, + detectionMode: mode, + params: { + ...prev.params, + confThreshold: defaults.confThreshold, + iouThreshold: defaults.iouThreshold, + }, + })); + }; + const handleTypeChange = (modelType: PretrainedModelType) => { const backend: KeypointBackend = 'yolo_pose'; const preset = KEYPOINT_BACKEND_PRESETS[backend]; @@ -286,11 +302,21 @@ export default function PretrainedModelFormModal({ } } + if ( + form.modelType === 'object_detection' && + form.detectionMode === 'open_vocab' && + !(form.promptClasses ?? []).length + ) { + setError('开放词表检测需要至少一个提示词'); + return; + } + const validation = await validatePretrainedModelPaths({ modelType: form.modelType, checkpointPath: form.checkpointPath, configPath: form.configPath, detectionMode: form.detectionMode, + promptClasses: form.promptClasses, keypointBackend: form.keypointBackend, keypointTemplateIds: form.keypointTemplateIds, auxiliaryPaths: form.auxiliaryPaths, @@ -311,6 +337,11 @@ export default function PretrainedModelFormModal({ name: form.name.trim(), checkpointPath: form.checkpointPath.trim(), configPath: form.configPath?.trim() || undefined, + promptClasses: + form.modelType === 'object_detection' && + form.detectionMode === 'open_vocab' + ? form.promptClasses + : undefined, keypointTemplateIds: form.keypointTemplateIds?.length ? form.keypointTemplateIds : undefined, @@ -600,6 +631,7 @@ export default function PretrainedModelFormModal({ [ ['detect', '普通矩形框 (Detect)'], ['obb', '旋转框 (OBB)'], + ['open_vocab', '开放词表 (YOLO-World)'], ] as [ObjectDetectionMode, string][] ).map(([value, label]) => ( @@ -622,6 +654,32 @@ export default function PretrainedModelFormModal({
)} + {form.modelType === 'object_detection' && + (form.detectionMode ?? 'detect') === 'open_vocab' && ( +
+ +