Merge branch 'main' into fix/fts-non-fatal-in-analyze

This commit is contained in:
Gergő Magyar 2026-05-23 08:17:27 +01:00 • committed by GitHub
commit 801ad355c4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 955 additions and 25 deletions

View file

@ -341,6 +341,7 @@ export const SettingsPanel = ({
'openrouter',
'minimax',
'glm',
'deepseek',
];
return (
@ -433,7 +434,9 @@ export const SettingsPanel = ({
? '⚡'
: provider === 'glm'
? '🔮'
: '☁️'}
: provider === 'deepseek'
? '🐋'
: '☁️'}
</div>
<span className="font-medium">{getProviderDisplayName(provider)}</span>
</button>
@ -856,6 +859,43 @@ export const SettingsPanel = ({
/>
)}
{/* DeepSeek Settings */}
{settings.activeProvider === 'deepseek' && (
<ProviderConfigCard
title="DeepSeek"
apiKey={{
value: settings.deepseek?.apiKey ?? '',
placeholder: 'Enter your DeepSeek API key',
helperText: 'Get your API key from',
helperLink: 'https://platform.deepseek.com/api_keys',
helperLinkLabel: 'DeepSeek Platform',
isVisible: !!showApiKey['deepseek'],
onChange: (value) =>
setSettings((prev) => ({
...prev,
deepseek: { ...prev.deepseek!, apiKey: value },
})),
onToggleVisibility: () => toggleApiKeyVisibility('deepseek'),
}}
model={{
value: settings.deepseek?.model ?? 'deepseek-v4-flash',
placeholder: 'e.g., deepseek-v4-flash, deepseek-v4-pro, deepseek-chat',
onChange: (value) =>
setSettings((prev) => ({
...prev,
deepseek: { ...prev.deepseek!, model: value },
})),
helperText:
'deepseek-v4-flash (default), deepseek-v4-pro, deepseek-chat (V3), deepseek-reasoner (R1)',
}}
>
<p className="text-xs text-text-muted">
Compatible via OpenAI API format. The deepseek-reasoner model uses thinking mode and
requires round-tripping reasoning content.
</p>
</ProviderConfigCard>
)}
{/* GLM Settings */}
{settings.activeProvider === 'glm' && (
<div className="animate-fade-in space-y-4">

View file

@ -6,7 +6,13 @@
*/
import { createReactAgent } from '@langchain/langgraph/prebuilt';
import { SystemMessage } from '@langchain/core/messages';
import {
SystemMessage,
HumanMessage,
AIMessage,
ToolMessage,
type BaseMessage,
} from '@langchain/core/messages';
import { ChatOpenAI, AzureChatOpenAI } from '@langchain/openai';
import { ChatGoogleGenerativeAI } from '@langchain/google-genai';
import { ChatAnthropic } from '@langchain/anthropic';
@ -23,10 +29,17 @@ import type {
OpenRouterConfig,
MiniMaxConfig,
GLMConfig,
DeepSeekConfig,
AgentStreamChunk,
AgentHistoryMessage,
} from './types';
import { type CodebaseContext, buildDynamicSystemPrompt } from './context-builder';
import { DEFAULT_OLLAMA_BASE_URL, DEFAULT_OPENROUTER_BASE_URL } from '../../config/ui-constants';
import {
DeepSeekChatOpenAI,
normalizeMessageContent,
normalizeToolCalls,
} from './deepseek-chat-model';
/**
* System prompt for the Graph RAG agent
@ -124,6 +137,7 @@ When generating diagrams:
BAD: A[User's Data] --> B(Process & Save)
GOOD: A["User Data"] --> B["Process and Save"]
`;
export const createChatModel = (config: ProviderConfig): BaseChatModel => {
switch (config.provider) {
case 'openai': {
@ -264,6 +278,26 @@ export const createChatModel = (config: ProviderConfig): BaseChatModel => {
});
}
case 'deepseek': {
const deepseekConfig = config as DeepSeekConfig;
if (!deepseekConfig.apiKey || deepseekConfig.apiKey.trim() === '') {
throw new Error('DeepSeek API key is required but was not provided');
}
return new DeepSeekChatOpenAI({
apiKey: deepseekConfig.apiKey,
modelName: deepseekConfig.model,
temperature: deepseekConfig.temperature ?? 0.1,
maxTokens: deepseekConfig.maxTokens,
configuration: {
apiKey: deepseekConfig.apiKey,
baseURL: 'https://api.deepseek.com',
},
streaming: true,
});
}
default:
throw new Error(`Unsupported provider: ${(config as any).provider}`);
}
@ -324,11 +358,65 @@ export const createGraphRAGAgent = (
/**
* Message type for agent conversation
*/
export interface AgentMessage {
role: 'user' | 'assistant';
content: string;
export type AgentMessage = { role: 'user'; content: string } | AgentHistoryMessage;
export interface AgentRuntimeOptions {
/** Capture assistant/tool messages for providers that require exact transcript replay. */
captureHistory?: boolean;
}
export const buildLangChainMessages = (messages: AgentMessage[]): BaseMessage[] =>
messages.map((message) => {
if (message.role === 'user') {
return new HumanMessage(message.content);
}
if (message.role === 'tool') {
return new ToolMessage({
content: message.content,
tool_call_id: message.toolCallId,
...(message.name ? { name: message.name } : {}),
});
}
return new AIMessage({
content: message.content,
...(typeof message.reasoningContent === 'string'
? { additional_kwargs: { reasoning_content: message.reasoningContent } }
: {}),
...(message.toolCalls?.length ? { tool_calls: message.toolCalls } : {}),
} as any);
});
export const serializeAgentHistoryMessages = (
messages: unknown[],
startIndex = 0,
): AgentHistoryMessage[] => {
const serialized: AgentHistoryMessage[] = [];
for (const rawMessage of messages.slice(startIndex)) {
const msg: any = rawMessage;
const msgType = msg?._getType?.() || msg?.type || msg?.constructor?.name || 'unknown';
if (msgType === 'ai' || msgType === 'AIMessage') {
const reasoningContent = (msg.additional_kwargs || msg.kwargs)?.reasoning_content;
const toolCalls = normalizeToolCalls(msg.tool_calls);
serialized.push({
role: 'assistant',
content: normalizeMessageContent(msg.content),
...(toolCalls?.length && typeof reasoningContent === 'string' ? { reasoningContent } : {}),
...(toolCalls?.length ? { toolCalls } : {}),
});
continue;
}
if (msgType === 'tool' || msgType === 'ToolMessage') {
serialized.push({
role: 'tool',
content: normalizeMessageContent(msg.content),
toolCallId: String(msg.tool_call_id ?? ''),
...(typeof msg.name === 'string' ? { name: msg.name } : {}),
});
}
}
return serialized;
};
/**
* Stream a response from the agent
* Uses BOTH streamModes for best of both worlds:
@ -340,12 +428,10 @@ export interface AgentMessage {
export async function* streamAgentResponse(
agent: ReturnType<typeof createReactAgent>,
messages: AgentMessage[],
options: AgentRuntimeOptions = {},
): AsyncGenerator<AgentStreamChunk> {
try {
const formattedMessages = messages.map((m) => ({
role: m.role,
content: m.content,
}));
const formattedMessages = buildLangChainMessages(messages);
// Use BOTH modes: 'values' for structure, 'messages' for token streaming
const stream = await agent.stream({ messages: formattedMessages }, {
@ -364,6 +450,9 @@ export async function* streamAgentResponse(
// Anything before the first tool call should be treated as "reasoning/narration"
// so the UI can show the Cursor-like loop: plan → tool → update → tool → answer.
let hasSeenToolCallThisTurn = false;
// Track the last set of messages so we can persist the raw assistant/tool
// transcript for the next user turn.
let lastStepMessages: any[] | null = null;
for await (const event of stream) {
// Events come as [streamMode, data] tuples when using multiple modes
@ -482,6 +571,9 @@ export async function* streamAgentResponse(
// Handle 'values' mode - state snapshots for structure
if (mode === 'values' && data?.messages) {
const stepMessages = data.messages || [];
if (options.captureHistory) {
lastStepMessages = stepMessages;
}
// Process new messages for tool calls/results we might have missed
for (let i = lastProcessedMsgCount; i < stepMessages.length; i++) {
@ -539,7 +631,14 @@ export async function* streamAgentResponse(
if (import.meta.env.DEV) {
console.log('✅ Stream completed normally, yielding done');
}
yield { type: 'done' };
yield {
type: 'done',
historyMessages:
options.captureHistory && lastStepMessages
? serializeAgentHistoryMessages(lastStepMessages, formattedMessages.length)
: undefined,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
// DEBUG: Stream error
@ -561,10 +660,7 @@ export const invokeAgent = async (
agent: ReturnType<typeof createReactAgent>,
messages: AgentMessage[],
): Promise<string> => {
const formattedMessages = messages.map((m) => ({
role: m.role,
content: m.content,
}));
const formattedMessages = buildLangChainMessages(messages);
const result = await agent.invoke({ messages: formattedMessages });

View file

@ -0,0 +1,257 @@
import {
ChatOpenAI,
ChatOpenAICompletions,
type ChatOpenAICallOptions,
type ChatOpenAICompletionsCallOptions,
type ChatOpenAIFields,
} from '@langchain/openai';
import type { BaseMessage } from '@langchain/core/messages';
import type { BaseLanguageModelInput } from '@langchain/core/language_models/base';
import type { AIMessageChunk } from '@langchain/core/messages';
import type { Runnable } from '@langchain/core/runnables';
import type { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager';
import type { ChatGenerationChunk, ChatResult } from '@langchain/core/outputs';
import type { AgentToolCall } from './types';
/**
* DeepSeek's thinking-mode chat API requires assistant `reasoning_content`
* from prior turns to be replayed verbatim on the next request. LangChain
* preserves the inbound value on `AIMessage.additional_kwargs`, but its
* OpenAI-compatible outbound converter currently drops that provider-specific
* field. This completions subclass keeps the behavior scoped to DeepSeek by
* replacing only the serialized request messages immediately before the
* DeepSeek API call.
*/
export class DeepSeekChatOpenAICompletions<
CallOptions extends ChatOpenAICompletionsCallOptions = ChatOpenAICompletionsCallOptions,
> extends ChatOpenAICompletions<CallOptions> {
private activeMessages: BaseMessage[] | null = null;
private setActiveMessages(messages: BaseMessage[]): void {
if (this.activeMessages !== null) {
throw new Error('DeepSeekChatOpenAICompletions does not support overlapping requests');
}
this.activeMessages = messages;
}
override async _generate(
messages: BaseMessage[],
options: this['ParsedCallOptions'],
runManager?: CallbackManagerForLLMRun,
): Promise<ChatResult> {
this.setActiveMessages(messages);
try {
return await super._generate(messages, options, runManager);
} finally {
this.activeMessages = null;
}
}
override async *_streamResponseChunks(
messages: BaseMessage[],
options: this['ParsedCallOptions'],
runManager?: CallbackManagerForLLMRun,
): AsyncGenerator<ChatGenerationChunk> {
this.setActiveMessages(messages);
try {
yield* super._streamResponseChunks(messages, options, runManager);
} finally {
this.activeMessages = null;
}
}
override async completionWithRetry(request: any, requestOptions?: any): Promise<any> {
const messages = this.activeMessages
? buildDeepSeekRequestMessages(this.activeMessages)
: request.messages;
return super.completionWithRetry({ ...request, messages }, requestOptions);
}
}
/**
* OpenAI-compatible DeepSeek chat model with a DeepSeek-specific completions
* serializer. Keeping this as a subclass avoids provider checks in the shared
* agent streaming path and ensures LangChain `withConfig()` clones used by tool
* binding retain the same request serialization behavior.
*/
export class DeepSeekChatOpenAI<
CallOptions extends ChatOpenAICallOptions = ChatOpenAICallOptions,
> extends ChatOpenAI<CallOptions> {
private readonly deepSeekFields: ChatOpenAIFields;
constructor(fields: ChatOpenAIFields) {
const deepSeekFields = {
...fields,
completions: new DeepSeekChatOpenAICompletions(fields),
} as ChatOpenAIFields;
super(deepSeekFields);
this.deepSeekFields = deepSeekFields;
}
override withConfig(
config: Partial<CallOptions>,
): Runnable<BaseLanguageModelInput, AIMessageChunk, CallOptions> {
// Mirror ChatOpenAI.withConfig() for this LangChain version, but keep the
// DeepSeek subclass. Calling super.withConfig() would drop our custom
// completions serializer by returning a plain ChatOpenAI instance.
const newModel = new DeepSeekChatOpenAI<CallOptions>(this.deepSeekFields);
newModel.defaultOptions = {
...this.defaultOptions,
...config,
} as typeof this.defaultOptions;
return newModel;
}
}
export const normalizeMessageContent = (content: unknown): string => {
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
return content
.filter((block: any) => block?.type === 'text' || typeof block === 'string')
.map((block: any) => (typeof block === 'string' ? block : block.text || ''))
.join('');
}
if (content == null) return '';
return String(content);
};
const normalizeToolCallArgs = (toolCall: any): Record<string, unknown> => {
if (toolCall?.args && typeof toolCall.args === 'object') {
return toolCall.args as Record<string, unknown>;
}
try {
return toolCall?.function?.arguments ? JSON.parse(toolCall.function.arguments) : {};
} catch {
return {};
}
};
export const normalizeToolCalls = (toolCalls: unknown): AgentToolCall[] | undefined => {
if (!Array.isArray(toolCalls) || toolCalls.length === 0) return undefined;
return toolCalls.map((toolCall: any) => ({
id: typeof toolCall?.id === 'string' ? toolCall.id : undefined,
name: toolCall?.name || toolCall?.function?.name || 'unknown',
args: normalizeToolCallArgs(toolCall),
type: typeof toolCall?.type === 'string' ? toolCall.type : 'tool_call',
}));
};
const stringifyToolArguments = (args: unknown): string => {
if (typeof args === 'string') return args;
try {
return JSON.stringify(args ?? {});
} catch {
return '{}';
}
};
const normalizeOpenAIContent = (content: unknown): string | Array<Record<string, unknown>> => {
if (typeof content === 'string') return content;
if (!Array.isArray(content)) return normalizeMessageContent(content);
const blocks = content.flatMap((block: any) => {
if (typeof block === 'string') {
return [{ type: 'text', text: block }];
}
if (block?.type === 'text' && typeof block.text === 'string') {
return [{ type: 'text', text: block.text }];
}
return [];
});
if (blocks.length === 0) return '';
if (blocks.length === 1) return blocks[0].text as string;
return blocks;
};
const getOpenAIRole = (message: any): string => {
const messageType =
message?._getType?.() || message?.type || message?.constructor?.name || 'unknown';
if ((message.additional_kwargs || {}).__openai_role__ === 'developer') {
return 'developer';
}
switch (messageType) {
case 'human':
case 'HumanMessage':
return 'user';
case 'ai':
case 'AIMessage':
return 'assistant';
case 'system':
case 'SystemMessage':
return 'system';
case 'tool':
case 'ToolMessage':
return 'tool';
case 'function':
case 'FunctionMessage':
return 'function';
default:
return typeof message.role === 'string' ? message.role : 'user';
}
};
export const buildDeepSeekRequestMessages = (
messages: Array<BaseMessage | Record<string, unknown>>,
): Array<Record<string, unknown>> =>
messages.map((message: any) => {
const role = getOpenAIRole(message);
const additionalKwargs =
message.additional_kwargs && typeof message.additional_kwargs === 'object'
? message.additional_kwargs
: {};
const requestMessage: Record<string, unknown> = {
role,
content: normalizeOpenAIContent(message.content),
};
if (typeof message.name === 'string' && message.name.length > 0) {
requestMessage.name = message.name;
}
if (role === 'assistant') {
const toolCalls = Array.isArray(message.tool_calls)
? message.tool_calls
: Array.isArray(additionalKwargs.tool_calls)
? additionalKwargs.tool_calls
: undefined;
if (toolCalls?.length) {
requestMessage.tool_calls = toolCalls.map((toolCall: any) => {
if (toolCall?.function) {
return {
id: toolCall.id,
type: toolCall.type ?? 'function',
function: {
name: toolCall.function.name,
arguments: stringifyToolArguments(toolCall.function.arguments),
},
};
}
return {
id: toolCall?.id,
type: 'function',
function: {
name: toolCall?.name ?? 'unknown',
arguments: stringifyToolArguments(toolCall?.args),
},
};
});
}
if (additionalKwargs.function_call != null) {
requestMessage.function_call = additionalKwargs.function_call;
}
if (toolCalls?.length && typeof additionalKwargs.reasoning_content === 'string') {
requestMessage.reasoning_content = additionalKwargs.reasoning_content;
}
return requestMessage;
}
if (role === 'tool' && typeof message.tool_call_id === 'string') {
requestMessage.tool_call_id = message.tool_call_id;
}
if (role === 'function' && typeof message.name === 'string') {
requestMessage.name = message.name;
}
return requestMessage;
});

View file

@ -17,6 +17,7 @@ import {
OpenRouterConfig,
MiniMaxConfig,
GLMConfig,
DeepSeekConfig,
ProviderConfig,
} from './types';
import { DEFAULT_OPENROUTER_BASE_URL, DEFAULT_OLLAMA_BASE_URL } from '../../config/ui-constants';
@ -59,6 +60,10 @@ const mergeWithDefaults = (parsed?: Partial<LLMSettings> | null): LLMSettings =>
...DEFAULT_LLM_SETTINGS.glm,
...parsed?.glm,
},
deepseek: {
...DEFAULT_LLM_SETTINGS.deepseek,
...parsed?.deepseek,
},
});
const readSettings = (storage: Storage): Partial<LLMSettings> | null => {
@ -144,7 +149,9 @@ export const updateProviderSettings = <T extends LLMProvider>(
? Partial<Omit<MiniMaxConfig, 'provider'>>
: T extends 'glm'
? Partial<Omit<GLMConfig, 'provider'>>
: never
: T extends 'deepseek'
? Partial<Omit<DeepSeekConfig, 'provider'>>
: never
>,
): LLMSettings => {
const current = loadSettings();
@ -239,6 +246,17 @@ export const updateProviderSettings = <T extends LLMProvider>(
saveSettings(updated);
return updated;
}
case 'deepseek': {
const updated: LLMSettings = {
...current,
deepseek: {
...(current.deepseek ?? {}),
...(updates as Partial<Omit<DeepSeekConfig, 'provider'>>),
},
};
saveSettings(updated);
return updated;
}
default: {
// Should be unreachable due to T extends LLMProvider, but keep a safe fallback
const updated: LLMSettings = { ...current };
@ -316,6 +334,10 @@ const providerBuilders: Record<LLMProvider, ProviderBuilder> = {
maxTokens: settings.glm.maxTokens,
} as GLMConfig;
},
deepseek: (settings) => {
if (!settings.deepseek?.apiKey) return null;
return { provider: 'deepseek', ...settings.deepseek } as DeepSeekConfig;
},
};
export const getActiveProviderConfig = (): ProviderConfig | null => {
@ -347,6 +369,24 @@ export const clearSettings = (): void => {
}
};
interface ProviderCapabilities {
/** Provider requires hidden assistant/tool transcript replay across turns. */
preserveAssistantTranscript: boolean;
}
const DEFAULT_PROVIDER_CAPABILITIES: ProviderCapabilities = {
preserveAssistantTranscript: false,
};
const PROVIDER_CAPABILITIES: Partial<Record<LLMProvider, ProviderCapabilities>> = {
deepseek: { preserveAssistantTranscript: true },
};
export const getProviderCapabilities = (provider: LLMProvider): ProviderCapabilities => ({
...DEFAULT_PROVIDER_CAPABILITIES,
...PROVIDER_CAPABILITIES[provider],
});
/**
* Get display name for a provider
*/
@ -368,6 +408,8 @@ export const getProviderDisplayName = (provider: LLMProvider): string => {
return 'MiniMax';
case 'glm':
return 'GLM (Z.AI)';
case 'deepseek':
return 'DeepSeek';
default:
return provider;
}
@ -398,6 +440,8 @@ export const getAvailableModels = (provider: LLMProvider): string[] => {
return ['MiniMax-M2.5', 'MiniMax-M2.5-highspeed'];
case 'glm':
return ['GLM-5', 'GLM-5-Turbo', 'GLM-4.7', 'GLM-4.5'];
case 'deepseek':
return ['deepseek-v4-flash', 'deepseek-v4-pro', 'deepseek-chat', 'deepseek-reasoner'];
default:
return [];
}

View file

@ -2,7 +2,7 @@
* LLM Provider Types
*
* Type definitions for multi-provider LLM support.
* Supports OpenAI, Azure OpenAI, Gemini, Anthropic, Ollama, OpenRouter, MiniMax, and GLM5.
* Supports OpenAI, Azure OpenAI, Gemini, Anthropic, Ollama, OpenRouter, MiniMax, GLM, and DeepSeek.
*/
/**
@ -17,7 +17,8 @@ export type LLMProvider =
| 'ollama'
| 'openrouter'
| 'minimax'
| 'glm';
| 'glm'
| 'deepseek';
/**
* Base configuration shared by all providers
@ -106,6 +107,15 @@ export interface GLMConfig extends BaseProviderConfig {
baseUrl?: string; // defaults to https://api.z.ai/api/coding/paas/v4
}
/**
* DeepSeek configuration — OpenAI-compatible API
*/
export interface DeepSeekConfig extends BaseProviderConfig {
provider: 'deepseek';
apiKey: string;
model: string; // e.g., 'deepseek-v4-flash', 'deepseek-v4-pro'
}
/**
* Union type for all provider configurations
*/
@ -117,7 +127,8 @@ export type ProviderConfig =
| OllamaConfig
| OpenRouterConfig
| MiniMaxConfig
| GLMConfig;
| GLMConfig
| DeepSeekConfig;
/**
* Stored settings (what goes to localStorage)
@ -136,6 +147,7 @@ export interface LLMSettings {
openrouter?: Partial<Omit<OpenRouterConfig, 'provider'>>;
minimax?: Partial<Omit<MiniMaxConfig, 'provider'>>;
glm?: Partial<Omit<GLMConfig, 'provider'>>;
deepseek?: Partial<Omit<DeepSeekConfig, 'provider'>>;
// Intelligent Clustering Settings
intelligentClustering: boolean;
@ -197,6 +209,11 @@ export const DEFAULT_LLM_SETTINGS: LLMSettings = {
baseUrl: 'https://api.z.ai/api/coding/paas/v4',
temperature: 0.1,
},
deepseek: {
apiKey: '',
model: 'deepseek-v4-flash',
temperature: 0.1,
},
};
/**
@ -219,6 +236,8 @@ export interface ChatMessage {
id: string;
role: 'user' | 'assistant' | 'tool';
content: string;
/** Hidden raw transcript for reconstructing future agent turns */
historyMessages?: AgentHistoryMessage[];
/** @deprecated Use steps instead for proper ordering */
toolCalls?: ToolCallInfo[];
/** Ordered steps: reasoning, tool calls, and final content interleaved */
@ -238,6 +257,34 @@ export interface ToolCallInfo {
status: 'pending' | 'running' | 'completed' | 'error';
}
/**
* Minimal tool-call payload needed to reconstruct prior assistant turns.
*/
export interface AgentToolCall {
id?: string;
name: string;
args: Record<string, unknown>;
type: 'tool_call';
}
/**
* Hidden per-turn transcript we keep so providers like DeepSeek can replay
* the original assistant/tool exchange on later user turns.
*/
export type AgentHistoryMessage =
| {
role: 'assistant';
content: string;
reasoningContent?: string;
toolCalls?: AgentToolCall[];
}
| {
role: 'tool';
content: string;
toolCallId: string;
name?: string;
};
/**
* Streaming chunk from agent
* Now supports step-based streaming where each step is a distinct message
@ -248,6 +295,8 @@ export interface AgentStreamChunk {
reasoning?: string;
/** Final answer content (streamed token by token) */
content?: string;
/** Hidden raw transcript for reconstructing future agent turns */
historyMessages?: AgentHistoryMessage[];
/** Tool call information */
toolCall?: ToolCallInfo;
/** Error message */

View file

@ -18,7 +18,12 @@ import type {
ToolCallInfo,
MessageStep,
} from '../core/llm/types';
import { loadSettings, getActiveProviderConfig, saveSettings } from '../core/llm/settings-service';
import {
loadSettings,
getActiveProviderConfig,
getProviderCapabilities,
saveSettings,
} from '../core/llm/settings-service';
import type { AgentMessage } from '../core/llm/agent';
import { type EdgeType } from '../lib/constants';
import {
@ -635,6 +640,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
const sendChatMessage = useCallback(
async (message: string): Promise<void> => {
if (isChatLoading) return;
// Refresh Code panel for the new question: keep user-pinned refs, clear old AI citations
clearAICodeReferences();
// Also clear previous tool-driven AI highlights (highlight_in_graph)
@ -674,11 +681,23 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
setIsChatLoading(true);
setCurrentToolCalls([]);
const providerCapabilities = getProviderCapabilities(llmSettings.activeProvider);
// Prepare message history for agent (convert our format to AgentMessage format)
const history: AgentMessage[] = [...chatMessages, userMessage].map((m) => ({
role: m.role === 'tool' ? 'assistant' : m.role,
content: m.content,
}));
const history: AgentMessage[] = [...chatMessages, userMessage].flatMap<AgentMessage>((m) => {
if (m.role === 'user') {
return [{ role: 'user', content: m.content }];
}
if (m.role === 'tool') {
return m.toolCallId
? [{ role: 'tool', content: m.content, toolCallId: m.toolCallId }]
: [];
}
if (providerCapabilities.preserveAssistantTranscript && m.historyMessages?.length) {
return m.historyMessages;
}
return [{ role: 'assistant', content: m.content }];
});
// Create placeholder for assistant response
const assistantMessageId = `assistant-${Date.now()}`;
@ -687,6 +706,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
// Keep toolCalls for backwards compat and currentToolCalls state
const toolCallsForMessage: ToolCallInfo[] = [];
let stepCounter = 0;
let assistantHistoryMessages: ChatMessage['historyMessages'];
// Helper to update the message with current steps
const updateMessage = () => {
@ -703,6 +723,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
id: assistantMessageId,
role: 'assistant' as const,
content,
historyMessages: assistantHistoryMessages,
steps: [...stepsForMessage],
toolCalls: [...toolCallsForMessage],
timestamp: existing?.timestamp ?? Date.now(),
@ -985,6 +1006,9 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
break;
case 'done':
assistantHistoryMessages = providerCapabilities.preserveAssistantTranscript
? chunk.historyMessages
: undefined;
// Finalize the assistant message - just call updateMessage one more time
scheduleMessageUpdate();
break;
@ -996,10 +1020,11 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
const agent = agentRef.current;
if (!agent) throw new Error('Agent not initialized');
const { streamAgentResponse } = await import('../core/llm/agent');
for await (const chunk of streamAgentResponse(agent, history)) {
for await (const chunk of streamAgentResponse(agent, history, {
captureHistory: providerCapabilities.preserveAssistantTranscript,
})) {
onChunk(chunk);
}
onChunk({ type: 'done' });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setAgentError(message);
@ -1019,6 +1044,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
clearAIToolHighlights,
graph,
embeddingStatus,
isChatLoading,
],
);

View file

@ -0,0 +1,396 @@
import { describe, expect, it } from 'vitest';
import {
buildLangChainMessages,
createChatModel,
serializeAgentHistoryMessages,
type AgentMessage,
} from '../../src/core/llm/agent';
import {
buildDeepSeekRequestMessages,
DeepSeekChatOpenAI,
DeepSeekChatOpenAICompletions,
} from '../../src/core/llm/deepseek-chat-model';
describe('buildLangChainMessages', () => {
it('reconstructs assistant tool-call turns for replay', () => {
const messages: AgentMessage[] = [
{ role: 'user', content: 'Check the weather' },
{
role: 'assistant',
content: 'Let me check that.',
reasoningContent: '',
toolCalls: [
{
id: 'call_weather',
name: 'get_weather',
args: { location: 'Hangzhou' },
type: 'tool_call',
},
],
},
{
role: 'tool',
content: 'Cloudy 7~13°C',
toolCallId: 'call_weather',
name: 'get_weather',
},
];
const langChainMessages = buildLangChainMessages(messages);
expect(langChainMessages).toHaveLength(3);
expect((langChainMessages[1] as any).additional_kwargs.reasoning_content).toBe('');
expect((langChainMessages[1] as any).tool_calls).toEqual([
{
id: 'call_weather',
name: 'get_weather',
args: { location: 'Hangzhou' },
type: 'tool_call',
},
]);
expect((langChainMessages[2] as any).tool_call_id).toBe('call_weather');
});
});
describe('serializeAgentHistoryMessages', () => {
it('captures assistant and tool messages from a completed turn', () => {
const serialized = serializeAgentHistoryMessages(
[
{ _getType: () => 'human', content: 'old prompt' },
{
_getType: () => 'ai',
content: 'Let me check that.',
additional_kwargs: { reasoning_content: 'Need weather tool.' },
tool_calls: [
{
id: 'call_weather',
name: 'get_weather',
args: { location: 'Hangzhou' },
type: 'tool_call',
},
],
},
{
_getType: () => 'tool',
content: 'Cloudy 7~13°C',
tool_call_id: 'call_weather',
name: 'get_weather',
},
{
_getType: () => 'ai',
content: 'Tomorrow will be cloudy.',
additional_kwargs: { reasoning_content: 'Result received.' },
},
],
1,
);
expect(serialized).toEqual([
{
role: 'assistant',
content: 'Let me check that.',
reasoningContent: 'Need weather tool.',
toolCalls: [
{
id: 'call_weather',
name: 'get_weather',
args: { location: 'Hangzhou' },
type: 'tool_call',
},
],
},
{
role: 'tool',
content: 'Cloudy 7~13°C',
toolCallId: 'call_weather',
name: 'get_weather',
},
{
role: 'assistant',
content: 'Tomorrow will be cloudy.',
},
]);
});
});
describe('buildDeepSeekRequestMessages', () => {
it('preserves reasoning_content on assistant tool-call messages', () => {
const requestMessages = buildDeepSeekRequestMessages(
buildLangChainMessages([
{ role: 'user', content: '如何支持Gitlab Repo' },
{
role: 'assistant',
content: '',
reasoningContent: 'I should inspect the repository support flow first.',
toolCalls: [
{
id: 'call_1',
name: 'search',
args: { query: 'Gitlab repo support' },
type: 'tool_call',
},
],
},
{
role: 'tool',
content: 'No matches',
toolCallId: 'call_1',
name: 'search',
},
]),
);
expect(requestMessages).toEqual([
{ role: 'user', content: '如何支持Gitlab Repo' },
{
role: 'assistant',
content: '',
reasoning_content: 'I should inspect the repository support flow first.',
tool_calls: [
{
id: 'call_1',
type: 'function',
function: {
name: 'search',
arguments: '{"query":"Gitlab repo support"}',
},
},
],
},
{
role: 'tool',
content: 'No matches',
name: 'search',
tool_call_id: 'call_1',
},
]);
});
});
it('drops reasoning_content from assistant messages without tool calls', () => {
const messages = buildLangChainMessages([
{ role: 'user', content: 'Hello' },
{
role: 'assistant',
content: 'Hi there',
reasoningContent: 'I should greet the user.',
},
]);
const requestMessages = buildDeepSeekRequestMessages(messages);
expect(requestMessages).toEqual([
{ role: 'user', content: 'Hello' },
{ role: 'assistant', content: 'Hi there' },
]);
});
it('drops reasoningContent from serialized assistant messages without tool calls', () => {
const serialized = serializeAgentHistoryMessages(
[
{
_getType: () => 'ai',
content: 'Simple answer.',
additional_kwargs: { reasoning_content: 'Thinking about it.' },
},
],
0,
);
expect(serialized).toEqual([
{
role: 'assistant',
content: 'Simple answer.',
},
]);
});
describe('createChatModel', () => {
it('keeps DeepSeek model subclasses on withConfig clones used for tool binding', () => {
const model = createChatModel({
provider: 'deepseek',
apiKey: 'test-key',
model: 'deepseek-v4-flash',
temperature: 0.1,
} as any) as any;
expect(model).toBeInstanceOf(DeepSeekChatOpenAI);
expect(model.completions).toBeInstanceOf(DeepSeekChatOpenAICompletions);
const clonedModel = model.withConfig({ tools: [] }) as any;
expect(clonedModel).toBeInstanceOf(DeepSeekChatOpenAI);
expect(clonedModel.completions).toBeInstanceOf(DeepSeekChatOpenAICompletions);
});
it('uses DeepSeek serialization on withConfig clones', async () => {
const model = createChatModel({
provider: 'deepseek',
apiKey: 'test-key',
model: 'deepseek-v4-flash',
temperature: 0.1,
} as any) as any;
const clonedModel = model.withConfig({ tools: [] }) as any;
clonedModel.completions.streaming = false;
let capturedRequest: any;
clonedModel.completions.client = {
chat: {
completions: {
create: async (request: any) => {
capturedRequest = request;
return {
choices: [
{
message: { role: 'assistant', content: 'ok' },
finish_reason: 'stop',
},
],
};
},
},
},
};
await clonedModel.completions._generate(
buildLangChainMessages([
{ role: 'user', content: 'Check the weather' },
{
role: 'assistant',
content: '',
reasoningContent: 'Need the weather tool.',
toolCalls: [
{
id: 'call_weather',
name: 'get_weather',
args: { location: 'Hangzhou' },
type: 'tool_call',
},
],
},
{
role: 'tool',
content: 'Cloudy 7~13°C',
toolCallId: 'call_weather',
name: 'get_weather',
},
]),
{ stream: false },
);
expect(capturedRequest.messages[1].reasoning_content).toBe('Need the weather tool.');
expect(capturedRequest.messages[1].tool_calls[0].function.arguments).toBe(
'{"location":"Hangzhou"}',
);
expect(capturedRequest.messages[2].tool_call_id).toBe('call_weather');
});
it('preserves reasoning_content through the streaming path used by DeepSeek tool calls', async () => {
const model = createChatModel({
provider: 'deepseek',
apiKey: 'test-key',
model: 'deepseek-v4-flash',
temperature: 0.1,
} as any) as any;
model.completions.streaming = true;
async function* mockStream() {
yield {
id: 'chatcmpl-1',
model: 'deepseek-v4-flash',
choices: [
{
index: 0,
delta: {
role: 'assistant',
reasoning_content: 'Need the weather tool.',
},
},
],
};
yield {
id: 'chatcmpl-1',
model: 'deepseek-v4-flash',
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: 0,
id: 'call_weather',
type: 'function',
function: {
name: 'get_weather',
arguments: '{"location":"Hangzhou"}',
},
},
],
},
finish_reason: 'tool_calls',
},
],
};
}
model.completions.client = {
chat: {
completions: {
create: async () => mockStream(),
},
},
};
let streamedMessage: any;
for await (const chunk of model.completions._streamResponseChunks(
buildLangChainMessages([{ role: 'user', content: 'Check the weather' }]),
{},
)) {
streamedMessage = streamedMessage ? streamedMessage.concat(chunk.message) : chunk.message;
}
expect(streamedMessage.additional_kwargs.reasoning_content).toBe('Need the weather tool.');
expect(streamedMessage.tool_calls).toEqual([
{
id: 'call_weather',
name: 'get_weather',
args: { location: 'Hangzhou' },
type: 'tool_call',
},
]);
expect(serializeAgentHistoryMessages([streamedMessage], 0)).toEqual([
{
role: 'assistant',
content: '',
reasoningContent: 'Need the weather tool.',
toolCalls: [
{
id: 'call_weather',
name: 'get_weather',
args: { location: 'Hangzhou' },
type: 'tool_call',
},
],
},
]);
});
it('rejects overlapping DeepSeek requests before reusing active messages', async () => {
const model = createChatModel({
provider: 'deepseek',
apiKey: 'test-key',
model: 'deepseek-v4-flash',
temperature: 0.1,
} as any) as any;
model.completions.activeMessages = buildLangChainMessages([{ role: 'user', content: 'busy' }]);
await expect(
model.completions._generate(
buildLangChainMessages([{ role: 'user', content: 'Check the weather' }]),
{ stream: false },
),
).rejects.toThrow('DeepSeekChatOpenAICompletions does not support overlapping requests');
});
});

View file

@ -8,6 +8,7 @@ import {
clearSettings,
getProviderDisplayName,
getAvailableModels,
getProviderCapabilities,
} from '../../src/core/llm/settings-service';
describe('loadSettings', () => {
@ -104,6 +105,17 @@ describe('getActiveProviderConfig', () => {
expect(config!.provider).toBe('openai');
});
it('returns config for deepseek when API key is set', () => {
const settings = loadSettings();
settings.activeProvider = 'deepseek';
settings.deepseek = { ...settings.deepseek, apiKey: 'sk-deepseek-123' };
saveSettings(settings);
const config = getActiveProviderConfig();
expect(config).not.toBeNull();
expect(config!.provider).toBe('deepseek');
});
it('returns null for openrouter with empty API key', () => {
const settings = loadSettings();
settings.activeProvider = 'openrouter';
@ -139,6 +151,7 @@ describe('getProviderDisplayName', () => {
expect(getProviderDisplayName('anthropic')).toBe('Anthropic');
expect(getProviderDisplayName('ollama')).toBe('Ollama (Local)');
expect(getProviderDisplayName('openrouter')).toBe('OpenRouter');
expect(getProviderDisplayName('deepseek')).toBe('DeepSeek');
});
});
@ -147,9 +160,18 @@ describe('getAvailableModels', () => {
expect(getAvailableModels('openai').length).toBeGreaterThan(0);
expect(getAvailableModels('ollama').length).toBeGreaterThan(0);
expect(getAvailableModels('anthropic')).toContain('claude-sonnet-4-20250514');
expect(getAvailableModels('deepseek')).toContain('deepseek-v4-flash');
});
it('returns empty array for unknown provider', () => {
expect(getAvailableModels('unknown' as any)).toEqual([]);
});
});
describe('getProviderCapabilities', () => {
it('enables transcript replay only for providers that require it', () => {
expect(getProviderCapabilities('deepseek').preserveAssistantTranscript).toBe(true);
expect(getProviderCapabilities('openai').preserveAssistantTranscript).toBe(false);
expect(getProviderCapabilities('anthropic').preserveAssistantTranscript).toBe(false);
});
});