From 20ed49aa76a9f47060c2245184606444cb35461d Mon Sep 17 00:00:00 2001 From: musnows Date: Sun, 14 Jun 2026 07:07:52 +0800 Subject: [PATCH 1/9] fix(chat): improve agent code block contrast --- .../components/chat/StreamdownCode.test.ts | 1 + .../src/components/chat/StreamdownCode.tsx | 2 +- src/renderer/src/styles/base-shell.css | 5 ++++ src/renderer/src/styles/markdown-code.css | 25 ++++++++++++++----- 4 files changed, 26 insertions(+), 7 deletions(-) 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/styles/base-shell.css b/src/renderer/src/styles/base-shell.css index 38264870d..53988db69 100644 --- a/src/renderer/src/styles/base-shell.css +++ b/src/renderer/src/styles/base-shell.css @@ -84,6 +84,7 @@ --ds-inline-code-bg: rgba(240, 245, 251, 0.92); --ds-inline-code-hover-bg: rgba(228, 238, 250, 0.98); --ds-pre-bg: rgba(245, 248, 253, 0.96); + --ds-code-block-bg: #edf3fb; --ds-table-head-bg: rgba(242, 246, 252, 0.96); --ds-scrollbar-thumb: rgba(84, 103, 140, 0.24); --ds-scrollbar-thumb-hover: rgba(84, 103, 140, 0.34); @@ -192,6 +193,7 @@ --ds-inline-code-bg: rgba(151, 192, 235, 0.09); --ds-inline-code-hover-bg: rgba(111, 176, 232, 0.2); --ds-pre-bg: #151c2e; + --ds-code-block-bg: #101827; --ds-table-head-bg: rgba(27, 35, 56, 0.94); --ds-scrollbar-thumb: rgba(151, 178, 215, 0.28); --ds-scrollbar-thumb-hover: rgba(171, 198, 233, 0.38); @@ -282,6 +284,7 @@ --ds-inline-code-bg: rgba(249, 242, 226, 0.92); --ds-inline-code-hover-bg: rgba(247, 233, 203, 0.98); --ds-pre-bg: rgba(252, 247, 235, 0.96); + --ds-code-block-bg: #f6ecd7; --ds-table-head-bg: rgba(250, 244, 229, 0.96); --ds-scrollbar-thumb: rgba(117, 96, 63, 0.24); --ds-scrollbar-thumb-hover: rgba(117, 96, 63, 0.34); @@ -372,6 +375,7 @@ --ds-inline-code-bg: rgba(238, 198, 128, 0.09); --ds-inline-code-hover-bg: rgba(242, 177, 61, 0.2); --ds-pre-bg: #1e1812; + --ds-code-block-bg: #15110d; --ds-table-head-bg: rgba(39, 31, 23, 0.94); --ds-scrollbar-thumb: rgba(214, 188, 142, 0.28); --ds-scrollbar-thumb-hover: rgba(233, 208, 162, 0.38); @@ -681,6 +685,7 @@ html { --ds-inline-code-bg: rgba(151, 192, 235, 0.075); --ds-inline-code-hover-bg: rgba(111, 176, 232, 0.18); --ds-pre-bg: #151c2e; + --ds-code-block-bg: #101827; --ds-table-head-bg: rgba(27, 35, 56, 0.94); --ds-scrollbar-thumb: rgba(170, 170, 170, 0.26); --ds-scrollbar-thumb-hover: rgba(200, 200, 200, 0.36); diff --git a/src/renderer/src/styles/markdown-code.css b/src/renderer/src/styles/markdown-code.css index f8dc3ae0e..cdf661709 100644 --- a/src/renderer/src/styles/markdown-code.css +++ b/src/renderer/src/styles/markdown-code.css @@ -143,11 +143,12 @@ max-width: 100%; width: 100%; min-width: 0; - border: 1px solid var(--ds-border); + border: 1px solid color-mix(in srgb, var(--ds-border-strong) 72%, var(--ds-border)); border-radius: 14px; - background: var(--ds-pre-bg); + background: var(--ds-code-block-bg, var(--ds-pre-bg)); padding: 0.6rem 0.72rem; box-sizing: border-box; + box-shadow: inset 0 1px 0 color-mix(in srgb, var(--ds-bg-canvas) 62%, transparent); } .ds-markdown pre code { @@ -221,9 +222,10 @@ width: 100%; min-width: 0; overflow: hidden; - border: 1px solid var(--ds-border); + border: 1px solid color-mix(in srgb, var(--ds-border-strong) 72%, var(--ds-border)); border-radius: 14px; - background: var(--ds-pre-bg); + background: var(--ds-code-block-bg, var(--ds-pre-bg)); + box-shadow: inset 0 1px 0 color-mix(in srgb, var(--ds-bg-canvas) 62%, transparent); box-sizing: border-box; } @@ -236,13 +238,24 @@ font: inherit; } +.ds-markdown .ds-plain-code-block { + border: 1px solid color-mix(in srgb, var(--ds-border-strong) 72%, var(--ds-border)); + border-radius: 14px; + background: var(--ds-code-block-bg, var(--ds-pre-bg)); + padding: 0.64rem 0.72rem; + font-family: 'SF Mono', 'JetBrains Mono', 'IBM Plex Mono', monospace; + font-size: 12px; + line-height: 1.55; + box-shadow: inset 0 1px 0 color-mix(in srgb, var(--ds-bg-canvas) 62%, transparent); +} + .ds-markdown .ds-code-block-header { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; border-bottom: 1px solid var(--ds-border-muted); - background: color-mix(in srgb, var(--ds-code-bg) 88%, var(--ds-card-muted)); + background: color-mix(in srgb, var(--ds-code-block-bg, var(--ds-code-bg)) 88%, var(--ds-card-muted)); padding: 0.42rem 0.6rem; } @@ -291,7 +304,7 @@ .ds-markdown .ds-code-block-body { position: relative; - background: var(--ds-pre-bg); + background: var(--ds-code-block-bg, var(--ds-pre-bg)); } .ds-markdown .ds-code-block-body.is-collapsed { From cf372a1dcb719c5e7848cc664e0bc9683201bc90 Mon Sep 17 00:00:00 2001 From: musnows Date: Sat, 13 Jun 2026 20:34:02 +0800 Subject: [PATCH 2/9] fix(chat): isolate model selection per thread --- .../src/store/chat-store-app-actions.test.ts | 116 ++++++++++++++++++ .../src/store/chat-store-app-actions.ts | 50 ++++++-- .../src/store/chat-store-helpers.test.ts | 44 +++++++ src/renderer/src/store/chat-store-helpers.ts | 109 +++++++++++++++- .../src/store/chat-store-thread-actions.ts | 47 ++++++- 5 files changed, 351 insertions(+), 15 deletions(-) diff --git a/src/renderer/src/store/chat-store-app-actions.test.ts b/src/renderer/src/store/chat-store-app-actions.test.ts index e94157a44..3e9215159 100644 --- a/src/renderer/src/store/chat-store-app-actions.test.ts +++ b/src/renderer/src/store/chat-store-app-actions.test.ts @@ -11,6 +11,7 @@ import { createAppActions } from './chat-store-app-actions' const COMPOSER_MODEL_STORAGE_KEY = 'kun.composerModel' const COMPOSER_PROVIDER_STORAGE_KEY = 'kun.composerProviderId' +const THREAD_COMPOSER_SELECTION_STORAGE_KEY = 'kun.threadComposerSelection.v1' function createMemoryStorage(): Storage { const items = new Map() @@ -39,6 +40,8 @@ function buildHarness(fetchModelsResult: FetchModelsResult): { state: ChatState } { let state = { + activeThreadId: null, + threads: [], composerModel: '', composerProviderId: '', composerPickList: mergeComposerPickList(false, []), @@ -138,6 +141,119 @@ describe('chat-store app actions composer model loading', () => { }) }) + it('keeps active-thread model changes out of the global Kun default', () => { + const { actions, state } = buildHarness({ + ok: true, + modelIds: ['MiniMax-M2'], + defaultModelId: 'deepseek-v4-pro', + modelGroups: [{ + providerId: 'minimax', + label: 'MiniMax', + modelIds: ['MiniMax-M2'] + }] + }) + state.activeThreadId = 'thread-a' + state.threads = [{ + id: 'thread-a', + title: 'Thread A', + workspace: '/tmp/project', + model: 'deepseek-v4-pro', + status: 'idle', + mode: 'agent', + updatedAt: '2026-06-01T00:00:00.000Z' + }] + state.composerModelGroups = [{ + providerId: 'minimax', + label: 'MiniMax', + modelIds: ['MiniMax-M2'] + }] + + actions.setComposerModel('MiniMax-M2', 'minimax') + + expect(state.composerModel).toBe('MiniMax-M2') + expect(state.composerProviderId).toBe('minimax') + expect(localStorage.getItem(COMPOSER_MODEL_STORAGE_KEY)).toBeNull() + expect(localStorage.getItem(COMPOSER_PROVIDER_STORAGE_KEY)).toBeNull() + expect(JSON.parse(localStorage.getItem(THREAD_COMPOSER_SELECTION_STORAGE_KEY) ?? '{}')).toEqual({ + 'thread-a': { model: 'MiniMax-M2', providerId: 'minimax' } + }) + expect(window.kunGui.saveSettingsSilent).not.toHaveBeenCalled() + }) + + it('restores a model selection from the active thread instead of the global picker', async () => { + localStorage.setItem(COMPOSER_MODEL_STORAGE_KEY, 'deepseek-v4-flash') + localStorage.setItem( + THREAD_COMPOSER_SELECTION_STORAGE_KEY, + JSON.stringify({ 'thread-a': { model: 'MiniMax-M2', providerId: 'minimax' } }) + ) + const { actions, state } = buildHarness({ + ok: true, + modelIds: ['MiniMax-M2'], + defaultModelId: 'deepseek-v4-pro', + modelGroups: [{ + providerId: 'minimax', + label: 'MiniMax', + modelIds: ['MiniMax-M2'] + }] + }) + state.activeThreadId = 'thread-a' + state.threads = [{ + id: 'thread-a', + title: 'Thread A', + workspace: '/tmp/project', + model: 'deepseek-v4-pro', + status: 'idle', + mode: 'agent', + updatedAt: '2026-06-01T00:00:00.000Z' + }] + + await actions.loadComposerModels() + + expect(state.composerModel).toBe('MiniMax-M2') + expect(state.composerProviderId).toBe('minimax') + expect(localStorage.getItem(COMPOSER_MODEL_STORAGE_KEY)).toBe('deepseek-v4-flash') + }) + + it('does not restore a per-thread selection filtered out of the composer menu', async () => { + localStorage.setItem( + THREAD_COMPOSER_SELECTION_STORAGE_KEY, + JSON.stringify({ 'thread-a': { model: 'Kwai-Kolors/Kolors', providerId: 'minimax' } }) + ) + const { actions, state } = buildHarness({ + ok: true, + modelIds: ['Kwai-Kolors/Kolors'], + defaultModelId: 'deepseek-v4-pro', + modelGroups: [{ + providerId: 'minimax', + label: 'MiniMax', + modelIds: ['Kwai-Kolors/Kolors'], + modelProfiles: { + 'kwai-kolors/kolors': { + inputModalities: ['text'], + outputModalities: ['image'], + supportsToolCalling: false, + messageParts: ['text'] + } + } + }] + }) + state.activeThreadId = 'thread-a' + state.threads = [{ + id: 'thread-a', + title: 'Thread A', + workspace: '/tmp/project', + model: 'deepseek-v4-pro', + status: 'idle', + mode: 'agent', + updatedAt: '2026-06-01T00:00:00.000Z' + }] + + await actions.loadComposerModels() + + expect(state.composerModel).toBe('deepseek-v4-pro') + expect(state.composerProviderId).toBe('') + }) + it('does not overwrite a stored custom model when only fallback models are available', async () => { localStorage.setItem(COMPOSER_MODEL_STORAGE_KEY, 'MiniMax-M2') const { actions, state } = buildHarness({ diff --git a/src/renderer/src/store/chat-store-app-actions.ts b/src/renderer/src/store/chat-store-app-actions.ts index 726f26565..5751a61fb 100644 --- a/src/renderer/src/store/chat-store-app-actions.ts +++ b/src/renderer/src/store/chat-store-app-actions.ts @@ -3,8 +3,12 @@ import type { AppSettingsV1 } from '@shared/app-settings' import { rendererRuntimeClient } from '../agent/runtime-client' import type { ChatState, ChatStoreGet, ChatStoreSet, InitialSetupMode, PluginHostRoute, SettingsRouteSection } from './chat-store-types' import { + composerModelSelectable, persistComposerProviderId, providerIdForComposerModel, + providerIdMatchesComposerModel, + readThreadComposerSelection, + rememberThreadComposerSelection, readStoredComposerProviderId } from './chat-store-helpers' @@ -63,12 +67,17 @@ export function createAppActions(options: CreateAppActionsOptions): Pick< setError: (message) => set({ error: message }), setComposerModel: (modelId, providerId) => { - persistComposerModel(modelId) const nextProviderId = providerId?.trim() || providerIdForComposerModel(get().composerModelGroups, modelId) - persistComposerProviderId(nextProviderId) + const activeThreadId = get().activeThreadId + if (activeThreadId) { + rememberThreadComposerSelection(activeThreadId, modelId, nextProviderId) + } else { + persistComposerModel(modelId) + persistComposerProviderId(nextProviderId) + } set({ composerModel: modelId, composerProviderId: nextProviderId }) const trimmed = modelId.trim() - if (trimmed && trimmed.toLowerCase() !== 'auto' && typeof window.kunGui !== 'undefined') { + if (!activeThreadId && trimmed && trimmed.toLowerCase() !== 'auto' && typeof window.kunGui !== 'undefined') { void window.kunGui.saveSettingsSilent({ agents: { kun: { model: trimmed } } }) } }, @@ -80,26 +89,43 @@ export function createAppActions(options: CreateAppActionsOptions): Pick< const res = await window.kunGui.fetchUpstreamModels() const pick = mergeComposerPickList(res.ok, res.ok ? res.modelIds : []) const groups = res.ok ? res.modelGroups ?? [] : [] - const allowed = new Set(pick) const runtimeDefault = res.ok ? res.defaultModelId?.trim() ?? '' : '' set((state) => { + const isSelectable = (model: string): boolean => composerModelSelectable(pick, groups, model) + const activeThread = state.activeThreadId + ? state.threads.find((thread) => thread.id === state.activeThreadId) ?? null + : null + const threadSelection = activeThread ? readThreadComposerSelection(activeThread.id) : null const currentModel = state.composerModel.trim() const normalizedCurrentModel = currentModel.toLowerCase() === 'auto' ? '' : currentModel const storedModel = readStoredComposerModel(pick) - let model = normalizedCurrentModel - let shouldPersist = model !== state.composerModel - if (model === '' || !allowed.has(model)) { - model = storedModel + let model = activeThread + ? threadSelection?.model?.trim() || activeThread.model.trim() + : normalizedCurrentModel + let shouldPersist = !activeThread && model !== state.composerModel + if (model === '' || !isSelectable(model)) { + model = activeThread ? '' : storedModel shouldPersist = false } - if (model === '' || !allowed.has(model)) { + if (model === '' || !isSelectable(model)) { model = fallbackComposerModel(pick, runtimeDefault) shouldPersist = false } if (shouldPersist) persistComposerModel(model) - const storedProviderId = readStoredComposerProviderId(groups, model) - const providerId = storedProviderId || providerIdForComposerModel(groups, model) - if (providerId !== state.composerProviderId) persistComposerProviderId(providerId) + const threadProviderId = + threadSelection && providerIdMatchesComposerModel(groups, threadSelection.providerId, model) + ? threadSelection.providerId + : '' + const storedProviderId = activeThread ? '' : readStoredComposerProviderId(groups, model) + const providerId = threadProviderId || storedProviderId || providerIdForComposerModel(groups, model) + if (!activeThread && providerId !== state.composerProviderId) persistComposerProviderId(providerId) + if ( + activeThread && + (!threadSelection || threadSelection.model !== model || threadSelection.providerId !== providerId) && + composerModelSelectable(pick, groups, model) + ) { + rememberThreadComposerSelection(activeThread.id, model, providerId) + } return { composerPickList: pick, composerModel: model, diff --git a/src/renderer/src/store/chat-store-helpers.test.ts b/src/renderer/src/store/chat-store-helpers.test.ts index c7fa68bde..6d8207fce 100644 --- a/src/renderer/src/store/chat-store-helpers.test.ts +++ b/src/renderer/src/store/chat-store-helpers.test.ts @@ -3,6 +3,7 @@ import type { ClawImChannelV1 } from '@shared/app-settings' import { CLAW_MANAGED_INSTRUCTIONS_HEADING } from '@shared/app-settings' import { MAX_TURN_MODEL_LABELS, + MAX_THREAD_COMPOSER_SELECTIONS, MAX_CODE_WORKSPACE_ROOTS, clawThreadIdsFromChannels, clawThreadTitleLooksManaged, @@ -12,12 +13,16 @@ import { isClawThread, mergeComposerPickList, newClawChannel, + normalizeThreadComposerSelectionMap, normalizeTurnModelMap, + readThreadComposerSelection, reconcileCodeWorkspaceRoots, + rememberThreadComposerSelection, rememberTurnModel } from './chat-store-helpers' const TURN_MODEL_STORAGE_KEY = 'kun.turnModelLabel' +const THREAD_COMPOSER_SELECTION_STORAGE_KEY = 'kun.threadComposerSelection.v1' function createMemoryStorage(): Storage { const items = new Map() @@ -247,4 +252,43 @@ describe('chat-store Claw helpers', () => { { kind: 'assistant', id: 'assistant-1', text: 'hi' } ]) }) + + it('normalizes and caps per-thread composer selections', () => { + const raw: Record = { + 'bad-empty-model': { model: '' }, + 'bad-number': 42 + } + for (let index = 0; index < MAX_THREAD_COMPOSER_SELECTIONS + 5; index += 1) { + raw[`thread-${index}`] = { + model: ` model-${index} `, + providerId: ` provider-${index} ` + } + } + + const normalized = normalizeThreadComposerSelectionMap(raw) + + expect(Object.keys(normalized)).toHaveLength(MAX_THREAD_COMPOSER_SELECTIONS) + expect(normalized['thread-0']).toBeUndefined() + expect(normalized['thread-5']).toEqual({ model: 'model-5', providerId: 'provider-5' }) + expect(normalized['bad-empty-model']).toBeUndefined() + expect(normalized['bad-number']).toBeUndefined() + }) + + it('persists composer model selections independently per thread', () => { + rememberThreadComposerSelection(' thread-a ', ' deepseek-v4-pro ', ' deepseek ') + rememberThreadComposerSelection(' thread-b ', ' MiniMax-M2 ', ' minimax ') + + expect(readThreadComposerSelection('thread-a')).toEqual({ + model: 'deepseek-v4-pro', + providerId: 'deepseek' + }) + expect(readThreadComposerSelection('thread-b')).toEqual({ + model: 'MiniMax-M2', + providerId: 'minimax' + }) + expect(JSON.parse(localStorage.getItem(THREAD_COMPOSER_SELECTION_STORAGE_KEY) ?? '{}')).toMatchObject({ + 'thread-a': { model: 'deepseek-v4-pro', providerId: 'deepseek' }, + 'thread-b': { model: 'MiniMax-M2', providerId: 'minimax' } + }) + }) }) diff --git a/src/renderer/src/store/chat-store-helpers.ts b/src/renderer/src/store/chat-store-helpers.ts index b93937311..05cca2076 100644 --- a/src/renderer/src/store/chat-store-helpers.ts +++ b/src/renderer/src/store/chat-store-helpers.ts @@ -4,6 +4,8 @@ import type { ModelProviderModelGroup } from '@shared/kun-gui-api' import { CLAW_MANAGED_INSTRUCTIONS_HEADING, CLAW_MODEL_IDS, + isComposerChatModelId, + modelProfileSupportsTextChat, type ClawImAgentProfileV1, type ClawImChannelV1, type ClawImPlatformCredentialV1, @@ -21,11 +23,18 @@ import { readBrowserStorageItem, writeBrowserStorageItem } from '../lib/browser- const COMPOSER_MODEL_STORAGE_KEY = 'kun.composerModel' const COMPOSER_PROVIDER_STORAGE_KEY = 'kun.composerProviderId' +const THREAD_COMPOSER_SELECTION_STORAGE_KEY = 'kun.threadComposerSelection.v1' const TURN_MODEL_STORAGE_KEY = 'kun.turnModelLabel' const CODE_WORKSPACE_ROOTS_STORAGE_KEY = 'kun.codeWorkspaceRoots.v1' export const MAX_CODE_WORKSPACE_ROOTS = 30 +export const MAX_THREAD_COMPOSER_SELECTIONS = 500 export const MAX_TURN_MODEL_LABELS = 500 +export type ThreadComposerSelection = { + model: string + providerId: string +} + export const CLAW_COMPOSER_MODEL_IDS = [...CLAW_MODEL_IDS] export function readStoredComposerModel(allowedIds: readonly string[]): string { @@ -63,6 +72,44 @@ export function persistComposerProviderId(providerId: string): void { } } +export function readThreadComposerSelection(threadId: string): ThreadComposerSelection | null { + const thread = threadId.trim() + if (!thread) return null + return loadThreadComposerSelectionMap()[thread] ?? null +} + +export function rememberThreadComposerSelection( + threadId: string, + model: string, + providerId = '' +): void { + const thread = threadId.trim() + const nextModel = model.trim() + if (!thread || !nextModel) return + const map = loadThreadComposerSelectionMap() + delete map[thread] + map[thread] = { + model: nextModel, + providerId: providerId.trim() + } + saveThreadComposerSelectionMap(map) +} + +export function normalizeThreadComposerSelectionMap(raw: unknown): Record { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {} + const entries: Array<[string, ThreadComposerSelection]> = [] + for (const [rawKey, rawValue] of Object.entries(raw as Record)) { + const key = rawKey.trim() + if (!key || !rawValue || typeof rawValue !== 'object' || Array.isArray(rawValue)) continue + const value = rawValue as Record + const model = typeof value.model === 'string' ? value.model.trim() : '' + const providerId = typeof value.providerId === 'string' ? value.providerId.trim() : '' + if (!model) continue + entries.push([key, { model, providerId }]) + } + return Object.fromEntries(entries.slice(-MAX_THREAD_COMPOSER_SELECTIONS)) +} + export function providerIdForComposerModel( modelGroups: readonly ModelProviderModelGroup[], modelId: string @@ -75,7 +122,50 @@ export function providerIdForComposerModel( function modelGroupHasModel(group: ModelProviderModelGroup, modelId: string): boolean { const normalized = normalizeComposerModelId(modelId) if (!normalized) return false - return group.modelIds.some((id) => normalizeComposerModelId(id) === normalized) + return group.modelIds.some((id) => normalizeComposerModelId(id) === normalized) || + Boolean(modelProfileForComposerModel(group, modelId)?.aliases?.some( + (alias: string) => normalizeComposerModelId(alias) === normalized + )) +} + +export function composerModelAllowed(pickList: readonly string[], modelId: string): boolean { + const normalized = normalizeComposerModelId(modelId) + if (!normalized) return false + return pickList.some((id) => normalizeComposerModelId(id) === normalized) +} + +export function composerModelSelectable( + pickList: readonly string[], + modelGroups: readonly ModelProviderModelGroup[], + modelId: string +): boolean { + if (!composerModelAllowed(pickList, modelId)) return false + if (!isComposerChatModelId(modelId)) return false + const group = modelGroups.find((item) => modelGroupHasModel(item, modelId)) + if (!group) return true + return modelProfileSupportsTextChat(modelProfileForComposerModel(group, modelId)) +} + +export function providerIdMatchesComposerModel( + modelGroups: readonly ModelProviderModelGroup[], + providerId: string, + modelId: string +): boolean { + const provider = providerId.trim() + if (!provider) return false + const group = modelGroups.find((item) => item.providerId === provider) + return group ? modelGroupHasModel(group, modelId) : false +} + +function modelProfileForComposerModel( + group: Pick, + modelId: string +): NonNullable[string] | undefined { + const model = modelId.trim() + const key = normalizeComposerModelId(model) + if (!key) return undefined + const profiles = group.modelProfiles ?? {} + return profiles[key] ?? profiles[model] } function normalizeComposerModelId(modelId: string): string { @@ -344,3 +434,20 @@ export function normalizeTurnModelMap(raw: unknown): Record { function saveTurnModelMap(map: Record): void { writeBrowserStorageItem(TURN_MODEL_STORAGE_KEY, JSON.stringify(normalizeTurnModelMap(map))) } + +function loadThreadComposerSelectionMap(): Record { + try { + const raw = readBrowserStorageItem(THREAD_COMPOSER_SELECTION_STORAGE_KEY) + if (!raw) return {} + return normalizeThreadComposerSelectionMap(JSON.parse(raw)) + } catch { + return {} + } +} + +function saveThreadComposerSelectionMap(map: Record): void { + writeBrowserStorageItem( + THREAD_COMPOSER_SELECTION_STORAGE_KEY, + JSON.stringify(normalizeThreadComposerSelectionMap(map)) + ) +} diff --git a/src/renderer/src/store/chat-store-thread-actions.ts b/src/renderer/src/store/chat-store-thread-actions.ts index 228a1f9f9..a5b1b2553 100644 --- a/src/renderer/src/store/chat-store-thread-actions.ts +++ b/src/renderer/src/store/chat-store-thread-actions.ts @@ -31,14 +31,18 @@ import { import type { ChatState, ChatStoreGet, ChatStoreSet } from './chat-store-types' import { activeClawChannel, + composerModelSelectable, compactCodeWorkspaceRoots, forgetCodeWorkspaceRoot, hydrateBlockModelLabels, isClawThread, optimisticUserModelLabel, + providerIdForComposerModel, + providerIdMatchesComposerModel, readCodeWorkspaceRoots, - readStoredComposerModel, + readThreadComposerSelection, rememberCodeWorkspaceRoots, + rememberThreadComposerSelection, rememberTurnModel } from './chat-store-helpers' import { @@ -133,6 +137,31 @@ async function ensureRuntimeProviderForSend(input: { } } +function composerSelectionForThread( + state: ChatState, + thread: Pick | null | undefined +): { model: string; providerId: string } | null { + if (!thread) return null + const pickList = state.composerPickList + const stored = readThreadComposerSelection(thread.id) + const storedModel = stored?.model.trim() ?? '' + const threadModel = thread.model.trim() + const model = composerModelSelectable(pickList, state.composerModelGroups, storedModel) + ? storedModel + : composerModelSelectable(pickList, state.composerModelGroups, threadModel) + ? threadModel + : '' + if (!model) return null + const storedProviderId = + stored && providerIdMatchesComposerModel(state.composerModelGroups, stored.providerId, model) + ? stored.providerId + : '' + return { + model, + providerId: storedProviderId || providerIdForComposerModel(state.composerModelGroups, model) + } +} + function subscribeThreadEventsWithRecovery( provider: AgentProvider, threadId: string, @@ -326,6 +355,8 @@ export function createThreadActions( const currentTurnUserId = busy ? latestUserMessageId ?? findLatestUserBlockId(blocks) : null + const threadSnap = get().threads.find((thread) => thread.id === id) ?? null + const composerSelection = composerSelectionForThread(get(), threadSnap) set({ watchTurnCompletion: nextWatch, unreadThreadIds: nextUnread, @@ -345,7 +376,13 @@ export function createThreadActions( turnReasoningFirstAtByUserId: {}, turnReasoningLastAtByUserId: {}, inspectorSelectedId: null, - queuedMessages: [] + queuedMessages: [], + ...(composerSelection + ? { + composerModel: composerSelection.model, + composerProviderId: composerSelection.providerId + } + : {}) }) syncTurnCompletionPoll(set, get) const ac = new AbortController() @@ -568,6 +605,9 @@ export function createThreadActions( throw new Error('Failed to resolve target thread id.') } activeThreadId = threadId + if (composerModel) { + rememberThreadComposerSelection(threadId, composerModel, composerProviderId) + } set((s) => ({ activeThreadId: threadId, codeWorkspaceRoots: rememberCodeWorkspaceRoots(s.codeWorkspaceRoots, [workspaceRoot, createdThread?.workspace]), @@ -609,6 +649,9 @@ export function createThreadActions( try { const seqAtSend = get().lastSeq const channel = get().route === 'claw' ? activeClawChannel(get()) : null + if (!channel && composerModel) { + rememberThreadComposerSelection(activeThreadId, composerModel, composerProviderId) + } await ensureRuntimeProviderForSend({ providerId: channel ? undefined : composerProviderId, model: composerModel, From 4b66ee8c9281ae25485f3458ebfd6153f090e611 Mon Sep 17 00:00:00 2001 From: XingYu-Zhong <1736101137@qq.com> Date: Tue, 16 Jun 2026 00:28:47 +0800 Subject: [PATCH 3/9] =?UTF-8?q?feat=EF=BC=9Aadd=20big=20change?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/KUN_CONFIG.md | 5 + kun/README.md | 1 + kun/README.zh-CN.md | 1 + ...ompat-model-client.endpoint-format.test.ts | 123 +++++++ kun/src/adapters/model/compat-model-client.ts | 18 +- kun/src/config/kun-config.test.ts | 14 +- kun/src/config/kun-config.ts | 12 +- kun/src/contracts/capabilities.ts | 7 +- kun/src/loop/model-context-profile.test.ts | 26 +- kun/src/loop/model-context-profile.ts | 10 +- kun/src/server/runtime-factory.ts | 5 +- package-lock.json | 36 ++- package.json | 1 + src/main/claw-platform-install.test.ts | 71 ++-- src/main/claw-platform-install.ts | 88 +++-- src/main/claw-runtime-helpers.test.ts | 81 +++++ src/main/claw-runtime-helpers.ts | 64 ++++ src/main/claw-runtime.test.ts | 302 +++++++++++++++++- src/main/claw-runtime.ts | 210 ++++++++++-- src/main/ipc/app-ipc-schemas.test.ts | 22 ++ src/main/ipc/app-ipc-schemas.ts | 28 +- src/main/ipc/register-app-ipc-handlers.ts | 48 ++- src/main/kun-process.test.ts | 3 + src/main/kun-process.ts | 4 +- .../legacy-session-import-service.test.ts | 165 ++++++++++ .../services/legacy-session-import-service.ts | 207 ++++++++++++ src/main/services/worktree-service.ts | 14 +- .../write-inline-completion-service.test.ts | 56 ++++ .../write-inline-completion-service.ts | 66 +++- src/main/settings-store.ts | 5 +- src/preload/index.ts | 6 + src/renderer/src/components/SettingsView.tsx | 15 +- src/renderer/src/components/Workbench.tsx | 32 +- .../chat/ContextCapacityPopover.tsx | 146 +++++++++ .../src/components/chat/FloatingComposer.tsx | 155 ++++++++- src/renderer/src/components/chat/Sidebar.tsx | 5 +- .../src/components/chat/WorkspaceModeTabs.tsx | 22 +- .../src/components/provider-model-editor.ts | 8 +- .../components/schedule/ScheduleTasksView.tsx | 12 +- .../components/settings-section-agents.tsx | 18 ++ ...settings-section-general-legacy-import.tsx | 178 +++++++++++ .../components/settings-section-general.tsx | 3 + .../settings-section-provider-models.tsx | 248 ++++++++++---- .../components/settings-section-providers.tsx | 294 +++++++++++------ .../components/settings-section-worktree.tsx | 8 +- .../src/components/settings-section-write.tsx | 204 ++++++++++++ .../components/write/WriteFontSizeControl.tsx | 79 +++++ .../src/components/write/WriteInlineAgent.tsx | 83 ++++- .../components/write/WriteMarkdownEditor.tsx | 141 +++++++- .../src/components/write/WriteSidebar.tsx | 5 +- .../write/WriteWorkspaceDocumentPane.tsx | 6 +- .../write/WriteWorkspaceToolbar.tsx | 34 +- .../components/write/WriteWorkspaceView.tsx | 117 +++++-- src/renderer/src/lib/apply-theme.ts | 15 + src/renderer/src/lib/context-capacity.test.ts | 114 +++++++ src/renderer/src/lib/context-capacity.ts | 186 +++++++++++ src/renderer/src/locales/en/common.json | 36 +++ src/renderer/src/locales/en/settings.json | 55 ++++ src/renderer/src/locales/zh/common.json | 36 +++ src/renderer/src/locales/zh/settings.json | 55 ++++ .../src/store/chat-store-app-actions.test.ts | 1 + .../src/store/chat-store-app-actions.ts | 3 + .../store/chat-store-navigation-actions.ts | 3 +- src/renderer/src/store/chat-store-runtime.ts | 10 +- src/renderer/src/store/chat-store-types.ts | 7 + src/renderer/src/store/chat-store.ts | 4 +- src/renderer/src/styles/write-editor.css | 109 ++++++- src/renderer/src/styles/write-rich-editor.css | 8 +- src/renderer/src/write/agent-presets.ts | 33 ++ .../write/inline-completion/feedback.test.ts | 14 + .../src/write/inline-completion/feedback.ts | 12 + src/renderer/src/write/quoted-selection.ts | 9 +- .../write-workspace-file-actions.test.ts | 5 + .../write/write-workspace-settings-actions.ts | 1 + .../write/write-workspace-store-helpers.ts | 12 +- .../src/write/write-workspace-store-types.ts | 15 +- .../src/write/write-workspace-store.ts | 33 ++ src/shared/app-settings-kun.ts | 22 +- src/shared/app-settings-provider.test.ts | 26 ++ src/shared/app-settings-provider.ts | 20 +- src/shared/app-settings-types.ts | 96 +++++- src/shared/app-settings-write.ts | 164 +++++++++- src/shared/app-settings.test.ts | 62 +++- src/shared/kun-gui-api.ts | 42 +++ src/shared/model-provider-presets.ts | 193 ++++++++++- src/shared/worktree.ts | 1 + 86 files changed, 4508 insertions(+), 406 deletions(-) create mode 100644 kun/src/adapters/model/compat-model-client.endpoint-format.test.ts create mode 100644 src/main/claw-runtime-helpers.test.ts create mode 100644 src/main/services/legacy-session-import-service.test.ts create mode 100644 src/main/services/legacy-session-import-service.ts create mode 100644 src/renderer/src/components/chat/ContextCapacityPopover.tsx create mode 100644 src/renderer/src/components/settings-section-general-legacy-import.tsx create mode 100644 src/renderer/src/components/write/WriteFontSizeControl.tsx create mode 100644 src/renderer/src/lib/context-capacity.test.ts create mode 100644 src/renderer/src/lib/context-capacity.ts create mode 100644 src/renderer/src/write/agent-presets.ts 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/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..548e07ccc 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 } 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/package-lock.json b/package-lock.json index dff15c4d2..2ff9e76dc 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", @@ -1278,6 +1279,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", @@ -10066,25 +10080,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", diff --git a/package.json b/package.json index 4f588147f..ece9bda58 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@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", 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/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..127871390 100644 --- a/src/main/ipc/app-ipc-schemas.ts +++ b/src/main/ipc/app-ipc-schemas.ts @@ -94,6 +94,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 +236,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 +334,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 +465,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({ 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/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/preload/index.ts b/src/preload/index.ts index 79ad234ad..a6d717336 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) => diff --git a/src/renderer/src/components/SettingsView.tsx b/src/renderer/src/components/SettingsView.tsx index accafa444..3dcdd46d4 100644 --- a/src/renderer/src/components/SettingsView.tsx +++ b/src/renderer/src/components/SettingsView.tsx @@ -24,7 +24,7 @@ import type { CoreRuntimeToolDiagnosticsJson } from '../agent/kun-contract' import type { WriteInlineCompletionDebugEntry } from '@shared/write-inline-completion' -import { applyTheme, applyUiFontScale } from '../lib/apply-theme' +import { applyTheme, applyUiFontScale, applyWriteTypography } from '../lib/apply-theme' import { formatWorkspacePickerError } from '../lib/format-workspace-picker-error' import type { SkillRootListItem } from '@shared/kun-gui-api' import { normalizeWorkspaceRoot } from '../lib/workspace-path' @@ -124,6 +124,7 @@ export function SettingsView(): ReactElement { const permissionsSectionRef = useRef(null) const formTheme = form?.theme const formUiFontScale = form?.uiFontScale + const writeTypography = form?.write?.typography const formWorkspaceRoot = form?.workspaceRoot const formKun = form ? getKunRuntimeSettings(form) : null const formPort = formKun?.port @@ -172,6 +173,18 @@ export function SettingsView(): ReactElement { applyUiFontScale(formUiFontScale) }, [formTheme, formUiFontScale]) + // Live-preview the Write editor typography as the form changes, mirroring the + // theme/scale preview above. Keyed on the scalar fields so it only re-applies + // on real changes. + useEffect(() => { + if (writeTypography) applyWriteTypography(writeTypography) + }, [ + writeTypography?.fontPreset, + writeTypography?.customFontFamily, + writeTypography?.fontSizePx, + writeTypography?.lineHeight + ]) + useEffect(() => { const onSettingsChanged = (event: Event): void => { const next = (event as CustomEvent).detail diff --git a/src/renderer/src/components/Workbench.tsx b/src/renderer/src/components/Workbench.tsx index cc4972e60..1a0127d03 100644 --- a/src/renderer/src/components/Workbench.tsx +++ b/src/renderer/src/components/Workbench.tsx @@ -55,6 +55,7 @@ import { SddAssistantPanel } from './sdd/SddAssistantPanel' import { SddDraftEditorView } from './sdd/SddDraftEditorView' import { SidebarTitlebarToggleButton } from './sidebar/SidebarPrimitives' import { composeWritePrompt } from '../write/quoted-selection' +import { resolveWriteAgentPreset } from '../write/agent-presets' import { useWriteWorkspaceStore } from '../write/write-workspace-store' import { isWriteThreadId } from '../write/write-thread-registry' import { buildSddDraftId, createSddDraft, forgetRememberedSddDraft, useSddDraftStore } from '../sdd/sdd-draft-store' @@ -1075,10 +1076,15 @@ export function Workbench(): ReactElement { } } const messageText = v || t('composerImageOnlyPrompt') + const activeAgentPreset = writeState.agentPresets.find( + (preset) => preset.id === writeState.assistantAgentPresetId + ) + const agentPersona = activeAgentPreset ? resolveWriteAgentPreset(activeAgentPreset).persona : '' const prompt = composeWritePrompt(messageText, writeState.quotedSelections, { workspaceRoot: writeWorkspaceRoot, activeFilePath: writeState.activeFilePath, - retrieval + retrieval, + ...(agentPersona ? { agentPersona } : {}) }) const model = writeState.assistantModel.trim() const providerId = @@ -2185,7 +2191,6 @@ export function Workbench(): ReactElement { onWriteOpen={openWriteMode} onOpenSettings={(section) => openSettings(section)} onToggleConnectPhone={toggleConnectPhone} - onToggleSidebar={toggleLeftSidebar} /> ) : ( )}
@@ -2265,6 +2269,7 @@ export function Workbench(): ReactElement { input={input} setInput={setInput} onSubmitPrompt={sendWritePrompt} + onOpenAgentSettings={() => openSettings('write')} /> {renderRightPanel()} @@ -2296,13 +2301,11 @@ export function Workbench(): ReactElement { leftSidebarCollapsed ? 'ds-window-controls-safe-inset' : '' }`} > - {leftSidebarCollapsed ? ( - - ) : null} +
@@ -2361,6 +2364,15 @@ export function Workbench(): ReactElement { busy={busy} runtimeReady={runtimeConnection === 'ready'} hasActiveThread={Boolean(activeThreadId)} + contextWindowTokens={runtimeInfo?.capabilities.model.contextWindowTokens} + runtimeToolCount={ + runtimeInfo + ? runtimeInfo.capabilities.mcp.search?.active + ? runtimeInfo.capabilities.mcp.search.advertisedToolCount + : runtimeInfo.capabilities.mcp.toolCount + : undefined + } + runtimeSkillCount={runtimeInfo?.capabilities.skills.discoveredSkills} composerModel={ route === 'claw' ? clawChannels.find((channel) => channel.id === activeClawChannelId)?.model ?? 'auto' diff --git a/src/renderer/src/components/chat/ContextCapacityPopover.tsx b/src/renderer/src/components/chat/ContextCapacityPopover.tsx new file mode 100644 index 000000000..dbe102ddc --- /dev/null +++ b/src/renderer/src/components/chat/ContextCapacityPopover.tsx @@ -0,0 +1,146 @@ +import type { ReactElement } from 'react' +import { useTranslation } from 'react-i18next' +import type { ContextCapacity, ContextCategoryKey } from '../../lib/context-capacity' +import { formatCompactNumber, formatPercent } from '../../hooks/use-thread-usage' + +const CATEGORY_COLORS: Record = { + tools: '#3b82d8', + system: '#8b7be8', + skills: '#1d9e75', + messages: '#e0673a', + other: '#d8910d' +} + +const CATEGORY_ORDER: ContextCategoryKey[] = ['tools', 'system', 'skills', 'messages', 'other'] + +const WARN_RATIO = 0.75 + +function stateColor(usedRatio: number, thresholdRatio: number): string { + if (usedRatio >= thresholdRatio) return '#d9544e' + if (usedRatio >= WARN_RATIO) return '#d9920f' + return 'var(--ds-accent)' +} + +type Props = { + capacity: ContextCapacity + /** Approximate auto-compaction trigger, as a share of the window. */ + thresholdRatio?: number +} + +export function ContextCapacityPopover({ capacity, thresholdRatio = 0.9 }: Props): ReactElement { + const { t } = useTranslation() + const accent = stateColor(capacity.usedRatio, thresholdRatio) + + const labelFor = (key: ContextCategoryKey): string => + t(`contextCapacityCat_${key}`, { defaultValue: key }) + + const visibleSegments = CATEGORY_ORDER.map((key) => + capacity.categories.find((c) => c.key === key) + ).filter((c): c is NonNullable => Boolean(c) && (c?.tokens ?? 0) > 0) + + const statusText = + capacity.usedRatio >= thresholdRatio + ? t('contextCapacityOverLimit') + : capacity.usedRatio >= WARN_RATIO + ? t('contextCapacityNearLimit') + : t('contextCapacityShareNote') + + return ( +
+
+ {t('contextCapacityTitle')} + + + {formatCompactNumber(capacity.usedTokens)} + + {' / '} + {formatCompactNumber(capacity.windowTokens)} + {' · '} + + {formatPercent(capacity.usedRatio)} + + +
+ +
+
+ {visibleSegments.map((segment) => ( + + ))} +
+
+ +
+ + {statusText} + + + {t('contextCapacityThresholdLabel', { percent: formatPercent(thresholdRatio) })} + +
+ +
+ {CATEGORY_ORDER.map((key) => { + const category = capacity.categories.find((c) => c.key === key) + if (!category) return null + return ( +
+ + {labelFor(key)} + + {formatCompactNumber(category.tokens)} + + + {formatPercent(category.ratio)} + +
+ ) + })} + +
+ + {t('contextCapacityCat_free')} + + {formatCompactNumber(capacity.freeTokens)} + + + {formatPercent(capacity.freeRatio)} + +
+
+ + {capacity.estimated ? ( +

+ {capacity.hasMeasuredTotal + ? t('contextCapacityEstimatedBreakdown') + : t('contextCapacityEstimatedAll')} +

+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/chat/FloatingComposer.tsx b/src/renderer/src/components/chat/FloatingComposer.tsx index 8b46c1a05..6182f9acf 100644 --- a/src/renderer/src/components/chat/FloatingComposer.tsx +++ b/src/renderer/src/components/chat/FloatingComposer.tsx @@ -38,7 +38,7 @@ import { import { useTranslation } from 'react-i18next' import type { ModelProviderModelGroup } from '@shared/kun-gui-api' import type { WorkspaceEntry } from '@shared/workspace-file' -import type { AttachmentReference, ReviewTarget } from '../../agent/types' +import type { AttachmentReference, ChatBlock, ReviewTarget } from '../../agent/types' import { useChatStore } from '../../store/chat-store' import { normalizeWorkspaceRoot } from '../../lib/workspace-path' import { @@ -72,6 +72,8 @@ import { formatPercent, useThreadUsageState } from '../../hooks/use-thread-usage' +import { buildContextCapacity, estimateBlockTokens } from '../../lib/context-capacity' +import { ContextCapacityPopover } from './ContextCapacityPopover' import { GitBranchPicker } from './GitBranchPicker' import { WorkspaceProjectPicker } from './WorkspaceProjectPicker' import { @@ -164,10 +166,17 @@ type Props = { * Hide the `/btw` slash entry (e.g. inside a side conversation). */ hideBtwCommand?: boolean + /** Active model's context window, for the 上下文容量 gauge. */ + contextWindowTokens?: number + /** Tool definitions advertised to the model (built-ins are added on top). */ + runtimeToolCount?: number + /** Skills in the always-injected catalog. */ + runtimeSkillCount?: number } type SkillCommand = NonNullable[number] +const EMPTY_CONTEXT_BLOCKS: ChatBlock[] = [] const EMPTY_MODEL_GROUPS: ModelProviderModelGroup[] = [] const EMPTY_ATTACHMENTS: AttachmentReference[] = [] const EMPTY_FILE_REFERENCES: ComposerFileReference[] = [] @@ -609,13 +618,17 @@ export function FloatingComposer({ onReviewChanges, reviewChangesDisabled = false, onBtwCommand, - hideBtwCommand = false + hideBtwCommand = false, + contextWindowTokens, + runtimeToolCount, + runtimeSkillCount }: Props): ReactElement { const { t, i18n } = useTranslation('common') const route = useChatStore((s) => s.route) const workspaceRoot = useChatStore((s) => s.workspaceRoot) const activeThreadId = useChatStore((s) => s.activeThreadId) const usageRefreshKey = useChatStore((s) => s.usageRefreshKey) + const lastTurnUsage = useChatStore((s) => s.lastTurnUsage) const threads = useChatStore((s) => s.threads) const compactActiveThread = useChatStore((s) => s.compactActiveThread) const forkActiveThread = useChatStore((s) => s.forkActiveThread) @@ -727,11 +740,90 @@ export function FloatingComposer({ const [dismissedFileMentionKey, setDismissedFileMentionKey] = useState(null) const [composerMenuOpen, setComposerMenuOpen] = useState(false) const [goalPanelOpen, setGoalPanelOpen] = useState(false) + const [contextCapacityOpen, setContextCapacityOpen] = useState(false) const [goalRuntimeNowMs, setGoalRuntimeNowMs] = useState(() => Date.now()) const composerRootRef = useRef(null) const composerMenuButtonRef = useRef(null) const composerMenuPanelRef = useRef(null) const goalPanelRef = useRef(null) + const contextCapacityRef = useRef(null) + const messageTokenCacheRef = useRef>(new WeakMap()) + // Cache the last-known runtime capacity inputs. `runtimeInfo` (and thus these + // props) goes null whenever the runtime drops/reconnects; without caching, the + // chip would vanish ("context 没有了") and flap in/out as the connection flaps, + // which itself reads as flicker. Writing refs during render is idempotent here. + const lastKnownWindowRef = useRef(0) + if (typeof contextWindowTokens === 'number' && contextWindowTokens > 0) { + lastKnownWindowRef.current = contextWindowTokens + } + const lastKnownToolCountRef = useRef(0) + if (typeof runtimeToolCount === 'number') lastKnownToolCountRef.current = runtimeToolCount + const lastKnownSkillCountRef = useRef(0) + if (typeof runtimeSkillCount === 'number') lastKnownSkillCountRef.current = runtimeSkillCount + const effectiveContextWindow = + typeof contextWindowTokens === 'number' && contextWindowTokens > 0 + ? contextWindowTokens + : lastKnownWindowRef.current + const effectiveToolCount = + typeof runtimeToolCount === 'number' ? runtimeToolCount : lastKnownToolCountRef.current + const effectiveSkillCount = + typeof runtimeSkillCount === 'number' ? runtimeSkillCount : lastKnownSkillCountRef.current + const canShowContextCapacity = + !compact && route === 'chat' && Boolean(activeThreadId) && effectiveContextWindow > 0 + // Freeze the measured total for the duration of a turn: the runtime can emit + // several `usage` events while streaming, and tracking them live makes the + // chip jitter (visible flicker). Adopt the latest value only while idle. + const liveMeasuredTotal = + lastTurnUsage && lastTurnUsage.threadId === activeThreadId + ? lastTurnUsage.snapshot.inputTokens + : null + const measuredTotalRef = useRef(null) + if (!busy) measuredTotalRef.current = liveMeasuredTotal + const measuredContextTotal = busy ? measuredTotalRef.current : liveMeasuredTotal + // The message estimate only feeds the per-category split (popover) or the + // no-measured-total fallback. Never subscribe to `blocks` while streaming with + // the popover closed — blocks churn on every delta and re-render the whole + // composer. Freeze the last estimate in a ref instead. + const needMessageEstimate = + canShowContextCapacity && (contextCapacityOpen || measuredContextTotal == null) + const subscribeContextBlocks = needMessageEstimate && (contextCapacityOpen || !busy) + const contextBlocks = useChatStore((s) => (subscribeContextBlocks ? s.blocks : EMPTY_CONTEXT_BLOCKS)) + const conversationTokensRef = useRef(0) + const conversationTokens = useMemo(() => { + if (!subscribeContextBlocks) return conversationTokensRef.current + // Cache per block: block identity is preserved for unchanged history across + // streaming updates, so only the block that changed is re-estimated. + const cache = messageTokenCacheRef.current + let sum = 0 + for (const block of contextBlocks) { + let cached = cache.get(block) + if (cached === undefined) { + cached = estimateBlockTokens(block) + cache.set(block, cached) + } + sum += cached + } + conversationTokensRef.current = sum + return sum + }, [subscribeContextBlocks, contextBlocks]) + const contextCapacity = useMemo(() => { + if (!canShowContextCapacity) return null + return buildContextCapacity({ + windowTokens: effectiveContextWindow, + lastTurnInputTokens: measuredContextTotal, + messageTokens: conversationTokens, + toolCount: effectiveToolCount, + skillCount: effectiveSkillCount + }) + }, [ + canShowContextCapacity, + effectiveContextWindow, + measuredContextTotal, + conversationTokens, + effectiveToolCount, + effectiveSkillCount + ]) + const showContextCapacity = canShowContextCapacity && Boolean(contextCapacity) const goalRuntimeStartedAtRef = useRef(null) const placeholder = !runtimeReady ? t('runtimeActionNeedsConnection') @@ -1055,6 +1147,25 @@ export function FloatingComposer({ } }, [composerMenuOpen, goalPanelOpen]) + useEffect(() => { + if (!contextCapacityOpen) return + const onPointerDown = (event: PointerEvent): void => { + const target = event.target + if (!(target instanceof Node)) return + if (contextCapacityRef.current?.contains(target)) return + setContextCapacityOpen(false) + } + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Escape') setContextCapacityOpen(false) + } + window.addEventListener('pointerdown', onPointerDown) + window.addEventListener('keydown', onKeyDown) + return () => { + window.removeEventListener('pointerdown', onPointerDown) + window.removeEventListener('keydown', onKeyDown) + } + }, [contextCapacityOpen]) + useEffect(() => { const shouldTimeGoal = busy && activeThreadGoal?.status === 'active' if (!shouldTimeGoal) { @@ -2055,6 +2166,46 @@ export function FloatingComposer({ ) : ( <> + {showContextCapacity && contextCapacity ? ( +
+ + {contextCapacityOpen ? ( +
+ +
+ ) : null} +
+ ) : null} {hideModelPicker ? null : ( 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/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/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) + }} + /> + +
+