diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 134884c98..ee971e410 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -202,7 +202,9 @@ jobs: - name: Install Linux packaging dependencies run: | sudo apt-get update - sudo apt-get install -y --no-install-recommends libarchive-tools rpm + # build-essential + python3: node-pty ships no Linux prebuild, so it + # must be compiled from source against Electron's ABI during dist. + sudo apt-get install -y --no-install-recommends libarchive-tools rpm build-essential python3 - name: Install dependencies run: npm ci diff --git a/docs/KUN_CONFIG.md b/docs/KUN_CONFIG.md index 0ad7fad44..6de0e2b5f 100644 --- a/docs/KUN_CONFIG.md +++ b/docs/KUN_CONFIG.md @@ -79,6 +79,11 @@ GUI 启动 Kun 时会按下面的顺序合并配置。 "summaryTimeoutMs": 15000, "summaryMaxTokens": 1200, "summaryInputMaxBytes": 98304 + }, + "runtime": { + "streamIdleTimeoutMs": 45000, + "toolStorm": { "enabled": true, "windowSize": 8, "threshold": 3 }, + "toolArgumentRepair": { "maxStringBytes": 524288 } } } ``` diff --git a/electron-builder.config.cjs b/electron-builder.config.cjs index 66506e7e7..510a758a8 100644 --- a/electron-builder.config.cjs +++ b/electron-builder.config.cjs @@ -96,6 +96,7 @@ module.exports = { '**/kun/package*.json', '**/kun/node_modules/**/*', '**/node_modules/better-sqlite3/**/*', + '**/node_modules/node-pty/**/*', '**/node_modules/bindings/**/*', '**/node_modules/file-uri-to-path/**/*' ], diff --git a/kun/README.md b/kun/README.md index 28d4c8a9e..88d0776e8 100644 --- a/kun/README.md +++ b/kun/README.md @@ -262,6 +262,7 @@ Feature flags are intentionally explicit: - `serve.tokenEconomy` / `tokenEconomyMode` compresses tool descriptions, tool results, and history context while preserving code, paths, commands, URLs, errors, and other high-value signals. - `contextCompaction` controls fallback long-thread compaction thresholds and summary behavior. Per-model thresholds live in `models.profiles`. Compaction preserves goals, constraints, decisions, touched files, tool outcomes, and unresolved next steps. - `serve.runtimeTuning.toolStorm` suppresses repeated identical tool calls within a turn so useless tool loops do not keep spending tokens. +- `runtime.streamIdleTimeoutMs` (top-level in `config.json`) caps the idle gap between streaming chunks before a turn fails with `stream_idle_timeout` (default `45000`). Raise it for local model servers that stay silent while prefilling a very large prompt; set `0` to disable the guard. - `capabilities.web` exposes `web_fetch` and/or `web_search`. The built-in provider can fetch HTTP(S) pages; search requires a provider implementation and may report unavailable. - `capabilities.skills` scans configured roots for `skill.json` manifests and, when `legacySkillMd` is true, older `SKILL.md` directories. - `capabilities.attachments` stores image bytes outside thread logs and allows turns to reference `attachmentIds`. Vision-capable models receive image parts; text-only models receive a bounded compressed base64 text fallback. diff --git a/kun/README.zh-CN.md b/kun/README.zh-CN.md index d165695ad..e351b2732 100644 --- a/kun/README.zh-CN.md +++ b/kun/README.zh-CN.md @@ -239,6 +239,7 @@ Kun 默认使用混合存储:`threads/{threadId}/messages.jsonl` 与 `events.j - `serve.tokenEconomy` / `tokenEconomyMode` 会压缩工具描述、工具结果和历史上下文;保留代码、路径、命令、URL、错误信号等高价值信息,同时省掉重复、超长或二进制 payload。 - `contextCompaction` 控制长会话压缩的兜底阈值和摘要方式;模型级阈值写在 `models.profiles`。压缩时保留目标、约束、决策、已触碰文件、工具结果和未解决事项。 - `serve.runtimeTuning.toolStorm` 会抑制同一回合内重复的相同工具调用,阻止无意义 tool loop 继续烧 token。 +- `runtime.streamIdleTimeoutMs`(`config.json` 顶层)限制流式分片之间的最大空闲间隔,超时会以 `stream_idle_timeout` 结束本轮(默认 `45000`)。本地模型预处理超大输入时会长时间静默,可调大此值;填 `0` 表示不限制。 - `capabilities.web` 暴露 `web_fetch` 与/或 `web_search`。内置 provider 负责 HTTP(S) 抓取;搜索功能依赖 provider 实现,未配置时会变为不可用。 - `capabilities.skills` 扫描 `roots` 下的 `skill.json`,并在 `legacySkillMd` 为 `true` 时兼容 `SKILL.md`。 - `capabilities.attachments` 将图片二进制从线程日志剥离,允许回合记录引用 `attachmentIds`。视觉模型直接接收图片部分,纯文本模型走受限文本 fallback。 diff --git a/kun/src/adapters/model/compat-model-client.endpoint-format.test.ts b/kun/src/adapters/model/compat-model-client.endpoint-format.test.ts new file mode 100644 index 000000000..0353ac01d --- /dev/null +++ b/kun/src/adapters/model/compat-model-client.endpoint-format.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest' +import { CompatModelClient } from './compat-model-client.js' +import type { ModelCapabilityMetadata } from '../../contracts/capabilities.js' +import type { ModelEndpointFormat } from '../../contracts/model-endpoint-format.js' +import type { ModelRequest, ModelStreamChunk } from '../../ports/model-client.js' + +// A single provider (OpenCode Go) routes some models over chat completions +// and others over Anthropic Messages. The wire format is resolved per request +// model from its capability metadata, falling back to the provider format. + +type CapturedCall = { url: string; body: Record } + +function modelCapabilities( + overrides: Record +): (model: string) => ModelCapabilityMetadata { + return (model) => ({ + id: model, + inputModalities: ['text'], + outputModalities: ['text'], + supportsToolCalling: true, + messageParts: ['text'], + ...(overrides[model] ? { endpointFormat: overrides[model] } : {}) + }) +} + +function fakeFetch(calls: CapturedCall[]): typeof fetch { + return (async (url: string, init: { body: string }) => { + const target = String(url) + calls.push({ url: target, body: JSON.parse(init.body) as Record }) + const json = target.endsWith('/messages') + ? { content: [{ type: 'text', text: 'ok' }], stop_reason: 'end_turn' } + : { choices: [{ index: 0, finish_reason: 'stop', message: { content: 'ok' } }] } + return new Response(JSON.stringify(json), { + status: 200, + headers: { 'content-type': 'application/json' } + }) + }) as unknown as typeof fetch +} + +function request(model: string): ModelRequest { + return { + threadId: 't1', + turnId: 'u1', + model, + systemPrompt: 'You are a helpful assistant.', + prefix: [], + history: [], + tools: [], + abortSignal: new AbortController().signal + } +} + +async function drain(iterable: AsyncIterable): Promise { + const chunks: ModelStreamChunk[] = [] + for await (const chunk of iterable) chunks.push(chunk) + return chunks +} + +describe('CompatModelClient per-model endpointFormat', () => { + it('routes an override model to the Anthropic Messages endpoint while others use chat completions', async () => { + const calls: CapturedCall[] = [] + const client = new CompatModelClient({ + baseUrl: 'https://opencode.ai/zen/go/v1', + apiKey: 'sk-test', + model: 'glm-5.1', + endpointFormat: 'chat_completions', + nonStreaming: true, + fetchImpl: fakeFetch(calls), + modelCapabilities: modelCapabilities({ 'minimax-m3': 'messages' }) + }) + + const messagesChunks = await drain(client.stream(request('minimax-m3'))) + const chatChunks = await drain(client.stream(request('glm-5.1'))) + + // The override model hits /messages with the Anthropic body shape. + expect(calls[0].url).toBe('https://opencode.ai/zen/go/v1/messages') + expect(calls[0].body.max_tokens).toBeDefined() + expect(calls[0].body).not.toHaveProperty('stream_options') + + // The non-override model inherits the provider format → /chat/completions. + expect(calls[1].url).toBe('https://opencode.ai/zen/go/v1/chat/completions') + expect(calls[1].body.messages).toBeDefined() + + // Both responses still materialize cleanly through their respective parsers. + expect(messagesChunks.some((c) => c.kind === 'assistant_text_delta')).toBe(true) + expect(messagesChunks.at(-1)).toEqual({ kind: 'completed', stopReason: 'stop' }) + expect(chatChunks.some((c) => c.kind === 'assistant_text_delta')).toBe(true) + expect(chatChunks.at(-1)).toEqual({ kind: 'completed', stopReason: 'stop' }) + }) + + it('sets the Anthropic auth + version headers only for the messages-routed model', async () => { + const headerCalls: Array> = [] + const capturingFetch = (async (_url: string, init: { headers: Record }) => { + headerCalls.push(init.headers) + const target = String(_url) + const json = target.endsWith('/messages') + ? { content: [{ type: 'text', text: 'ok' }], stop_reason: 'end_turn' } + : { choices: [{ index: 0, finish_reason: 'stop', message: { content: 'ok' } }] } + return new Response(JSON.stringify(json), { + status: 200, + headers: { 'content-type': 'application/json' } + }) + }) as unknown as typeof fetch + const client = new CompatModelClient({ + baseUrl: 'https://opencode.ai/zen/go/v1', + apiKey: 'sk-test', + model: 'glm-5.1', + endpointFormat: 'chat_completions', + nonStreaming: true, + fetchImpl: capturingFetch, + modelCapabilities: modelCapabilities({ 'minimax-m3': 'messages' }) + }) + + await drain(client.stream(request('minimax-m3'))) + await drain(client.stream(request('glm-5.1'))) + + expect(headerCalls[0]['anthropic-version']).toBe('2023-06-01') + expect(headerCalls[0]['x-api-key']).toBe('sk-test') + expect(headerCalls[1]['anthropic-version']).toBeUndefined() + expect(headerCalls[1]['x-api-key']).toBeUndefined() + expect(headerCalls[1].Authorization).toBe('Bearer sk-test') + }) +}) diff --git a/kun/src/adapters/model/compat-model-client.ts b/kun/src/adapters/model/compat-model-client.ts index 8ed5e7c1d..e711e9950 100644 --- a/kun/src/adapters/model/compat-model-client.ts +++ b/kun/src/adapters/model/compat-model-client.ts @@ -232,7 +232,11 @@ export class CompatModelClient implements ModelClient { yield { kind: 'error', message: 'request was aborted before start' } return } - const configuredEndpointFormat = this.endpointFormat() + const requestModel = request.model?.trim() || this.config.model + // Resolve the wire format per request model: a single provider (e.g. + // OpenCode Go) can route some models to chat completions and others to + // Anthropic Messages. Falls back to the provider/runtime format. + const configuredEndpointFormat = this.endpointFormatForModel(requestModel) const endpointFormat = resolveModelEndpointFormat(configuredEndpointFormat, this.config.baseUrl) if (!endpointFormat) { yield { @@ -243,7 +247,6 @@ export class CompatModelClient implements ModelClient { } const url = buildModelEndpointUrl(this.config.baseUrl, configuredEndpointFormat) const stream = request.stream ?? !this.config.nonStreaming - const requestModel = request.model?.trim() || this.config.model const body = this.buildRequestBody(request, stream, { endpointFormat }) if (round) { round.requestBody = body @@ -329,6 +332,17 @@ export class CompatModelClient implements ModelClient { return normalizeModelEndpointFormat(this.config.endpointFormat ?? DEFAULT_MODEL_ENDPOINT_FORMAT) } + /** + * The wire format for a specific model: a per-model override (carried on + * the model's capability metadata) takes precedence over the + * provider/runtime format. Lets one provider mix chat completions and + * Anthropic Messages models (e.g. OpenCode Go's minimax/qwen entries). + */ + private endpointFormatForModel(model: string): ModelEndpointFormat { + const perModel = this.config.modelCapabilities?.(model).endpointFormat + return normalizeModelEndpointFormat(perModel ?? this.config.endpointFormat ?? DEFAULT_MODEL_ENDPOINT_FORMAT) + } + private modelReasoningFor(model: string): ModelCapabilityMetadata['reasoning'] | undefined { return this.config.modelCapabilities?.(model).reasoning } @@ -1382,18 +1396,23 @@ export class CompatModelClient implements ModelClient { const promptDetails = usage.prompt_tokens_details as | { cached_tokens?: number } | undefined + const inputDetails = usage.input_tokens_details as + | { cached_tokens?: number } + | undefined const nativeHit = Number(usage.prompt_cache_hit_tokens ?? 0) || 0 const nativeMiss = Number(usage.prompt_cache_miss_tokens ?? 0) || 0 const hasNativeCache = nativeHit > 0 || nativeMiss > 0 - const cachedTokens = Number(promptDetails?.cached_tokens ?? 0) || 0 + const cachedTokens = Number(promptDetails?.cached_tokens ?? inputDetails?.cached_tokens ?? 0) || 0 const cacheRead = Number(usage.cache_read_input_tokens ?? 0) || 0 const cacheCreation = Number(usage.cache_creation_input_tokens ?? 0) || 0 // Anthropic-protocol usage (MiniMax et al.) reports input_tokens // EXCLUDING cache reads/writes; OpenAI-style prompt_tokens includes - // everything and marks the cached subset in prompt_tokens_details. + // everything and marks the cached subset in prompt_tokens_details or + // Responses API input_tokens_details. const anthropicUsage = usage.prompt_tokens === undefined && usage.prompt_eval_count === undefined && - usage.input_tokens !== undefined + usage.input_tokens !== undefined && + inputDetails?.cached_tokens === undefined const reportedPromptTokens = Number(usage.prompt_tokens ?? usage.prompt_eval_count ?? usage.input_tokens ?? 0) || 0 const promptTokens = anthropicUsage ? reportedPromptTokens + cacheRead + cacheCreation @@ -1923,7 +1942,7 @@ function applyReasoningEffort( } switch (normalized) { case 'off': - if (nativeDeepSeek) body.thinking = { type: 'disabled' } + if (includeThinking) body.thinking = { type: 'disabled' } break case 'low': case 'medium': diff --git a/kun/src/config/kun-config.test.ts b/kun/src/config/kun-config.test.ts index 3c59fc9da..30f667f62 100644 --- a/kun/src/config/kun-config.test.ts +++ b/kun/src/config/kun-config.test.ts @@ -1,7 +1,19 @@ import { homedir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' -import { expandHomePath } from './kun-config.js' +import { expandHomePath, RuntimeTuningConfigSchema } from './kun-config.js' + +describe('RuntimeTuningConfigSchema streamIdleTimeoutMs', () => { + it('accepts a custom timeout, including 0 to disable the guard', () => { + expect(RuntimeTuningConfigSchema.safeParse({ streamIdleTimeoutMs: 300_000 }).success).toBe(true) + expect(RuntimeTuningConfigSchema.safeParse({ streamIdleTimeoutMs: 0 }).success).toBe(true) + }) + + it('rejects negative or fractional timeouts', () => { + expect(RuntimeTuningConfigSchema.safeParse({ streamIdleTimeoutMs: -1 }).success).toBe(false) + expect(RuntimeTuningConfigSchema.safeParse({ streamIdleTimeoutMs: 1.5 }).success).toBe(false) + }) +}) describe('expandHomePath', () => { it('expands Windows-style home-relative paths', () => { diff --git a/kun/src/config/kun-config.ts b/kun/src/config/kun-config.ts index 72a64ffa9..1c64b74ce 100644 --- a/kun/src/config/kun-config.ts +++ b/kun/src/config/kun-config.ts @@ -62,7 +62,13 @@ export const ModelContextProfileConfigSchema = z outputModalities: z.array(ModelInputModality).optional(), supportsToolCalling: z.boolean().optional(), messageParts: z.array(ModelMessagePartSupport).optional(), - reasoning: ModelReasoningCapabilityMetadata.optional() + reasoning: ModelReasoningCapabilityMetadata.optional(), + // Per-model wire-format override. Omitted means "inherit the + // provider/runtime endpointFormat" — no default coercion here, otherwise + // every model would be pinned to chat_completions. + endpointFormat: z + .preprocess(normalizeModelEndpointFormat, z.enum(MODEL_ENDPOINT_FORMATS)) + .optional() }) .strict() .superRefine((profile, ctx) => { @@ -119,6 +125,10 @@ export const ContextCompactionConfigSchema = z export const RuntimeTuningConfigSchema = z .object({ + // Max idle gap (ms) between streaming chunks before a turn fails with + // `stream_idle_timeout`. Local LLM servers prefilling a huge prompt can + // stay silent well past the 45s default; `0` disables the guard entirely. + streamIdleTimeoutMs: z.number().int().min(0).optional(), toolStorm: z .object({ enabled: z.boolean().optional(), diff --git a/kun/src/contracts/capabilities.ts b/kun/src/contracts/capabilities.ts index ef1cf4a64..309230c80 100644 --- a/kun/src/contracts/capabilities.ts +++ b/kun/src/contracts/capabilities.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { MODEL_ENDPOINT_FORMATS } from './model-endpoint-format.js' export const RUNTIME_CAPABILITY_CONTRACT_VERSION = 1 @@ -51,7 +52,11 @@ export const ModelCapabilityMetadata = z supportsToolCalling: z.boolean(), contextWindowTokens: z.number().int().positive().optional(), messageParts: z.array(ModelMessagePartSupport).min(1), - reasoning: ModelReasoningCapabilityMetadata.optional() + reasoning: ModelReasoningCapabilityMetadata.optional(), + // Per-model wire-format override. Lets one provider route some models to + // chat completions and others to Anthropic Messages / OpenAI Responses + // (e.g. OpenCode Go). Absent means "inherit the provider/runtime format". + endpointFormat: z.enum(MODEL_ENDPOINT_FORMATS).optional() }) .strict() export type ModelCapabilityMetadata = z.infer diff --git a/kun/src/loop/model-context-profile.test.ts b/kun/src/loop/model-context-profile.test.ts index 5b3bec812..2f5d34495 100644 --- a/kun/src/loop/model-context-profile.test.ts +++ b/kun/src/loop/model-context-profile.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { contextThresholdsForModel } from './model-context-profile.js' +import { + contextThresholdsForModel, + modelCapabilitiesForModel, + modelContextProfilesFromConfig +} from './model-context-profile.js' describe('contextThresholdsForModel safety cap', () => { it('caps soft/hard thresholds to 75%/85% of the context window', () => { @@ -49,3 +53,23 @@ describe('contextThresholdsForModel safety cap', () => { expect(thresholds).toEqual(fallback) }) }) + +describe('per-model endpointFormat', () => { + it('carries a configured endpointFormat from models.profiles into capabilities', () => { + const profiles = modelContextProfilesFromConfig({ + models: { + profiles: { + 'minimax-m3': { contextWindowTokens: 256_000, endpointFormat: 'messages' }, + 'glm-5.1': { contextWindowTokens: 131_072 } + } + } + }) + expect(modelCapabilitiesForModel('minimax-m3', profiles).endpointFormat).toBe('messages') + // A model without an override inherits (no endpointFormat emitted). + expect(modelCapabilitiesForModel('glm-5.1', profiles).endpointFormat).toBeUndefined() + }) + + it('omits endpointFormat for unknown models so they inherit the provider format', () => { + expect(modelCapabilitiesForModel('unknown-model', []).endpointFormat).toBeUndefined() + }) +}) diff --git a/kun/src/loop/model-context-profile.ts b/kun/src/loop/model-context-profile.ts index fca6b54fd..5d6aa43ee 100644 --- a/kun/src/loop/model-context-profile.ts +++ b/kun/src/loop/model-context-profile.ts @@ -4,6 +4,7 @@ import type { ModelMessagePartSupport, ModelReasoningCapabilityMetadata } from '../contracts/capabilities.js' +import type { ModelEndpointFormat } from '../contracts/model-endpoint-format.js' export type ModelContextThresholds = { softThreshold: number @@ -26,6 +27,7 @@ export type ModelContextProfile = ModelContextThresholds & { supportsToolCalling: boolean messageParts: readonly ModelMessagePartSupport[] reasoning?: ModelReasoningCapabilityMetadata + endpointFormat?: ModelEndpointFormat } export type ModelContextProfileConfig = { @@ -45,6 +47,7 @@ export type ModelContextProfileConfig = { supportsToolCalling?: boolean messageParts?: readonly ModelMessagePartSupport[] reasoning?: ModelReasoningCapabilityMetadata + endpointFormat?: ModelEndpointFormat } export type ModelConfig = { @@ -148,7 +151,8 @@ export function modelCapabilitiesForModel( supportsToolCalling: profile?.supportsToolCalling ?? true, contextWindowTokens: profile?.contextWindowTokens, messageParts: [...(profile?.messageParts ?? DEFAULT_MODEL_MESSAGE_PARTS)], - ...(profile?.reasoning ? { reasoning: copyReasoningCapability(profile.reasoning) } : {}) + ...(profile?.reasoning ? { reasoning: copyReasoningCapability(profile.reasoning) } : {}), + ...(profile?.endpointFormat ? { endpointFormat: profile.endpointFormat } : {}) } } @@ -232,6 +236,7 @@ function mergeModelContextProfile( ...(input.aliases ?? []) ]) const reasoning = input.reasoning ?? current?.reasoning + const endpointFormat = input.endpointFormat ?? current?.endpointFormat return { canonicalModel, modelIds, @@ -244,7 +249,8 @@ function mergeModelContextProfile( messageParts: uniqueModelCapabilityValues(input.messageParts ?? current?.messageParts ?? DEFAULT_MODEL_MESSAGE_PARTS), ...(reasoning ? { reasoning: copyReasoningCapability(reasoning) } - : {}) + : {}), + ...(endpointFormat ? { endpointFormat } : {}) } } diff --git a/kun/src/server/runtime-factory.ts b/kun/src/server/runtime-factory.ts index 716e74b7e..1c75b41c2 100644 --- a/kun/src/server/runtime-factory.ts +++ b/kun/src/server/runtime-factory.ts @@ -162,7 +162,10 @@ export async function createKunServeRuntime( endpointFormat: options.endpointFormat ?? DEFAULT_MODEL_ENDPOINT_FORMAT, model: options.model, modelCapabilities, - debugSink: llmDebug + debugSink: llmDebug, + ...(options.runtime?.streamIdleTimeoutMs !== undefined + ? { streamIdleTimeoutMs: options.runtime.streamIdleTimeoutMs } + : {}) }) const reviewService = new ReviewService({ threadStore, diff --git a/kun/src/skills/skill-runtime.ts b/kun/src/skills/skill-runtime.ts index 29e43c1bf..70a04338e 100644 --- a/kun/src/skills/skill-runtime.ts +++ b/kun/src/skills/skill-runtime.ts @@ -1,3 +1,4 @@ +import { type Dirent } from 'node:fs' import { readdir, readFile, stat } from 'node:fs/promises' import { basename, extname, join, resolve } from 'node:path' import { z } from 'zod' @@ -333,16 +334,33 @@ async function packageCandidates(root: string): Promise { } const entries = await readdir(root, { withFileTypes: true }) for (const entry of entries) { - if (entry.isDirectory()) { - const dir = join(root, entry.name) - if (await exists(join(dir, 'skill.json')) || await exists(join(dir, 'SKILL.md'))) { - candidates.add(dir) - } + const dir = join(root, entry.name) + if (!(await entryIsDirectory(entry, dir))) continue + if (await exists(join(dir, 'skill.json')) || await exists(join(dir, 'SKILL.md'))) { + candidates.add(dir) } } return [...candidates] } +/** + * Whether a directory entry is — or resolves to — a directory. `readdir` with + * `withFileTypes` describes the link itself, so a symlinked skill package (e.g. + * the per-skill links `cc switch` drops into `.claude/skills`) reports + * `isDirectory() === false` and would be skipped. Follow such links via `stat` + * so those packages are still discovered. Also covers filesystems that report + * an unknown `d_type`. (#320) + */ +async function entryIsDirectory(entry: Dirent, path: string): Promise { + if (entry.isDirectory()) return true + if (entry.isFile()) return false + try { + return (await stat(path)).isDirectory() + } catch { + return false + } +} + async function loadSkillPackage(root: string, allowLegacy: boolean): Promise { const manifestPath = join(root, 'skill.json') if (await exists(manifestPath)) { diff --git a/kun/tests/model-client.test.ts b/kun/tests/model-client.test.ts index 508bdbd4d..7372ea2de 100644 --- a/kun/tests/model-client.test.ts +++ b/kun/tests/model-client.test.ts @@ -187,6 +187,50 @@ describe('CompatModelClient', () => { ]) }) + it('maps Responses API cached input token details into cache telemetry', async () => { + const fetchImpl: typeof fetch = async () => + new Response(JSON.stringify({ + id: 'resp_cache', + status: 'completed', + output_text: 'cached', + usage: { + input_tokens: 400, + output_tokens: 20, + total_tokens: 420, + input_tokens_details: { cached_tokens: 300 } + } + }), { + status: 200, + headers: { 'content-type': 'application/json' } + }) + const client = new CompatModelClient({ + baseUrl: 'https://example.com/api/v1', + apiKey: 'k', + model: 'gpt-5-mini', + endpointFormat: 'responses', + fetchImpl, + nonStreaming: true + }) + + const chunks: ModelStreamChunk[] = [] + for await (const chunk of client.stream(buildRequest(new AbortController().signal))) { + chunks.push(chunk) + } + + const usageChunk = chunks.find((chunk) => chunk.kind === 'usage') + const usage = usageChunk && usageChunk.kind === 'usage' ? usageChunk.usage : null + expect(usage).not.toBeNull() + expect(usage).toMatchObject({ + promptTokens: 400, + completionTokens: 20, + totalTokens: 420, + cachedTokens: 300, + cacheHitTokens: 300, + cacheMissTokens: 100 + }) + expect(usage?.cacheHitRate).toBeCloseTo(0.75) + }) + it('uses the Anthropic Messages API format when selected', async () => { const sentUrls: string[] = [] const sentBodies: Array> = [] diff --git a/kun/tests/skill-runtime.test.ts b/kun/tests/skill-runtime.test.ts index ddef8a63f..110dd49e8 100644 --- a/kun/tests/skill-runtime.test.ts +++ b/kun/tests/skill-runtime.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -87,6 +87,34 @@ describe('SkillRuntime', () => { expect(diagnostics.validationErrors).toEqual([]) }) + it('discovers a skill package symlinked into a root (e.g. cc switch)', async (ctx) => { + // cc switch keeps the real skill files in its own config dir and symlinks + // the skill directory into the scanned root; the link must still load. (#320) + const realDir = await mkdtemp(join(tmpdir(), 'kun-skill-real-')) + try { + await writeFile(join(realDir, 'skill.json'), JSON.stringify({ + id: 'linked', + name: 'Linked', + triggers: { commands: ['/linked'] } + }), 'utf8') + await writeFile(join(realDir, 'SKILL.md'), 'linked body', 'utf8') + try { + await symlink(realDir, join(root, 'linked'), 'dir') + } catch { + // Symlink creation can be unprivileged (e.g. Windows) — skip there. + ctx.skip() + return + } + + const runtime = await createRuntime() + + expect(runtime.diagnostics().skills.map((skill) => skill.id)).toContain('linked') + expect(runtime.resolveTurn({ prompt: '/linked go', workspace: root }).activeSkillIds).toEqual(['linked']) + } finally { + await rm(realDir, { recursive: true, force: true }) + } + }) + it('matches triggers deterministically and respects injection budgets', async () => { await writeSkill('big', { id: 'big', diff --git a/package-lock.json b/package-lock.json index dff15c4d2..b8e8f3269 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,14 +7,15 @@ "": { "name": "kun-gui", "version": "0.1.0", - "license": "PolyForm-Noncommercial-1.0.0", "hasInstallScript": true, + "license": "PolyForm-Noncommercial-1.0.0", "dependencies": { "@aws-sdk/client-s3": "^3.1049.0", "@codemirror/commands": "^6.10.3", "@codemirror/lang-markdown": "^6.5.0", "@codemirror/language": "^6.12.3", "@codemirror/language-data": "^6.5.2", + "@codemirror/merge": "^6.12.2", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.43.0", "@larksuiteoapi/node-sdk": "^1.64.0", @@ -28,12 +29,16 @@ "@tiptap/pm": "^3.26.0", "@tiptap/react": "^3.26.0", "@tiptap/starter-kit": "^3.26.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/xterm": "^6.0.0", "better-sqlite3": "^12.10.0", "electron-store": "^10.1.0", "electron-updater": "^6.8.3", "html-to-docx": "^1.8.0", "i18next": "^25.4.2", "lucide-react": "^0.544.0", + "node-pty": "^1.1.0", "openclaw": "file:vendor/openclaw-shim", "pdfjs-dist": "^5.4.394", "qrcode.react": "^4.2.0", @@ -621,6 +626,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1278,6 +1284,19 @@ "crelt": "^1.0.5" } }, + "node_modules/@codemirror/merge": { + "version": "6.12.2", + "resolved": "https://registry.npmmirror.com/@codemirror/merge/-/merge-6.12.2.tgz", + "integrity": "sha512-V8JvyAPjHbPupqP7BeMcsdsYCbyPij74jxIbaIJDORI+VZzW44zFmon8bF+oxGWvOKhcRmkiUMXd8MxHr3YA2w==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/highlight": "^1.0.0", + "style-mod": "^4.1.0" + } + }, "node_modules/@codemirror/state": { "version": "6.6.0", "resolved": "https://registry.npmmirror.com/@codemirror/state/-/state-6.6.0.tgz", @@ -1901,17 +1920,6 @@ "@floating-ui/utils": "^0.2.11" } }, - "node_modules/@floating-ui/dom": { - "version": "1.7.6", - "resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.6.tgz", - "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "@floating-ui/core": "^1.7.5", - "@floating-ui/utils": "^0.2.11" - } - }, "node_modules/@floating-ui/utils": { "version": "0.2.11", "resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.11.tgz", @@ -3109,6 +3117,7 @@ "resolved": "https://registry.npmmirror.com/@tiptap/core/-/core-3.26.0.tgz", "integrity": "sha512-7jTed/RirIVsp+lLdLvGzGqF3EBGpnGHGYKOwz6t28V2BIJLAFdUhfEVdWie7xPxQNWK0TP+fPlsqZS0vxfHBg==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -3344,6 +3353,7 @@ "resolved": "https://registry.npmmirror.com/@tiptap/extension-list/-/extension-list-3.26.0.tgz", "integrity": "sha512-EM8woyHDNKLEQ+lWUEoDtA4KrwP6fei/mYX1NxseMzKHHo7LFecx7wk6sovAXZrUvdML/yFBihgiMiO5VIsfkg==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -3463,6 +3473,7 @@ "resolved": "https://registry.npmmirror.com/@tiptap/extensions/-/extensions-3.26.0.tgz", "integrity": "sha512-4wajuqnO2X0+LVvsBjW/xk3/tmdb16bNL939QhicAay4YYqXITeV2v3XJsryzmG4L5GkK1yLxvRGk4aLoxWrnA==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -3494,6 +3505,7 @@ "resolved": "https://registry.npmmirror.com/@tiptap/pm/-/pm-3.26.0.tgz", "integrity": "sha512-q4RDeWwVrhOL0jJCGRgGxLSdjOYwzQ4h2InURZVhC66433ipcHd6f3bqSOhcXZ4r0sFmMNsuF7aZmUntjWLc7w==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-commands": "^1.6.2", @@ -4025,6 +4037,7 @@ "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4034,6 +4047,7 @@ "resolved": "https://registry.npmmirror.com/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4123,6 +4137,7 @@ "integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.59.4", "@typescript-eslint/types": "8.59.4", @@ -4471,6 +4486,27 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmmirror.com/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/addon-web-links": { + "version": "0.12.0", + "resolved": "https://registry.npmmirror.com/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz", + "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz", @@ -4515,6 +4551,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4880,6 +4917,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -5392,6 +5430,7 @@ "resolved": "https://registry.npmmirror.com/cytoscape/-/cytoscape-3.33.3.tgz", "integrity": "sha512-Gej7U+OKR+LZ8kvX7rb2HhCYJ0IhvEFsnkud4SB1PR+BUY/TsSO0dmOW59WEVLu51b1Rm+gQRKoz4bLYxGSZ2g==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10" } @@ -5801,6 +5840,7 @@ "resolved": "https://registry.npmmirror.com/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -6575,6 +6615,7 @@ "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -7744,6 +7785,7 @@ "resolved": "https://registry.npmmirror.com/hono/-/hono-4.12.21.tgz", "integrity": "sha512-uV63apnb0kyPtAUwoWgaGh9HyIFcv8lgmzPZSiTBQAFOFGIzka5EZ1dZocmGnn0XdX0+XTqJ6Tqv7selMuGLRQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -7904,6 +7946,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.27.6" }, @@ -8227,6 +8270,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -9733,6 +9777,12 @@ "node": ">=10" } }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmmirror.com/node-fetch/-/node-fetch-2.7.0.tgz", @@ -9753,6 +9803,16 @@ } } }, + "node_modules/node-pty": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/node-pty/-/node-pty-1.1.0.tgz", + "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.1.0" + } + }, "node_modules/node-releases": { "version": "2.0.38", "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.38.tgz", @@ -10066,25 +10126,25 @@ "dev": true, "license": "MIT" }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmmirror.com/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true, - "license": "MIT" - }, "node_modules/pdfjs-dist": { "version": "5.4.394", "resolved": "https://registry.npmmirror.com/pdfjs-dist/-/pdfjs-dist-5.4.394.tgz", "integrity": "sha512-9ariAYGqUJzx+V/1W4jHyiyCep6IZALmDzoaTLZ6VNu8q9LWi1/ukhzHgE2Xsx96AZi0mbZuK4/ttIbqSbLypg==", "license": "Apache-2.0", - "optionalDependencies": { - "@napi-rs/canvas": "^0.1.81" - }, "engines": { "node": ">=20.16.0 || >=22.3.0" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.81" } }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", @@ -10170,6 +10230,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -10730,6 +10791,7 @@ "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.6.tgz", "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -11908,6 +11970,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -12107,6 +12170,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12405,6 +12469,7 @@ "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -12498,6 +12563,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -12840,6 +12906,7 @@ "resolved": "https://registry.npmmirror.com/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 4f588147f..d68268909 100644 --- a/package.json +++ b/package.json @@ -40,11 +40,15 @@ "@codemirror/lang-markdown": "^6.5.0", "@codemirror/language": "^6.12.3", "@codemirror/language-data": "^6.5.2", + "@codemirror/merge": "^6.12.2", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.43.0", "@larksuiteoapi/node-sdk": "^1.64.0", "@modelcontextprotocol/sdk": "^1.29.0", "@tencent-weixin/openclaw-weixin": "2.4.3", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/xterm": "^6.0.0", "@tiptap/core": "^3.26.0", "@tiptap/extension-image": "^3.26.0", "@tiptap/extension-list": "^3.26.0", @@ -59,6 +63,7 @@ "html-to-docx": "^1.8.0", "i18next": "^25.4.2", "lucide-react": "^0.544.0", + "node-pty": "^1.1.0", "openclaw": "file:vendor/openclaw-shim", "pdfjs-dist": "^5.4.394", "qrcode.react": "^4.2.0", diff --git a/scripts/after-pack.cjs b/scripts/after-pack.cjs index cf1a4f558..7fd2eeb62 100644 --- a/scripts/after-pack.cjs +++ b/scripts/after-pack.cjs @@ -1,5 +1,5 @@ const { execFileSync } = require('node:child_process') -const { existsSync, rmSync } = require('node:fs') +const { chmodSync, existsSync, readdirSync, rmSync } = require('node:fs') const { join } = require('node:path') const KUN_RUNTIME_REQUIRED_PATHS = [ @@ -112,9 +112,29 @@ function maybeAdhocSignMacApp(context) { ) } +// node-pty execs a bundled `spawn-helper` binary to fork the child shell. +// asar unpacking can drop the executable bit, which makes every PTY spawn +// fail with `posix_spawnp`. Re-chmod every bundled helper after packing so +// the built-in terminal works in the shipped app. Non-fatal: best effort. +function ensureNodePtyHelpersExecutable(context) { + const root = unpackedAppRoot(context) + const prebuildsDir = join(root, 'node_modules', 'node-pty', 'prebuilds') + if (!existsSync(prebuildsDir)) return + for (const folder of readdirSync(prebuildsDir)) { + const helper = join(prebuildsDir, folder, 'spawn-helper') + if (!existsSync(helper)) continue + try { + chmodSync(helper, 0o755) + } catch (error) { + console.warn(`[after-pack] could not chmod node-pty spawn-helper (${folder}):`, error.message) + } + } +} + async function afterPack(context) { prunePackedKunDependencies(context) validateBundledKunRuntime(context) + ensureNodePtyHelpersExecutable(context) maybeAdhocSignMacApp(context) } @@ -125,6 +145,7 @@ exports._internals = { unpackedAppRoot, npmCommand, prunePackedKunDependencies, - validateBundledKunRuntime + validateBundledKunRuntime, + ensureNodePtyHelpersExecutable } exports.default = afterPack diff --git a/scripts/postinstall.cjs b/scripts/postinstall.cjs index 5e0f29716..cc5f229db 100644 --- a/scripts/postinstall.cjs +++ b/scripts/postinstall.cjs @@ -35,3 +35,48 @@ try { } catch (error) { console.warn('[postinstall] skipped better-sqlite3 electron prebuild:', error.message) } + +// node-pty is a native module used by the built-in terminal and is always +// loaded inside the Electron main process. It ships its own prebuilt +// `pty.node` + `spawn-helper` binaries under prebuilds/-/, but +// npm does not always preserve the executable bit on `spawn-helper`, which +// node-pty execs to fork the child — without it `posix_spawnp` fails. Best +// effort: re-chmod the helper for every bundled platform so the terminal +// works out of the box. A failure is non-fatal. +try { + const { existsSync, readdirSync, chmodSync } = require('node:fs') + const prebuildsDir = join(__dirname, '..', 'node_modules', 'node-pty', 'prebuilds') + if (existsSync(prebuildsDir)) { + for (const folder of readdirSync(prebuildsDir)) { + const helper = join(prebuildsDir, folder, 'spawn-helper') + if (existsSync(helper)) { + try { + chmodSync(helper, 0o755) + } catch (error) { + console.warn(`[postinstall] could not chmod node-pty spawn-helper (${folder}):`, error.message) + } + } + } + } +} catch (error) { + console.warn('[postinstall] skipped node-pty spawn-helper chmod:', error.message) +} + +// Some environments also need an Electron-ABI rebuild (no Node prebuild +// matches). This is best-effort; the bundled prebuilds already target an +// ABI-compatible Node build for current Electron versions, so a failure here +// is usually harmless and leaves the terminal working. +try { + const electronVersion = require('electron/package.json').version + const result = run('npx', [ + '--yes', + 'prebuild-install', + `--runtime=electron`, + `--target=${electronVersion}` + ], { cwd: join(__dirname, '..', 'node_modules', 'node-pty') }) + if (result.status !== 0) { + console.warn('[postinstall] node-pty electron prebuild fell back to bundled binaries') + } +} catch (error) { + console.warn('[postinstall] skipped node-pty electron prebuild:', error.message) +} diff --git a/src/main/claw-platform-install.test.ts b/src/main/claw-platform-install.test.ts index 5ee79bb77..d11282fd7 100644 --- a/src/main/claw-platform-install.test.ts +++ b/src/main/claw-platform-install.test.ts @@ -37,36 +37,53 @@ describe('claw platform install', () => { configureManagedWeixinBridgeUrlResolver(null) }) - it('returns the official user code and polls the matching Feishu/Lark target', async () => { + it('always begins on Feishu and switches to Lark only when tenant_brand says so', async () => { + const seenActions: Array = [] + const beginHosts: string[] = [] + let beginCount = 0 const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const hostname = requestHostname(input) const body = new URLSearchParams(String(init?.body ?? '')) const action = body.get('action') - - if (action === 'init') { - return jsonResponse({ nonce: 'nonce' }) - } + seenActions.push(action) if (action === 'begin') { - const isLark = hostname === 'accounts.larksuite.com' + beginHosts.push(hostname) + beginCount += 1 + // First start() stands in for a Feishu tenant, the second for Lark. + const deviceCode = beginCount === 1 ? 'feishu-device' : 'lark-device' return jsonResponse({ - device_code: isLark ? 'lark-device' : 'feishu-device', - user_code: isLark ? 'LARK-CODE' : 'FEI-CODE', - verification_uri_complete: isLark - ? 'https://open.larksuite.com/page/launcher?user_code=LARK-CODE' - : 'https://open.feishu.cn/page/launcher?user_code=FEI-CODE', + device_code: deviceCode, + user_code: `CODE-${beginCount}`, + verification_uri_complete: `https://open.feishu.cn/page/launcher?user_code=CODE-${beginCount}`, expires_in: 3600, interval: 5 }) } if (action === 'poll') { - const isLark = hostname === 'accounts.larksuite.com' - return jsonResponse({ - client_id: isLark ? 'cli_lark' : 'cli_feishu', - client_secret: 'secret', - user_info: { tenant_brand: isLark ? 'lark' : 'feishu' } - }) + const deviceCode = body.get('device_code') + if (deviceCode === 'feishu-device') { + // Feishu tenant: credentials are issued directly by accounts.feishu.cn. + return jsonResponse({ + client_id: 'cli_feishu', + client_secret: 'secret', + user_info: { tenant_brand: 'feishu' } + }) + } + if (deviceCode === 'lark-device') { + if (hostname === 'accounts.feishu.cn') { + // Lark tenant detected, but the secret lives on larksuite.com. + return jsonResponse({ user_info: { tenant_brand: 'lark' } }) + } + if (hostname === 'accounts.larksuite.com') { + return jsonResponse({ + client_id: 'cli_lark', + client_secret: 'secret', + user_info: { tenant_brand: 'lark' } + }) + } + } } return jsonResponse({ message: 'unexpected request' }, 400) @@ -75,10 +92,20 @@ describe('claw platform install', () => { const feishuStart = await startFeishuInstallQrcode(false) const larkStart = await startFeishuInstallQrcode(true) - - expect(feishuStart).toMatchObject({ ok: true, userCode: 'FEI-CODE' }) - expect(larkStart).toMatchObject({ ok: true, userCode: 'LARK-CODE' }) - + expect(feishuStart).toMatchObject({ ok: true }) + expect(larkStart).toMatchObject({ ok: true }) + + // #316: the QR is always minted on Feishu — even for the Lark selection the + // scannable link points at open.feishu.cn, never open.larksuite.com (the + // latter is what the Lark app rejected as "Link expired"). + if (!larkStart.ok) throw new Error(larkStart.message) + expect(larkStart.url).toContain('https://open.feishu.cn/') + expect(larkStart.url).not.toContain('larksuite.com') + expect(beginHosts).toEqual(['accounts.feishu.cn', 'accounts.feishu.cn']) + // ...and the flow goes straight to `begin`, with no `action: 'init'` step. + expect(seenActions).not.toContain('init') + + // Feishu tenant: secret comes straight from accounts.feishu.cn. await expect(pollFeishuInstall('feishu-device')).resolves.toEqual({ done: true, kind: 'feishu', @@ -88,6 +115,8 @@ describe('claw platform install', () => { }) expect(String(fetchMock.mock.calls.at(-1)?.[0])).toContain('accounts.feishu.cn') + // Lark tenant: the Feishu poll reveals tenant_brand=lark, so a single + // pollFeishuInstall call switches to accounts.larksuite.com for the secret. await expect(pollFeishuInstall('lark-device')).resolves.toEqual({ done: true, kind: 'feishu', diff --git a/src/main/claw-platform-install.ts b/src/main/claw-platform-install.ts index 942346261..3a494c51b 100644 --- a/src/main/claw-platform-install.ts +++ b/src/main/claw-platform-install.ts @@ -10,8 +10,11 @@ type ClawPlatformInstallPollResult = | { done: true; kind: 'weixin'; accountId: string; sessionKey: string } | { done: false; error?: string } -let feishuInstallIsLark = false -const feishuInstallTargets = new Map() +type FeishuRegistrationDomain = 'feishu' | 'lark' +const FEISHU_ACCOUNTS_URL = 'https://accounts.feishu.cn' +const LARK_ACCOUNTS_URL = 'https://accounts.larksuite.com' +let feishuInstallSelectedIsLark = false +const feishuInstallDomains = new Map() const MAX_FEISHU_INSTALL_TARGETS = 32 const weixinInstallSessions = new Map() const MAX_WEIXIN_INSTALL_SESSIONS = 32 @@ -98,18 +101,25 @@ function normalizeIntervalSeconds(value: unknown, fallback: number): number { return Number.isFinite(parsed) ? Math.max(3, Math.floor(parsed)) : fallback } -function rememberFeishuInstallTarget(deviceCode: string, isLark: boolean): void { - feishuInstallTargets.delete(deviceCode) - feishuInstallTargets.set(deviceCode, isLark) - while (feishuInstallTargets.size > MAX_FEISHU_INSTALL_TARGETS) { - const oldestDeviceCode = feishuInstallTargets.keys().next().value +function feishuAccountsBaseUrl(domain: FeishuRegistrationDomain): string { + return domain === 'lark' ? LARK_ACCOUNTS_URL : FEISHU_ACCOUNTS_URL +} + +function rememberFeishuInstallDomain(deviceCode: string, domain: FeishuRegistrationDomain): void { + feishuInstallDomains.delete(deviceCode) + feishuInstallDomains.set(deviceCode, domain) + while (feishuInstallDomains.size > MAX_FEISHU_INSTALL_TARGETS) { + const oldestDeviceCode = feishuInstallDomains.keys().next().value if (!oldestDeviceCode) break - feishuInstallTargets.delete(oldestDeviceCode) + feishuInstallDomains.delete(oldestDeviceCode) } } -function resolveFeishuInstallTarget(deviceCode: string): boolean { - return feishuInstallTargets.get(deviceCode) ?? feishuInstallIsLark +function resolveFeishuInstallDomain(deviceCode: string): FeishuRegistrationDomain { + // Registration always begins on Feishu, so polling starts there too and only + // switches once tenant_brand reveals a Lark tenant. The selected brand is a + // fallback for the rare case the device code was evicted from the map. + return feishuInstallDomains.get(deviceCode) ?? (feishuInstallSelectedIsLark ? 'lark' : 'feishu') } function rememberWeixinInstallSession(deviceCode: string, sessionKey: string): void { @@ -225,14 +235,22 @@ async function startWeixinBridgeChannel( export async function startFeishuInstallQrcode(isLark: boolean): Promise { try { - const baseUrl = isLark ? 'https://accounts.larksuite.com' : 'https://accounts.feishu.cn' - feishuInstallIsLark = isLark - await postForm(`${baseUrl}/oauth/v1/app/registration`, { action: 'init' }) + // Always begin on Feishu — even when the user picked Lark. The official + // Lark CLI and @larksuiteoapi SDK both mint the QR on accounts.feishu.cn + // (so the user scans an open.feishu.cn launcher link) and only switch to + // Lark while polling, once the response's tenant_brand comes back "lark". + // Minting the QR on accounts.larksuite.com instead yields an + // open.larksuite.com link that the Lark app rejects as "Link expired" + // (issue #316). There is also no `action: 'init'` step — the CLI/SDK go + // straight to `begin`; init only returns an unused 60s nonce. + feishuInstallSelectedIsLark = isLark + const baseUrl = FEISHU_ACCOUNTS_URL const data = await postForm(`${baseUrl}/oauth/v1/app/registration`, { action: 'begin', archetype: 'PersonalAgent', auth_method: 'client_secret', - request_user_info: 'open_id' + // Request tenant_brand so polling can detect a Lark tenant and switch. + request_user_info: 'open_id tenant_brand' }) const url = recordString(data, 'verification_uri_complete') const deviceCode = recordString(data, 'device_code') @@ -240,7 +258,7 @@ export async function startFeishuInstallQrcode(isLark: boolean): Promise }> { + return postFormResult(`${feishuAccountsBaseUrl(domain)}/oauth/v1/app/registration`, { + action: 'poll', + device_code: deviceCode + }) +} + export async function pollFeishuInstall(deviceCode: string): Promise { try { - const baseUrl = resolveFeishuInstallTarget(deviceCode) ? 'https://accounts.larksuite.com' : 'https://accounts.feishu.cn' - const result = await postFormResult(`${baseUrl}/oauth/v1/app/registration`, { - action: 'poll', - device_code: deviceCode - }) + let domain = resolveFeishuInstallDomain(deviceCode) + let result = await pollFeishuRegistration(domain, deviceCode) + + // Once the scanning user is identified as a Lark tenant, the credentials are + // issued by accounts.larksuite.com — switch there and re-poll immediately, + // then persist the switch for subsequent polls (matches the official + // CLI/SDK). The Feishu poll returns tenant_brand="lark" with no secret. + if (domain === 'feishu') { + const tenantBrand = recordString(asRecord(result.data.user_info), 'tenant_brand') + const hasSecret = recordString(result.data, 'client_secret') !== '' + if (tenantBrand === 'lark' && !hasSecret) { + domain = 'lark' + rememberFeishuInstallDomain(deviceCode, 'lark') + result = await pollFeishuRegistration(domain, deviceCode) + } + } + const data = result.data const error = recordString(data, 'error') if (error) { if (error === 'authorization_pending' || error === 'slow_down') return { done: false } - feishuInstallTargets.delete(deviceCode) + feishuInstallDomains.delete(deviceCode) return { done: false, error: recordString(data, 'error_description') || error } } if (!result.ok) { - feishuInstallTargets.delete(deviceCode) + feishuInstallDomains.delete(deviceCode) return { done: false, error: recordString(data, 'error_description') || recordString(data, 'message') || `HTTP ${result.status}` @@ -278,9 +318,7 @@ export async function pollFeishuInstall(deviceCode: string): Promise { + it('returns the concluding text that follows the last tool activity', () => { + const detail = singleTurnDetail([ + { kind: 'assistant_text', text: '我的计划:先读文件,再修改' }, + { kind: 'tool_call' }, + { kind: 'tool_result' }, + { kind: 'assistant_text', text: '已完成:结果是 42' } + ]) + expect(finalAssistantReplyText(detail, { turnId: 'turn_1' })).toBe('已完成:结果是 42') + }) + + it('skips the pre-tool plan when the turn ends without concluding text', () => { + // The exact bug: the model narrates a plan as text, performs the work + // through tools, and stops without a final message. The plan must not + // be mistaken for the result. + const detail = singleTurnDetail([ + { kind: 'assistant_reasoning', text: '正在思考……' }, + { kind: 'assistant_text', text: '我的计划:先读文件,再修改' }, + { kind: 'tool_call' }, + { kind: 'tool_result' } + ]) + expect(finalAssistantReplyText(detail, { turnId: 'turn_1' })).toBe('') + }) + + it('never treats reasoning as the reply', () => { + const detail = singleTurnDetail([ + { kind: 'assistant_reasoning', text: '思考:结论应该是 X' }, + { kind: 'tool_call' }, + { kind: 'tool_result' }, + { kind: 'assistant_reasoning', text: '结束思考:已经完整完成 X' } + ]) + expect(finalAssistantReplyText(detail, { turnId: 'turn_1' })).toBe('') + }) + + it('returns the last message for a pure chat turn with no tools', () => { + const detail = singleTurnDetail([ + { kind: 'assistant_text', text: '第一段' }, + { kind: 'assistant_text', text: '最终答案' } + ]) + expect(finalAssistantReplyText(detail, { turnId: 'turn_1' })).toBe('最终答案') + }) + + it('scopes extraction to the requested turn and ignores earlier turns', () => { + const detail: ThreadDetailJson = { + turns: [ + { id: 'turn_prev', status: 'completed', items: [{ kind: 'assistant_text', text: '旧回复' }] }, + { id: 'turn_cur', status: 'completed', items: [{ kind: 'tool_call' }, { kind: 'tool_result' }] } + ] + } + expect(finalAssistantReplyText(detail, { turnId: 'turn_cur' })).toBe('') + expect(finalAssistantReplyText(detail, { turnId: 'turn_prev' })).toBe('旧回复') + }) +}) + +describe('imCompletionReplyForPush', () => { + it('is the plain completion note when no files were produced', () => { + expect(imCompletionReplyForPush([])).toBe(IM_COMPLETED_NO_TEXT_REPLY) + }) + + it('lists generated file names so they can be retrieved later', () => { + const reply = imCompletionReplyForPush([ + { path: '/w/a.md', fileName: 'a.md' }, + { path: '/w/b.png', fileName: 'b.png' } + ]) + expect(reply).toContain('a.md') + expect(reply).toContain('b.png') + }) +}) diff --git a/src/main/claw-runtime-helpers.ts b/src/main/claw-runtime-helpers.ts index 7ce5f4343..28a66b0b8 100644 --- a/src/main/claw-runtime-helpers.ts +++ b/src/main/claw-runtime-helpers.ts @@ -194,6 +194,70 @@ export function latestAssistantText( return '' } +/** Reply sent when a turn finished but produced no concluding text. */ +export const IM_COMPLETED_NO_TEXT_REPLY = '✅ 任务已完成。' + +/** + * Ack sent when a turn outruns the IM response timeout. The turn keeps + * running and the real result is pushed back when it finishes. + */ +export const IM_PROCESSING_ACK = '⏳ 收到,正在处理,完成后会把结果发给你。' + +const TOOL_ITEM_KINDS = new Set(['tool_call', 'tool_result']) + +/** + * The turn's *concluding* assistant message — the last `assistant_text` + * that appears after the final tool activity. + * + * Mid-turn narration is intentionally skipped: a model often writes an + * upfront plan as text ("先做 X,再做 Y") and then performs the work + * through tool calls, frequently ending without any further text (the + * wrap-up stays in `reasoning_content`, or it just stops after the last + * tool succeeds). `latestAssistantText` would return that stale plan, + * so the phone received the plan instead of the result. Scanning only + * the post-tool tail fixes that. + * + * Pure chat turns (no tool calls) fall back to the last assistant + * message. Returns '' when the turn ended without concluding text; + * callers then substitute {@link IM_COMPLETED_NO_TEXT_REPLY}. + */ +export function finalAssistantReplyText( + detail: ThreadDetailJson, + options: { turnId?: string } = {} +): string { + const turnId = options.turnId?.trim() + const items = turnId + ? threadItems(detail).filter((item) => item.turnId === turnId) + : threadItems(detail) + let lastToolIndex = -1 + for (let index = items.length - 1; index >= 0; index -= 1) { + if (TOOL_ITEM_KINDS.has(items[index].kind)) { + lastToolIndex = index + break + } + } + for (let index = items.length - 1; index > lastToolIndex; index -= 1) { + const item = items[index] + if (item.kind !== 'assistant_text' && item.kind !== 'agent_message') continue + const text = (item.text ?? item.detail ?? item.summary ?? '').trim() + if (text) return text + } + return '' +} + +/** + * Reply used by the asynchronous result push when the finished turn has + * no concluding text. Files generated during a long run cannot be media + * pushed out-of-band, so their names are surfaced for retrieval instead. + */ +export function imCompletionReplyForPush(files: readonly ClawGeneratedFileV1[]): string { + if (files.length > 0) { + const names = files.map((file) => file.fileName).join('、') + return `${IM_COMPLETED_NO_TEXT_REPLY}(生成的文件:${names},回复"发给我"获取)` + } + return IM_COMPLETED_NO_TEXT_REPLY +} + function outputRecord(output: unknown): Record | null { return typeof output === 'object' && output !== null && !Array.isArray(output) ? output as Record diff --git a/src/main/claw-runtime.test.ts b/src/main/claw-runtime.test.ts index 72b4675d6..7efd9dea6 100644 --- a/src/main/claw-runtime.test.ts +++ b/src/main/claw-runtime.test.ts @@ -1877,11 +1877,13 @@ describe('ClawRuntime', () => { handleWebhook: (request: typeof req, response: typeof res) => Promise }).handleWebhook(req, res) - expect(status).toBe(500) - expect(JSON.parse(responseBody)).toMatchObject({ - ok: false, - message: 'Internal server error.' - }) + // A completed turn with no concluding text of its own replies with a + // completion note — never the previous turn's historical text. + expect(status).toBe(200) + const parsed = JSON.parse(responseBody) + expect(parsed.ok).toBe(true) + expect(parsed.reply).toContain('已完成') + expect(responseBody).not.toContain('previous reply') }) it('does not return historical WeChat text when the current turn fails', async () => { @@ -1968,10 +1970,294 @@ describe('ClawRuntime', () => { handleWebhook: (request: typeof req, response: typeof res) => Promise }).handleWebhook(req, res) + // A failed turn surfaces the failure — never the previous turn's text. expect(status).toBe(500) - expect(JSON.parse(responseBody)).toMatchObject({ - ok: false, - message: 'Internal server error.' + const parsed = JSON.parse(responseBody) + expect(parsed.ok).toBe(false) + expect(parsed.message).toContain('failed') + expect(responseBody).not.toContain('previous reply') + }) + + it('replies with a completion note, not the mid-turn plan, when the turn ends without concluding text', async () => { + const settings = buildSettings() + settings.claw.im.enabled = true + settings.claw.im.responseTimeoutMs = 2_000 + settings.claw.channels = [buildChannel({ + provider: 'weixin' as const, + id: 'channel_weixin', + label: 'WeChat', + threadId: 'thr_weixin', + welcomeSentAt: new Date().toISOString(), + conversations: [buildConversation({ + chatId: 'wx_user_1', + latestMessageId: 'wx_previous', + senderId: 'wx_user_1', + senderName: 'Alice', + localThreadId: 'thr_weixin' + })] + })] + const { store } = mutableSettingsStore(settings) + const runtimeRequest = vi.fn(async (_settings, path, init) => { + if (path === '/v1/threads/thr_weixin/turns' && init?.method === 'POST') { + return { ok: true, status: 202, body: JSON.stringify({ turnId: 'turn_current' }) } + } + if (path === '/v1/threads/thr_weixin' && init?.method === 'GET') { + return { + ok: true, + status: 200, + body: JSON.stringify({ + id: 'thr_weixin', + status: 'idle', + turns: [ + { + id: 'turn_current', + status: 'completed', + // The model narrated a plan, did the work via tools, then + // stopped without a final message — the classic shape that + // used to leak the plan to the phone. + items: [ + { kind: 'assistant_text', text: '我的计划:先读文件,再修改' }, + { kind: 'tool_call' }, + { kind: 'tool_result' } + ] + } + ] + }) + } + } + throw new Error(`unexpected path ${path}`) + }) + const runtime = createClawRuntime({ + store: store as never, + runtimeRequest: runtimeRequest as never, + logError: () => undefined, + createScheduledTaskFromText: vi.fn(async () => ({ kind: 'noop' as const })) + }) + const body = JSON.stringify({ + text: 'do the task', + provider: 'weixin', + channelId: 'channel_weixin', + chatId: 'wx_user_1', + messageId: 'wx_msg_2', + senderId: 'wx_user_1', + senderName: 'Alice' + }) + const req = { + method: 'POST', + url: settings.claw.im.path, + headers: {}, + async *[Symbol.asyncIterator]() { + yield Buffer.from(body) + } + } + let status = 0 + let responseBody = '' + const res = { + writeHead: vi.fn((nextStatus: number) => { + status = nextStatus + }), + end: vi.fn((payload: string) => { + responseBody = payload + }) + } + + await (runtime as unknown as { + handleWebhook: (request: typeof req, response: typeof res) => Promise + }).handleWebhook(req, res) + + expect(status).toBe(200) + const parsed = JSON.parse(responseBody) + expect(parsed.ok).toBe(true) + expect(parsed.reply).toContain('已完成') + expect(responseBody).not.toContain('我的计划') + }) + + it('returns the concluding text produced after tool calls', async () => { + const settings = buildSettings() + settings.claw.im.enabled = true + settings.claw.im.responseTimeoutMs = 2_000 + settings.claw.channels = [buildChannel({ + provider: 'weixin' as const, + id: 'channel_weixin', + label: 'WeChat', + threadId: 'thr_weixin', + welcomeSentAt: new Date().toISOString(), + conversations: [buildConversation({ + chatId: 'wx_user_1', + latestMessageId: 'wx_previous', + senderId: 'wx_user_1', + senderName: 'Alice', + localThreadId: 'thr_weixin' + })] + })] + const { store } = mutableSettingsStore(settings) + const runtimeRequest = vi.fn(async (_settings, path, init) => { + if (path === '/v1/threads/thr_weixin/turns' && init?.method === 'POST') { + return { ok: true, status: 202, body: JSON.stringify({ turnId: 'turn_current' }) } + } + if (path === '/v1/threads/thr_weixin' && init?.method === 'GET') { + return { + ok: true, + status: 200, + body: JSON.stringify({ + id: 'thr_weixin', + status: 'idle', + turns: [ + { + id: 'turn_current', + status: 'completed', + items: [ + { kind: 'assistant_text', text: '我的计划:先读文件,再修改' }, + { kind: 'tool_call' }, + { kind: 'tool_result' }, + { kind: 'assistant_text', text: '已完成:共修改 3 处' } + ] + } + ] + }) + } + } + throw new Error(`unexpected path ${path}`) + }) + const runtime = createClawRuntime({ + store: store as never, + runtimeRequest: runtimeRequest as never, + logError: () => undefined, + createScheduledTaskFromText: vi.fn(async () => ({ kind: 'noop' as const })) + }) + const body = JSON.stringify({ + text: 'do the task', + provider: 'weixin', + channelId: 'channel_weixin', + chatId: 'wx_user_1', + messageId: 'wx_msg_2', + senderId: 'wx_user_1', + senderName: 'Alice' + }) + const req = { + method: 'POST', + url: settings.claw.im.path, + headers: {}, + async *[Symbol.asyncIterator]() { + yield Buffer.from(body) + } + } + let status = 0 + let responseBody = '' + const res = { + writeHead: vi.fn((nextStatus: number) => { + status = nextStatus + }), + end: vi.fn((payload: string) => { + responseBody = payload + }) + } + + await (runtime as unknown as { + handleWebhook: (request: typeof req, response: typeof res) => Promise + }).handleWebhook(req, res) + + expect(status).toBe(200) + const parsed = JSON.parse(responseBody) + expect(parsed.ok).toBe(true) + expect(parsed.reply).toContain('已完成:共修改 3 处') + expect(responseBody).not.toContain('我的计划') + }) + + it('acks a long-running turn and pushes the result back to WeChat once it finishes', async () => { + const settings = buildSettings() + settings.claw.im.enabled = true + // Tiny window so the synchronous wait times out on the first poll. + settings.claw.im.responseTimeoutMs = 10 + settings.claw.channels = [buildChannel({ + provider: 'weixin' as const, + id: 'channel_weixin', + label: 'WeChat', + threadId: 'thr_weixin', + platformCredential: { + kind: 'weixin', + accountId: 'acc_1', + sessionKey: 'sess_1', + createdAt: '2026-06-02T00:00:00.000Z' + }, + conversations: [buildConversation({ + chatId: 'wx_user_1', + latestMessageId: 'wx_previous', + senderId: 'wx_user_1', + senderName: 'Alice', + localThreadId: 'thr_weixin' + })] + })] + const { store } = mutableSettingsStore(settings) + let getCount = 0 + const runtimeRequest = vi.fn(async (_settings, path, init) => { + if (path === '/v1/threads/thr_weixin/turns' && init?.method === 'POST') { + return { ok: true, status: 202, body: JSON.stringify({ turnId: 'turn_current' }) } + } + if (path === '/v1/threads/thr_weixin' && init?.method === 'GET') { + getCount += 1 + // First read (synchronous wait): still running → ack. Later reads + // (background push poller): completed with the real result. + const status = getCount <= 1 ? 'in_progress' : 'completed' + const items = getCount <= 1 ? [] : [{ kind: 'assistant_text', text: '已完成:最终结论 XYZ' }] + return { + ok: true, + status: 200, + body: JSON.stringify({ id: 'thr_weixin', status: 'idle', turns: [{ id: 'turn_current', status, items }] }) + } + } + throw new Error(`unexpected path ${path}`) + }) + const sendWeixinBridgeMessage = vi.fn(async () => ({ ok: true as const, messageId: 'wx_out_1' })) + const runtime = createClawRuntime({ + store: store as never, + runtimeRequest: runtimeRequest as never, + logError: () => undefined, + sendWeixinBridgeMessage, + createScheduledTaskFromText: vi.fn(async () => ({ kind: 'noop' as const })) + }) + const body = JSON.stringify({ + text: 'do a long task', + provider: 'weixin', + channelId: 'channel_weixin', + chatId: 'wx_user_1', + messageId: 'wx_msg_2', + senderId: 'wx_user_1', + senderName: 'Alice' + }) + const req = { + method: 'POST', + url: settings.claw.im.path, + headers: {}, + async *[Symbol.asyncIterator]() { + yield Buffer.from(body) + } + } + let status = 0 + let responseBody = '' + const res = { + writeHead: vi.fn((nextStatus: number) => { + status = nextStatus + }), + end: vi.fn((payload: string) => { + responseBody = payload + }) + } + + await (runtime as unknown as { + handleWebhook: (request: typeof req, response: typeof res) => Promise + }).handleWebhook(req, res) + + // Synchronous reply is the ack, never an intermediate step. + expect(status).toBe(200) + expect(JSON.parse(responseBody).reply).toContain('正在处理') + + // The real result is pushed back once the turn finishes. + await vi.waitFor(() => expect(sendWeixinBridgeMessage).toHaveBeenCalledTimes(1), { timeout: 8_000, interval: 100 }) + expect(sendWeixinBridgeMessage).toHaveBeenCalledWith({ + accountId: 'acc_1', + to: 'wx_user_1', + text: '已完成:最终结论 XYZ' }) }) diff --git a/src/main/claw-runtime.ts b/src/main/claw-runtime.ts index 79dceccc3..e00bc80fa 100644 --- a/src/main/claw-runtime.ts +++ b/src/main/claw-runtime.ts @@ -49,10 +49,13 @@ import { extractIncomingRemoteSession, extractSenderLabel, feishuSenderLabel, + finalAssistantReplyText, formatFeishuMirrorText, + imCompletionReplyForPush, isRunningStatus, + IM_COMPLETED_NO_TEXT_REPLY, + IM_PROCESSING_ACK, latestGeneratedFiles, - latestAssistantText, nestedRecord, normalizeTaskModel, parseJsonObject, @@ -401,6 +404,13 @@ export function imWelcomeText(settings: AppSettingsV1, channel?: ClawImChannelV1 ].join('\n\n') } +/** + * How long the background push keeps polling a turn that outran the IM + * response window before giving up (30 min). Generous enough for long + * agentic runs, bounded so a stuck turn never leaks a forever-poll. + */ +const RESULT_PUSH_MAX_WAIT_MS = 30 * 60 * 1_000 + export class ClawRuntime { private readonly deps: ClawRuntimeDeps private server: Server | null = null @@ -412,6 +422,8 @@ export class ClawRuntime { private readonly welcomeInFlight = new Set() /** WeChat channels already greeted (or attempted) at connect time this run. */ private readonly weixinConnectWelcomeAttempted = new Set() + /** `${threadId}:${turnId}` of turns with an in-flight delayed-result push. */ + private readonly pendingResultPushes = new Set() constructor(deps: ClawRuntimeDeps) { this.deps = deps @@ -603,14 +615,39 @@ export class ClawRuntime { return { ok: true, threadId: thread.id, turnId, message: 'Started' } } - const result = await this.waitForAssistantResult(runtimeSettings, thread.id, turnId, options.responseTimeoutMs, workspace) + const outcome = await this.waitForAssistantResult( + runtimeSettings, + thread.id, + turnId, + options.responseTimeoutMs, + workspace + ) + if (outcome.status === 'failed' || outcome.status === 'aborted') { + return { ok: false, message: outcome.error || `Agent turn ${outcome.status}.` } + } + if (outcome.status === 'timeout') { + // The turn outran the response window but keeps running in the + // runtime. Ack now; the caller pushes the real result back when + // the turn finishes (see `scheduleImResultPush`). Returning the + // last-seen text here is what used to leak an intermediate plan. + return { + ok: true, + threadId: thread.id, + turnId, + text: '', + message: IM_PROCESSING_ACK, + files: [], + completed: false + } + } return { ok: true, threadId: thread.id, turnId, - text: result.text, - message: result.text || 'Completed', - files: result.files + text: outcome.text, + message: outcome.text || IM_COMPLETED_NO_TEXT_REPLY, + files: outcome.files, + completed: true } } @@ -626,16 +663,26 @@ export class ClawRuntime { ) } + /** + * Polls a turn to completion. Resolves with the turn's *concluding* + * text (never an intermediate plan — see {@link finalAssistantReplyText}) + * and any generated files. Non-throwing on a failed/aborted/timed-out + * turn so both the synchronous reply and the asynchronous push can + * decide what to send; still throws when the thread read itself fails. + */ private async waitForAssistantResult( settings: AppSettingsV1, threadId: string, turnId: string, timeoutMs: number, workspaceRoot?: string - ): Promise<{ text: string; files: ClawGeneratedFileV1[] }> { + ): Promise<{ + status: 'completed' | 'failed' | 'aborted' | 'timeout' + text: string + files: ClawGeneratedFileV1[] + error?: string + }> { const deadline = Date.now() + timeoutMs - let lastText = '' - let lastDetail: ThreadDetailJson | null = null while (Date.now() < deadline) { await sleep(1_500) const detailRes = await this.deps.runtimeRequest( @@ -647,31 +694,122 @@ export class ClawRuntime { throw new Error(runtimeErrorMessage(detailRes, 'Failed to read thread result.')) } const detail = JSON.parse(detailRes.body) as ThreadDetailJson - lastDetail = detail - lastText = latestAssistantText(detail, { turnId }) || lastText const targetTurn = Array.isArray(detail.turns) ? detail.turns.find((turn) => turn.id === turnId) : undefined if (!targetTurn) continue if (isRunningStatus(targetTurn.status)) continue if (targetTurn.status === 'failed' || targetTurn.status === 'aborted') { - const error = targetTurn.error?.trim() - throw new Error(error || `Agent turn ${targetTurn.status}.`) + return { + status: targetTurn.status, + text: '', + files: [], + error: targetTurn.error?.trim() || `Agent turn ${targetTurn.status}.` + } } - if (targetTurn.status === 'completed' && lastText) { + if (targetTurn.status === 'completed') { return { - text: lastText, + status: 'completed', + text: finalAssistantReplyText(detail, { turnId }), files: latestGeneratedFiles(detail, { turnId, workspaceRoot }) } } } - if (lastText && lastDetail) { - return { - text: lastText, - files: latestGeneratedFiles(lastDetail, { turnId, workspaceRoot }) + return { status: 'timeout', text: '', files: [] } + } + + /** + * Fire-and-forget delivery of a turn's result that outran the IM + * response window. Keeps polling in the background and pushes the + * concluding text (or a completion note) back over the bridge when the + * turn finishes. No-op for providers/recipients we cannot push to, and + * deduped per turn so a retried inbound never double-pushes. + */ + private scheduleImResultPush( + settings: AppSettingsV1, + input: { + channel?: ClawImChannelV1 + remoteSession?: Pick + threadId: string + turnId?: string + workspaceRoot: string + } + ): void { + const { channel, turnId } = input + if (!channel || !turnId) return + const canPush = + (channel.provider === 'weixin' && Boolean(this.deps.sendWeixinBridgeMessage)) || + (channel.provider === 'feishu' && this.feishuChannels.has(channel.id)) + if (!canPush) return + const key = `${input.threadId}:${turnId}` + if (this.pendingResultPushes.has(key)) return + this.pendingResultPushes.add(key) + void (async () => { + try { + const outcome = await this.waitForAssistantResult( + settings, + input.threadId, + turnId, + RESULT_PUSH_MAX_WAIT_MS, + input.workspaceRoot + ) + if (outcome.status === 'timeout') { + this.deps.logError( + 'claw-im', + 'Gave up pushing a delayed agent result: turn still running after the maximum wait.', + { threadId: input.threadId, turnId } + ) + return + } + const body = + outcome.status === 'completed' + ? outcome.text.trim() || imCompletionReplyForPush(outcome.files) + : `❌ 任务未完成:${outcome.error || outcome.status}` + await this.pushImMessage(channel, input.remoteSession, body) + } catch (error) { + this.deps.logError('claw-im', 'Failed to push a delayed agent result.', { + message: errorMessage(error), + threadId: input.threadId, + turnId + }) + } finally { + this.pendingResultPushes.delete(key) } + })() + } + + /** Pushes a standalone bridge message to the sender of an inbound IM. */ + private async pushImMessage( + channel: ClawImChannelV1, + remoteSession: Pick | undefined, + text: string + ): Promise { + if (channel.provider === 'weixin') { + const credential = channel.platformCredential + if (credential?.kind !== 'weixin' || !credential.accountId.trim() || !this.deps.sendWeixinBridgeMessage) return + const to = remoteSession?.chatId.trim() || channel.remoteSession?.chatId.trim() || '' + if (!to) return + const result = await this.deps.sendWeixinBridgeMessage({ accountId: credential.accountId, to, text }) + if (!result.ok) { + this.deps.logError('claw-weixin', 'Failed to push delayed result over the WeChat bridge.', { + channelId: channel.id, + message: result.message + }) + } + return + } + if (channel.provider === 'feishu') { + const bridge = this.feishuChannels.get(channel.id) + const to = remoteSession?.chatId.trim() || channel.remoteSession?.chatId.trim() || '' + if (!bridge || !to) return + await this.sendFeishuMessage( + bridge, + to, + { markdown: text }, + {}, + { purpose: 'agent-reply-delayed', channelId: channel.id, chatId: to } + ) } - throw new Error('Timed out waiting for agent response.') } private resolveChannelWorkspaceRoot(settings: AppSettingsV1, channel?: ClawImChannelV1): string { @@ -1587,6 +1725,18 @@ export class ClawRuntime { return } + if (result.ok && result.completed === false) { + // The turn outran the response window; the reply below is the ack + // (carried on `result.message`). Deliver the real result when the + // turn finishes. + this.scheduleImResultPush(settings, { + channel, + remoteSession, + threadId: result.threadId, + turnId: result.turnId, + workspaceRoot + }) + } const generatedFiles = result.ok ? result.files ?? [] : [] const filesToSend = result.ok && (generatedFiles.length > 0 || shouldSendGeneratedFilesForPrompt(message.content)) ? await this.resolveImGeneratedFiles(generatedFiles, workspaceRoot, { @@ -1955,6 +2105,25 @@ export class ClawRuntime { writeJson(res, 500, result) return } + if (result.completed === false) { + // The turn outran the response window. Ack now and push the real + // result back when it finishes, instead of replying with whatever + // intermediate text happened to exist at the timeout. + this.scheduleImResultPush(settings, { + channel, + remoteSession: remoteSession ?? undefined, + threadId: result.threadId, + turnId: result.turnId, + workspaceRoot: this.resolveIncomingWorkspaceRoot(settings, channel, conversation, remoteSession ?? undefined) + }) + writeJson(res, 200, { + ok: true, + threadId: result.threadId, + turnId: result.turnId, + reply: `${welcomePrefix}${IM_PROCESSING_ACK}` + }) + return + } // Current-turn deliverable media files ride along in the response so // push-capable bridges (WeChat) can upload them after the text reply. // The prompt heuristic remains as a fallback for explicit file-send @@ -1973,7 +2142,8 @@ export class ClawRuntime { } ) : [] - writeJson(res, 200, { ...result, files, reply: `${welcomePrefix}${result.text ?? ''}` }) + const replyBody = result.text?.trim() || result.message?.trim() || IM_COMPLETED_NO_TEXT_REPLY + writeJson(res, 200, { ...result, files, reply: `${welcomePrefix}${replyBody}` }) } catch (error) { const message = error instanceof Error ? error.message : String(error) this.deps.logError('claw-webhook', 'Claw IM webhook request failed', { message }) diff --git a/src/main/index.ts b/src/main/index.ts index 55f23cdb1..f2047f0c4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -65,6 +65,7 @@ import { startWeixinInstallQrcode } from './claw-platform-install' import { registerRuntimeSseIpc } from './runtime-sse-ipc' +import { registerTerminalPtyIpc } from './terminal/terminal-pty-ipc' import { configureWeixinBridgeRuntimeContextProvider, ensureWeixinBridgeRpcUrl, @@ -1394,6 +1395,7 @@ app.whenReady().then(async () => { }) registerRuntimeSseIpc({ ipcMain, store, ensureRuntime, logError }) + registerTerminalPtyIpc({ ipcMain, getMainWindow: () => mainWindow, logError }) traceStartup('ipc registration:done') createWindow({ suppressInitialShow: shouldStartHidden(initial) }) diff --git a/src/main/ipc/app-ipc-schemas.test.ts b/src/main/ipc/app-ipc-schemas.test.ts index d2ad30aa7..c3e566b9a 100644 --- a/src/main/ipc/app-ipc-schemas.test.ts +++ b/src/main/ipc/app-ipc-schemas.test.ts @@ -409,6 +409,28 @@ describe('app-ipc-schemas', () => { expect(payload.keyboardShortcuts?.bindings?.settings).toEqual(['Ctrl+,']) }) + it('accepts a configurable stream idle timeout in runtime tuning patches', () => { + const payload = settingsPatchSchema.parse({ + agents: { + kun: { + runtimeTuning: { + streamIdleTimeoutMs: 300000 + } + } + } + }) + + expect(payload.agents?.kun?.runtimeTuning?.streamIdleTimeoutMs).toBe(300000) + }) + + it('rejects an out-of-range stream idle timeout', () => { + expect(() => + settingsPatchSchema.parse({ + agents: { kun: { runtimeTuning: { streamIdleTimeoutMs: -1 } } } + }) + ).toThrow() + }) + it('rejects unknown settings patch fields', () => { expect(() => settingsPatchSchema.parse({ diff --git a/src/main/ipc/app-ipc-schemas.ts b/src/main/ipc/app-ipc-schemas.ts index 777166034..ea5073fb7 100644 --- a/src/main/ipc/app-ipc-schemas.ts +++ b/src/main/ipc/app-ipc-schemas.ts @@ -48,6 +48,15 @@ import { KEYBOARD_SHORTCUT_COMMANDS } from '../../shared/keyboard-shortcuts' import { WRITE_EXPORT_FORMATS } from '../../shared/write-export' import { WRITE_INFOGRAPHIC_MAX_TEXT_CHARS } from '../../shared/write-infographic' import { SPEECH_TRANSCRIPTION_MAX_BASE64_CHARS, SPEECH_TRANSCRIPTION_MAX_DURATION_MS } from '../../shared/speech-to-text' +import { + TERMINAL_DEFAULT_COLS, + TERMINAL_DEFAULT_ROWS, + TERMINAL_MAX_COLS, + TERMINAL_MAX_CWD_LENGTH, + TERMINAL_MAX_DATA_WRITE_BYTES, + TERMINAL_MAX_ROWS, + TERMINAL_MAX_SESSION_ID_LENGTH +} from '../../shared/terminal' const MAX_BODY_BYTES = 2_000_000 const MAX_PATH_LENGTH = 4_096 @@ -94,6 +103,12 @@ export const confirmDialogPayloadSchema = z }) .strict() +export const legacySessionImportPayloadSchema = z + .object({ + sourceDir: defaultPathSchema + }) + .strict() + export const providerProbePayloadSchema = z .object({ baseUrl: trimmedString(MAX_URL_LENGTH), @@ -230,7 +245,8 @@ const modelProfilePatchSchema = z.object({ supportedEfforts: z.array(modelReasoningEffortSchema).min(1).max(8), defaultEffort: modelReasoningEffortSchema, requestProtocol: modelReasoningRequestProtocolSchema - }).strict().optional() + }).strict().optional(), + endpointFormat: modelEndpointFormatSchema.optional() }).strict() const modelProviderPatchSchema = z.object({ @@ -327,6 +343,7 @@ const kunRuntimePatchSchema = z.object({ summaryInputMaxBytes: z.number().int().positive().max(8 * 1024 * 1024).optional() }).strict().optional(), runtimeTuning: z.object({ + streamIdleTimeoutMs: z.number().int().min(0).max(3_600_000).optional(), toolStorm: z.object({ enabled: z.boolean().optional(), windowSize: z.number().int().positive().max(128).optional(), @@ -457,12 +474,28 @@ const writeSelectionAssistPatchSchema = z.object({ quickActions: z.array(writeQuickActionSchema).max(24).optional() }).strict() +const writeTypographyPatchSchema = z.object({ + fontPreset: z.string().max(32).optional(), + customFontFamily: z.string().max(200).optional(), + fontSizePx: z.number().optional(), + lineHeight: z.number().optional() +}).strict() + +const writeAgentPresetSchema = z.object({ + id: trimmedString(64), + name: z.string().max(64).optional(), + emoji: z.string().max(16).optional(), + persona: z.string().max(4_000).optional() +}).strict() + const writeSettingsPatchSchema = z.object({ defaultWorkspaceRoot: defaultPathSchema, activeWorkspaceRoot: defaultPathSchema, workspaces: z.array(trimmedString(MAX_PATH_LENGTH)).max(256).optional(), inlineCompletion: writeInlineCompletionPatchSchema.optional(), - selectionAssist: writeSelectionAssistPatchSchema.optional() + selectionAssist: writeSelectionAssistPatchSchema.optional(), + typography: writeTypographyPatchSchema.optional(), + agentPresets: z.array(writeAgentPresetSchema).max(24).optional() }).strict() const clawSkillPatchSchema = z.object({ @@ -1075,3 +1108,29 @@ export const uiPluginIdPayloadSchema = z id: z.string().trim().regex(/^[a-z0-9][a-z0-9-]{1,39}$/) }) .strict() + +export const terminalSessionIdSchema = trimmedString(TERMINAL_MAX_SESSION_ID_LENGTH) + +export const terminalCreatePayloadSchema = z + .object({ + sessionId: trimmedString(TERMINAL_MAX_SESSION_ID_LENGTH), + cwd: optionalTrimmedString(TERMINAL_MAX_CWD_LENGTH), + cols: z.number().int().min(1).max(TERMINAL_MAX_COLS).optional(), + rows: z.number().int().min(1).max(TERMINAL_MAX_ROWS).optional() + }) + .strict() + +export const terminalWritePayloadSchema = z + .object({ + sessionId: trimmedString(TERMINAL_MAX_SESSION_ID_LENGTH), + data: z.string().min(1).max(TERMINAL_MAX_DATA_WRITE_BYTES) + }) + .strict() + +export const terminalResizePayloadSchema = z + .object({ + sessionId: trimmedString(TERMINAL_MAX_SESSION_ID_LENGTH), + cols: z.number().int().min(1).max(TERMINAL_MAX_COLS).default(TERMINAL_DEFAULT_COLS), + rows: z.number().int().min(1).max(TERMINAL_MAX_ROWS).default(TERMINAL_DEFAULT_ROWS) + }) + .strict() diff --git a/src/main/ipc/register-app-ipc-handlers.ts b/src/main/ipc/register-app-ipc-handlers.ts index b2d441ff4..4a1099894 100644 --- a/src/main/ipc/register-app-ipc-handlers.ts +++ b/src/main/ipc/register-app-ipc-handlers.ts @@ -75,8 +75,11 @@ import { writeInlineCompletionPayloadSchema, writePrototypeFilePayloadSchema, writeRetrievalPayloadSchema, - workspaceRootSchema + workspaceRootSchema, + legacySessionImportPayloadSchema } from './app-ipc-schemas' +import { DEFAULT_KUN_DATA_DIR, resolveKunRuntimeSettings } from '../../shared/app-settings' +import { detectLegacySessions, importLegacySessions } from '../services/legacy-session-import-service' import type { JsonSettingsStore } from '../settings-store' import { probeModelProvider } from '../provider-connection' import type { ClawRuntime } from '../claw-runtime' @@ -771,6 +774,49 @@ export function registerAppIpcHandlers(options: RegisterAppIpcHandlersOptions): } }) + const resolveKunThreadsDataDir = async (): Promise => { + const settings = await store.load() + const runtime = resolveKunRuntimeSettings(settings) + return expandHomePath(runtime.dataDir?.trim() || DEFAULT_KUN_DATA_DIR) + } + + ipcMain.handle('kun:sessions:detect-legacy', async () => + detectLegacySessions({ homeDir: homedir(), destDataDir: await resolveKunThreadsDataDir() }) + ) + + ipcMain.handle('kun:sessions:import-legacy', async (_, payload: unknown) => { + const request = parseIpcPayload('kun:sessions:import-legacy', legacySessionImportPayloadSchema, payload) + try { + const summary = await importLegacySessions({ + homeDir: homedir(), + destDataDir: await resolveKunThreadsDataDir(), + ...(request.sourceDir ? { sourceDir: request.sourceDir } : {}), + log: (message, detail) => logError('legacy-session-import', message, detail) + }) + return { ok: true as const, ...summary } + } catch (error) { + return { + ok: false as const, + message: error instanceof Error ? error.message : String(error) + } + } + }) + + ipcMain.handle('kun:sessions:pick-source-dir', async (): Promise => { + const options: Electron.OpenDialogOptions = { + title: 'Select a folder containing previous conversations', + properties: ['openDirectory', 'dontAddToRecent'] + } + const mainWindow = getMainWindow() + const result = mainWindow + ? await dialog.showOpenDialog(mainWindow, options) + : await dialog.showOpenDialog(options) + return { + canceled: result.canceled, + path: result.canceled ? null : (result.filePaths[0] ?? null) + } + }) + ipcMain.handle('git:branches', async (_, workspaceRoot: unknown) => getGitBranches(parseIpcPayload('git:branches', workspaceRootSchema, workspaceRoot)) ) diff --git a/src/main/kun-process.test.ts b/src/main/kun-process.test.ts index f2cc3ef17..f0a1160f2 100644 --- a/src/main/kun-process.test.ts +++ b/src/main/kun-process.test.ts @@ -335,6 +335,7 @@ describe('syncGuiManagedKunConfig', () => { hardThreshold: 990_000 } }) + expect(parsed.runtime.streamIdleTimeoutMs).toBe(45000) expect(parsed.runtime.toolStorm).toMatchObject({ enabled: true, windowSize: 8, threshold: 3 }) expect(parsed.runtime.toolArgumentRepair).toMatchObject({ maxStringBytes: 524288 }) expect(parsed.capabilities.attachments).toMatchObject({ enabled: true }) @@ -697,6 +698,7 @@ describe('syncGuiManagedKunConfig', () => { summaryInputMaxBytes: 131072 }, runtimeTuning: { + streamIdleTimeoutMs: 120000, toolStorm: { enabled: false, windowSize: 12, @@ -786,6 +788,7 @@ describe('syncGuiManagedKunConfig', () => { expect(parsed.runtime.toolStorm.customStormFlag).toBeUndefined() expect(parsed.runtime.customRuntimeFlag).toBeUndefined() expect(parsed.runtime.toolArgumentRepair).toMatchObject({ maxStringBytes: 262144 }) + expect(parsed.runtime.streamIdleTimeoutMs).toBe(120000) expect(parsed.capabilities.attachments).toMatchObject({ enabled: true }) expect(parsed.capabilities.mcp.servers.github.command).toBe('github-mcp') expect(parsed.capabilities.web.fetchEnabled).toBe(true) diff --git a/src/main/kun-process.ts b/src/main/kun-process.ts index e9028d463..4679ffe0c 100644 --- a/src/main/kun-process.ts +++ b/src/main/kun-process.ts @@ -678,7 +678,8 @@ function modelConfigProfilesFromProviderProfiles( outputModalities: profile.outputModalities, supportsToolCalling: profile.supportsToolCalling, messageParts: profile.messageParts, - ...(profile.reasoning ? { reasoning: profile.reasoning } : {}) + ...(profile.reasoning ? { reasoning: profile.reasoning } : {}), + ...(profile.endpointFormat ? { endpointFormat: profile.endpointFormat } : {}) } } return out @@ -851,6 +852,7 @@ function runtimeTuningConfigForRuntime( const existingToolArgumentRepair = objectValue(existing.toolArgumentRepair) return { ...existing, + streamIdleTimeoutMs: runtimeTuning.streamIdleTimeoutMs, toolStorm: { ...existingToolStorm, enabled: runtimeTuning.toolStorm.enabled, diff --git a/src/main/services/legacy-session-import-service.test.ts b/src/main/services/legacy-session-import-service.test.ts new file mode 100644 index 000000000..94cce4af1 --- /dev/null +++ b/src/main/services/legacy-session-import-service.test.ts @@ -0,0 +1,165 @@ +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + defaultLegacySourceCandidates, + detectLegacySessions, + importLegacySessions +} from './legacy-session-import-service' + +const tempRoots: string[] = [] + +async function makeTempRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'kun-session-import-')) + tempRoots.push(root) + return root +} + +/** Create a thread folder with a minimal metadata.jsonl so it hydrates like the real store. */ +async function writeThread(threadsDir: string, threadId: string, title = threadId): Promise { + const dir = join(threadsDir, threadId) + await mkdir(dir, { recursive: true }) + const metadata = { + kind: 'thread_metadata', + version: 1, + timestamp: '2026-06-15T00:00:00.000Z', + thread: { id: threadId, title, turns: [] } + } + await writeFile(join(dir, 'metadata.jsonl'), `${JSON.stringify(metadata)}\n`, 'utf8') + await writeFile(join(dir, 'messages.jsonl'), '', 'utf8') + await writeFile(join(dir, 'events.jsonl'), '', 'utf8') +} + +afterEach(async () => { + while (tempRoots.length > 0) { + const root = tempRoots.pop() + if (root) await rm(root, { recursive: true, force: true }) + } +}) + +describe('defaultLegacySourceCandidates', () => { + it('points at the legacy DeepSeek GUI kun and coreagent threads dirs', () => { + const candidates = defaultLegacySourceCandidates('/home/zoe') + expect(candidates.map((c) => c.path)).toEqual([ + join('/home/zoe', '.deepseekgui', 'kun', 'threads'), + join('/home/zoe', '.deepseekgui', 'coreagent', 'threads') + ]) + expect(candidates.map((c) => c.kind)).toEqual(['kun', 'coreagent']) + }) +}) + +describe('detectLegacySessions', () => { + it('reports thread counts and how many are new vs the destination', async () => { + const root = await makeTempRoot() + const kunThreads = join(root, '.deepseekgui', 'kun', 'threads') + await writeThread(kunThreads, 'thr_a') + await writeThread(kunThreads, 'thr_b') + const coreagentThreads = join(root, '.deepseekgui', 'coreagent', 'threads') + await writeThread(coreagentThreads, 'thr_c') + // One of the kun threads already exists in the destination. + const dataDir = join(root, '.kun', 'data') + await writeThread(join(dataDir, 'threads'), 'thr_a') + + const detection = await detectLegacySessions({ homeDir: root, destDataDir: dataDir }) + + expect(detection.destDir).toBe(join(dataDir, 'threads')) + const kun = detection.sources.find((s) => s.kind === 'kun') + expect(kun).toMatchObject({ threadCount: 2, newCount: 1 }) + const coreagent = detection.sources.find((s) => s.kind === 'coreagent') + expect(coreagent).toMatchObject({ threadCount: 1, newCount: 1 }) + }) + + it('omits sources that do not exist', async () => { + const root = await makeTempRoot() + await writeThread(join(root, '.deepseekgui', 'kun', 'threads'), 'thr_a') + const detection = await detectLegacySessions({ + homeDir: root, + destDataDir: join(root, '.kun', 'data') + }) + expect(detection.sources.map((s) => s.kind)).toEqual(['kun']) + }) +}) + +describe('importLegacySessions', () => { + it('copies all auto-detected legacy threads into the destination', async () => { + const root = await makeTempRoot() + await writeThread(join(root, '.deepseekgui', 'kun', 'threads'), 'thr_a') + await writeThread(join(root, '.deepseekgui', 'kun', 'threads'), 'thr_b') + await writeThread(join(root, '.deepseekgui', 'coreagent', 'threads'), 'thr_c') + const dataDir = join(root, '.kun', 'data') + + const summary = await importLegacySessions({ homeDir: root, destDataDir: dataDir }) + + expect(summary).toMatchObject({ total: 3, imported: 3, skipped: 0 }) + const copied = (await readdir(join(dataDir, 'threads'))).sort() + expect(copied).toEqual(['thr_a', 'thr_b', 'thr_c']) + // Content is copied verbatim (no transformation). + const meta = await readFile(join(dataDir, 'threads', 'thr_a', 'metadata.jsonl'), 'utf8') + expect(meta).toContain('"id":"thr_a"') + }) + + it('never overwrites a thread that already exists in the destination', async () => { + const root = await makeTempRoot() + await writeThread(join(root, '.deepseekgui', 'kun', 'threads'), 'thr_a', 'legacy title') + const dataDir = join(root, '.kun', 'data') + await writeThread(join(dataDir, 'threads'), 'thr_a', 'current title') + + const summary = await importLegacySessions({ homeDir: root, destDataDir: dataDir }) + + expect(summary).toMatchObject({ total: 1, imported: 0, skipped: 1 }) + const meta = await readFile(join(dataDir, 'threads', 'thr_a', 'metadata.jsonl'), 'utf8') + expect(meta).toContain('current title') + expect(meta).not.toContain('legacy title') + }) + + it('imports from an explicitly chosen folder, descending into a threads subdir', async () => { + const root = await makeTempRoot() + // User picks the parent (…/backup/kun), not the threads dir itself. + const pickedParent = join(root, 'backup', 'kun') + await writeThread(join(pickedParent, 'threads'), 'thr_x') + const dataDir = join(root, '.kun', 'data') + + const summary = await importLegacySessions({ + homeDir: root, + destDataDir: dataDir, + sourceDir: pickedParent + }) + + expect(summary).toMatchObject({ total: 1, imported: 1, skipped: 0 }) + expect(await readdir(join(dataDir, 'threads'))).toEqual(['thr_x']) + }) + + it('ignores non-thread entries and accepts marker-only folders', async () => { + const root = await makeTempRoot() + const source = join(root, 'backup') + await writeThread(source, 'thr_a') + // A loose index file and an unrelated directory must be ignored. + await writeFile(join(source, 'index.json'), '{}', 'utf8') + await mkdir(join(source, 'notes'), { recursive: true }) + await writeFile(join(source, 'notes', 'todo.txt'), 'hi', 'utf8') + // A folder without the thr_ prefix but containing a thread marker is accepted. + const oddDir = join(source, 'session-1') + await mkdir(oddDir, { recursive: true }) + await writeFile(join(oddDir, 'thread.json'), '{"id":"session-1","title":"x","turns":[]}', 'utf8') + const dataDir = join(root, '.kun', 'data') + + const summary = await importLegacySessions({ + homeDir: root, + destDataDir: dataDir, + sourceDir: source + }) + + expect(summary.imported).toBe(2) + expect((await readdir(join(dataDir, 'threads'))).sort()).toEqual(['session-1', 'thr_a']) + }) + + it('returns zero when there is nothing to import', async () => { + const root = await makeTempRoot() + const summary = await importLegacySessions({ + homeDir: root, + destDataDir: join(root, '.kun', 'data') + }) + expect(summary).toMatchObject({ total: 0, imported: 0, skipped: 0 }) + }) +}) diff --git a/src/main/services/legacy-session-import-service.ts b/src/main/services/legacy-session-import-service.ts new file mode 100644 index 000000000..0637fc98e --- /dev/null +++ b/src/main/services/legacy-session-import-service.ts @@ -0,0 +1,207 @@ +import { cp, mkdir, readdir, realpath, stat } from 'node:fs/promises' +import { homedir } from 'node:os' +import { join } from 'node:path' +import type { + LegacySessionDetectResult, + LegacySessionDetectedSource, + LegacySessionImportSummary, + LegacySessionSourceKind +} from '../../shared/kun-gui-api' + +/** + * 把“DeepSeek GUI”时代遗留的会话目录导入到当前 Kun 数据目录。 + * + * 关键事实(决定了实现方式): + * - 新旧版本会话的磁盘格式完全一致:每个线程一个目录,内含 + * metadata.jsonl / messages.jsonl / events.jsonl(早期还有 thread.json)。 + * - HybridThreadStore 把 JSONL 文件当作权威来源、SQLite 只是可重建的索引; + * 启动时的 backfill() 会扫描线程目录并把未入库的线程补进索引。 + * 因此“导入”本质上只是把线程目录拷进 {dataDir}/threads,再重启运行时 + * 触发 backfill 即可——无需任何格式转换。 + * + * 设计约束: + * 1. 绝不覆盖目标已存在的线程目录(按目录名/线程 ID 去重),保证幂等且非破坏性。 + * 2. 拷贝而非移动,旧数据原地保留作为兜底。 + * 3. 任何单个线程拷贝失败都被吞掉并计入 skipped,不让整个导入中断。 + * + * 这个模块刻意不 import electron,方便在 vitest 里注入临时目录直接测试。 + */ + +const THREAD_DIR_MARKERS = ['metadata.jsonl', 'thread.json', 'messages.jsonl'] as const + +export type LegacySessionSourceCandidate = { + id: string + kind: LegacySessionSourceKind + /** 旧版线程目录的绝对路径,如 ~/.deepseekgui/kun/threads。 */ + path: string +} + +export type LegacySessionImportLogger = (message: string, detail?: unknown) => void + +/** + * 自动检测的旧数据来源。顺序即展示优先级:先列近期版本(kun), + * 再列更早的 coreagent 时代数据。两者磁盘格式相同。 + */ +export function defaultLegacySourceCandidates(homeDir: string): LegacySessionSourceCandidate[] { + return [ + { id: 'deepseekgui-kun', kind: 'kun', path: join(homeDir, '.deepseekgui', 'kun', 'threads') }, + { + id: 'deepseekgui-coreagent', + kind: 'coreagent', + path: join(homeDir, '.deepseekgui', 'coreagent', 'threads') + } + ] +} + +async function pathExists(target: string): Promise { + try { + await stat(target) + return true + } catch { + return false + } +} + +/** realpath 解析失败(路径不存在等)时回退到原路径,只用于同目录判定。 */ +async function safeRealpath(target: string): Promise { + try { + return await realpath(target) + } catch { + return target + } +} + +/** + * 列出 parent 下“看起来像线程目录”的子目录名。判定:目录名以 thr_ 开头, + * 或目录内含已知线程标志文件(兼容自定义命名 / 更早格式)。 + */ +async function listThreadDirNames(parent: string): Promise { + const entries = await readdir(parent, { withFileTypes: true }).catch(() => null) + if (!entries) return [] + const names: string[] = [] + for (const entry of entries) { + if (!entry.isDirectory()) continue + if (entry.name.startsWith('thr_')) { + names.push(entry.name) + continue + } + const dir = join(parent, entry.name) + for (const marker of THREAD_DIR_MARKERS) { + if (await pathExists(join(dir, marker))) { + names.push(entry.name) + break + } + } + } + return names +} + +/** + * 把用户手选的文件夹解析成真正的 threads 目录:既支持直接选中 threads 目录, + * 也支持选中它的上级(如 .../kun),自动下探一层 threads。 + */ +async function resolveSourceThreadsDir(picked: string): Promise { + if ((await listThreadDirNames(picked)).length > 0) return picked + const nested = join(picked, 'threads') + if ((await listThreadDirNames(nested)).length > 0) return nested + return picked +} + +/** 检测可导入的旧会话来源,以及其中有多少是目标里尚不存在的。 */ +export async function detectLegacySessions(input: { + destDataDir: string + homeDir?: string +}): Promise { + const homeDir = input.homeDir ?? homedir() + const destDir = join(input.destDataDir, 'threads') + const destReal = await safeRealpath(destDir) + const existing = new Set(await listThreadDirNames(destDir)) + + const sources: LegacySessionDetectedSource[] = [] + for (const candidate of defaultLegacySourceCandidates(homeDir)) { + if (!(await pathExists(candidate.path))) continue + // 已经是当前数据目录本身(老版本启动迁移留下的符号链接)——无需再导入。 + if ((await safeRealpath(candidate.path)) === destReal) continue + const names = await listThreadDirNames(candidate.path) + if (names.length === 0) continue + const newCount = names.reduce((count, name) => (existing.has(name) ? count : count + 1), 0) + sources.push({ + id: candidate.id, + kind: candidate.kind, + path: candidate.path, + threadCount: names.length, + newCount + }) + } + return { destDir, sources } +} + +/** + * 执行导入。sourceDir 为空 = 导入所有自动检测到的默认来源;否则只导入用户 + * 手选的目录。已存在的线程目录一律跳过(skipped),不覆盖。 + */ +export async function importLegacySessions(input: { + destDataDir: string + homeDir?: string + sourceDir?: string + log?: LegacySessionImportLogger +}): Promise { + const homeDir = input.homeDir ?? homedir() + const destDir = join(input.destDataDir, 'threads') + await mkdir(destDir, { recursive: true }) + const destReal = await safeRealpath(destDir) + + const sourceDirs: string[] = [] + const picked = input.sourceDir?.trim() + if (picked) { + sourceDirs.push(await resolveSourceThreadsDir(picked)) + } else { + for (const candidate of defaultLegacySourceCandidates(homeDir)) { + if (await pathExists(candidate.path)) sourceDirs.push(candidate.path) + } + } + + const summary: LegacySessionImportSummary = { + destDir, + total: 0, + imported: 0, + skipped: 0, + sources: [] + } + + for (const sourceDir of sourceDirs) { + // 跳过指向目标本身的来源,避免把目录拷进自己。 + if ((await safeRealpath(sourceDir)) === destReal) continue + const names = await listThreadDirNames(sourceDir) + let imported = 0 + let skipped = 0 + for (const name of names) { + const target = join(destDir, name) + if (await pathExists(target)) { + skipped += 1 + continue + } + try { + await cp(join(sourceDir, name), target, { + recursive: true, + preserveTimestamps: true, + errorOnExist: false + }) + imported += 1 + } catch (error) { + input.log?.('legacy-session-import: failed to copy thread', { + name, + sourceDir, + message: error instanceof Error ? error.message : String(error) + }) + skipped += 1 + } + } + summary.sources.push({ path: sourceDir, total: names.length, imported, skipped }) + summary.total += names.length + summary.imported += imported + summary.skipped += skipped + } + + return summary +} diff --git a/src/main/services/skill-service.test.ts b/src/main/services/skill-service.test.ts index bdfc5a4b3..94aec2068 100644 --- a/src/main/services/skill-service.test.ts +++ b/src/main/services/skill-service.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -121,6 +121,43 @@ describe('skill-service', () => { expect(comparable(claude?.path ?? '')).toBe(comparable(join(workspaceRoot, '.claude', 'skills'))) }) + it('discovers and counts skills symlinked into .claude/skills (e.g. cc switch)', async (ctx) => { + const workspaceRoot = join(tempRoot, 'ws-symlink') + // cc switch stores the real skill files in its own config dir... + const realSkill = join(tempRoot, 'cc-config', 'skills', 'linked-skill') + await mkdir(realSkill, { recursive: true }) + await writeFile(join(realSkill, 'SKILL.md'), [ + '---', 'name: linked-skill', 'description: Linked via symlink.', '---', '', 'Body.' + ].join('\n'), 'utf8') + // ...and symlinks the per-skill directory into .claude/skills. + const claudeSkills = join(workspaceRoot, '.claude', 'skills') + await mkdir(claudeSkills, { recursive: true }) + try { + await symlink(realSkill, join(claudeSkills, 'linked-skill'), 'dir') + } catch { + // Symlink creation can be unprivileged (e.g. Windows) — skip there. + ctx.skip() + return + } + + const settings = createSettings(workspaceRoot) + const result = await listGuiSkills(settings, workspaceRoot) + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.skills).toContainEqual(expect.objectContaining({ + id: 'linked-skill', + name: 'Linked Skill', + description: 'Linked via symlink.', + scope: 'project' + })) + + const roots = await listGuiSkillRoots(settings, workspaceRoot) + expect(roots.ok).toBe(true) + if (!roots.ok) return + const claude = roots.roots.find((root) => root.labelKey === 'pluginSkillRootWorkspaceClaude') + expect(claude?.skillCount).toBe(1) + }) + it('omits a directory disabled via disabledDirs from runtime roots but still lists it', async () => { const workspaceRoot = join(tempRoot, 'ws-toggle') const claudeSkill = join(workspaceRoot, '.claude', 'skills', 'demo') diff --git a/src/main/services/skill-service.ts b/src/main/services/skill-service.ts index d6e951e2c..dccee29e3 100644 --- a/src/main/services/skill-service.ts +++ b/src/main/services/skill-service.ts @@ -1,5 +1,5 @@ -import { existsSync, readdirSync } from 'node:fs' -import { readdir, readFile } from 'node:fs/promises' +import { existsSync, readdirSync, statSync, type Dirent } from 'node:fs' +import { readdir, readFile, stat } from 'node:fs/promises' import { homedir } from 'node:os' import { basename, join, resolve } from 'node:path' import type { AppSettingsV1 } from '../../shared/app-settings' @@ -316,7 +316,7 @@ function skillRootHasPackages(root: string): boolean { if (existsSync(join(root, 'SKILL.md')) || existsSync(join(root, 'skill.json'))) return true try { return readdirSync(root, { withFileTypes: true }).some((entry) => - entry.isDirectory() && + entryIsDirectorySync(entry, join(root, entry.name)) && (existsSync(join(root, entry.name, 'SKILL.md')) || existsSync(join(root, entry.name, 'skill.json'))) ) } catch { @@ -331,8 +331,8 @@ async function packageCandidates(root: string): Promise { } const entries = await readdir(root, { withFileTypes: true }) for (const entry of entries) { - if (!entry.isDirectory()) continue const dir = join(root, entry.name) + if (!(await entryIsDirectory(entry, dir))) continue if (existsSync(join(dir, 'skill.json')) || existsSync(join(dir, 'SKILL.md'))) { candidates.add(dir) } @@ -340,6 +340,34 @@ async function packageCandidates(root: string): Promise { return [...candidates] } +/** + * Whether a directory entry is — or resolves to — a directory. `readdir`/ + * `readdirSync` with `withFileTypes` describe the link itself, so a symlinked + * skill package (e.g. the per-skill links `cc switch` drops into + * `.claude/skills`) reports `isDirectory() === false` and would be skipped. + * Follow such links via `stat` so those packages are still discovered. Also + * covers filesystems that report an unknown `d_type`. (#320) + */ +async function entryIsDirectory(entry: Dirent, path: string): Promise { + if (entry.isDirectory()) return true + if (entry.isFile()) return false + try { + return (await stat(path)).isDirectory() + } catch { + return false + } +} + +function entryIsDirectorySync(entry: Dirent, path: string): boolean { + if (entry.isDirectory()) return true + if (entry.isFile()) return false + try { + return statSync(path).isDirectory() + } catch { + return false + } +} + async function loadSkillSummary(root: string, scope: GuiSkillScope): Promise { const manifestPath = join(root, 'skill.json') if (existsSync(manifestPath)) { diff --git a/src/main/services/worktree-service.ts b/src/main/services/worktree-service.ts index 02e58abb9..ae1438b37 100644 --- a/src/main/services/worktree-service.ts +++ b/src/main/services/worktree-service.ts @@ -150,8 +150,16 @@ export async function listWorktrees(params: { }): Promise { const { projectPath, worktreeRoot } = params const poolDir = resolvePoolDir(projectPath, worktreeRoot) - const mainBranch = await detectMainBranch(projectPath) - const headCommit = await getHeadCommit(projectPath) + + let mainBranch: string + let headCommit: string + try { + mainBranch = await detectMainBranch(projectPath) + headCommit = await getHeadCommit(projectPath) + } catch { + return { projectPath, poolDir, mainBranch: '', headCommit: '', worktrees: [], inUseCount: 0, isGitRepo: false } + } + const worktrees: WorktreeInfo[] = [] let inUseCount = 0 @@ -177,7 +185,7 @@ export async function listWorktrees(params: { }) } - return { projectPath, poolDir, mainBranch, headCommit, worktrees, inUseCount } + return { projectPath, poolDir, mainBranch, headCommit, worktrees, inUseCount, isGitRepo: true } } export async function removeWorktree(params: { diff --git a/src/main/services/write-inline-completion-service.test.ts b/src/main/services/write-inline-completion-service.test.ts index fd2708840..99bae087b 100644 --- a/src/main/services/write-inline-completion-service.test.ts +++ b/src/main/services/write-inline-completion-service.test.ts @@ -815,4 +815,60 @@ describe('parseWriteInlineAction', () => { scopeKind: 'selection' }) }) + + it('returns an empty completion for a malformed marker skeleton instead of leaking markers', () => { + // Regression: a degenerate single-line skeleton used to fall through to the + // plain-text fallback and render ">>> <<>> <<>> <<>> <<>> <<>> <<>>', { fallbackKind: 'long' })).toEqual({ + kind: 'long', + text: '' + }) + }) + + it('returns an empty completion when the model parrots the protocol template', () => { + const template = [ + '<<>>', + '<<>>', + '<<>>' + ].join('\n') + expect(parseWriteInlineAction(template)).toEqual({ kind: 'short', text: '' }) + }) + + it('parses same-line marked blocks', () => { + expect(parseWriteInlineAction('<<>>')).toEqual({ + kind: 'short', + text: 'next words' + }) + }) + + it('prefers the first non-empty block when an earlier block is empty', () => { + expect(parseWriteInlineAction('<<>>\n<<>>')).toEqual({ + kind: 'long', + text: 'A fuller continuation.' + }) + }) + + it('extracts a block that dropped its closing marker without swallowing the next marker', () => { + expect(parseWriteInlineAction('<<>>')).toEqual({ + kind: 'short', + text: 'next words' + }) + }) + + it('keeps plain text that legitimately contains >>> when no protocol marker is present', () => { + expect(parseWriteInlineAction('>>> a Python prompt')).toEqual({ + kind: 'short', + text: '>>> a Python prompt' + }) + }) }) diff --git a/src/main/services/write-inline-completion-service.ts b/src/main/services/write-inline-completion-service.ts index 7d16830b8..122f280a9 100644 --- a/src/main/services/write-inline-completion-service.ts +++ b/src/main/services/write-inline-completion-service.ts @@ -35,6 +35,21 @@ const MAX_INLINE_COMPLETION_DEBUG_ENTRIES = 120 const MAX_DEBUG_TEXT_CHARS = 80_000 const INPUT_BOUNDARY_MARKERS = ['PREFIX', 'SUFFIX', 'EDIT_SCOPE'] as const const OUTPUT_ACTION_MARKERS = ['SHORT', 'LONG', 'EDIT'] as const +// Every protocol marker name, longest first so the alternation prefers EDIT_SCOPE +// over EDIT. Used to terminate a marked body that lost its closing >>> and to +// scrub malformed marker soup that must never reach the ghost text. +const PROTOCOL_MARKER_NAMES = [...INPUT_BOUNDARY_MARKERS, ...OUTPUT_ACTION_MARKERS] + .slice() + .sort((a, b) => b.length - a.length) + .join('|') +const PROTOCOL_MARKER_OPENER = new RegExp(`<<<[ \\t]*(?:${PROTOCOL_MARKER_NAMES})\\b`, 'i') +// The placeholder lines from buildResponseProtocolPromptBlock(). A weak model +// sometimes parrots them verbatim; such an echo is never a real suggestion. +const PROTOCOL_PLACEHOLDER_BODIES = new Set([ + 'short text to insert at the cursor', + 'longer continuation to insert at the cursor', + 'replacement text for the editable local scope' +]) type ChatCompletionResponse = { choices?: Array<{ @@ -642,15 +657,50 @@ function containsInputBoundaryEcho(text: string): boolean { text.includes('Return only the text to insert at the cursor.') } +/** + * Strip protocol marker tokens that leaked into a malformed response so they can + * never surface as ghost text. Guarded on an actual marker opener being present, + * so ordinary prose that merely contains ">>>" (a REPL transcript, a merge + * conflict marker) is returned untouched. + */ +function stripActionMarkerArtifacts(text: string): string { + if (!PROTOCOL_MARKER_OPENER.test(text)) return text + return text + .replace(new RegExp(`<<<[ \\t]*(?:${PROTOCOL_MARKER_NAMES})\\b[ \\t]*`, 'gi'), '') + .replace(/>>>/g, '') + // Drop any line that is a bare echo of the protocol placeholder text, so a + // full-template parrot collapses to empty rather than leaking the sample lines. + .split('\n') + .filter((line) => !PROTOCOL_PLACEHOLDER_BODIES.has(line.trim().toLowerCase())) + .join('\n') + .replace(/[ \t]+$/gm, '') + .replace(/\n{3,}/g, '\n\n') + .trim() +} + function parseMarkedActionBlock( text: string, options: { editTarget?: WriteInlineActionEditTarget } ): WriteInlineCompletionAction | null { + // A body ends at the first closing >>> or the next protocol opener, whichever + // comes first, so a block that dropped its >>> never swallows later markers. + const bodyTerminator = new RegExp(`>>>|<<<[ \\t]*(?:${PROTOCOL_MARKER_NAMES})\\b`, 'i') for (const marker of OUTPUT_ACTION_MARKERS) { - const exact = new RegExp(`^<<<[ \\t]*${marker}[ \\t]*\\n([\\s\\S]*?)\\n?>>>$`, 'i').exec(text) - const embedded = exact ?? new RegExp(`<<<[ \\t]*${marker}[ \\t]*\\n([\\s\\S]*?)\\n?>>>`, 'i').exec(text) - if (!embedded) continue - const body = trimMarkerPadding(embedded[1]) + // Tolerant opener: trailing spaces/tabs and an optional single newline after + // the keyword, so same-line bodies (<<>>) parse too. + const opener = new RegExp(`<<<[ \\t]*${marker}\\b[ \\t]*\\n?`, 'i').exec(text) + if (!opener) continue + const rest = text.slice(opener.index + opener[0].length) + const end = rest.search(bodyTerminator) + // Drop horizontal whitespace abutting the close marker, then a single + // trailing newline; leading whitespace is kept so a continuation like + // " next words" stays intact. + const body = trimMarkerPadding((end >= 0 ? rest.slice(0, end) : rest).replace(/[ \t]+$/, '')) + // Skip empty blocks (a contentless SHORT must not shadow a filled LONG, and + // a pure marker skeleton must fall through to the scrubbing fallback below) + // and skip a verbatim echo of the protocol's own placeholder text. + const condensed = body.trim().toLowerCase() + if (!condensed || PROTOCOL_PLACEHOLDER_BODIES.has(condensed)) continue if (marker === 'SHORT') return completionAction(body, 'short') if (marker === 'LONG') return completionAction(body, 'long') return editAction(body, options.editTarget) @@ -716,9 +766,13 @@ export function parseWriteInlineAction( const labeledEdit = trimmed.match(/^(?:edit|replacement|replace|new text|edited text|替换文本|修改后|修改|替换)[::]\s*([\s\S]*)$/i) if (labeledEdit) return editAction(labeledEdit[1], options.editTarget) + // Last resort: treat the response as plain insertable text, but scrub any + // leaked protocol markers first so a malformed skeleton (">>> <<>> + // << 0 ? writeWorkspaces : [writeDefaultRoot], - inlineCompletion: normalized.write.inlineCompletion, - selectionAssist: normalized.write.selectionAssist + workspaces: writeWorkspaces.length > 0 ? writeWorkspaces : [writeDefaultRoot] }, claw: { ...normalized.claw, diff --git a/src/main/terminal/terminal-pty-ipc.ts b/src/main/terminal/terminal-pty-ipc.ts new file mode 100644 index 000000000..7536d382a --- /dev/null +++ b/src/main/terminal/terminal-pty-ipc.ts @@ -0,0 +1,287 @@ +/** + * Main-process PTY lifecycle for the built-in terminal. + * + * Architecture mirrors `runtime-sse-ipc.ts`: the main process owns the real + * resource (a node-pty pseudo-terminal), streams chunks to the renderer over + * `terminal:data`, and reports exit via `terminal:exit`. node-pty is loaded + * lazily so a missing/broken native build disables the terminal gracefully + * instead of crashing app startup. + * + * Cross-platform notes: + * - macOS / Linux: node-pty uses forkpty; the `$SHELL` env var (fallback + * /bin/zsh on mac, /bin/bash on linux) selects the program. + * - Windows: node-pty uses ConPTY (`useConpty: true`); we prefer PowerShell + * 7 (pwsh.exe), then Windows PowerShell, then cmd.exe. + * - `useConpty` is a no-op on non-Windows, so we always pass it. + */ +import { existsSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import type { BrowserWindow, IpcMain, WebContents } from 'electron' +import type { IPty } from 'node-pty' +import { + TERMINAL_DEFAULT_COLS, + TERMINAL_DEFAULT_ROWS, + TERMINAL_MAX_SESSIONS, + TERMINAL_RING_BUFFER_BYTES +} from '../../shared/terminal' +import { + terminalCreatePayloadSchema, + terminalResizePayloadSchema, + terminalSessionIdSchema, + terminalWritePayloadSchema +} from '../ipc/app-ipc-schemas' + +type TerminalSession = { + pty: IPty + sender: WebContents + /** Last ~64KB of output, replayed when a panel re-attaches. */ + ringBuffer: string + exited: boolean +} + +let nodePty: typeof import('node-pty') | null | undefined + +async function loadNodePty(): Promise { + if (nodePty !== undefined) return nodePty + try { + // Dynamic import keeps the main bundle compiling even if the native + // prebuild is missing on the current platform; failure surfaces as a + // friendly message in the panel instead of a hard crash. + nodePty = await import('node-pty') + } catch (error) { + console.warn('[terminal] node-pty failed to load; built-in terminal disabled:', error) + nodePty = null + } + return nodePty +} + +/** + * Pick a default shell for the current platform. + * + * macOS: respects $SHELL (set by the OS for the user's default terminal), + * falling back to zsh which has shipped as the system default since + * Catalina. + * Linux: respects $SHELL, falling back to bash (the de-facto standard). + * Windows: PowerShell 7 (pwsh.exe) if installed, else Windows PowerShell, + * else the COMSPEC command interpreter (usually cmd.exe). + */ +function resolveDefaultShell(): { file: string; args: string[] } { + if (process.platform === 'win32') { + const programFiles = process.env.PROGRAMFILES ?? 'C:\\Program Files' + const systemRoot = process.env.SystemRoot ?? process.env.WINDIR ?? 'C:\\Windows' + const pwsh7 = join(programFiles, 'PowerShell', '7', 'pwsh.exe') + if (existsSync(pwsh7)) return { file: pwsh7, args: ['-NoLogo'] } + const windowsPwsh = join( + systemRoot, + 'System32', + 'WindowsPowerShell', + 'v1.0', + 'powershell.exe' + ) + if (existsSync(windowsPwsh)) return { file: windowsPwsh, args: ['-NoLogo'] } + return { file: process.env.COMSPEC ?? 'cmd.exe', args: [] } + } + const fallback = process.platform === 'darwin' ? '/bin/zsh' : '/bin/bash' + return { file: process.env.SHELL || fallback, args: [] } +} + +function buildShellEnv(): NodeJS.ProcessEnv { + // xterm-256color matches what xterm.js advertises and keeps color-capable + // programs (ls, git, etc.) emitting escape codes. + return { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor' } +} + +function pushToRingBuffer(session: TerminalSession, chunk: string): void { + session.ringBuffer += chunk + if (session.ringBuffer.length > TERMINAL_RING_BUFFER_BYTES) { + session.ringBuffer = session.ringBuffer.slice(-TERMINAL_RING_BUFFER_BYTES) + } +} + +function sendToSender(sender: WebContents, channel: string, payload: unknown): void { + if (sender.isDestroyed()) return + sender.send(channel, payload) +} + +export type RegisterTerminalPtyIpcOptions = { + ipcMain: IpcMain + getMainWindow: () => BrowserWindow | null + logError: (category: string, message: string, detail?: unknown) => void +} + +export function registerTerminalPtyIpc(options: RegisterTerminalPtyIpcOptions): void { + const { ipcMain, getMainWindow, logError } = options + const sessions = new Map() + + const disposeSession = (sessionId: string, killedByClient: boolean): boolean => { + const session = sessions.get(sessionId) + if (!session) return false + try { + session.pty.kill() + } catch (error) { + logError('terminal', 'Failed to kill PTY process', { + sessionId, + message: error instanceof Error ? error.message : String(error) + }) + } + sessions.delete(sessionId) + if (!killedByClient && !session.sender.isDestroyed()) { + sendToSender(session.sender, 'terminal:exit', { sessionId, exitCode: null }) + } + return true + } + + const disposeForSender = (sender: WebContents): void => { + for (const [sessionId, session] of sessions) { + if (session.sender === sender) disposeSession(sessionId, true) + } + } + + // When the renderer window closes, tear down any PTY it owned. Listening + // on the main window's webContents covers the normal single-window case. + const attachSenderCleanup = (sender: WebContents): void => { + if (sender.isDestroyed()) { + disposeForSender(sender) + return + } + sender.once('destroyed', () => disposeForSender(sender)) + } + + ipcMain.handle('terminal:create', async (event, args: unknown) => { + const request = terminalCreatePayloadSchema.parse(args) + + // Re-attach to an existing session: replay the ring buffer so reopening + // the panel shows recent output instead of a blank screen. + const existing = sessions.get(request.sessionId) + if (existing && !existing.exited) { + if (existing.ringBuffer) { + sendToSender(event.sender, 'terminal:data', { + sessionId: request.sessionId, + data: existing.ringBuffer + }) + } + // Rebind to the current sender in case the window was recreated. + existing.sender = event.sender + attachSenderCleanup(event.sender) + return { ok: true as const, sessionId: request.sessionId, replayed: true } + } + if (existing && existing.exited) { + disposeSession(request.sessionId, true) + } + + if (sessions.size >= TERMINAL_MAX_SESSIONS) { + return { + ok: false as const, + message: `Too many terminal sessions (limit ${TERMINAL_MAX_SESSIONS}).` + } + } + + const ptyModule = await loadNodePty() + if (!ptyModule) { + return { + ok: false as const, + message: 'The terminal backend (node-pty) is not available on this system.' + } + } + + const { file, args: shellArgs } = resolveDefaultShell() + const cols = request.cols ?? TERMINAL_DEFAULT_COLS + const rows = request.rows ?? TERMINAL_DEFAULT_ROWS + const cwd = request.cwd && request.cwd.trim() ? request.cwd.trim() : homedir() + + try { + const pty = ptyModule.spawn(file, shellArgs, { + name: 'xterm-256color', + cols, + rows, + cwd, + env: buildShellEnv(), + // ConPTY on Windows, ignored elsewhere. + useConpty: true + }) + + const session: TerminalSession = { + pty, + sender: event.sender, + ringBuffer: '', + exited: false + } + sessions.set(request.sessionId, session) + attachSenderCleanup(event.sender) + + pty.onData((data) => { + if (session.exited) return + pushToRingBuffer(session, data) + sendToSender(session.sender, 'terminal:data', { sessionId: request.sessionId, data }) + }) + + pty.onExit(({ exitCode }) => { + session.exited = true + sendToSender(session.sender, 'terminal:exit', { sessionId: request.sessionId, exitCode }) + // Keep the entry briefly so a slow re-attach can still replay; the + // next create disposes it. Full cleanup also happens on app quit. + }) + + return { ok: true as const, sessionId: request.sessionId } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + logError('terminal', 'Failed to spawn PTY', { sessionId: request.sessionId, message }) + return { ok: false as const, message } + } + }) + + ipcMain.handle('terminal:write', async (event, args: unknown) => { + const request = terminalWritePayloadSchema.parse(args) + const session = sessions.get(request.sessionId) + if (!session || session.exited) return false + try { + session.pty.write(request.data) + return true + } catch (error) { + logError('terminal', 'Failed to write to PTY', { + sessionId: request.sessionId, + message: error instanceof Error ? error.message : String(error) + }) + return false + } + }) + + ipcMain.handle('terminal:resize', async (event, args: unknown) => { + const request = terminalResizePayloadSchema.parse(args) + const session = sessions.get(request.sessionId) + if (!session || session.exited) return false + try { + session.pty.resize(request.cols, request.rows) + return true + } catch (error) { + logError('terminal', 'Failed to resize PTY', { + sessionId: request.sessionId, + message: error instanceof Error ? error.message : String(error) + }) + return false + } + }) + + ipcMain.handle('terminal:dispose', async (_event, sessionId: unknown) => { + const normalized = terminalSessionIdSchema.parse(sessionId) + return disposeSession(normalized, true) + }) + + // App-wide teardown so no orphaned shell survives a normal quit. Lazily + // importing `electron` here keeps the module side-effect-free for tests. + void import('electron').then(({ app }) => { + app.on('before-quit', () => { + for (const sessionId of Array.from(sessions.keys())) { + disposeSession(sessionId, true) + } + }) + }) + + // If the main window is recreated (e.g. on macOS reactivation), make sure + // stale sessions bound to a destroyed window are torn down. + const mainWindow = getMainWindow() + if (mainWindow && !mainWindow.isDestroyed()) { + attachSenderCleanup(mainWindow.webContents) + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 79ad234ad..1b1a4bb6e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -27,6 +27,12 @@ const api = { ipcRenderer.invoke('workspace:pick-directory', defaultPath), confirmDialog: (options) => ipcRenderer.invoke('dialog:confirm', options), + detectLegacySessions: () => + ipcRenderer.invoke('kun:sessions:detect-legacy'), + importLegacySessions: (sourceDir) => + ipcRenderer.invoke('kun:sessions:import-legacy', { sourceDir }), + pickLegacySessionDir: () => + ipcRenderer.invoke('kun:sessions:pick-source-dir'), listSkills: (workspaceRoot) => ipcRenderer.invoke('skill:list', { workspaceRoot }), listSkillRoots: (workspaceRoot) => @@ -232,7 +238,27 @@ const api = { logError: (category, message, detail) => ipcRenderer.invoke('log:error', { category, message, detail }), getLogPath: () => ipcRenderer.invoke('log:get-path'), - openLogDir: () => ipcRenderer.invoke('log:open-dir') + openLogDir: () => ipcRenderer.invoke('log:open-dir'), + createTerminal: (payload) => ipcRenderer.invoke('terminal:create', payload), + writeToTerminal: (payload) => ipcRenderer.invoke('terminal:write', payload), + resizeTerminal: (payload) => ipcRenderer.invoke('terminal:resize', payload), + disposeTerminal: (sessionId) => ipcRenderer.invoke('terminal:dispose', sessionId), + onTerminalData: (handler) => { + const wrapped = ( + _: Electron.IpcRendererEvent, + payload: Parameters[0] + ) => handler(payload) + ipcRenderer.on('terminal:data', wrapped) + return () => ipcRenderer.removeListener('terminal:data', wrapped) + }, + onTerminalExit: (handler) => { + const wrapped = ( + _: Electron.IpcRendererEvent, + payload: Parameters[0] + ) => handler(payload) + ipcRenderer.on('terminal:exit', wrapped) + return () => ipcRenderer.removeListener('terminal:exit', wrapped) + } } satisfies KunGuiApi contextBridge.exposeInMainWorld('kunGui', api) diff --git a/src/renderer/src/components/PluginMarketplaceView.test.ts b/src/renderer/src/components/PluginMarketplaceView.test.ts index 293f79f54..065470a50 100644 --- a/src/renderer/src/components/PluginMarketplaceView.test.ts +++ b/src/renderer/src/components/PluginMarketplaceView.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import type { SkillRootListItem } from '@shared/kun-gui-api' import { buildMcpConfig, customMcpConfigFragment, @@ -7,7 +8,8 @@ import { mergeMcpJsonConfig, recommendedMarketplaceItemIds, setMcpServerEnabled, - skillMarketplaceItemsFromDiscoveredSkills + skillMarketplaceItemsFromDiscoveredSkills, + skillRootOptionsFromRoots } from './PluginMarketplaceView' describe('PluginMarketplaceView MCP config helpers', () => { @@ -267,3 +269,55 @@ describe('skillMarketplaceItemsFromDiscoveredSkills', () => { ]) }) }) + +describe('skillRootOptionsFromRoots', () => { + const roots: SkillRootListItem[] = [ + { + id: 'workspace-claude', + disableKey: 'workspace-claude', + path: '/ws/.claude/skills', + scope: 'project', + source: 'common', + labelKey: 'pluginSkillRootWorkspaceClaude', + exists: true, + enabled: true, + skillCount: 2 + }, + { + id: 'global-codex', + disableKey: 'global-codex', + path: '/home/me/.codex/skills', + scope: 'global', + source: 'common', + labelKey: 'pluginSkillRootGlobalCodex', + exists: false, + enabled: false, + skillCount: 0 + }, + { + id: '/opt/team/skills', + disableKey: '/opt/team/skills', + path: '/opt/team/skills', + scope: 'global', + source: 'extra', + exists: true, + enabled: true, + skillCount: 5 + } + ] + + it('maps backend roots — common (.claude/.codex) and custom dirs — into picker options synced with settings', () => { + const options = skillRootOptionsFromRoots(roots, (key) => `t:${key}`) + + expect(options).toEqual([ + { id: 'workspace-claude', label: 't:pluginSkillRootWorkspaceClaude', path: '/ws/.claude/skills', scope: 'project', enabled: true, exists: true, skillCount: 2 }, + { id: 'global-codex', label: 't:pluginSkillRootGlobalCodex', path: '/home/me/.codex/skills', scope: 'global', enabled: false, exists: false, skillCount: 0 }, + // Custom extra dir has no i18n labelKey, so it falls back to a short path label. + { id: '/opt/team/skills', label: 'team/skills', path: '/opt/team/skills', scope: 'global', enabled: true, exists: true, skillCount: 5 } + ]) + }) + + it('returns an empty list when the backend reports no roots', () => { + expect(skillRootOptionsFromRoots([], (key) => key)).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/PluginMarketplaceView.tsx b/src/renderer/src/components/PluginMarketplaceView.tsx index 4d429436d..9cfad07c3 100644 --- a/src/renderer/src/components/PluginMarketplaceView.tsx +++ b/src/renderer/src/components/PluginMarketplaceView.tsx @@ -14,7 +14,6 @@ import { } from 'lucide-react' import { rendererRuntimeClient } from '../agent/runtime-client' import { - joinFsPath, loadPreferredSkillRootId, savePreferredSkillRootId, type SkillRootId @@ -22,7 +21,7 @@ import { import { readBrowserStorageItem, writeBrowserStorageItem } from '../lib/browser-storage' import { normalizeWorkspaceRoot } from '../lib/workspace-path' import { getProvider } from '../agent/registry' -import type { SkillListItem } from '@shared/kun-gui-api' +import type { SkillListItem, SkillRootListItem } from '@shared/kun-gui-api' import type { CoreRuntimeInfoJson, CoreRuntimeToolDiagnosticsJson @@ -63,7 +62,10 @@ type SkillRootOption = { id: SkillRootId label: string path: string - available: boolean + scope: 'project' | 'global' + enabled: boolean + exists: boolean + skillCount: number } const INSTALLED_STORAGE_KEY = 'kun.installedPlugins' @@ -348,6 +350,34 @@ export function skillMarketplaceItemsFromDiscoveredSkills( })) } +/** Last two path segments, e.g. `/Users/me/.claude/skills` → `.claude/skills`. */ +export function skillRootShortLabel(path: string): string { + const parts = path.split(/[\\/]+/).filter(Boolean) + return parts.slice(-2).join('/') || path +} + +/** + * Builds the skill-root picker options from the backend's detected roots + * (`skill:list-roots`) — the same source the settings page renders — so the + * marketplace stays in sync instead of hardcoding a fixed subset of dirs. + * Common dirs use their i18n label; user-added extra dirs fall back to a short + * path label. (#321) + */ +export function skillRootOptionsFromRoots( + roots: SkillRootListItem[], + t: (key: string) => string +): SkillRootOption[] { + return roots.map((root) => ({ + id: root.id, + label: root.labelKey ? t(root.labelKey) : skillRootShortLabel(root.path), + path: root.path, + scope: root.scope, + enabled: root.enabled, + exists: root.exists, + skillCount: root.skillCount + })) +} + export function mcpMarketplaceItemsFromConfigAndDiagnostics( configText: string, diagnostics: CoreRuntimeToolDiagnosticsJson | null, @@ -536,50 +566,27 @@ export function PluginMarketplaceView(): ReactElement { const [discoveredSkills, setDiscoveredSkills] = useState([]) const [skillListLoading, setSkillListLoading] = useState(false) const [skillListError, setSkillListError] = useState('') + const [skillRoots, setSkillRoots] = useState([]) const [disabledSkillIds, setDisabledSkillIds] = useState([]) const [skillToggleBusyId, setSkillToggleBusyId] = useState(null) - const skillRootOptions = useMemo(() => { - const hasWorkspace = !!workspaceRoot - return [ - { - id: 'workspace-agents', - label: t('pluginSkillRootWorkspaceAgents'), - path: workspaceRoot ? joinFsPath(workspaceRoot, '.agents/skills') : '', - available: hasWorkspace - }, - { - id: 'workspace-skills', - label: t('pluginSkillRootWorkspaceSkills'), - path: workspaceRoot ? joinFsPath(workspaceRoot, 'skills') : '', - available: hasWorkspace - }, - { - id: 'global-agents', - label: t('pluginSkillRootGlobalAgents'), - path: '~/.agents/skills', - available: true - }, - { - id: 'global-deepseek', - label: t('pluginSkillRootGlobalDeepseek'), - path: '~/.kun/skills', - available: true - } - ] - }, [t, workspaceRoot]) + const skillRootOptions = useMemo( + () => skillRootOptionsFromRoots(skillRoots, t), + [skillRoots, t] + ) const selectedSkillRoot = - skillRootOptions.find((option) => option.id === skillRootId && option.available) ?? - skillRootOptions.find((option) => option.available) + skillRootOptions.find((option) => option.id === skillRootId) ?? + skillRootOptions.find((option) => option.enabled) ?? + skillRootOptions[0] useEffect(() => { - const selectedOption = skillRootOptions.find((option) => option.id === skillRootId && option.available) - if (selectedOption) { + if (skillRootOptions.length === 0) return + if (skillRootOptions.some((option) => option.id === skillRootId)) { savePreferredSkillRootId(skillRootId) return } - const fallback = skillRootOptions.find((option) => option.available) + const fallback = skillRootOptions.find((option) => option.enabled) ?? skillRootOptions[0] if (fallback && fallback.id !== skillRootId) { setSkillRootId(fallback.id) } @@ -666,10 +673,24 @@ export function PluginMarketplaceView(): ReactElement { } }, [t, workspaceRoot]) + const refreshSkillRoots = useCallback(async (): Promise => { + if (typeof window.kunGui?.listSkillRoots !== 'function') { + setSkillRoots([]) + return + } + try { + const result = await window.kunGui.listSkillRoots(workspaceRoot || undefined) + setSkillRoots(result.ok ? result.roots : []) + } catch { + setSkillRoots([]) + } + }, [workspaceRoot]) + useEffect(() => { if (activeKind !== 'skill') return void refreshSkillList() - }, [activeKind, refreshSkillList]) + void refreshSkillRoots() + }, [activeKind, refreshSkillList, refreshSkillRoots]) useEffect(() => { if (activeKind !== 'skill') return @@ -820,7 +841,7 @@ export function PluginMarketplaceView(): ReactElement { return } markInstalled(storageKey('skill', item.id)) - await refreshSkillList() + await Promise.all([refreshSkillList(), refreshSkillRoots()]) setNotice({ tone: 'success', message: t('pluginSkillAdded', { path: result.path }) }) } catch (e) { setNotice({ tone: 'error', message: e instanceof Error ? e.message : String(e) }) @@ -862,7 +883,7 @@ export function PluginMarketplaceView(): ReactElement { return } markInstalled(storageKey('skill', id)) - await refreshSkillList() + await Promise.all([refreshSkillList(), refreshSkillRoots()]) setNotice({ tone: 'success', message: t('pluginSkillAdded', { path: result.path }) }) } setCustomName('') @@ -1009,13 +1030,18 @@ export function PluginMarketplaceView(): ReactElement { + {contextCapacityOpen ? ( +
+ +
+ ) : null} + + ) : null} {hideModelPicker ? null : ( @@ -2182,7 +2340,7 @@ export function FloatingComposer({ · {t('sessionUsageCache', { - cache: formatPercent(threadUsage.lastTurnCacheHitRate ?? threadUsage.cacheHitRate) + cache: formatPercent(primaryCacheHitRate(threadUsage)) })} · diff --git a/src/renderer/src/components/chat/MessageTimeline.tsx b/src/renderer/src/components/chat/MessageTimeline.tsx index f2aa82f33..2889e74f7 100644 --- a/src/renderer/src/components/chat/MessageTimeline.tsx +++ b/src/renderer/src/components/chat/MessageTimeline.tsx @@ -50,6 +50,7 @@ type Props = { onBuildPlan?: () => void /** Opens/focuses the Plan panel (Open button on the inline card). */ onOpenPlan?: () => void + compactCards?: boolean } const TURN_PAGE_SIZE = 18 @@ -118,7 +119,8 @@ export function MessageTimeline({ devPreviewCard, planActionsBusy, onBuildPlan, - onOpenPlan + onOpenPlan, + compactCards = false }: Props): ReactElement { const { t } = useTranslation('common') const { @@ -322,6 +324,7 @@ export function MessageTimeline({ onBuildPlan={onBuildPlan} onOpenPlan={onOpenPlan} viewportRef={containerRef} + compactCards={compactCards} /> ) @@ -355,6 +358,7 @@ export function MessageTimeline({ live={live} devPreviewCard={devPreviewCard} viewportRef={containerRef} + compactCards={compactCards} durationMs={ currentTurnUserId && typeof turnStartedAtByUserId[currentTurnUserId] === 'number' ? Math.max(0, tickNow - turnStartedAtByUserId[currentTurnUserId]) @@ -386,7 +390,8 @@ function MessageTurn({ planActionsBusy, onBuildPlan, onOpenPlan, - viewportRef + viewportRef, + compactCards = false }: { turn: Turn isProcessing: boolean @@ -399,6 +404,7 @@ function MessageTurn({ onBuildPlan?: () => void onOpenPlan?: () => void viewportRef: RefObject + compactCards?: boolean }): ReactElement { const workspaceRoot = useChatStore((s) => s.workspaceRoot) const activeThreadGoal = useChatStore((s) => s.activeThreadGoal) @@ -513,7 +519,7 @@ function MessageTurn({ ) : null} {!isProcessing && turnFileChanges.length > 0 ? ( - + ) : null} ) @@ -560,5 +566,6 @@ const MemoMessageTurn = memo(MessageTurn, (prev, next) => ( prev.planActionsBusy === next.planActionsBusy && prev.onBuildPlan === next.onBuildPlan && prev.onOpenPlan === next.onOpenPlan && + prev.compactCards === next.compactCards && prev.viewportRef === next.viewportRef )) diff --git a/src/renderer/src/components/chat/Sidebar.tsx b/src/renderer/src/components/chat/Sidebar.tsx index bed611d9a..944ee3552 100644 --- a/src/renderer/src/components/chat/Sidebar.tsx +++ b/src/renderer/src/components/chat/Sidebar.tsx @@ -58,7 +58,6 @@ type Props = { onCodeOpen: () => void onWriteOpen: () => void onScheduleOpen: () => void - onToggleSidebar: () => void } export function Sidebar({ @@ -88,8 +87,7 @@ export function Sidebar({ onToggleConnectPhone, onCodeOpen, onWriteOpen, - onScheduleOpen, - onToggleSidebar + onScheduleOpen }: Props): ReactElement { const { t, i18n } = useTranslation('common') const workspaceRoot = useChatStore((s) => s.workspaceRoot) @@ -117,7 +115,6 @@ export function Sidebar({ <>
diff --git a/src/renderer/src/components/chat/StreamdownCode.test.ts b/src/renderer/src/components/chat/StreamdownCode.test.ts index 8cc1be264..16d4ae1be 100644 --- a/src/renderer/src/components/chat/StreamdownCode.test.ts +++ b/src/renderer/src/components/chat/StreamdownCode.test.ts @@ -14,6 +14,7 @@ describe('StreamdownCode plain text fences', () => { ) expect(html).toContain('ds-plain-text-block') + expect(html).toContain('ds-plain-code-block') expect(html).toContain('refactor(chat): simplify composer') expect(html).toContain('- Keep only Stop') expect(html).not.toContain('ds-code-block-header') diff --git a/src/renderer/src/components/chat/StreamdownCode.tsx b/src/renderer/src/components/chat/StreamdownCode.tsx index bfb4b54d9..e1a0dc80c 100644 --- a/src/renderer/src/components/chat/StreamdownCode.tsx +++ b/src/renderer/src/components/chat/StreamdownCode.tsx @@ -100,7 +100,7 @@ function PlainTextBlock({ code }: { code: string }): ReactNode { if (!trimmedCode.trim()) return null return ( -
+
{trimmedCode}
) diff --git a/src/renderer/src/components/chat/WorkbenchTopBar.tsx b/src/renderer/src/components/chat/WorkbenchTopBar.tsx index ac959412e..27893b03d 100644 --- a/src/renderer/src/components/chat/WorkbenchTopBar.tsx +++ b/src/renderer/src/components/chat/WorkbenchTopBar.tsx @@ -1,5 +1,5 @@ import type { ReactElement } from 'react' -import { useEffect, useMemo, useRef, useState } from 'react' +import { Fragment, useEffect, useMemo, useRef, useState } from 'react' import type { EditorInfo } from '@shared/editor' import type { GuiUpdateState } from '@shared/gui-update' import { @@ -22,12 +22,21 @@ import { import { useTranslation } from 'react-i18next' import { readPreferredEditorId, writePreferredEditorId } from '../../lib/editor-preferences' -export type RightPanelMode = 'todo' | 'changes' | 'browser' | 'file' | 'plan' | 'sdd-ai' | null +export type RightPanelMode = + | 'todo' + | 'changes' + | 'browser' + | 'file' + | 'plan' + | 'sdd-ai' + | null type Props = { rightPanelMode: RightPanelMode onToggleRightPanelMode: (mode: Exclude) => void planPanelEnabled?: boolean + terminalOpen?: boolean + onToggleTerminal?: () => void sideChatCount?: number sideChatRunningCount?: number sideChatOpen?: boolean @@ -39,6 +48,8 @@ export function WorkbenchTopBar({ rightPanelMode, onToggleRightPanelMode, planPanelEnabled = false, + terminalOpen = false, + onToggleTerminal, sideChatCount = 0, sideChatRunningCount = 0, sideChatOpen = false, @@ -341,22 +352,40 @@ export function WorkbenchTopBar({ {items.map((item) => { const active = rightPanelMode === item.mode const Icon = item.icon + const isChanges = item.mode === 'changes' return ( - + + + {isChanges && onToggleTerminal ? ( + + ) : null} + ) })}
diff --git a/src/renderer/src/components/chat/WorkspaceModeTabs.tsx b/src/renderer/src/components/chat/WorkspaceModeTabs.tsx index 9406a1b8e..f544628c1 100644 --- a/src/renderer/src/components/chat/WorkspaceModeTabs.tsx +++ b/src/renderer/src/components/chat/WorkspaceModeTabs.tsx @@ -16,24 +16,24 @@ export function WorkspaceModeTabs({ const { t } = useTranslation('common') const tabClass = (active: boolean): string => - `group inline-flex min-h-[32px] flex-1 min-w-0 items-center justify-center gap-2 rounded-[8px] px-2.5 py-1.5 text-left text-[13px] outline-none transition focus-visible:ring-2 focus-visible:ring-black/10 dark:focus-visible:ring-white/20 ${ + `group inline-flex min-h-[30px] flex-1 min-w-0 items-center justify-center gap-1.5 rounded-[7px] px-2.5 py-1 text-[13px] outline-none transition-[background-color,color,box-shadow] duration-150 focus-visible:ring-2 focus-visible:ring-black/10 dark:focus-visible:ring-white/20 ${ active - ? 'bg-[var(--ds-sidebar-field-focus)] font-medium text-[#182230] shadow-[0_1px_3px_rgba(20,47,95,0.07),inset_0_0_0_1px_var(--ds-sidebar-row-ring),inset_0_1px_0_rgba(255,255,255,0.78)] dark:bg-white/[0.09] dark:text-white dark:shadow-[0_1px_5px_rgba(0,0,0,0.24),inset_0_0_0_1px_rgba(255,255,255,0.1)]' - : 'font-normal text-[#5c6675] hover:bg-[color-mix(in_srgb,var(--ds-sidebar-field-focus)_56%,transparent)] hover:text-[#1f2733] dark:text-white/58 dark:hover:bg-white/[0.055] dark:hover:text-white/88' + ? 'bg-white font-medium text-[#1f2733] shadow-[0_1px_2px_rgba(20,47,95,0.12),0_2px_5px_rgba(20,47,95,0.06)] dark:bg-white/[0.12] dark:text-white dark:shadow-[0_1px_2px_rgba(0,0,0,0.35)]' + : 'font-normal text-[#646e7c] hover:text-[#1f2733] dark:text-white/55 dark:hover:text-white/90' }` const iconClass = (active: boolean): string => - `flex h-[21px] w-[21px] shrink-0 items-center justify-center rounded-[7px] transition ${ + `h-[15px] w-[15px] shrink-0 transition-colors ${ active - ? 'bg-[var(--ds-accent-soft)] text-[var(--ds-accent)] shadow-[inset_0_0_0_1px_rgba(59,130,216,0.12)] dark:bg-[rgba(111,176,232,0.2)] dark:text-[#78bdff] dark:shadow-[inset_0_0_0_1px_rgba(111,176,232,0.16)]' - : 'text-[#6f7a89] group-hover:bg-white/55 group-hover:text-[#344055] dark:text-white/48 dark:group-hover:bg-white/[0.06] dark:group-hover:text-white/78' + ? 'text-[#1f2733] dark:text-white' + : 'text-[#8b95a3] group-hover:text-[#1f2733] dark:text-white/45 dark:group-hover:text-white/85' }` return (
diff --git a/src/renderer/src/components/chat/message-timeline-cards.tsx b/src/renderer/src/components/chat/message-timeline-cards.tsx index 1957416e7..0a61c3354 100644 --- a/src/renderer/src/components/chat/message-timeline-cards.tsx +++ b/src/renderer/src/components/chat/message-timeline-cards.tsx @@ -175,10 +175,12 @@ export function ReviewSummaryCard({ review }: { review: ReviewBlock }): ReactEle export function TurnChangeSummary({ changes, - viewportRef + viewportRef, + compact = false }: { changes: ToolBlock[] viewportRef: RefObject + compact?: boolean }): ReactElement { const { t } = useTranslation('common') const [expanded, setExpanded] = useState(false) @@ -211,22 +213,36 @@ export function TurnChangeSummary({ }) return ( -
+
{open && change.detail ? ( -
+
diff --git a/src/renderer/src/components/provider-model-editor.ts b/src/renderer/src/components/provider-model-editor.ts index 43085afda..92c3ec754 100644 --- a/src/renderer/src/components/provider-model-editor.ts +++ b/src/renderer/src/components/provider-model-editor.ts @@ -11,6 +11,7 @@ import { isSpeechToTextModelId, isTextToSpeechModelId, isVideoGenerationModelId, + type ModelEndpointFormat, type ModelProviderModelProfileV1, type ModelProviderProfileV1, type ModelProviderReasoningCapabilityV1, @@ -50,6 +51,8 @@ export type ProviderModelForm = { reasoningEfforts: ModelReasoningEffort[] reasoningDefaultEffort: ModelReasoningEffort reasoningProtocol: ModelReasoningRequestProtocol + /** Per-model wire-format override; null means "inherit the provider's format". */ + endpointFormat: ModelEndpointFormat | null aliases: string[] } @@ -100,6 +103,7 @@ export function newProviderModelForm( reasoningEfforts: [...PROVIDER_MODEL_REASONING_EFFORT_CHOICES], reasoningDefaultEffort: 'medium', reasoningProtocol: defaultReasoningProtocolForProvider(provider), + endpointFormat: null, aliases: [] } } @@ -128,6 +132,7 @@ export function providerModelFormForExisting( : base.reasoningEfforts, reasoningDefaultEffort: profile.reasoning?.defaultEffort ?? base.reasoningDefaultEffort, reasoningProtocol: profile.reasoning?.requestProtocol ?? base.reasoningProtocol, + endpointFormat: profile.endpointFormat ?? null, aliases: [...(profile.aliases ?? [])] } } @@ -408,7 +413,8 @@ function chatProfileFromForm(form: ProviderModelForm): ModelProviderModelProfile messageParts: form.visionInput ? ['text', 'image_url'] : ['text'], ...(form.reasoningEnabled && form.reasoningEfforts.length > 0 ? { reasoning: reasoningCapabilityFromForm(form) } - : {}) + : {}), + ...(form.endpointFormat ? { endpointFormat: form.endpointFormat } : {}) } } diff --git a/src/renderer/src/components/schedule/ScheduleTasksView.tsx b/src/renderer/src/components/schedule/ScheduleTasksView.tsx index e1fb2f559..029a982f6 100644 --- a/src/renderer/src/components/schedule/ScheduleTasksView.tsx +++ b/src/renderer/src/components/schedule/ScheduleTasksView.tsx @@ -605,13 +605,11 @@ export function ScheduleTasksView({ leftSidebarCollapsed ? 'ds-window-controls-safe-inset' : '' }`} > - {leftSidebarCollapsed ? ( - - ) : null} +

{t('schedule')}

diff --git a/src/renderer/src/components/settings-section-agents.tsx b/src/renderer/src/components/settings-section-agents.tsx index c511b2f10..8cf18ecd5 100644 --- a/src/renderer/src/components/settings-section-agents.tsx +++ b/src/renderer/src/components/settings-section-agents.tsx @@ -284,6 +284,7 @@ export function AgentsSettingsSection({ ctx }: { ctx: Record }): Re fallbackHardThreshold: contextCompaction.defaultHardThreshold }) const runtimeTuning = kun.runtimeTuning ?? { + streamIdleTimeoutMs: 45000, toolStorm: { enabled: true, windowSize: 8, @@ -1181,6 +1182,23 @@ export function AgentsSettingsSection({ ctx }: { ctx: Record }): Re
} /> + + updateRuntimeTuning({ streamIdleTimeoutMs: Number(e.target.value) }) + } + /> + } + /> ) => string + +const buttonClass = + 'inline-flex items-center gap-1.5 rounded-xl border border-ds-border bg-ds-card px-3 py-2 text-[13px] font-medium text-ds-ink shadow-sm transition hover:bg-ds-hover disabled:cursor-not-allowed disabled:opacity-50' + +function sum(detection: LegacySessionDetectResult | null, key: 'threadCount' | 'newCount'): number { + return detection?.sources.reduce((total, source) => total + source[key], 0) ?? 0 +} + +export function LegacySessionImportCard({ + t, + tCommon +}: { + t: TranslateFn + tCommon: TranslateFn +}): ReactElement { + const [detection, setDetection] = useState(null) + const [detecting, setDetecting] = useState(true) + const [busy, setBusy] = useState(false) + const [restarting, setRestarting] = useState(false) + const [notice, setNotice] = useState(null) + + const refreshDetection = useCallback(async () => { + if (typeof window.kunGui?.detectLegacySessions !== 'function') { + setDetecting(false) + return + } + setDetecting(true) + try { + setDetection(await window.kunGui.detectLegacySessions()) + } catch (error) { + setNotice({ tone: 'error', message: error instanceof Error ? error.message : String(error) }) + } finally { + setDetecting(false) + } + }, []) + + useEffect(() => { + void refreshDetection() + }, [refreshDetection]) + + const runImport = useCallback( + async (sourceDir?: string) => { + if (typeof window.kunGui?.importLegacySessions !== 'function') return + setBusy(true) + setNotice(null) + try { + const result = await window.kunGui.importLegacySessions(sourceDir) + if (!result.ok) { + setNotice({ tone: 'error', message: result.message }) + return + } + if (result.total === 0) { + setNotice({ tone: 'info', message: t('legacyImportResultNone') }) + return + } + setNotice({ + tone: 'success', + message: t('legacyImportResult', { imported: result.imported, skipped: result.skipped }) + }) + await refreshDetection() + if (result.imported > 0 && typeof window.kunGui?.confirmDialog === 'function') { + const restart = await window.kunGui.confirmDialog({ + message: t('legacyImportRestartTitle'), + detail: t('legacyImportRestartDetail', { count: result.imported }), + confirmLabel: t('legacyImportRestartConfirm'), + cancelLabel: tCommon('cancel') + }) + if (restart && typeof window.kunGui?.restartRuntime === 'function') { + setRestarting(true) + try { + await window.kunGui.restartRuntime() + } finally { + setRestarting(false) + } + } + } + } catch (error) { + setNotice({ tone: 'error', message: error instanceof Error ? error.message : String(error) }) + } finally { + setBusy(false) + } + }, + [refreshDetection, t, tCommon] + ) + + const pickAndImport = useCallback(async () => { + if (typeof window.kunGui?.pickLegacySessionDir !== 'function') return + try { + const picked = await window.kunGui.pickLegacySessionDir() + if (picked.canceled || !picked.path) return + await runImport(picked.path) + } catch (error) { + setNotice({ tone: 'error', message: error instanceof Error ? error.message : String(error) }) + } + }, [runImport]) + + const totalNew = sum(detection, 'newCount') + const totalFound = sum(detection, 'threadCount') + const working = busy || restarting + + const statusText = detecting + ? t('legacyImportScanning') + : totalNew > 0 + ? t('legacyImportFound', { count: totalNew }) + : totalFound > 0 + ? t('legacyImportAllPresent') + : t('legacyImportNoneFound') + + return ( + + +
+ {detecting ? : null} + {statusText} +
+ + {detection && detection.sources.length > 0 ? ( +
    + {detection.sources.map((source) => ( +
  • + + {t('legacyImportSourceCount', { + newCount: source.newCount, + total: source.threadCount + })} + + {source.path} +
  • + ))} +
+ ) : null} + +
+ + +
+ + {notice ? : null} +
+ } + /> + + ) +} diff --git a/src/renderer/src/components/settings-section-general.tsx b/src/renderer/src/components/settings-section-general.tsx index 623500248..e99663145 100644 --- a/src/renderer/src/components/settings-section-general.tsx +++ b/src/renderer/src/components/settings-section-general.tsx @@ -18,6 +18,7 @@ import { SettingRow, Toggle } from './settings-controls' +import { LegacySessionImportCard } from './settings-section-general-legacy-import' export function GeneralSettingsSection({ ctx }: { ctx: Record }): ReactElement { const { @@ -263,6 +264,8 @@ export function GeneralSettingsSection({ ctx }: { ctx: Record }): R /> + + = { 'deepseek-chat-completions': 'providerModelReasoningProtocolDeepseek', 'glm-chat-completions': 'providerModelReasoningProtocolGlm', @@ -62,6 +72,13 @@ const REASONING_EFFORT_LABEL_KEYS: Record = { max: 'providerModelEffortMax' } +const ENDPOINT_FORMAT_LABEL_KEYS: Record = { + chat_completions: 'modelEndpointChatCompletions', + responses: 'modelEndpointResponses', + messages: 'modelEndpointMessages', + custom_endpoint: 'modelEndpointCustomEndpoint' +} + const MODEL_KIND_META: Array<{ kind: ProviderModelKind icon: typeof MessageSquareText @@ -263,6 +280,8 @@ export function ProviderModelsManager({ onChange: (next: ModelProviderProfileV1) => void }): ReactElement { const [editor, setEditor] = useState(null) + const [query, setQuery] = useState('') + const [page, setPage] = useState(0) const updateForm = (patch: Partial): void => { setEditor((prev) => prev ? { ...prev, form: { ...prev.form, ...patch } } : prev) @@ -286,6 +305,18 @@ export function ProviderModelsManager({ } const modelEntries = providerModelListEntries(provider) + // Search + pagination only kick in once a provider has more than one page of + // models; smaller lists stay as a plain list (search box would just be noise). + const showListTools = modelEntries.length > MODEL_LIST_PAGE_SIZE + const normalizedQuery = query.trim().toLowerCase() + const filteredEntries = showListTools && normalizedQuery + ? modelEntries.filter(({ modelId }) => modelId.toLowerCase().includes(normalizedQuery)) + : modelEntries + const pageCount = Math.max(1, Math.ceil(filteredEntries.length / MODEL_LIST_PAGE_SIZE)) + const safePage = Math.min(page, pageCount - 1) + const visibleEntries = showListTools + ? filteredEntries.slice(safePage * MODEL_LIST_PAGE_SIZE, safePage * MODEL_LIST_PAGE_SIZE + MODEL_LIST_PAGE_SIZE) + : filteredEntries const effectiveForm = editor ? effectiveFormForEditor(editor) : null const errors = editor && effectiveForm ? validateProviderModelForm(effectiveForm, provider) : [] const showNonTextWarning = Boolean(effectiveForm && chatModelIdLooksNonText(effectiveForm)) @@ -304,71 +335,128 @@ export function ProviderModelsManager({ {t('providerModelEmpty')}

) : ( -
    - {modelEntries.map(({ kind, modelId }) => { - const profile = kind === 'chat' ? chatModelProfile(provider, modelId) : undefined - const active = editingKey !== '' && editingKey === modelEntryKey(kind, modelId) - return ( -
  • - - - - - {t(modelKindLabelKey(kind))} - - {kind === 'chat' && profile ? ( - <> - {profile.contextWindowTokens ? ( - {t('providerModelContextBadge', { - size: describeContextWindowTokens(profile.contextWindowTokens) - })} - ) : null} - {profile.inputModalities.includes('image') ? ( - }> - {t('modelProviderVisionBadge')} - - ) : null} - {profile.reasoning ? ( - }> - {t('providerModelReasoningBadge')} - - ) : null} - {!profile.supportsToolCalling ? ( - {t('providerModelNoToolsBadge')} - ) : null} - - ) : kind === 'chat' ? ( - {t('providerModelDefaultProfileBadge')} - ) : null} - - - - - + + + + + {t(modelKindLabelKey(kind))} + + {kind === 'chat' && profile ? ( + <> + {profile.contextWindowTokens ? ( + {t('providerModelContextBadge', { + size: describeContextWindowTokens(profile.contextWindowTokens) + })} + ) : null} + {profile.inputModalities.includes('image') ? ( + }> + {t('modelProviderVisionBadge')} + + ) : null} + {profile.reasoning ? ( + }> + {t('providerModelReasoningBadge')} + + ) : null} + {!profile.supportsToolCalling ? ( + {t('providerModelNoToolsBadge')} + ) : null} + + ) : kind === 'chat' ? ( + {t('providerModelDefaultProfileBadge')} + ) : null} + + + + + + +
  • + ) + })} +
+ )} + {showListTools && filteredEntries.length > MODEL_LIST_PAGE_SIZE ? ( +
+ + {t('providerModelPageCount', { shown: visibleEntries.length, total: filteredEntries.length })} + +
+ + + {t('providerModelPageIndicator', { page: safePage + 1, total: pageCount })} - - ) - })} - + +
+
+ ) : null} + )} {editor === null ? ( + ) + } + + const addMenuEntries = MODEL_PROVIDER_PRESETS.flatMap((preset) => { + const entries: { + preset: ModelProviderPreset + mode: 'api' | 'token-plan' + profileId: string + label: string + group: 'subscription' | 'api' + }[] = [ + { + preset, + mode: 'api', + profileId: preset.id, + label: preset.name, + group: preset.category === 'subscription' ? 'subscription' : 'api' + } + ] + if (preset.tokenPlan) { + entries.push({ + preset, + mode: 'token-plan', + profileId: tokenPlanProviderId(preset.id), + label: `${preset.name} · Token Plan`, + group: 'subscription' + }) + } + return entries + }) + const planAddEntries = addMenuEntries.filter((entry) => entry.group === 'subscription') + const apiAddEntries = addMenuEntries.filter((entry) => entry.group === 'api') + const renderAddEntry = (entry: (typeof addMenuEntries)[number]): ReactElement => { + const exists = modelProviders.some((item) => item.id === entry.profileId) + return ( + + ) + } + return ( }): wideControl control={
-
- {displayProviders.map((item) => { - const selected = activeProvider?.id === item.id - const isDraft = draftProvider?.id === item.id - const inUse = !isDraft && activeKunProviderId === item.id - const missingKey = !item.apiKey.trim() - return ( - - ) - })} -
+
+ {grouped ? ( + <> + + {planProviders.map(renderProviderButton)} + + + {apiProviders.map(renderProviderButton)} + + + ) : ( +
{displayProviders.map(renderProviderButton)}
+ )} +
{addMenuOpen ? ( - <> +
+
+ {t('modelProviderGroupPlans')} +
+ {planAddEntries.map(renderAddEntry)} +
+
+ {t('modelProviderGroupApi')} +
+ {apiAddEntries.map(renderAddEntry)} +
- ) - }) - })} -
- -
- + {t('modelProviderAddMenuCustom')} + +
) : null}
diff --git a/src/renderer/src/components/settings-section-worktree.tsx b/src/renderer/src/components/settings-section-worktree.tsx index d415c9ca9..eae2d2af1 100644 --- a/src/renderer/src/components/settings-section-worktree.tsx +++ b/src/renderer/src/components/settings-section-worktree.tsx @@ -216,11 +216,15 @@ export function WorktreeSettingsSection({ ctx }: { ctx: Record }):
- {error && ( + {poolStatus?.isGitRepo === false ? ( +
+ {t('worktreeNotGitRepo')} +
+ ) : error ? (
{error}
- )} + ) : null} {/* Pool cards */}
diff --git a/src/renderer/src/components/settings-section-write.tsx b/src/renderer/src/components/settings-section-write.tsx index 9cbbc1371..193718cbf 100644 --- a/src/renderer/src/components/settings-section-write.tsx +++ b/src/renderer/src/components/settings-section-write.tsx @@ -5,11 +5,21 @@ import { DEFAULT_WRITE_INLINE_COMPLETION_MODEL, DEFAULT_WRITE_INLINE_LONG_COMPLETION_MAX_TOKENS, DEFAULT_MODEL_PROVIDER_ID, + WRITE_EDITOR_FONT_SIZE_MAX, + WRITE_EDITOR_FONT_SIZE_MIN, + WRITE_EDITOR_LINE_HEIGHT_MAX, + WRITE_EDITOR_LINE_HEIGHT_MIN, + WRITE_FONT_PRESETS, + WRITE_AGENT_PRESET_MAX_COUNT, WRITE_INLINE_COMPLETION_MODEL_IDS, WRITE_QUICK_ACTION_MAX_COUNT, defaultModelProviderSettings, + defaultWriteAgentPresets, defaultWriteSelectionAssistSettings, + defaultWriteTypography, resolveWriteInlineCompletionProviderId, + type WriteAgentPresetV1, + type WriteFontPreset, type WriteQuickActionV1 } from '@shared/app-settings' import { WRITE_DESIGN_DRAFT_DEFAULT_PROMPT, WRITE_INFOGRAPHIC_DEFAULT_PROMPT } from '@shared/write-infographic' @@ -29,6 +39,17 @@ const textInputClass = const ghostButtonClass = 'inline-flex items-center gap-1.5 rounded-xl border border-ds-border bg-ds-card px-3 py-2 text-[13px] font-medium text-ds-ink shadow-sm transition hover:bg-ds-hover' +const WRITE_FONT_PRESET_LABEL_KEYS: Record = { + system: 'writeFontSystem', + sourceHanSans: 'writeFontSourceHanSans', + yahei: 'writeFontYahei', + pingfang: 'writeFontPingfang', + simhei: 'writeFontSimhei', + simsun: 'writeFontSimsun', + kaiti: 'writeFontKaiti', + custom: 'writeFontCustom' +} + export function writeInlineCompletionModelOptions(providerModels: readonly string[]): string[] { const scopedModels = providerModels .map((model) => model.trim()) @@ -56,6 +77,11 @@ export function WriteSettingsSection({ ctx }: { ctx: Record }): Rea const { t: tCommon } = useTranslation('common') const providerSettings = provider ?? defaultModelProviderSettings() const selectionAssist = form.write.selectionAssist ?? defaultWriteSelectionAssistSettings() + const typography = form.write.typography ?? defaultWriteTypography() + const agentPresets: WriteAgentPresetV1[] = form.write.agentPresets ?? defaultWriteAgentPresets() + const updateAgentPresets = (next: WriteAgentPresetV1[]): void => { + update({ write: { agentPresets: next } }) + } const updateQuickActions = (quickActions: WriteQuickActionV1[]): void => { update({ write: { selectionAssist: { quickActions } } }) } @@ -123,6 +149,103 @@ export function WriteSettingsSection({ ctx }: { ctx: Record }): Rea /> + + + + {typography.fontPreset === 'custom' ? ( + + update({ write: { typography: { customFontFamily: e.target.value } } }) + } + placeholder={t('writeFontCustomPlaceholder')} + /> + ) : null} +
+ } + /> + + + update({ write: { typography: { fontSizePx: Number(e.target.value) } } }) + } + className="flex-1 accent-accent" + aria-label={t('writeFontSize')} + /> + + {typography.fontSizePx}px + +
+ } + /> + + + update({ write: { typography: { lineHeight: Number(e.target.value) } } }) + } + className="flex-1 accent-accent" + aria-label={t('writeLineHeight')} + /> + + {typography.lineHeight.toFixed(2)} + +
+ } + /> + update({ write: { typography: defaultWriteTypography() } })} + className={ghostButtonClass} + > + + {t('writeTypographyResetButton')} + + } + /> + + }): Rea
+ +

+ {t('writeAgentPresetsDesc')} +

+
+ {agentPresets.map((preset: WriteAgentPresetV1, index: number) => { + return ( +
+
+ { + const next = [...agentPresets] + next[index] = { ...preset, emoji: e.target.value } + updateAgentPresets(next) + }} + /> + { + const next = [...agentPresets] + next[index] = { ...preset, name: e.target.value } + updateAgentPresets(next) + }} + /> + +
+