diff --git a/gitnexus-web/src/components/SettingsPanel.tsx b/gitnexus-web/src/components/SettingsPanel.tsx index 2bd79a6b3..e6b933e49 100644 --- a/gitnexus-web/src/components/SettingsPanel.tsx +++ b/gitnexus-web/src/components/SettingsPanel.tsx @@ -341,6 +341,7 @@ export const SettingsPanel = ({ 'openrouter', 'minimax', 'glm', + 'deepseek', ]; return ( @@ -433,7 +434,9 @@ export const SettingsPanel = ({ ? '⚡' : provider === 'glm' ? '🔮' - : '☁️'} + : provider === 'deepseek' + ? '🐋' + : '☁️'} {getProviderDisplayName(provider)} @@ -856,6 +859,43 @@ export const SettingsPanel = ({ /> )} + {/* DeepSeek Settings */} + {settings.activeProvider === 'deepseek' && ( + + 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)', + }} + > +

+ Compatible via OpenAI API format. The deepseek-reasoner model uses thinking mode and + requires round-tripping reasoning content. +

+
+ )} + {/* GLM Settings */} {settings.activeProvider === 'glm' && (
diff --git a/gitnexus-web/src/core/llm/agent.ts b/gitnexus-web/src/core/llm/agent.ts index 49862a9e6..5153e35fb 100644 --- a/gitnexus-web/src/core/llm/agent.ts +++ b/gitnexus-web/src/core/llm/agent.ts @@ -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, messages: AgentMessage[], + options: AgentRuntimeOptions = {}, ): AsyncGenerator { 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, messages: AgentMessage[], ): Promise => { - const formattedMessages = messages.map((m) => ({ - role: m.role, - content: m.content, - })); + const formattedMessages = buildLangChainMessages(messages); const result = await agent.invoke({ messages: formattedMessages }); diff --git a/gitnexus-web/src/core/llm/deepseek-chat-model.ts b/gitnexus-web/src/core/llm/deepseek-chat-model.ts new file mode 100644 index 000000000..9abeb46e2 --- /dev/null +++ b/gitnexus-web/src/core/llm/deepseek-chat-model.ts @@ -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 { + 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 { + 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 { + this.setActiveMessages(messages); + try { + yield* super._streamResponseChunks(messages, options, runManager); + } finally { + this.activeMessages = null; + } + } + + override async completionWithRetry(request: any, requestOptions?: any): Promise { + 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 { + 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, + ): Runnable { + // 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(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 => { + if (toolCall?.args && typeof toolCall.args === 'object') { + return toolCall.args as Record; + } + 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> => { + 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>, +): Array> => + messages.map((message: any) => { + const role = getOpenAIRole(message); + const additionalKwargs = + message.additional_kwargs && typeof message.additional_kwargs === 'object' + ? message.additional_kwargs + : {}; + const requestMessage: Record = { + 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; + }); diff --git a/gitnexus-web/src/core/llm/settings-service.ts b/gitnexus-web/src/core/llm/settings-service.ts index 86330d2a5..79a7a4309 100644 --- a/gitnexus-web/src/core/llm/settings-service.ts +++ b/gitnexus-web/src/core/llm/settings-service.ts @@ -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 | null): LLMSettings => ...DEFAULT_LLM_SETTINGS.glm, ...parsed?.glm, }, + deepseek: { + ...DEFAULT_LLM_SETTINGS.deepseek, + ...parsed?.deepseek, + }, }); const readSettings = (storage: Storage): Partial | null => { @@ -144,7 +149,9 @@ export const updateProviderSettings = ( ? Partial> : T extends 'glm' ? Partial> - : never + : T extends 'deepseek' + ? Partial> + : never >, ): LLMSettings => { const current = loadSettings(); @@ -239,6 +246,17 @@ export const updateProviderSettings = ( saveSettings(updated); return updated; } + case 'deepseek': { + const updated: LLMSettings = { + ...current, + deepseek: { + ...(current.deepseek ?? {}), + ...(updates as Partial>), + }, + }; + 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 = { 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> = { + 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 []; } diff --git a/gitnexus-web/src/core/llm/types.ts b/gitnexus-web/src/core/llm/types.ts index bebbab830..c568c7d93 100644 --- a/gitnexus-web/src/core/llm/types.ts +++ b/gitnexus-web/src/core/llm/types.ts @@ -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>; minimax?: Partial>; glm?: Partial>; + deepseek?: Partial>; // 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; + 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 */ diff --git a/gitnexus-web/src/hooks/useAppState.tsx b/gitnexus-web/src/hooks/useAppState.tsx index 876d56852..5a7e85457 100644 --- a/gitnexus-web/src/hooks/useAppState.tsx +++ b/gitnexus-web/src/hooks/useAppState.tsx @@ -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 => { + 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((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, ], ); diff --git a/gitnexus-web/test/unit/agent-history.test.ts b/gitnexus-web/test/unit/agent-history.test.ts new file mode 100644 index 000000000..756534b25 --- /dev/null +++ b/gitnexus-web/test/unit/agent-history.test.ts @@ -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'); + }); +}); diff --git a/gitnexus-web/test/unit/settings-service.test.ts b/gitnexus-web/test/unit/settings-service.test.ts index 17514725c..a9ded356f 100644 --- a/gitnexus-web/test/unit/settings-service.test.ts +++ b/gitnexus-web/test/unit/settings-service.test.ts @@ -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); + }); +});