diff --git a/docs/llm-providers.md b/docs/llm-providers.md index bc2b202a..bd0e3c70 100644 --- a/docs/llm-providers.md +++ b/docs/llm-providers.md @@ -31,7 +31,7 @@ OpenRouter is a gateway that provides access to hundreds of models from many pro Direct access to GPT-4.1, GPT-5, and other OpenAI models. - **Get a key**: https://platform.openai.com/api-keys -- **Models include**: GPT-5.2, GPT-5 mini, GPT-5 nano, GPT-4.1, GPT-4.1 mini, GPT-4.1 nano, o4-mini (reasoning) +- **Models include**: GPT-5.4, GPT-5 mini, GPT-5 nano, GPT-4.1, GPT-4.1 mini, GPT-4.1 nano, o4-mini (reasoning) - **Setting key**: `openAIApiKey` ### Anthropic diff --git a/docs/models-and-parameters.md b/docs/models-and-parameters.md index 3def0cf6..36f284c4 100644 --- a/docs/models-and-parameters.md +++ b/docs/models-and-parameters.md @@ -17,9 +17,9 @@ Copilot comes with a set of built-in models across many providers. Some are alwa | google/gemini-2.5-pro | OpenRouter | Vision | | google/gemini-3-flash-preview | OpenRouter | Vision, Reasoning | | google/gemini-3.1-pro-preview | OpenRouter | Vision, Reasoning | -| openai/gpt-5.2 | OpenRouter | Vision | +| openai/gpt-5.4 | OpenRouter | Vision | | openai/gpt-5-mini | OpenRouter | Vision | -| gpt-5.2 | OpenAI | Vision | +| gpt-5.4 | OpenAI | Vision | | gpt-5-mini | OpenAI | Vision | | gpt-4.1 | OpenAI | Vision | | gpt-4.1-mini | OpenAI | Vision | diff --git a/src/LLMProviders/ChatOpenRouter.ts b/src/LLMProviders/ChatOpenRouter.ts index 12953350..325dbf46 100644 --- a/src/LLMProviders/ChatOpenRouter.ts +++ b/src/LLMProviders/ChatOpenRouter.ts @@ -30,7 +30,7 @@ export interface ChatOpenRouterInput extends BaseChatModelParams { * Reasoning effort level: "minimal", "low", "medium", "high", or "xhigh" * Controls the amount of reasoning the model uses * Note: "minimal" will be treated as "low" for OpenRouter - * Note: "xhigh" is only supported by GPT-5.2 models + * Note: "xhigh" is only supported by GPT-5.4 models */ reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh"; diff --git a/src/LLMProviders/chainManager.ts b/src/LLMProviders/chainManager.ts index e490d7e5..0437e20c 100644 --- a/src/LLMProviders/chainManager.ts +++ b/src/LLMProviders/chainManager.ts @@ -6,7 +6,7 @@ import { setChainType, } from "@/aiParams"; import ChainFactory, { ChainType, Document } from "@/chainFactory"; -import { BUILTIN_CHAT_MODELS, DEFAULT_MAX_SOURCE_CHUNKS, USER_SENDER } from "@/constants"; +import { BUILTIN_CHAT_MODELS, USER_SENDER } from "@/constants"; import { AutonomousAgentChainRunner, ChainRunner, @@ -220,12 +220,12 @@ export default class ChainManager { const retriever = settings.enableSemanticSearchV3 ? new (await import("@/search/hybridRetriever")).HybridRetriever({ minSimilarityScore: 0.01, - maxK: DEFAULT_MAX_SOURCE_CHUNKS, + maxK: settings.maxSourceChunks, salientTerms: [], }) : new (await import("@/search/v3/TieredLexicalRetriever")).TieredLexicalRetriever(app, { minSimilarityScore: 0.01, - maxK: DEFAULT_MAX_SOURCE_CHUNKS, + maxK: settings.maxSourceChunks, salientTerms: [], textWeight: undefined, returnAll: false, diff --git a/src/LLMProviders/chainRunner/AutonomousAgentChainRunner.ts b/src/LLMProviders/chainRunner/AutonomousAgentChainRunner.ts index b3fbd27a..a195dd8f 100644 --- a/src/LLMProviders/chainRunner/AutonomousAgentChainRunner.ts +++ b/src/LLMProviders/chainRunner/AutonomousAgentChainRunner.ts @@ -43,9 +43,14 @@ import { serializeReasoningBlock, summarizeToolCall, summarizeToolResult, - QueryExpansionInfo, } from "./utils/AgentReasoningState"; -import { QueryExpander } from "@/search/v3/QueryExpander"; +import { findDuplicateQuery, stripLeakedRoleLines } from "./utils/queryDeduplication"; + +const AGENT_LOOP_GUIDANCE = `## Agent Behavior +- You have a limited number of tool calls. Use them wisely. +- NEVER search for the same or very similar query twice. If results were insufficient, try substantially different terms. +- After 1-2 searches, synthesize an answer from the results you have. Do not keep searching unless the results are clearly insufficient. +- If you have enough information to answer, respond directly without calling any more tools.`; type AgentSource = { title: string; @@ -89,6 +94,7 @@ interface AgentRunContext { */ interface ReActLoopParams { boundModel: Runnable; + chatModel: Runnable; // Raw model without tools, used for forced synthesis tools: StructuredTool[]; messages: BaseMessage[]; originalPrompt: string; @@ -286,6 +292,9 @@ export class AutonomousAgentChainRunner extends CopilotPlusChainRunner { return serializeReasoningBlock(this.reasoningState); } + // TODO: Unify system prompt construction -- this static method and prepareAgentConversation() + // both independently gather tool metadata, build tool instructions, and append AGENT_LOOP_GUIDANCE. + // Extract a shared helper like buildAgentSystemPromptSuffix(tools) to avoid drift. /** * Generate system prompt for the autonomous agent. * Note: Tool schemas are handled by bindTools(), so we only include @@ -310,10 +319,12 @@ export class AutonomousAgentChainRunner extends CopilotPlusChainRunner { .map((meta) => `For ${meta.displayName}: ${meta.customPromptInstructions}`) .join("\n"); + const parts = [basePrompt]; if (toolInstructions) { - return `${basePrompt}\n\n## Tool Guidelines\n${toolInstructions}`; + parts.push(`## Tool Guidelines\n${toolInstructions}`); } - return basePrompt; + parts.push(AGENT_LOOP_GUIDANCE); + return parts.join("\n\n"); } /** @@ -422,6 +433,7 @@ export class AutonomousAgentChainRunner extends CopilotPlusChainRunner { // Run the simplified ReAct loop with native tool calling const loopResult = await this.runReActLoop({ boundModel: context.loopDeps.boundModel, + chatModel, tools: context.loopDeps.availableTools, messages: context.messages, originalPrompt: context.originalUserPrompt, @@ -575,10 +587,11 @@ export class AutonomousAgentChainRunner extends CopilotPlusChainRunner { .map((meta) => `For ${meta.displayName}: ${meta.customPromptInstructions}`) .join("\n"); - // Combine system message with tool guidelines + // Combine system message with tool guidelines and agent loop guidance const systemContent = [ systemMessage?.content || "", toolInstructions ? `\n## Tool Guidelines\n${toolInstructions}` : "", + AGENT_LOOP_GUIDANCE, ] .filter(Boolean) .join("\n\n"); @@ -642,6 +655,8 @@ export class AutonomousAgentChainRunner extends CopilotPlusChainRunner { const collectedSources: AgentSource[] = []; const loopStartTime = Date.now(); + const previousSearchQueries: string[] = []; + let consecutiveAllSkipped = 0; let iteration = 0; let responseMetadata: ResponseMetadata | undefined; @@ -671,11 +686,19 @@ export class AutonomousAgentChainRunner extends CopilotPlusChainRunner { tokenUsage: streamingResult.tokenUsage ?? undefined, }; + const trimmedContent = content?.trim(); + logInfo( + `[Agent] Iteration ${iteration} model output:`, + trimmedContent ? trimmedContent.slice(0, 200) : "(empty)" + ); + // Check for native tool calls const toolCalls = aiMessage.tool_calls || []; + logInfo(`[Agent] Iteration ${iteration}: ${toolCalls.length} tool call(s)`); // No tool calls = final response if (toolCalls.length === 0) { + logInfo(`[Agent] Iteration ${iteration}: Final response (no tool calls)`); // Stop reasoning timer and finalize the reasoning block this.stopReasoningTimer(); this.reasoningState.status = "complete"; @@ -732,22 +755,72 @@ export class AutonomousAgentChainRunner extends CopilotPlusChainRunner { }; } - // Add AI message with tool calls - but DON'T accumulate intermediate content - // Intermediate content (like "I'll search for..." ) should not appear in final response - messages.push(aiMessage); + // Clean leaked role tokens from intermediate content before adding to conversation. + // Small local models leak chat template fragments (e.g. <|im_start|>user -> "user") + // that would otherwise pollute the conversation history and confuse subsequent iterations. + const cleanedContent = stripLeakedRoleLines(content); + const intermediateMessage = new AIMessage({ + content: cleanedContent, + tool_calls: aiMessage.tool_calls, + }); + messages.push(intermediateMessage); // For iterations > 1, the model's content often contains its summary of findings // from previous tool calls. Extract first sentence as a "finding summary". // (Iteration 1 has no previous findings - its content is just "I'll search for...") - if (iteration > 1 && content && content.trim().length > 0) { - const findingSummary = extractFirstSentence(content); + if (iteration > 1 && cleanedContent && cleanedContent.trim().length > 0) { + const findingSummary = extractFirstSentence(cleanedContent); if (findingSummary) { this.addReasoningStep(findingSummary); } } - // Execute each tool + // --- Pre-deduplicate tool calls before execution --- + // Prevents within-batch duplicates (model emits 3 similar searches at once) + // and cross-iteration duplicates (model retries a query from a previous iteration). + // Duplicates are silently handled: ToolMessage feedback is sent but no reasoning step shown. + // TODO: Make dedup a ToolRegistry metadata property (e.g. deduplicateQueries: true) + // so the loop handles it generically instead of hardcoding "localSearch". + const uniqueToolCalls: typeof toolCalls = []; + const batchQueries: string[] = []; + // Parallel array: for each entry in uniqueToolCalls, the associated localSearch query + // (or null for non-localSearch calls). Used to track queries post-execution. + const uniqueToolCallQueries: Array = []; + for (const tc of toolCalls) { + if (tc.name === "localSearch") { + const query = (tc.args as Record)?.query as string | undefined; + if (query) { + const duplicate = + findDuplicateQuery(query, previousSearchQueries) ?? + findDuplicateQuery(query, batchQueries); + if (duplicate) { + logInfo(`[Agent] Dedup: "${query}" (similar to: "${duplicate}")`); + messages.push( + createToolResultMessage( + tc.id || generateToolCallId(), + tc.name, + `You already searched for a similar query: "${duplicate}". Synthesize your answer from existing results.` + ) + ); + continue; + } + batchQueries.push(query); + uniqueToolCallQueries.push(query); + } else { + uniqueToolCallQueries.push(null); + } + } else { + uniqueToolCallQueries.push(null); + } + uniqueToolCalls.push(tc); + } + // Note: queries are tracked in previousSearchQueries after each tool executes, + // not here, so that transient failures leave queries retryable on the next iteration. + + // Execute unique tool calls + for (let tcIdx = 0; tcIdx < uniqueToolCalls.length; tcIdx++) { + const tc = uniqueToolCalls[tcIdx]; if (abortController.signal.aborted) break; const toolCall = { @@ -755,58 +828,10 @@ export class AutonomousAgentChainRunner extends CopilotPlusChainRunner { args: tc.args as Record, }; - // Pre-expand query for localSearch to show expanded terms BEFORE search - let preExpandedTerms: QueryExpansionInfo | undefined; - if (tc.name === "localSearch") { - const query = toolCall.args.query as string | undefined; - if (query) { - try { - const expander = new QueryExpander({ - getChatModel: async () => { - return this.chainManager.chatModelManager.getChatModel(); - }, - }); - const expansion = await expander.expand(query); - // Compute recall terms (all terms used for search) - const seen = new Set(); - const recallTerms: string[] = []; - const addTerm = (term: unknown) => { - if (typeof term !== "string") return; - const trimmed = term.trim(); - // Filter out invalid terms like "[object Object]" - if (!trimmed || trimmed === "[object Object]" || trimmed.startsWith("[object ")) - return; - const normalized = trimmed.toLowerCase(); - if (!seen.has(normalized)) { - seen.add(normalized); - recallTerms.push(trimmed); - } - }; - if (expansion.originalQuery) addTerm(expansion.originalQuery); - (expansion.salientTerms || []).forEach(addTerm); - (expansion.expandedQueries || []).forEach(addTerm); - - preExpandedTerms = { - originalQuery: expansion.originalQuery, - salientTerms: expansion.salientTerms, - expandedQueries: expansion.expandedQueries, - recallTerms, - }; - } catch { - // Ignore expansion errors, fall back to basic summary - } - } - } - // Add tool call step (shown in both rolling display and expanded view) - const toolCallSummary = summarizeToolCall(tc.name, toolCall.args, preExpandedTerms); + const toolCallSummary = summarizeToolCall(tc.name, toolCall.args); this.addReasoningStep(toolCallSummary, tc.name); - // Inject pre-expanded query data into args to avoid double expansion in search - if (preExpandedTerms) { - toolCall.args._preExpandedQuery = preExpandedTerms; - } - logToolCall(toolCall, iteration); // Execute the tool @@ -834,6 +859,16 @@ export class AutonomousAgentChainRunner extends CopilotPlusChainRunner { logToolResult(tc.name, result); + // Track the executed query only on success so transient failures remain retryable. + // A failed localSearch means no results were retrieved; the model should be able + // to issue the same query again on the next iteration if it chooses to. + if (tc.name === "localSearch" && result.success) { + const executedQuery = uniqueToolCallQueries[tcIdx]; + if (executedQuery) { + previousSearchQueries.push(executedQuery); + } + } + // Add tool result step (shown in rolling display - this is the "what was found") const resultSummary = summarizeToolResult(tc.name, result, sourceInfo, toolCall.args); this.addReasoningStep(resultSummary, tc.name); @@ -846,6 +881,61 @@ export class AutonomousAgentChainRunner extends CopilotPlusChainRunner { ); messages.push(toolMessage); } + + // Detect stuck model: all tool calls were duplicates (none made it through dedup) + if (uniqueToolCalls.length === 0 && toolCalls.length > 0) { + consecutiveAllSkipped++; + logInfo( + `[Agent] All ${toolCalls.length} tool call(s) skipped as duplicates ` + + `(${consecutiveAllSkipped} consecutive)` + ); + + if (consecutiveAllSkipped >= 2) { + // Model is stuck in a search loop -- force synthesis without tools + logInfo("[Agent] Model stuck in search loop, forcing synthesis without tools"); + this.addReasoningStep("Synthesizing answer from search results"); + + messages.push( + new HumanMessage( + "You have already searched and found relevant results. Do not call any tools. " + + "Answer the following question now based ONLY on the search results above:\n\n" + + originalPrompt + ) + ); + + // Call raw model (without tools) to guarantee text-only response + const synthesis = await this.streamModelResponse( + params.chatModel, + messages, + abortController, + updateCurrentAiMessage + ); + + responseMetadata = { + wasTruncated: synthesis.streamingResult.wasTruncated, + tokenUsage: synthesis.streamingResult.tokenUsage ?? undefined, + }; + + // Finalize as final response + this.stopReasoningTimer(); + this.reasoningState.status = "complete"; + const reasoningBlock = this.buildReasoningBlockMarkup(); + const finalContent = + synthesis.content || "Unable to synthesize a response from the search results."; + const finalResponse = reasoningBlock + ? reasoningBlock + "\n\n" + finalContent + : finalContent; + updateCurrentAiMessage(finalResponse); + + return { + finalResponse, + sources: collectedSources, + responseMetadata, + }; + } + } else { + consecutiveAllSkipped = 0; + } } // Stop reasoning timer @@ -921,6 +1011,8 @@ export class AutonomousAgentChainRunner extends CopilotPlusChainRunner { true // excludeThinking = true for agent mode ); + let rawContent = ""; // Accumulate raw content before think-stripping, for debug logging + try { const stream = await withSuppressedTokenWarnings(() => boundModel.stream(messages, { @@ -946,6 +1038,10 @@ export class AutonomousAgentChainRunner extends CopilotPlusChainRunner { } } + // Accumulate raw content for debug logging (before think-stripping) + const chunkContent = typeof chunk.content === "string" ? chunk.content : ""; + if (chunkContent) rawContent += chunkContent; + // Process chunk through ThinkBlockStreamer to strip thinking content thinkStreamer.processChunk(chunk); } @@ -954,6 +1050,15 @@ export class AutonomousAgentChainRunner extends CopilotPlusChainRunner { const streamingResult = thinkStreamer.close(); const fullContent = streamingResult.content; + // Log raw vs stripped content difference for debugging model behavior + const rawTrimmed = rawContent.trim(); + const strippedTrimmed = fullContent.trim(); + if (rawTrimmed && !strippedTrimmed) { + logInfo( + `[Agent] Model produced content that was entirely stripped (likely think blocks): ${rawTrimmed.slice(0, 300)}` + ); + } + // Build tool calls from accumulated chunks (with sanitization for empty objects) const toolCalls = buildToolCallsFromChunks(toolCallChunks); diff --git a/src/LLMProviders/chainRunner/VaultQAChainRunner.ts b/src/LLMProviders/chainRunner/VaultQAChainRunner.ts index d904ddcc..7a8b1a46 100644 --- a/src/LLMProviders/chainRunner/VaultQAChainRunner.ts +++ b/src/LLMProviders/chainRunner/VaultQAChainRunner.ts @@ -1,9 +1,4 @@ -import { - ABORT_REASON, - DEFAULT_MAX_SOURCE_CHUNKS, - ModelCapability, - RETRIEVED_DOCUMENT_TAG, -} from "@/constants"; +import { ABORT_REASON, ModelCapability, RETRIEVED_DOCUMENT_TAG } from "@/constants"; import { getStandaloneQuestion } from "@/chainUtils"; import { LayerToMessagesConverter } from "@/context/LayerToMessagesConverter"; import { logInfo } from "@/logger"; @@ -106,7 +101,7 @@ export class VaultQAChainRunner extends BaseChainRunner { const hasTagTerms = tags.length > 0; const filterRetriever = new FilterRetriever(app, { salientTerms: hasTagTerms ? [...tags] : [], - maxK: DEFAULT_MAX_SOURCE_CHUNKS, + maxK: settings.maxSourceChunks, returnAll: hasTagTerms, }); const filterDocs = await filterRetriever.getRelevantDocuments(standaloneQuestion); @@ -119,7 +114,7 @@ export class VaultQAChainRunner extends BaseChainRunner { app, { minSimilarityScore: 0.01, - maxK: DEFAULT_MAX_SOURCE_CHUNKS, + maxK: settings.maxSourceChunks, salientTerms: hasTagTerms ? [...tags] : [], tagTerms: tags, returnAll: hasTagTerms, @@ -134,8 +129,8 @@ export class VaultQAChainRunner extends BaseChainRunner { const { filterResults, searchResults } = mergeFilterAndSearchResults(filterDocs, searchDocs); // Cap total docs to prevent oversized prompts (filter results prioritized) const merged = [...filterResults, ...searchResults]; - const retrieverCapReached = merged.length > DEFAULT_MAX_SOURCE_CHUNKS; - const retrievedDocs = merged.slice(0, DEFAULT_MAX_SOURCE_CHUNKS); + const retrieverCapReached = merged.length > settings.maxSourceChunks; + const retrievedDocs = merged.slice(0, settings.maxSourceChunks); // Store retrieved documents for sources this.chainManager.storeRetrieverDocuments(retrievedDocs); @@ -165,7 +160,7 @@ export class VaultQAChainRunner extends BaseChainRunner { const sourceCatalog = formatSourceCatalog(sourceEntries).join("\n"); const capNotice = retrieverCapReached - ? `\n\nIMPORTANT: The retrieval limit of ${DEFAULT_MAX_SOURCE_CHUNKS} documents was reached. ${merged.length - DEFAULT_MAX_SOURCE_CHUNKS} additional matching documents were omitted. Inform the user: "Note: The retrieval cap was reached — some matching documents were not included. Upgrade to Copilot Plus for more complete answers."` + ? `\n\nIMPORTANT: The retrieval limit of ${settings.maxSourceChunks} documents was reached. ${merged.length - settings.maxSourceChunks} additional matching documents were omitted. Inform the user: "Note: The retrieval cap was reached — some matching documents were not included. Upgrade to Copilot Plus for more complete answers."` : ""; const qaInstructions = diff --git a/src/LLMProviders/chainRunner/utils/AgentReasoningState.ts b/src/LLMProviders/chainRunner/utils/AgentReasoningState.ts index 08045d4f..184a57e4 100644 --- a/src/LLMProviders/chainRunner/utils/AgentReasoningState.ts +++ b/src/LLMProviders/chainRunner/utils/AgentReasoningState.ts @@ -278,6 +278,9 @@ export function summarizeToolResult( } } +// TODO: The `expansion` parameter and QueryExpansionInfo interface are now dead code -- +// the agent runner no longer pre-expands queries. Clean up the expansion branch below +// and the _preExpandedQuery schema field in SearchTools.ts. /** * Generate a summary for when a tool is being called. * diff --git a/src/LLMProviders/chainRunner/utils/ThinkBlockStreamer.ts b/src/LLMProviders/chainRunner/utils/ThinkBlockStreamer.ts index a77c687a..be0e27ed 100644 --- a/src/LLMProviders/chainRunner/utils/ThinkBlockStreamer.ts +++ b/src/LLMProviders/chainRunner/utils/ThinkBlockStreamer.ts @@ -25,6 +25,9 @@ export class ThinkBlockStreamer { private tokenUsage: TokenUsage | null = null; // Track if we've handled text-level think tags (e.g., from nvidia/nemotron) private hasHandledTextLevelThinkTag = false; + // Character index where an excluded text-level think block started. + // -1 means we're not currently inside an excluded block. + private excludedThinkBlockStart = -1; // Native tool call accumulation private toolCallChunks: Map = new Map(); @@ -38,33 +41,24 @@ export class ThinkBlockStreamer { } /** - * Handle text-level think tags embedded in content (e.g., nvidia/nemotron models). + * Handle text-level think tags embedded in content (e.g., nvidia/nemotron, qwen3 models). * Some models output thinking with only closing tag, no opening tag. * This method detects and fixes this during streaming. + * + * When excludeThinking is true, thinking content is suppressed during streaming + * (not just stripped after the close tag arrives) by tracking the position where + * the excluded block started and truncating fullResponse on each chunk. */ private handleTextLevelThinkTags() { + if (this.excludeThinking) { + this.handleExcludedThinkTags(); + return; + } + const hasCloseTag = this.fullResponse.includes(""); const hasOpenTag = this.fullResponse.includes(""); - // Skip if there's no tag yet - if (!hasCloseTag) { - return; - } - - // If excludeThinking is true, strip all think content - if (this.excludeThinking) { - // Handle case: content with but no (malformed) - // Strip everything before and including - if (!hasOpenTag) { - const closeTagIndex = this.fullResponse.indexOf(""); - this.fullResponse = this.fullResponse.substring(closeTagIndex + "".length).trim(); - } else { - // Handle case: proper ... tags - // Strip all think blocks - this.fullResponse = this.fullResponse.replace(/[\s\S]*?<\/think>/g, "").trim(); - } - return; - } + if (!hasCloseTag) return; // excludeThinking is false - fix missing opening tag if needed if (!hasOpenTag && !this.hasHandledTextLevelThinkTag) { @@ -77,6 +71,55 @@ export class ThinkBlockStreamer { } } + /** + * Strip text-level think tags when excludeThinking is true. + * Handles three streaming states: + * 1. Not inside a think block -- look for `` to enter one + * 2. Inside an incomplete block (no `` yet) -- truncate to pre-block content + * 3. Block just closed -- strip the complete block and exit the state + */ + private handleExcludedThinkTags() { + // Currently inside an excluded block from a previous chunk + if (this.excludedThinkBlockStart >= 0) { + const closeIdx = this.fullResponse.indexOf("", this.excludedThinkBlockStart); + if (closeIdx !== -1) { + // Block closed -- stitch content before and after the block + const before = this.fullResponse.substring(0, this.excludedThinkBlockStart); + const after = this.fullResponse.substring(closeIdx + "".length); + this.fullResponse = (before + after).trimStart(); + this.excludedThinkBlockStart = -1; + } else { + // Still streaming thinking -- truncate to the safe prefix + this.fullResponse = this.fullResponse.substring(0, this.excludedThinkBlockStart); + } + return; + } + + // Not inside a block -- check for a new one + const openIdx = this.fullResponse.indexOf(""); + if (openIdx !== -1) { + this.excludedThinkBlockStart = openIdx; + const closeIdx = this.fullResponse.indexOf("", openIdx); + if (closeIdx !== -1) { + // Complete block in one pass + const before = this.fullResponse.substring(0, openIdx); + const after = this.fullResponse.substring(closeIdx + "".length); + this.fullResponse = (before + after).trimStart(); + this.excludedThinkBlockStart = -1; + } else { + // Incomplete -- truncate + this.fullResponse = this.fullResponse.substring(0, openIdx); + } + return; + } + + // Handle malformed: only without (e.g., some chat templates) + const closeIdx = this.fullResponse.indexOf(""); + if (closeIdx !== -1) { + this.fullResponse = this.fullResponse.substring(closeIdx + "".length).trimStart(); + } + } + private handleClaudeChunk(content: any[]) { let textContent = ""; let hasThinkingContent = false; diff --git a/src/LLMProviders/chainRunner/utils/citationUtils.test.ts b/src/LLMProviders/chainRunner/utils/citationUtils.test.ts index 8f23971a..5f619673 100644 --- a/src/LLMProviders/chainRunner/utils/citationUtils.test.ts +++ b/src/LLMProviders/chainRunner/utils/citationUtils.test.ts @@ -1,5 +1,6 @@ import { addFallbackSources, + deduplicateAdjacentCitations, formatSourceCatalog, getLocalSearchGuidance, getQACitationInstructions, @@ -8,6 +9,7 @@ import { normalizeCitations, processInlineCitations, sanitizeContentForCitations, + updateCitationsForConsolidation, type SourceCatalogEntry, } from "./citationUtils"; @@ -465,7 +467,8 @@ More content // Verify citations in text point to correct consolidated sources expect(result).toContain("references [1] and also [2]"); // [^14]->[1], [^17]->[2] expect(result).toContain("uses [3] again"); // [^3]->[3] - expect(result).toContain("cites [1] and [1]"); // [^14]->[1], [^22]->[1] (consolidated) + expect(result).toContain("cites [1]"); // [^14]->[1], [^22]->[1] (consolidated + deduplicated) + expect(result).not.toContain("cites [1] and [1]"); // adjacent dupes should be collapsed expect(result).toContain("mentions [3] once more"); // [^3]->[3] }); @@ -497,5 +500,113 @@ More content expect(result).not.toContain("[^8]"); // Should not contain any unconverted citations expect(result).not.toContain("[^7]"); // Should not contain any unconverted citations }); + + it("should deduplicate adjacent citations after consolidation", () => { + // Two chunks from the same note cited adjacently: [^1][^2] both -> [[Same Note]] + const content = `Key finding here [^1][^2] and another point [^3]. + +#### Sources: +[^1]: [[Same Note]] +[^2]: [[Same Note]] +[^3]: [[Other Note]]`; + + const result = processInlineCitations(content); + + // After consolidation, [^1] and [^2] both map to source 1 + // Adjacent [1][1] should be collapsed to [1] + expect(result).toContain("finding here [1] and another"); + expect(result).not.toContain("[1][1]"); + expect(result).toContain( + '[1][[Same Note]]' + ); + expect(result).toContain( + '[2][[Other Note]]' + ); + }); + + it("should deduplicate citations separated by 'and' after consolidation", () => { + // Use separate brackets since buildCitationMap scans [^N] individually + const content = `Claim here [^1] and [^2] and more [^3]. + +#### Sources: +[^1]: [[Note A]] +[^2]: [[Note A]] +[^3]: [[Note B]]`; + + const result = processInlineCitations(content); + + // [^1] and [^2] both map to [[Note A]] -> consolidated to source 1 + // "[1] and [1]" should collapse to "[1]" + expect(result).toContain("Claim here [1] and more"); + expect(result).not.toContain("[1] and [1]"); + }); + }); + + describe("updateCitationsForConsolidation", () => { + it("should deduplicate numbers within a bracket group", () => { + const map = new Map([ + [1, 1], + [2, 1], + [3, 2], + ]); + expect(updateCitationsForConsolidation("[1, 2] text [3]", map)).toBe("[1] text [2]"); + }); + + it("should preserve order of first occurrence", () => { + const map = new Map([ + [1, 1], + [2, 1], + [3, 1], + ]); + expect(updateCitationsForConsolidation("[1, 2, 3]", map)).toBe("[1]"); + }); + }); + + describe("deduplicateAdjacentCitations", () => { + it("should collapse identical adjacent brackets", () => { + expect(deduplicateAdjacentCitations("[1][1]")).toBe("[1]"); + }); + + it("should collapse triple identical adjacent brackets", () => { + expect(deduplicateAdjacentCitations("[1][1][1]")).toBe("[1]"); + }); + + it("should not collapse different adjacent brackets", () => { + expect(deduplicateAdjacentCitations("[1][2]")).toBe("[1][2]"); + }); + + it("should collapse when second is a subset of first", () => { + expect(deduplicateAdjacentCitations("[1, 2][1]")).toBe("[1, 2]"); + }); + + it("should not collapse when second has new numbers", () => { + expect(deduplicateAdjacentCitations("[1][1, 2]")).toBe("[1][1, 2]"); + }); + + it("should handle mixed cases in sequence", () => { + expect(deduplicateAdjacentCitations("[1][1][2]")).toBe("[1][2]"); + }); + + it("should handle spaces between brackets", () => { + expect(deduplicateAdjacentCitations("[1] [1]")).toBe("[1]"); + }); + + it("should preserve surrounding text", () => { + expect(deduplicateAdjacentCitations("claim [1][1] and more [2]")).toBe( + "claim [1] and more [2]" + ); + }); + + it("should collapse duplicates separated by 'and'", () => { + expect(deduplicateAdjacentCitations("cites [1] and [1]")).toBe("cites [1]"); + }); + + it("should not collapse different citations separated by 'and'", () => { + expect(deduplicateAdjacentCitations("cites [1] and [2]")).toBe("cites [1] and [2]"); + }); + + it("should collapse duplicates separated by comma", () => { + expect(deduplicateAdjacentCitations("cites [1], [1]")).toBe("cites [1]"); + }); }); }); diff --git a/src/LLMProviders/chainRunner/utils/citationUtils.ts b/src/LLMProviders/chainRunner/utils/citationUtils.ts index 88ebc446..55861e27 100644 --- a/src/LLMProviders/chainRunner/utils/citationUtils.ts +++ b/src/LLMProviders/chainRunner/utils/citationUtils.ts @@ -400,6 +400,7 @@ export function consolidateDuplicateSources(items: string[]): { /** * Updates citations in content to reflect consolidated numbering. + * Deduplicates within each bracket group after remapping. */ export function updateCitationsForConsolidation( content: string, @@ -409,14 +410,46 @@ export function updateCitationsForConsolidation( return content.replace(/\[(\d+(?:\s*,\s*\d+)*)\]/g, (_match, nums) => { const parts = nums.split(/\s*,\s*/); - const remappedParts = parts.map((n: string) => { - const oldNum = parseInt(n, 10); - return String(consolidationMap.get(oldNum) || oldNum); - }); - return `[${remappedParts.join(", ")}]`; + const seen = new Set(); + const unique: number[] = []; + for (const n of parts) { + const remapped = consolidationMap.get(parseInt(n, 10)) || parseInt(n, 10); + if (!seen.has(remapped)) { + seen.add(remapped); + unique.push(remapped); + } + } + return `[${unique.join(", ")}]`; }); } +/** + * Collapses duplicate citation brackets that appear next to each other. + * Handles truly adjacent brackets ([1][1]) and brackets separated by + * connectors like " and " or ", " ([1] and [1]). + * Only collapses when the second bracket is a subset of the first. + */ +export function deduplicateAdjacentCitations(content: string): string { + let result = content; + let prev; + do { + prev = result; + // Match citation brackets separated by optional whitespace or connectors (" and ", ", ") + result = result.replace( + /\[(\d+(?:\s*,\s*\d+)*)\](?:\s*(?:and|,)\s*|\s*)\[(\d+(?:\s*,\s*\d+)*)\]/g, + (match, first, second) => { + const firstNums = new Set(first.split(/\s*,\s*/).map((s: string) => s.trim())); + const secondNums = second.split(/\s*,\s*/).map((s: string) => s.trim()); + if (secondNums.every((n: string) => firstNums.has(n))) { + return `[${[...firstNums].join(", ")}]`; + } + return match; + } + ); + } while (result !== prev); + return result; +} + interface SourcesDisplayItem { index: number; html: string; @@ -538,9 +571,10 @@ export function processInlineCitations( let items = convertFootnoteDefinitions(sourcesBlock, citationMap); const { uniqueItems, consolidationMap } = consolidateDuplicateSources(items); - // Update citations to reflect consolidation + // Update citations to reflect consolidation and deduplicate if (consolidationMap.size > 0) { mainContent = updateCitationsForConsolidation(mainContent, consolidationMap); + mainContent = deduplicateAdjacentCitations(mainContent); items = uniqueItems; } diff --git a/src/LLMProviders/chainRunner/utils/queryDeduplication.test.ts b/src/LLMProviders/chainRunner/utils/queryDeduplication.test.ts new file mode 100644 index 00000000..5ec6a32b --- /dev/null +++ b/src/LLMProviders/chainRunner/utils/queryDeduplication.test.ts @@ -0,0 +1,126 @@ +import { computeWordOverlap, findDuplicateQuery, stripLeakedRoleLines } from "./queryDeduplication"; + +describe("computeWordOverlap", () => { + it("returns 1 for identical strings", () => { + expect(computeWordOverlap("hello world", "hello world")).toBe(1); + }); + + it("returns 1 for both empty strings", () => { + expect(computeWordOverlap("", "")).toBe(1); + }); + + it("returns 0 when one string is empty", () => { + expect(computeWordOverlap("hello", "")).toBe(0); + expect(computeWordOverlap("", "hello")).toBe(0); + }); + + it("returns 0 for completely disjoint strings", () => { + expect(computeWordOverlap("hello world", "foo bar")).toBe(0); + }); + + it("is case insensitive", () => { + expect(computeWordOverlap("Hello World", "hello world")).toBe(1); + }); + + it("computes correct Jaccard similarity for partial overlap", () => { + // "paul graham mistakes" vs "paul graham errors" + // intersection: {paul, graham} = 2, union: {paul, graham, mistakes, errors} = 4 + expect(computeWordOverlap("paul graham mistakes", "paul graham errors")).toBe(0.5); + }); + + it("catches inflection variants with sufficient shared context", () => { + // "Paul Graham mistake founders" vs "Paul Graham mistakes founders" + // intersection: {paul, graham, founders} = 3, union: {paul, graham, mistake, founders, mistakes} = 5 + const overlap = computeWordOverlap( + "Paul Graham mistake founders", + "Paul Graham mistakes founders" + ); + expect(overlap).toBe(0.6); + }); + + it("handles duplicate words in input", () => { + // Sets deduplicate, so "hello hello" -> {"hello"} + expect(computeWordOverlap("hello hello", "hello")).toBe(1); + }); +}); + +describe("findDuplicateQuery", () => { + it("returns null for empty previous queries", () => { + expect(findDuplicateQuery("hello world", [])).toBeNull(); + }); + + it("finds exact duplicate", () => { + expect(findDuplicateQuery("hello world", ["hello world"])).toBe("hello world"); + }); + + it("finds near-duplicate above threshold", () => { + const previous = ["Paul Graham mistake founders"]; + const result = findDuplicateQuery("Paul Graham mistakes founders", previous); + expect(result).toBe("Paul Graham mistake founders"); + }); + + it("returns null when below threshold", () => { + const previous = ["completely different query about cooking"]; + expect(findDuplicateQuery("Paul Graham mistakes founders", previous)).toBeNull(); + }); + + it("returns first match when multiple duplicates exist", () => { + const previous = ["search query alpha beta", "search query alpha gamma"]; + const result = findDuplicateQuery("search query alpha beta", previous); + expect(result).toBe("search query alpha beta"); + }); + + it("respects custom threshold", () => { + // overlap = 0.5, default threshold catches it + const previous = ["paul graham mistakes"]; + expect(findDuplicateQuery("paul graham errors", previous, 0.5)).toBe("paul graham mistakes"); + // Higher threshold misses it + expect(findDuplicateQuery("paul graham errors", previous, 0.6)).toBeNull(); + }); +}); + +describe("stripLeakedRoleLines", () => { + it("strips bare 'user' lines", () => { + // "user\n\nHello" splits to ["user", "", "Hello"] -> filter removes "user" -> "\nHello" + expect(stripLeakedRoleLines("user\n\nHello")).toBe("\nHello"); + }); + + it("strips bare 'assistant' lines", () => { + expect(stripLeakedRoleLines("assistant\nI will help")).toBe("I will help"); + }); + + it("strips bare 'system' lines", () => { + expect(stripLeakedRoleLines("system\nYou are helpful")).toBe("You are helpful"); + }); + + it("strips multiple role lines from real model output", () => { + // Real case: "user\n\nuser\n Paul Graham" -> removes both "user" lines + expect(stripLeakedRoleLines("user\n\nuser\n Paul Graham")).toBe("\n Paul Graham"); + }); + + it("preserves 'user' when part of longer text", () => { + expect(stripLeakedRoleLines("The user asked about this")).toBe("The user asked about this"); + }); + + it("preserves 'assistant' in normal sentences", () => { + expect(stripLeakedRoleLines("My assistant helped me")).toBe("My assistant helped me"); + }); + + it("is case sensitive (only strips lowercase)", () => { + expect(stripLeakedRoleLines("User\nHello")).toBe("User\nHello"); + }); + + it("handles empty and null-ish input", () => { + expect(stripLeakedRoleLines("")).toBe(""); + expect(stripLeakedRoleLines(" ")).toBe(" "); + }); + + it("preserves indented role words (code safety)", () => { + // Indented "system" or "user" in code should NOT be stripped + expect(stripLeakedRoleLines(" user \nHello")).toBe(" user \nHello"); + }); + + it("strips role word with trailing whitespace at column 0", () => { + expect(stripLeakedRoleLines("user \nHello")).toBe("Hello"); + }); +}); diff --git a/src/LLMProviders/chainRunner/utils/queryDeduplication.ts b/src/LLMProviders/chainRunner/utils/queryDeduplication.ts new file mode 100644 index 00000000..dc41a0c3 --- /dev/null +++ b/src/LLMProviders/chainRunner/utils/queryDeduplication.ts @@ -0,0 +1,74 @@ +/** + * Utilities for the ReAct agent loop with small local models. + * - Query deduplication to prevent near-identical repeated searches + * - Leaked role token cleanup for models that leak chat template fragments + */ + +/** + * Compute Jaccard similarity between two strings based on lowercased word sets. + * + * @param a - First string + * @param b - Second string + * @returns Jaccard similarity coefficient (0-1) + */ +export function computeWordOverlap(a: string, b: string): number { + const wordsA = new Set(a.toLowerCase().split(/\s+/).filter(Boolean)); + const wordsB = new Set(b.toLowerCase().split(/\s+/).filter(Boolean)); + + if (wordsA.size === 0 && wordsB.size === 0) return 1; + if (wordsA.size === 0 || wordsB.size === 0) return 0; + + let intersection = 0; + for (const word of wordsA) { + if (wordsB.has(word)) intersection++; + } + + const union = wordsA.size + wordsB.size - intersection; + return intersection / union; +} + +/** + * Check if a query is a near-duplicate of any previously issued query. + * + * @param query - The new query to check + * @param previousQueries - List of previously issued queries + * @param threshold - Jaccard similarity threshold above which queries are considered duplicates (default 0.5) + * @returns The first matching previous query if found, or null + */ +export function findDuplicateQuery( + query: string, + previousQueries: string[], + threshold = 0.5 +): string | null { + for (const prev of previousQueries) { + if (computeWordOverlap(query, prev) >= threshold) { + return prev; + } + } + return null; +} + +/** + * Regex matching lines that contain ONLY a leaked chat template role identifier + * starting at column 0. After stripSpecialTokens removes markers like `<|im_start|>`, + * the bare role name (e.g. "user", "assistant") can remain. These pollute conversation + * history and confuse small models on subsequent iterations. + * No leading \s* -- preserves indented code lines like " system". + */ +const LEAKED_ROLE_LINE = /^(user|assistant|system)\s*$/; + +/** + * Strip lines that are only leaked chat template role identifiers. + * Applied to intermediate model output (when tool calls are present) to prevent + * leaked tokens like "user" from polluting the conversation history. + * + * @param text - Model output text after special token stripping + * @returns Cleaned text with role-only lines removed + */ +export function stripLeakedRoleLines(text: string): string { + if (!text) return text; + return text + .split("\n") + .filter((line) => !LEAKED_ROLE_LINE.test(line)) + .join("\n"); +} diff --git a/src/components/CopilotView.tsx b/src/components/CopilotView.tsx index 49a06e4a..184bf276 100644 --- a/src/components/CopilotView.tsx +++ b/src/components/CopilotView.tsx @@ -1,5 +1,6 @@ import ChainManager from "@/LLMProviders/chainManager"; import Chat from "@/components/Chat"; +import { ChatViewLayout } from "@/components/chat-components/ChatViewLayout"; import { CHAT_VIEWTYPE } from "@/constants"; import { AppContext, EventTargetContext } from "@/context"; import CopilotPlugin from "@/main"; @@ -19,6 +20,7 @@ export default class CopilotView extends ItemView { private handleSaveAsNote: (() => Promise) | null = null; private keyboardObserver: MutationObserver | null = null; private drawerHideObserver: MutationObserver | null = null; + private layout: ChatViewLayout | null = null; private lastDrawerEl: HTMLElement | null = null; eventTarget: EventTarget; @@ -61,6 +63,7 @@ export default class CopilotView extends ItemView { }; this.renderView(handleSaveAsNote, updateUserMessageHistory); + this.layout = new ChatViewLayout(this.containerEl, this.app.workspace); this.setupMobileKeyboardObserver(); this.setupDrawerHideObserver(); @@ -206,6 +209,8 @@ export default class CopilotView extends ItemView { this.keyboardObserver = null; this.drawerHideObserver?.disconnect(); this.drawerHideObserver = null; + this.layout?.destroy(); + this.layout = null; // Reason: Clean up the class on the tracked drawer element when the view is closed. // Use lastDrawerEl instead of querying closest(), because the view may have already // been detached from the drawer DOM by the time onClose fires. diff --git a/src/components/chat-components/ChatSingleMessage.tsx b/src/components/chat-components/ChatSingleMessage.tsx index fda2849f..cbde7bd6 100644 --- a/src/components/chat-components/ChatSingleMessage.tsx +++ b/src/components/chat-components/ChatSingleMessage.tsx @@ -83,6 +83,111 @@ export const normalizeFootnoteRendering = (root: HTMLElement): void => { }); }; +const INLINE_CITATION_RE = /\[(\d+(?:\s*,\s*\d+)*)\]/g; + +/** + * Makes inline citation numbers (e.g., [1], [2]) clickable by linking them + * to the corresponding source note. Reads the source mapping from the + * rendered .copilot-sources section in the same message. + */ +export const linkInlineCitations = (root: HTMLElement): void => { + // Build citation number -> source anchor mapping from the rendered sources section. + // We store the anchor element (not just the href) so we can copy Obsidian-specific + // attributes like data-href and class="internal-link" onto the inline citation link. + const sourceItems = root.querySelectorAll(".copilot-sources__item"); + if (sourceItems.length === 0) return; + + const citationAnchors = new Map(); + sourceItems.forEach((item) => { + const indexEl = item.querySelector(".copilot-sources__index"); + const textEl = item.querySelector(".copilot-sources__text"); + if (!indexEl || !textEl) return; + + const indexMatch = indexEl.textContent?.match(/\[(\d+)\]/); + if (!indexMatch) return; + const num = parseInt(indexMatch[1], 10); + + const link = textEl.querySelector("a"); + if (link) { + citationAnchors.set(num, link); + } + }); + + if (citationAnchors.size === 0) return; + + // Collect text nodes that contain citation patterns (outside sources section) + const sourcesEl = root.querySelector(".copilot-sources"); + const textNodes: Text[] = []; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { + acceptNode(node) { + if (sourcesEl?.contains(node)) return NodeFilter.FILTER_REJECT; + if (node.parentElement?.closest("code, pre")) return NodeFilter.FILTER_REJECT; + INLINE_CITATION_RE.lastIndex = 0; + if (INLINE_CITATION_RE.test(node.textContent || "")) { + INLINE_CITATION_RE.lastIndex = 0; + return NodeFilter.FILTER_ACCEPT; + } + return NodeFilter.FILTER_REJECT; + }, + }); + + let n: Text | null; + while ((n = walker.nextNode() as Text | null)) textNodes.push(n); + + // Replace citation text with clickable links + textNodes.forEach((node) => { + const text = node.textContent || ""; + INLINE_CITATION_RE.lastIndex = 0; + + const fragment = document.createDocumentFragment(); + let lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = INLINE_CITATION_RE.exec(text)) !== null) { + // Text before the citation + if (match.index > lastIndex) { + fragment.appendChild(document.createTextNode(text.slice(lastIndex, match.index))); + } + + const nums = match[1].split(/\s*,\s*/).map((s) => parseInt(s.trim(), 10)); + const allResolved = nums.every((num) => citationAnchors.has(num)); + + if (allResolved) { + const span = document.createElement("span"); + span.className = "copilot-citation-group"; + span.appendChild(document.createTextNode("[")); + nums.forEach((num, i) => { + if (i > 0) span.appendChild(document.createTextNode(", ")); + const sourceAnchor = citationAnchors.get(num)!; + const link = document.createElement("a"); + // Copy all attributes from the source anchor so Obsidian internal-link + // metadata (e.g. data-href, class="internal-link") is preserved. + for (const attr of Array.from(sourceAnchor.attributes)) { + link.setAttribute(attr.name, attr.value); + } + // Override class and add our citation-specific styling + link.className = `copilot-citation-link${sourceAnchor.className ? ` ${sourceAnchor.className}` : ""}`; + link.textContent = String(num); + link.setAttribute("aria-label", `Source ${num}`); + span.appendChild(link); + }); + span.appendChild(document.createTextNode("]")); + fragment.appendChild(span); + } else { + fragment.appendChild(document.createTextNode(match[0])); + } + + lastIndex = match.index + match[0].length; + } + + if (lastIndex < text.length) { + fragment.appendChild(document.createTextNode(text.slice(lastIndex))); + } + + node.parentNode?.replaceChild(fragment, node); + }); +}; + function MessageContext({ context }: { context: ChatMessage["context"] }) { if ( !context || @@ -572,6 +677,7 @@ const ChatSingleMessage: React.FC = ({ MarkdownRenderer.renderMarkdown(segment.content, textDiv, "", componentRef.current!); normalizeFootnoteRendering(textDiv); + linkInlineCitations(textDiv); currentIndex++; } else if (segment.type === "toolCall" && segment.toolCall) { const toolCallId = segment.toolCall.id; diff --git a/src/components/chat-components/ChatViewLayout.ts b/src/components/chat-components/ChatViewLayout.ts new file mode 100644 index 00000000..2ae6f10e --- /dev/null +++ b/src/components/chat-components/ChatViewLayout.ts @@ -0,0 +1,83 @@ +import { Platform, Workspace } from "obsidian"; + +/** Wait for CSS transitions to settle before re-measuring after a theme switch. */ +const CSS_CHANGE_DEBOUNCE_MS = 600; + +/** + * Manages layout concerns for the Copilot chat view, such as status bar + * clearance and (in the future) chat input collapse state. + * + * Instantiated once per CopilotView and tied to its lifecycle. + */ +export class ChatViewLayout { + private debounceTimer: ReturnType | null = null; + private cssChangeRef: ReturnType | null = null; + + constructor( + private containerEl: HTMLElement, + private workspace: Workspace + ) { + this.setupStatusBarClearance(); + } + + /** + * Tear down observers and timers. Call from CopilotView.onClose(). + */ + destroy(): void { + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + this.debounceTimer = null; + } + if (this.cssChangeRef) { + this.workspace.offref(this.cssChangeRef); + this.cssChangeRef = null; + } + } + + /** + * Measure how much the status bar overlaps the view-content and expose + * the overlap as a CSS variable so padding adapts to any theme. + * + * Works by temporarily zeroing the clearance, measuring the geometric + * overlap, then setting the correct value -- all within one synchronous + * reflow so nothing flickers. Themes that already position content above + * the status bar (no overlap) get 0 clearance automatically. Auto-hide + * themes (opacity: 0) also get 0 since the bar is transparent. + */ + private setupStatusBarClearance(): void { + if (Platform.isMobile) return; + + const syncClearance = () => { + // Re-query each time to avoid stale references after theme reloads. + const statusBar = document.querySelector(".status-bar") as HTMLElement | null; + const viewContent = this.containerEl.querySelector(".view-content") as HTMLElement | null; + if (!statusBar || !viewContent) return; + + // Zero out clearance and force reflow to measure natural overlap. + viewContent.style.setProperty("--copilot-status-bar-clearance", "0px"); + const overlap = + viewContent.getBoundingClientRect().bottom - statusBar.getBoundingClientRect().top; + + if (overlap <= 0) { + // Theme layout already clears the status bar. + return; + } + + // Overlap exists -- only add clearance if the bar is actually visible. + const s = getComputedStyle(statusBar); + const hidden = + s.display === "none" || s.visibility === "hidden" || parseFloat(s.opacity) === 0; + viewContent.style.setProperty( + "--copilot-status-bar-clearance", + `${hidden ? 0 : Math.ceil(overlap)}px` + ); + }; + + syncClearance(); + + this.cssChangeRef = this.workspace.on("css-change", () => { + if (this.debounceTimer) clearTimeout(this.debounceTimer); + this.debounceTimer = setTimeout(syncClearance, CSS_CHANGE_DEBOUNCE_MS); + }); + } +} diff --git a/src/components/ui/ModelParametersEditor.tsx b/src/components/ui/ModelParametersEditor.tsx index 26573df4..b934ec2e 100644 --- a/src/components/ui/ModelParametersEditor.tsx +++ b/src/components/ui/ModelParametersEditor.tsx @@ -217,7 +217,7 @@ export function ModelParametersEditor({ (opt) => opt.value !== ReasoningEffort.MINIMAL && opt.value !== ReasoningEffort.XHIGH ), - ...(model.name.startsWith("gpt-5.2") && model.provider === "openai" + ...(model.name.startsWith("gpt-5.4") && model.provider === "openai" ? [{ value: ReasoningEffort.XHIGH, label: "Extra High" }] : []), ]} @@ -234,8 +234,8 @@ export function ModelParametersEditor({
  • Low: Faster responses, basic reasoning (default)
  • Medium: Balanced performance
  • High: Thorough reasoning, slower responses
  • - {model.name.startsWith("gpt-5.2") && model.provider === "openai" && ( -
  • Extra High: Maximum reasoning depth (GPT-5.2 only)
  • + {model.name.startsWith("gpt-5.4") && model.provider === "openai" && ( +
  • Extra High: Maximum reasoning depth (GPT-5.4 only)
  • )} {!hasReasoningCapability && !isOpenAIReasoningModel && ( diff --git a/src/constants.ts b/src/constants.ts index e9da9fe9..f3a1df6b 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -173,7 +173,7 @@ export const DEFAULT_OLLAMA_NUM_CTX = 131072; export enum ChatModels { COPILOT_PLUS_FLASH = "copilot-plus-flash", - GPT_5_2 = "gpt-5.2", + GPT_5_4 = "gpt-5.4", GPT_5_mini = "gpt-5-mini", GPT_5_nano = "gpt-5-nano", GPT_41 = "gpt-4.1", @@ -197,7 +197,7 @@ export enum ChatModels { OPENROUTER_GEMINI_3_PRO_PREVIEW = "google/gemini-3.1-pro-preview", OPENROUTER_GEMINI_2_5_FLASH = "google/gemini-2.5-flash", OPENROUTER_GEMINI_2_5_PRO = "google/gemini-2.5-pro", - OPENROUTER_GPT_5_2 = "openai/gpt-5.2", + OPENROUTER_GPT_5_4 = "openai/gpt-5.4", OPENROUTER_GPT_5_MINI = "openai/gpt-5-mini", OPENROUTER_GROK_4_1_FAST = "x-ai/grok-4.1-fast", SILICONFLOW_DEEPSEEK_V3 = "deepseek-ai/DeepSeek-V3", @@ -281,7 +281,7 @@ export const BUILTIN_CHAT_MODELS: CustomModel[] = [ capabilities: [ModelCapability.VISION], }, { - name: ChatModels.OPENROUTER_GPT_5_2, + name: ChatModels.OPENROUTER_GPT_5_4, provider: ChatModelProviders.OPENROUTERAI, enabled: true, isBuiltIn: true, @@ -317,7 +317,7 @@ export const BUILTIN_CHAT_MODELS: CustomModel[] = [ capabilities: [ModelCapability.VISION], }, { - name: ChatModels.GPT_5_2, + name: ChatModels.GPT_5_4, provider: ChatModelProviders.OPENAI, enabled: true, isBuiltIn: true, @@ -613,7 +613,7 @@ export const ProviderInfo: Record = { curlBaseURL: "https://api.openai.com/v1", keyManagementURL: "https://platform.openai.com/api-keys", listModelURL: "https://api.openai.com/v1/models", - testModel: ChatModels.GPT_5_2, + testModel: ChatModels.GPT_5_4, }, [ChatModelProviders.XAI]: { label: "XAI", diff --git a/src/settings/v2/components/QASettings.tsx b/src/settings/v2/components/QASettings.tsx index 7a2b4833..99c43fd8 100644 --- a/src/settings/v2/components/QASettings.tsx +++ b/src/settings/v2/components/QASettings.tsx @@ -188,6 +188,18 @@ export const QASettings: React.FC = () => { placeholder="Strategy" /> + {/* Max Sources */} + updateSetting("maxSourceChunks", value)} + /> + {/* Embedding-related settings - Only shown when semantic search is enabled */} {settings.enableSemanticSearchV3 && ( <> diff --git a/src/styles/tailwind.css b/src/styles/tailwind.css index 88ad6e16..9937144a 100644 --- a/src/styles/tailwind.css +++ b/src/styles/tailwind.css @@ -897,11 +897,12 @@ If your plugin does not need CSS, delete this file. } /* use 'important' to prevent ob default css style from being third-party theme overridden */ +/* --copilot-status-bar-clearance is set dynamically by ChatViewLayout */ .workspace-leaf-content[data-type="copilot-chat-view"] .view-content { - padding-bottom: max( - calc(var(--safe-area-inset-bottom, 0px) + var(--size-4-1)), - var(--size-4-8) + padding-bottom: calc( + var(--safe-area-inset-bottom, 0px) + var(--copilot-status-bar-clearance, 0px) + + var(--size-4-1) ) !important; } @@ -1039,6 +1040,16 @@ body.is-mobile .workspace-drawer.copilot-keyboard-open .workspace-drawer-tab-opt color: var(--text-accent); } +/* Inline citation links */ +.copilot-citation-link { + color: var(--text-accent); + cursor: pointer; + text-decoration: none; +} +.copilot-citation-link:hover { + text-decoration: underline; +} + /* fix the width of the popover on mobile */ body.is-mobile div[data-radix-popper-content-wrapper] { min-width: fit-content !important; diff --git a/src/tools/SearchTools.ts b/src/tools/SearchTools.ts index 5684770d..4c9db140 100644 --- a/src/tools/SearchTools.ts +++ b/src/tools/SearchTools.ts @@ -1,5 +1,5 @@ import { getStandaloneQuestion } from "@/chainUtils"; -import { DEFAULT_MAX_SOURCE_CHUNKS, TEXT_WEIGHT } from "@/constants"; +import { TEXT_WEIGHT } from "@/constants"; import { BrevilabsClient } from "@/LLMProviders/brevilabsClient"; import { hasSelfHostSearchKey, selfHostWebSearch } from "@/LLMProviders/selfHostServices"; import { logInfo } from "@/logger"; @@ -100,10 +100,11 @@ async function performLexicalSearch({ forceLexical?: boolean; preExpandedQuery?: QueryExpansionInfo; }) { + const settings = getSettings(); // Extract tag terms for self-host retriever (server-side tag filtering) const tagTerms = salientTerms.filter((term) => term.startsWith("#")); - const effectiveMaxK = DEFAULT_MAX_SOURCE_CHUNKS; + const effectiveMaxK = settings.maxSourceChunks; logInfo(`lexicalSearch effectiveMaxK: ${effectiveMaxK}, forceLexical: ${forceLexical}`); @@ -254,8 +255,9 @@ const semanticSearchTool = createLangChainTool({ schema: localSearchSchema, func: async ({ timeRange: rawTimeRange, query, salientTerms }) => { const timeRange = validateTimeRange(rawTimeRange); + const settings = getSettings(); - const effectiveMaxK = DEFAULT_MAX_SOURCE_CHUNKS; + const effectiveMaxK = settings.maxSourceChunks; logInfo(`semanticSearch effectiveMaxK: ${effectiveMaxK}`); @@ -362,7 +364,7 @@ async function performMiyoSearch({ timeRange?: { startTime: number; endTime: number }; }) { const tagTerms = salientTerms.filter((term) => term.startsWith("#")); - const effectiveMaxK = DEFAULT_MAX_SOURCE_CHUNKS; + const effectiveMaxK = getSettings().maxSourceChunks; // FilterRetriever for local tag/title/time-range matches const filterRetriever = new FilterRetriever(app, {