fix: agent loop improvements, clickable citations, and UI fixes (#2287)

* fix: prevent agent ReAct loop from getting stuck with small local models

Small local models (e.g. qwen3.5-4b-mlx) get stuck calling localSearch
repeatedly with near-identical queries and never synthesizing an answer.

- Remove QueryExpander from agent loop to avoid model contention on
  single-connection local models (retriever still expands internally)
- Add query deduplication that pre-filters tool call batches using
  Jaccard word-overlap similarity before execution
- Force synthesis via raw model (without tools) after 2 consecutive
  iterations where all tool calls are duplicates
- Add agent-specific system prompt guidance for search limits
- Strip leaked chat template role tokens from intermediate content
- Add intermediate model output logging for debugging

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: make inline citation numbers clickable and restore Max Sources setting

Inline citation numbers like [1], [2] in chat responses are now clickable
links that open the corresponding source note. Also restores the Max Sources
slider in QA settings, reverting the hardcoded DEFAULT_MAX_SOURCE_CHUNKS.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: remove excessive 32px bottom padding from chat view on desktop

The padding-bottom on .view-content used max(safe-area + 4px, 32px),
forcing a 32px minimum even on desktop where safe-area-inset-bottom is 0.
Simplified to safe-area + 4px so desktop gets minimal padding while
devices with safe areas still get proper spacing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(agent): track dedup queries post-execution to keep failed searches retryable

Move previousSearchQueries tracking from before to after each localSearch tool
call executes. Previously, queries were marked as seen before any tool ran, so
if a call was aborted or the loop threw early, those queries were still blocked
from retrying on the next iteration. Now each query is added to
previousSearchQueries immediately after its tool call returns, keeping the dedup
effective for completed calls while leaving transient failures retryable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(citations): preserve Obsidian internal-link attributes on inline citation anchors

When building clickable inline citation links (e.g. [1], [2]), the previous code
only copied the href from the source section anchor. Obsidian vault-note links have
additional attributes such as data-href and class="internal-link" that are required
for in-app note navigation. Without them, clicking an inline citation resolves the
link as a plain relative URL instead of opening the corresponding note.

Fix: store the source anchor element rather than just the href, then copy all
attributes onto the generated citation anchor before setting the citation-specific
class and aria-label overrides.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(agent): only mark localSearch queries as seen after successful execution

Change the dedup tracking condition from always tracking queries post-execution
to only tracking when the tool call succeeds (result.success is true). This
ensures transient failures (e.g. temporary index unavailability) leave the
query retryable: if a localSearch fails, the model can issue the same query
again on the next iteration. Previously, even a failed execution would mark
the query as seen, blocking recovery.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: deduplicate inline citations after source consolidation

When multiple chunks come from the same note, the LLM cites them
separately. After consolidation maps them to the same source number,
adjacent duplicates like [1][1] or [1] and [1] now collapse to [1].

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: update GPT-5.2 to GPT-5.4 in builtin models

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add dynamic status bar clearance for desktop chat view

The chat input was hidden behind Obsidian's status bar on desktop.
Adds ChatViewLayout which measures the geometric overlap between
the view-content and the status bar, setting a CSS variable to
provide the exact padding needed. Adapts to any theme: no clearance
for auto-hide themes (opacity: 0) or themes that already account
for the status bar in their layout; correct clearance for themes
where the bar is visible and overlapping. Re-checks on theme
switches via debounced css-change listener.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: suppress thinking tokens during streaming when reasoning is disabled

When a local model (e.g. Qwen 3.5 via LM Studio) emits text-level
<think> tags but the user has not enabled the reasoning capability,
the thinking content was visibly rendered during streaming and only
stripped once the </think> tag arrived. Now tracks the position of
excluded think blocks across chunks and truncates fullResponse to
the safe prefix on each chunk, so thinking content never appears.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Logan Yang 2026-03-14 16:17:37 -07:00 committed by GitHub
parent 1703b283d0
commit d3ee3f999b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 830 additions and 120 deletions

View file

@ -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

View file

@ -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 |

View file

@ -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";

View file

@ -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,

View file

@ -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<string | null> = [];
for (const tc of toolCalls) {
if (tc.name === "localSearch") {
const query = (tc.args as Record<string, unknown>)?.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<string, unknown>,
};
// 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<string>();
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);

View file

@ -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 =

View file

@ -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.
*

View file

@ -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<number, ToolCallChunk> = 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 </think> 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("</think>");
const hasOpenTag = this.fullResponse.includes("<think>");
// Skip if there's no </think> tag yet
if (!hasCloseTag) {
return;
}
// If excludeThinking is true, strip all think content
if (this.excludeThinking) {
// Handle case: content with </think> but no <think> (malformed)
// Strip everything before and including </think>
if (!hasOpenTag) {
const closeTagIndex = this.fullResponse.indexOf("</think>");
this.fullResponse = this.fullResponse.substring(closeTagIndex + "</think>".length).trim();
} else {
// Handle case: proper <think>...</think> tags
// Strip all think blocks
this.fullResponse = this.fullResponse.replace(/<think>[\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 `<think>` to enter one
* 2. Inside an incomplete block (no `</think>` 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("</think>", 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 + "</think>".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("<think>");
if (openIdx !== -1) {
this.excludedThinkBlockStart = openIdx;
const closeIdx = this.fullResponse.indexOf("</think>", openIdx);
if (closeIdx !== -1) {
// Complete block in one pass
const before = this.fullResponse.substring(0, openIdx);
const after = this.fullResponse.substring(closeIdx + "</think>".length);
this.fullResponse = (before + after).trimStart();
this.excludedThinkBlockStart = -1;
} else {
// Incomplete -- truncate
this.fullResponse = this.fullResponse.substring(0, openIdx);
}
return;
}
// Handle malformed: only </think> without <think> (e.g., some chat templates)
const closeIdx = this.fullResponse.indexOf("</think>");
if (closeIdx !== -1) {
this.fullResponse = this.fullResponse.substring(closeIdx + "</think>".length).trimStart();
}
}
private handleClaudeChunk(content: any[]) {
let textContent = "";
let hasThinkingContent = false;

View file

@ -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(
'<span class="copilot-sources__index">[1]</span><span class="copilot-sources__text">[[Same Note]]</span>'
);
expect(result).toContain(
'<span class="copilot-sources__index">[2]</span><span class="copilot-sources__text">[[Other Note]]</span>'
);
});
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]");
});
});
});

View file

@ -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<number>();
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;
}

View file

@ -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");
});
});

View file

@ -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");
}

View file

@ -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<void>) | 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.

View file

@ -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<number, HTMLAnchorElement>();
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<ChatSingleMessageProps> = ({
MarkdownRenderer.renderMarkdown(segment.content, textDiv, "", componentRef.current!);
normalizeFootnoteRendering(textDiv);
linkInlineCitations(textDiv);
currentIndex++;
} else if (segment.type === "toolCall" && segment.toolCall) {
const toolCallId = segment.toolCall.id;

View file

@ -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<typeof setTimeout> | null = null;
private cssChangeRef: ReturnType<Workspace["on"]> | 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);
});
}
}

View file

@ -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({
<li>Low: Faster responses, basic reasoning (default)</li>
<li>Medium: Balanced performance</li>
<li>High: Thorough reasoning, slower responses</li>
{model.name.startsWith("gpt-5.2") && model.provider === "openai" && (
<li>Extra High: Maximum reasoning depth (GPT-5.2 only)</li>
{model.name.startsWith("gpt-5.4") && model.provider === "openai" && (
<li>Extra High: Maximum reasoning depth (GPT-5.4 only)</li>
)}
</ul>
{!hasReasoningCapability && !isOpenAIReasoningModel && (

View file

@ -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<Provider, ProviderMetadata> = {
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",

View file

@ -188,6 +188,18 @@ export const QASettings: React.FC = () => {
placeholder="Strategy"
/>
{/* Max Sources */}
<SettingItem
type="slider"
title="Max Sources"
description="Copilot goes through your vault to find relevant notes and passes the top N to the LLM. Default for N is 30. Increase if you want more notes included in the answer generation step."
min={1}
max={128}
step={1}
value={settings.maxSourceChunks}
onChange={(value) => updateSetting("maxSourceChunks", value)}
/>
{/* Embedding-related settings - Only shown when semantic search is enabled */}
{settings.enableSemanticSearchV3 && (
<>

View file

@ -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;

View file

@ -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, {