Skip to content

Commit 7cdd88c

Browse files
author
bealqiu
committed
feat: 添加 DeepSeek 模型支持,集成 @langchain/deepseek,添加 DeepSeek 提供商支持,修复多轮对话中 reasoning_content 的处理逻辑。
1 parent b2971ba commit 7cdd88c

12 files changed

Lines changed: 166 additions & 20 deletions

File tree

packages/app/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"@huggingface/transformers": "^3.8.1",
1414
"@langchain/anthropic": "^1.3.21",
1515
"@langchain/core": "^1.1.29",
16+
"@langchain/deepseek": "^1.0.15",
1617
"@langchain/google-genai": "^2.1.21",
1718
"@langchain/langgraph": "^1.2.0",
1819
"@langchain/openai": "^1.2.11",

packages/app/src/components/chat/PartRenderer.tsx

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -107,16 +107,14 @@ function TextPartView({ part }: { part: TextPart }) {
107107
}
108108

109109
function ReasoningPartView({ part }: { part: ReasoningPart }) {
110-
// Auto-expand when streaming (running), auto-collapse when completed
111-
const [isOpen, setIsOpen] = useState(part.status === "running");
110+
// Start expanded when streaming; keep expanded after completion
111+
const [isOpen, setIsOpen] = useState(part.status === "running" || part.status === "completed");
112112
const throttledText = useThrottledText(part.text);
113113

114-
// Sync isOpen with part status: expand on running, collapse on completed
114+
// Expand when streaming starts
115115
useEffect(() => {
116116
if (part.status === "running") {
117117
setIsOpen(true);
118-
} else if (part.status === "completed") {
119-
setIsOpen(false);
120118
}
121119
}, [part.status]);
122120

@@ -158,9 +156,6 @@ function ReasoningPartView({ part }: { part: ReasoningPart }) {
158156
<p className="whitespace-pre-wrap text-sm leading-relaxed text-violet-900">
159157
{throttledText}
160158
</p>
161-
{part.status === "running" && (
162-
<span className="inline-block h-4 w-1 animate-pulse bg-violet-500" />
163-
)}
164159
</div>
165160
</CollapsibleContent>
166161
</div>

packages/app/src/components/settings/AISettings.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ const PROVIDER_DEFAULTS: Record<AIProviderType, { baseUrl: string; placeholder:
2727
openai: { baseUrl: "https://api.openai.com/v1", placeholder: "https://api.openai.com/v1", keyPlaceholder: "sk-..." },
2828
anthropic: { baseUrl: "", placeholder: "https://api.anthropic.com", keyPlaceholder: "sk-ant-..." },
2929
google: { baseUrl: "", placeholder: "https://generativelanguage.googleapis.com", keyPlaceholder: "AIza..." },
30+
deepseek: { baseUrl: "https://api.deepseek.com", placeholder: "https://api.deepseek.com", keyPlaceholder: "sk-..." },
3031
};
3132

3233
function EndpointCard({
@@ -128,6 +129,7 @@ function EndpointCard({
128129
<SelectItem value="openai">{t("settings.ai_provider_openai")}</SelectItem>
129130
<SelectItem value="anthropic">{t("settings.ai_provider_anthropic")}</SelectItem>
130131
<SelectItem value="google">{t("settings.ai_provider_google")}</SelectItem>
132+
<SelectItem value="deepseek">{t("settings.ai_provider_deepseek")}</SelectItem>
131133
</SelectContent>
132134
</Select>
133135
</div>

packages/app/src/i18n/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@
230230
"ai_provider_openai": "OpenAI Compatible",
231231
"ai_provider_anthropic": "Anthropic Claude",
232232
"ai_provider_google": "Google Gemini",
233+
"ai_provider_deepseek": "DeepSeek",
233234
"ai_endpointName": "Name",
234235
"ai_endpointNamePlaceholder": "e.g. OpenAI, Ollama, Claude...",
235236
"ai_apiKey": "API Key",

packages/app/src/i18n/locales/zh.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@
230230
"ai_provider_openai": "OpenAI 兼容",
231231
"ai_provider_anthropic": "Anthropic Claude",
232232
"ai_provider_google": "Google Gemini",
233+
"ai_provider_deepseek": "DeepSeek",
233234
"ai_endpointName": "名称",
234235
"ai_endpointNamePlaceholder": "如 OpenAI、Ollama、Claude...",
235236
"ai_apiKey": "API 密钥",

packages/app/src/lib/ai/agents/reading-agent.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ async function executeTool(tool: ToolDefinition, args: Record<string, unknown>):
8888
export async function* streamReadingAgent(
8989
options: ReadingAgentOptions,
9090
userInput: string,
91-
history: Array<{ role: "user" | "assistant"; content: string }> = [],
91+
history: Array<{ role: "user" | "assistant"; content: string; reasoning?: string }> = [],
9292
): AsyncGenerator<AgentStreamEvent> {
9393
const { aiConfig, book, semanticContext, enabledSkills, isVectorized, deepThinking } = options;
9494

@@ -119,10 +119,26 @@ export async function* streamReadingAgent(
119119
});
120120

121121
// Build input messages (history + user input, without system — handled by agent prompt)
122+
// For DeepSeek reasoner, we must include reasoning_content in assistant messages
123+
// to avoid 400 errors during multi-turn tool-calling conversations.
124+
const isDeepSeek = aiConfig.endpoints.find(
125+
(e) => e.id === aiConfig.activeEndpointId,
126+
)?.provider === "deepseek";
127+
122128
const inputMessages: BaseMessage[] = [
123-
...history.map((h) =>
124-
h.role === "user" ? new HumanMessage(h.content) : new AIMessage(h.content),
125-
),
129+
...history.map((h) => {
130+
if (h.role === "user") {
131+
return new HumanMessage(h.content);
132+
}
133+
// For DeepSeek, include reasoning_content in additional_kwargs
134+
if (isDeepSeek && h.reasoning) {
135+
return new AIMessage({
136+
content: h.content,
137+
additional_kwargs: { reasoning_content: h.reasoning },
138+
});
139+
}
140+
return new AIMessage(h.content);
141+
}),
126142
new HumanMessage(userInput),
127143
];
128144

@@ -202,6 +218,13 @@ export async function* streamReadingAgent(
202218
}
203219
}
204220

221+
// Handle DeepSeek reasoning_content from @langchain/deepseek
222+
// ChatDeepSeek puts reasoning_content in additional_kwargs.reasoning_content
223+
const reasoningContent = chunk.additional_kwargs?.reasoning_content;
224+
if (typeof reasoningContent === "string" && reasoningContent) {
225+
yield { type: "reasoning", content: reasoningContent, stepType: "thinking" };
226+
}
227+
205228
// Detect tool_call_chunks in streaming and emit tool_call as soon as we have the name.
206229
// This eliminates the delay between the last text token and on_chat_model_end.
207230
const toolCallChunks = chunk.tool_call_chunks;

packages/app/src/lib/ai/llm-provider.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,75 @@ export async function createChatModelFromEndpoint(
9595
streaming,
9696
});
9797
}
98+
99+
case "deepseek": {
100+
const { ChatDeepSeek } = await import("@langchain/deepseek");
101+
102+
// Create a subclass that fixes the missing reasoning_content issue.
103+
// Bug: @langchain/deepseek stores reasoning_content in additional_kwargs
104+
// when receiving, but doesn't inject it back when sending requests.
105+
// DeepSeek API requires reasoning_content on every assistant message
106+
// during tool-calling loops, or it returns a 400 error.
107+
class ChatDeepSeekFixed extends ChatDeepSeek {
108+
private _reasoningMap = new Map<number, string>();
109+
110+
// biome-ignore lint: override needs any
111+
async _generate(messages: any[], options: any, runManager?: any) {
112+
this._buildReasoningMap(messages);
113+
return super._generate(messages, options, runManager);
114+
}
115+
116+
// biome-ignore lint: override needs any
117+
async *_streamResponseChunks(messages: any[], options: any, runManager?: any) {
118+
this._buildReasoningMap(messages);
119+
yield* super._streamResponseChunks(messages, options, runManager);
120+
}
121+
122+
// biome-ignore lint: override needs any
123+
// @ts-expect-error -- overloaded signature; runtime type is correct
124+
async completionWithRetry(request: any, requestOptions?: any) {
125+
// Inject reasoning_content into assistant messages in the API request
126+
if (request.messages && this._reasoningMap.size > 0) {
127+
let assistantIdx = 0;
128+
for (const msg of request.messages) {
129+
if (msg.role === "assistant") {
130+
const reasoning = this._reasoningMap.get(assistantIdx);
131+
if (reasoning !== undefined) {
132+
msg.reasoning_content = reasoning;
133+
}
134+
assistantIdx++;
135+
}
136+
}
137+
}
138+
return super.completionWithRetry(request, requestOptions);
139+
}
140+
141+
// biome-ignore lint: messages is BaseMessage[]
142+
private _buildReasoningMap(messages: any[]) {
143+
this._reasoningMap.clear();
144+
let assistantIdx = 0;
145+
for (const msg of messages) {
146+
if (msg._getType?.() === "ai" || msg.constructor?.name === "AIMessage" || msg.constructor?.name === "AIMessageChunk") {
147+
const reasoning = msg.additional_kwargs?.reasoning_content;
148+
if (typeof reasoning === "string") {
149+
this._reasoningMap.set(assistantIdx, reasoning);
150+
}
151+
assistantIdx++;
152+
}
153+
}
154+
}
155+
}
156+
157+
return new ChatDeepSeekFixed({
158+
model,
159+
apiKey: endpoint.apiKey,
160+
configuration: endpoint.baseUrl ? { baseURL: endpoint.baseUrl } : undefined,
161+
temperature,
162+
maxTokens,
163+
streaming,
164+
} as ConstructorParameters<typeof ChatDeepSeek>[0]);
165+
}
166+
98167
default: {
99168
const { ChatOpenAI } = await import("@langchain/openai");
100169

packages/app/src/lib/ai/message-pipeline.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,16 @@ interface PipelineContext {
2020
userLanguage: string;
2121
}
2222

23+
export interface ProcessedMessage {
24+
role: "user" | "assistant";
25+
content: string;
26+
/** DeepSeek reasoning_content — needed for multi-turn tool-calling with reasoner models */
27+
reasoning?: string;
28+
}
29+
2330
interface ProcessedMessages {
2431
systemPrompt: string;
25-
messages: Array<{ role: "user" | "assistant"; content: string }>;
32+
messages: ProcessedMessage[];
2633
}
2734

2835
const DEFAULT_CONFIG: PipelineConfig = {
@@ -40,13 +47,20 @@ export function processMessages(
4047
// Apply sliding window — keep last N messages
4148
const windowedMessages = applySlidingWindow(thread.messages, config.slidingWindowSize);
4249

43-
// Process citations in messages
44-
const processed = windowedMessages
50+
// Process citations in messages, preserving reasoning for DeepSeek multi-turn
51+
const processed: ProcessedMessage[] = windowedMessages
4552
.filter((m) => m.role !== "system")
46-
.map((m) => ({
47-
role: m.role as "user" | "assistant",
48-
content: injectCitations(m),
49-
}));
53+
.map((m) => {
54+
const msg: ProcessedMessage = {
55+
role: m.role as "user" | "assistant",
56+
content: injectCitations(m),
57+
};
58+
// Preserve reasoning content for assistant messages (needed by DeepSeek reasoner)
59+
if (m.role === "assistant" && m.reasoning && m.reasoning.length > 0) {
60+
msg.reasoning = m.reasoning.map((r) => r.content).join("\n");
61+
}
62+
return msg;
63+
});
5064

5165
return { systemPrompt, messages: processed };
5266
}

packages/app/src/lib/ai/streaming.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export class StreamingChat {
4848
const history = messages.slice(0, -1).map((m) => ({
4949
role: m.role as "user" | "assistant",
5050
content: m.content,
51+
reasoning: m.reasoning,
5152
}));
5253

5354
try {

packages/app/src/stores/settings-store.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@ async function fetchModelsFromEndpoint(endpoint: AIEndpoint): Promise<string[]>
8080
return fetchAnthropicModels(endpoint);
8181
case "google":
8282
return fetchGoogleModels(endpoint);
83+
case "deepseek":
84+
return fetchDeepSeekModels(endpoint);
8385
case "openai":
8486
default:
8587
return fetchOpenAIModels(endpoint);
@@ -160,6 +162,27 @@ async function fetchGoogleModels(endpoint: AIEndpoint): Promise<string[]> {
160162
.sort((a: string, b: string) => a.localeCompare(b));
161163
}
162164

165+
/** DeepSeek — uses OpenAI-compatible /models endpoint with fallback */
166+
async function fetchDeepSeekModels(endpoint: AIEndpoint): Promise<string[]> {
167+
const baseUrl = (endpoint.baseUrl || "https://api.deepseek.com").replace(/\/+$/, "");
168+
try {
169+
const response = await fetch(`${baseUrl}/models`, {
170+
headers: { Authorization: `Bearer ${endpoint.apiKey}` },
171+
});
172+
if (response.ok) {
173+
const data = await response.json();
174+
const models = (data.data || [])
175+
.map((m: { id: string }) => m.id)
176+
.sort((a: string, b: string) => a.localeCompare(b));
177+
if (models.length > 0) return models;
178+
}
179+
} catch {
180+
// Fall through to fallback
181+
}
182+
// Fallback: well-known DeepSeek models
183+
return ["deepseek-chat", "deepseek-reasoner"];
184+
}
185+
163186
export const useSettingsStore = create<SettingsState>()(
164187
withPersist("settings", (set, get, _api) => ({
165188
readSettings: defaultReadSettings,

0 commit comments

Comments
 (0)