diff --git a/.gitignore b/.gitignore index 34be0c9bc..625a1059f 100755 --- a/.gitignore +++ b/.gitignore @@ -151,3 +151,4 @@ tasks/ cloudcli-sidebar-app-source.tar.gz cloudcli-sidebar.html electron/*.tar.gz +MERGE-CONFLICTS.md diff --git a/server/claude-sdk.js b/server/claude-sdk.js index a0a795c6f..c3817dd80 100644 --- a/server/claude-sdk.js +++ b/server/claude-sdk.js @@ -41,6 +41,27 @@ const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEO const TOOLS_REQUIRING_INTERACTION = new Set(['AskUserQuestion', 'ExitPlanMode']); +/** + * Extracts the prompt text from a Task subagent tool_use input. + * Mirrors the logic in claude-sessions.provider.ts extractSubagentPrompt(). + * Handles both parsed objects and JSON-stringified input. + */ +function extractSubagentPrompt(toolInput) { + if (!toolInput) return null; + let parsed = toolInput; + if (typeof toolInput === 'string') { + try { + parsed = JSON.parse(toolInput); + } catch { + return null; + } + } + if (typeof parsed !== 'object' || parsed === null) return null; + const prompt = typeof parsed.prompt === 'string' ? parsed.prompt : null; + if (!prompt) return null; + return prompt.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim(); +} + function createRequestId() { if (typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); @@ -227,6 +248,11 @@ function mapCliOptionsToSDK(options = {}) { sdkOptions.resume = sessionId; } + // Enable partial messages so SDK yields content_block_delta events for streaming. + // Without this, SDK only emits complete assistant messages at the end of each turn, + // which means the frontend never receives stream_delta frames. + sdkOptions.includePartialMessages = true; + return sdkOptions; } @@ -518,6 +544,7 @@ async function queryClaudeSDK(command, options = {}, ws) { let sessionCreatedSent = false; let tempImagePaths = []; let tempDir = null; + const streamingSubagentPrompts = new Set(); const emitNotification = (event) => { notifyUserIfEnabled({ @@ -684,7 +711,6 @@ async function queryClaudeSDK(command, options = {}, ws) { } // Process streaming messages - console.log('Starting async generator loop for session:', capturedSessionId || 'NEW'); for await (const message of queryInstance) { // Capture session ID from first message if (message.session_id && !capturedSessionId) { @@ -710,8 +736,27 @@ async function queryClaudeSDK(command, options = {}, ws) { const transformedMessage = transformMessage(message); const sid = capturedSessionId || sessionId || null; + // Collect Task subagent prompts so they can be filtered during normalization + if (message.type === 'assistant' || transformedMessage.message?.role === 'assistant') { + if (Array.isArray(transformedMessage.message?.content)) { + for (const part of transformedMessage.message.content) { + if (part.type === 'tool_use' && part.name === 'Task') { + const prompt = extractSubagentPrompt(part.input); + if (prompt) { + streamingSubagentPrompts.add(prompt); + } + } + } + } + } + // Use adapter to normalize SDK events into NormalizedMessage[] - const normalized = sessionsService.normalizeMessage('claude', transformedMessage, sid); + const normalized = sessionsService.normalizeMessage( + 'claude', + transformedMessage, + sid, + streamingSubagentPrompts.size > 0 ? streamingSubagentPrompts : null, + ); for (const msg of normalized) { // Preserve parentToolUseId from SDK wrapper for subagent tool grouping if (transformedMessage.parentToolUseId && !msg.parentToolUseId) { @@ -801,7 +846,7 @@ async function abortClaudeSDKSession(sessionId) { } try { - console.log(`Aborting SDK session: ${sessionId}`); + // Call interrupt() on the query instance // Mark before interrupting so the run loop knows not to emit its own // terminal complete (the abort handler sends the aborted one). @@ -879,7 +924,6 @@ function reconnectSessionWriter(sessionId, newRawWs) { const session = getSession(sessionId); if (!session?.writer?.updateWebSocket) return false; session.writer.updateWebSocket(newRawWs); - console.log(`[RECONNECT] Writer swapped for session ${sessionId}`); return true; } diff --git a/server/modules/providers/list/claude/claude-session-synchronizer.provider.ts b/server/modules/providers/list/claude/claude-session-synchronizer.provider.ts index 9320a2fe0..a76969dc6 100644 --- a/server/modules/providers/list/claude/claude-session-synchronizer.provider.ts +++ b/server/modules/providers/list/claude/claude-session-synchronizer.provider.ts @@ -108,8 +108,232 @@ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer { } /** - * Extracts session metadata from one Claude JSONL session file. + * Resolve Anthropic API key AND config from settings.json env block. */ + private async resolveAnthropicConfig(): Promise<{ key: string; baseUrl: string; model: string } | null> { + let key: string | null = null; + let baseUrl = 'https://api.anthropic.com'; + let model = 'claude-haiku-4-20250915'; + + // process.env first + if (process.env.ANTHROPIC_API_KEY?.trim()) key = process.env.ANTHROPIC_API_KEY.trim(); + if (process.env.ANTHROPIC_AUTH_TOKEN?.trim()) key = process.env.ANTHROPIC_AUTH_TOKEN.trim(); + if (process.env.ANTHROPIC_BASE_URL?.trim()) baseUrl = process.env.ANTHROPIC_BASE_URL.trim(); + + // Read from ~/.claude/settings.json env block + try { + const settingsPath = path.join(this.claudeHome, 'settings.json'); + const content = await readFile(settingsPath, 'utf8'); + const settings: any = JSON.parse(content); + const env = settings?.env; + if (typeof env === 'object' && env) { + if (typeof env.ANTHROPIC_API_KEY === 'string' && env.ANTHROPIC_API_KEY.trim()) key = env.ANTHROPIC_API_KEY.trim(); + if (typeof env.ANTHROPIC_AUTH_TOKEN === 'string' && env.ANTHROPIC_AUTH_TOKEN.trim()) key = env.ANTHROPIC_AUTH_TOKEN.trim(); + if (typeof env.ANTHROPIC_BASE_URL === 'string' && env.ANTHROPIC_BASE_URL.trim()) baseUrl = env.ANTHROPIC_BASE_URL.trim(); + if (typeof env.ANTHROPIC_DEFAULT_HAIKU_MODEL === 'string' && env.ANTHROPIC_DEFAULT_HAIKU_MODEL.trim()) model = env.ANTHROPIC_DEFAULT_HAIKU_MODEL.trim(); + } + } catch { /* no settings.json */ } + + // Read OAuth access token from ~/.claude/.credentials.json + if (!key) { + try { + const credPath = path.join(this.claudeHome, '.credentials.json'); + const content = await readFile(credPath, 'utf8'); + const creds: any = JSON.parse(content); + const oauth = creds?.claudeAiOauth; + if (oauth && typeof oauth.accessToken === 'string') { + const expiresAt = typeof oauth.expiresAt === 'number' ? oauth.expiresAt : null; + if (!expiresAt || Date.now() < expiresAt) { + key = oauth.accessToken; + } + } + } catch { /* no credentials.json */ } + } + + if (!key) return null; + return { key, baseUrl, model }; + } + + /** + * Generate a concise session title. + * Tries AI generation first (for non-reasoning models that produce text blocks). + * Falls back to smart truncation of the user's first prompt — reliable across all models. + */ + private async generateAiTitle(userPrompt: string): Promise { + const config = await this.resolveAnthropicConfig(); + if (!config) return this.truncateToTitle(userPrompt); + + const url = `${config.baseUrl.replace(/\/+$/, '')}/v1/messages`; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 8000); // 8s timeout + + try { + const res = await fetch(url, { + method: 'POST', + signal: controller.signal, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${config.key}`, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model: config.model, + max_tokens: 512, + messages: [ + { + role: 'user', + content: `Generate a short, descriptive title (max 50 chars) for a chat session. Use the same language as the user's message. Just return the title, nothing else.\n\nUser's first message:\n${userPrompt.slice(0, 500)}`, + }, + ], + }), + }); + + if (!res.ok) return this.truncateToTitle(userPrompt); + const data: any = await res.json(); + // Find first text content block + for (const block of data?.content || []) { + if (block?.type === 'text' && typeof block.text === 'string' && block.text.trim().length > 0) { + const title = block.text.trim(); + // Guard: reject titles that look like prompt quotes (e.g. "- **User's message:** ...") + // or that match the user prompt itself. + if (this.isPromptMatch(title, userPrompt)) continue; + if (title.length <= 60) return title; + return title.slice(0, 60); + } + } + // No text block — reasoning model only produced thinking. Extract title from thinking. + const titleFromThinking = this.extractTitleFromThinking(data, userPrompt); + return titleFromThinking ?? this.truncateToTitle(userPrompt); + } catch { + return this.truncateToTitle(userPrompt); + } finally { + clearTimeout(timer); + } + } + + /** + * Extract a title from thinking blocks produced by reasoning models. + * Reasoning models (e.g. Qwen) produce thinking instead of text blocks. + * We look for the final chosen title in patterns like: + * - "Let's go with `TITLE`" or "Let's go with "TITLE"" + * - "I'll use `TITLE`" / backtick-quoted candidates + * - Bullet list items with domain keywords + * If none found, falls back to truncateToTitle. + * + * CRITICAL: userPrompt is passed so we can reject candidates that are + * literally the user's own message echoed back in the thinking block. + */ + private extractTitleFromThinking(data: any, userPrompt: string): string | undefined { + for (const block of data?.content || []) { + if (block?.type === 'thinking' && typeof block.thinking === 'string') { + const thinking = block.thinking; + // 1. Decision pattern with quotes: "Let's go with "TITLE"" + const decisionPatterns = [ + /(?:Let's go with|I'll use|I choose|I go with|Best choice|Final choice|最终选择)[::]\s*"([^"]{5,60})"/i, + /(?:title is|title:|the title is)\s*"([^"]{5,60})"/i, + ]; + for (const pattern of decisionPatterns) { + const match = thinking.match(pattern); + const candidate = match?.[1]?.trim(); + if (candidate && !this.isPromptMatch(candidate, userPrompt)) return candidate; + } + // 2. Decision pattern with backticks: `Let's go with \`TITLE\`` + const backtickPatterns = [ + /(?:Let's go with|I'll use|I choose|I go with|Best choice|Final choice)[::]\s*`([^`]{5,60})`/i, + ]; + for (const pattern of backtickPatterns) { + const match = thinking.match(pattern); + const candidate = match?.[1]?.trim(); + if (candidate && !this.isPromptMatch(candidate, userPrompt)) return candidate; + } + // 3. All backtick-quoted phrases (model's brainstormed candidates) + // Filter out any that match the user's prompt — the model often + // quotes the user message in its thinking, not the generated title. + const allBackticks = [...thinking.matchAll(/`([^`]{5,60})`/g)]; + const validBackticks = allBackticks.filter((m) => !this.isPromptMatch(m[1], userPrompt)); + if (validBackticks.length > 0) { + return validBackticks[validBackticks.length - 1][1].trim(); + } + // 4. Bullet titles with domain keywords + const bulletTitles = thinking.match(/[-*]\s+(.{5,60}(?:项目|配置|修复|分析|查看|环境|讨论|方案|优化|测试|部署|升级|迁移|排查|对比|总结|问题|报错|失败|检查|安装|设置))/); + if (bulletTitles) return bulletTitles[1].trim(); + // 5. All quoted phrases (double quotes) — same prompt guard as above + const allQuotes = [...thinking.matchAll(/"([^"]{5,60})"/g)]; + const validQuotes = allQuotes.filter((m) => !this.isPromptMatch(m[1], userPrompt)); + if (validQuotes.length > 0) { + return validQuotes[validQuotes.length - 1][1].trim(); + } + } + } + return undefined; + } + + /** + * Returns true if candidate is the user's prompt or a close variant of it. + * This prevents the model quoting the user message in thinking from being + * mistaken for a generated title. + */ + private isPromptMatch(candidate: string, userPrompt: string): boolean { + // Strip leading markdown prefixes like "- **User's message:** ", "- **User:** ", etc. + // Also strip wrapper quotes and backticks that the model uses when quoting user text. + let c = candidate.trim(); + c = c.replace(/^[-*]\s*\*\*[^*]*\*\*\s*:?[""]?\s*/, ''); + c = c.replace(/^[-*]\s*["`]\s*/, ''); + c = c.replace(/["`\s]+$/, '').trim(); + c = c.toLowerCase(); + + const p = userPrompt.trim().toLowerCase(); + if (c === p) return true; + // Strip trailing punctuation for comparison + const pClean = p.replace(/[。!?.?!,,、;;::]+$/, ''); + if (c === pClean) return true; + // Direct substring check — but don't reject valid titles just because they're + // contained in the prompt (e.g., short title like "修复bug" appears in "帮我修复bug") + const isNearFullOverlap = (left: string, right: string): boolean => { + const shorter = Math.min(left.length, right.length); + const longer = Math.max(left.length, right.length); + return shorter >= 4 && longer > 0 && shorter / longer >= 0.8 && (left.includes(right) || right.includes(left)); + }; + if (isNearFullOverlap(c, p)) return true; + if (isNearFullOverlap(c, pClean)) return true; + // One starts with the other + if (c.length >= 4 && p.length >= 4 && (c.startsWith(p) || p.startsWith(c))) return true; + // Shared prefix overlap + if (c.length + p.length > 0 && Math.min(c.length, p.length) / Math.max(c.length, p.length) > 0.5) { + if (c.includes(p.slice(0, 8)) || p.includes(c.slice(0, 8))) return true; + } + return false; + } + + /** + * Smart truncation of user prompt to a readable session title. + * - Strip common prefixes ("帮我", "你看下", "你帮我看下", etc.) + * - Truncate to max 60 chars at a sentence boundary or whitespace + */ + private truncateToTitle(prompt: string): string { + const maxLen = 60; + let title = prompt.trim(); + + // Strip common Chinese conversational prefixes (order matters: longest first) + title = title.replace(/^(\s*?(你帮[我忙]?[下看查]+|你看[下查]+|你帮[下看查]+|你给我|你(帮)?[下看查]+|请帮[我忙]?[下看查]+|请[下看查]+|帮[我忙]?[下看查]+|查[一下]?|调查[一下]?|看[一下一眼]?|我先了解下?|我先了解[下查]+|你先[了解下查]+)[\s::]*)/, ''); + + if (title.length <= maxLen) { + return title; + } + // Truncate at a sentence boundary or whitespace near maxLen + const cut = title.slice(0, maxLen); + const sentenceBreak = Math.max(cut.lastIndexOf('。'), cut.lastIndexOf('.'), cut.lastIndexOf('!'), cut.lastIndexOf('!')); + if (sentenceBreak > maxLen * 0.5) { + return cut.slice(0, sentenceBreak + 1); + } + // Truncate at last whitespace + const lastSpace = cut.lastIndexOf(' '); + if (lastSpace > maxLen * 0.5) { + return cut.slice(0, lastSpace); + } + return cut; + } private async processSessionFile( filePath: string, nameMap: Map @@ -138,16 +362,57 @@ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer { const existingSession = sessionsDb.getSessionByProviderSessionId(parsed.sessionId) ?? sessionsDb.getSessionById(parsed.sessionId); const existingSessionName = existingSession?.custom_name; + // Only skip title generation if the session already has a real custom name. + // We must not skip when: + // 1. It is the default 'Untitled Claude Session' placeholder + // 2. It is a long truncated prompt (>60 chars) + // 3. It matches the first user prompt (meaning it was set from truncateToTitle, not AI) + let shouldSkip = false; if (existingSessionName && existingSessionName !== 'Untitled Claude Session') { + // Check if the existing name is just the raw prompt or a prefix of it + const lastPrompt = await this.extractLastPrompt(filePath); + const trimmedPrompt = lastPrompt?.trim(); + const trimmedExistingName = existingSessionName.trim(); + if ( + trimmedPrompt + && ( + trimmedExistingName === trimmedPrompt + || (trimmedExistingName.length >= 60 && trimmedPrompt.startsWith(trimmedExistingName)) + ) + ) { + // Existing name is derived from the prompt, not AI generated — regenerate + shouldSkip = false; + } else { + shouldSkip = true; + } + } + if (shouldSkip) { return { ...parsed, - sessionName: normalizeSessionName(existingSessionName, 'Untitled Claude Session'), + sessionName: normalizeSessionName(existingSessionName ?? undefined, 'Untitled Claude Session'), }; } let sessionName = nameMap.get(parsed.sessionId); if (!sessionName) { - sessionName = await this.extractSessionAiTitleFromEnd(filePath, parsed.sessionId); + sessionName = (await this.extractSessionAiTitleFromEnd(filePath))?.title; + } + + // If title from custom-title/ai-title is too long (>60 chars), truncate it. + // Claude CLI writes the full user prompt as custom-title, which can be 120+ chars. + if (sessionName && sessionName.length > 60) { + sessionName = this.truncateToTitle(sessionName); + } + + // If still no title from custom-title/ai-title events, try AI generation + // using the last user prompt from the JSONL file. + if (!sessionName) { + const lastPrompt = await this.extractLastPrompt(filePath); + if (lastPrompt) { + console.debug(`[AutoTitle] Generating AI title for session ${parsed.sessionId}`); + sessionName = await this.generateAiTitle(lastPrompt); + console.debug(`[AutoTitle] Generated AI title`, { sessionId: parsed.sessionId, hasTitle: Boolean(sessionName) }); + } } return { @@ -156,10 +421,32 @@ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer { }; } + /** + * Extract the last user prompt from the JSONL file for AI title generation. + */ + private async extractLastPrompt(filePath: string): Promise { + try { + const content = await readFile(filePath, 'utf8'); + const lines = content.split(/\r?\n/); + + for (let index = lines.length - 1; index >= 0; index -= 1) { + const line = lines[index]?.trim(); + if (!line) continue; + let parsed: unknown; + try { parsed = JSON.parse(line); } catch { continue; } + const data = parsed as Record; + if (data.type === 'last-prompt') { + const lastPrompt = typeof data.lastPrompt === 'string' ? data.lastPrompt : undefined; + if (lastPrompt?.trim()) return lastPrompt.trim(); + } + } + } catch { /* ignore */ } + return undefined; + } + private async extractSessionAiTitleFromEnd( filePath: string, - sessionId: string - ): Promise { + ): Promise<{ title: string; kind: 'custom-title' | 'ai-title' } | undefined> { try { const content = await readFile(filePath, 'utf8'); const lines = content.split(/\r?\n/); @@ -179,17 +466,22 @@ export class ClaudeSessionSynchronizer implements IProviderSessionSynchronizer { const data = parsed as Record; const eventType = typeof data.type === 'string' ? data.type : undefined; - const eventSessionId = typeof data.sessionId === 'string' ? data.sessionId : undefined; const aiTitle = typeof data.aiTitle === 'string' ? data.aiTitle : undefined; - const lastPrompt = typeof data.lastPrompt === 'string' ? data.lastPrompt : undefined; const claudeRenamedTitle = typeof data.customTitle === 'string' ? data.customTitle : undefined; - if ( - (eventType === 'ai-title' && eventSessionId === sessionId && aiTitle?.trim()) || - (eventType === 'last-prompt' && eventSessionId === sessionId && lastPrompt?.trim()) || - (eventType === "custom-title" && eventSessionId === sessionId && claudeRenamedTitle?.trim()) - ) { - return aiTitle || lastPrompt || claudeRenamedTitle; + // Only return custom-title and ai-title; last-prompt is raw user text + // and should be sent through the AI title generator instead. + if (eventType === 'custom-title' && claudeRenamedTitle?.trim()) { + // Ignore the default "Untitled Claude Session" placeholder — treat it + // as if there was no title at all so AI generation kicks in. + const trimmedTitle = claudeRenamedTitle.trim(); + if (trimmedTitle === 'Untitled Claude Session') { + return undefined; + } + return { title: trimmedTitle, kind: 'custom-title' }; + } + if (eventType === 'ai-title' && aiTitle?.trim()) { + return { title: aiTitle.trim(), kind: 'ai-title' }; } } } catch { diff --git a/src/hooks/useProjectsState.ts b/src/hooks/useProjectsState.ts index a2cdbd06d..49482819a 100644 --- a/src/hooks/useProjectsState.ts +++ b/src/hooks/useProjectsState.ts @@ -254,7 +254,24 @@ const upsertSessionIntoProject = (project: Project, event: SessionUpsertedEvent) for (const [index, session] of sessions.entries()) { if (index === existingIndex) { - const updated = { ...session, ...normalizedSession }; + // Prevent title downgrade: if the incoming summary is empty or the + // default "Untitled Claude Session" placeholder, preserve the + // existing (likely user-set) summary instead of overwriting it. + const incomingSummary = normalizedSession.summary || normalizedSession.name; + const existingSummary = session.summary || session.name; + const isDegradedSummary = + !incomingSummary || + incomingSummary === 'Untitled Claude Session'; + const updated = isDegradedSummary && existingSummary + ? { + ...session, + ...normalizedSession, + summary: existingSummary, + name: normalizedSession.name && normalizedSession.name !== 'Untitled Claude Session' + ? normalizedSession.name + : session.name || existingSummary, + } + : { ...session, ...normalizedSession }; if (serialize(session) !== serialize(updated)) { changed = true; }