Skip to content

Commit 03619ad

Browse files
committed
feat: merge 9 PRs for v1.4.0
Merged PRs from xiaoY233/Chat2API: - xiaoY233#74: DeepSeek V4 Pro/Flash model support - xiaoY233#92: Credential encryption toggle - xiaoY233#93: Kimi K2.6 upgrade - xiaoY233#84: Account weight-based load balancing - xiaoY233#83: Qwen silent content drop fix - xiaoY233#85: data.json performance optimization (log buffering) - xiaoY233#87: Multi-turn conversation session reuse - xiaoY233#77: Settings page draft mode with save button - xiaoY233#106: Qwen context loss fix (messagesToPrompt)
1 parent 1a066c6 commit 03619ad

48 files changed

Lines changed: 5071 additions & 244 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "chat2api",
3-
"version": "1.3.0",
3+
"version": "1.4.0",
44
"description": "Chat2API 管理器 - 统一管理多个 AI 服务提供商,提供 OpenAI 兼容 API 接口",
55
"main": "./out/main/index.js",
66
"author": {

pr-diffs/pr-106.diff

Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,338 @@
1+
diff --git a/src/main/proxy/adapters/qwen-ai.ts b/src/main/proxy/adapters/qwen-ai.ts
2+
index 631639d..9b53e2f 100644
3+
--- a/src/main/proxy/adapters/qwen-ai.ts
4+
+++ b/src/main/proxy/adapters/qwen-ai.ts
5+
@@ -9,6 +9,7 @@ import { PassThrough } from 'stream'
6+
import { createParser } from 'eventsource-parser'
7+
import { Account, Provider } from '../../store/types'
8+
import { hasToolUse, parseToolUse, ToolCall } from '../promptToolUse'
9+
+import { messagesToPrompt } from '../utils/messageToPrompt'
10+
11+
const QWEN_AI_BASE = 'https://chat.qwen.ai'
12+
13+
@@ -260,25 +261,10 @@ export class QwenAiAdapter {
14+
const chatId = await this.createChat(modelId, 'OpenAI_API_Chat')
15+
console.log('[QwenAI] Created new chat:', chatId)
16+
17+
- const messages = request.messages
18+
-
19+
- // Extract system message and user message
20+
- let systemContent = ''
21+
- let userContent = ''
22+
-
23+
- // Single-turn mode: extract all messages
24+
- for (const msg of messages) {
25+
- if (msg.role === 'system') {
26+
- systemContent += (systemContent ? '\n\n' : '') + msg.content
27+
- } else if (msg.role === 'user') {
28+
- userContent = msg.content
29+
- }
30+
- }
31+
-
32+
- // If system prompt exists, prepend it to user content
33+
- if (systemContent) {
34+
- userContent = `${systemContent}\n\nUser: ${userContent}`
35+
- }
36+
+ // Convert entire conversation history to a single prompt string
37+
+ // This ensures multi-turn context is preserved across requests
38+
+ const userContent = messagesToPrompt(request.messages as any)
39+
+ console.log('[QwenAI] Converted conversation history to prompt, length:', userContent.length)
40+
41+
const fid = uuid()
42+
const childId = uuid()
43+
diff --git a/src/main/proxy/adapters/qwen.ts b/src/main/proxy/adapters/qwen.ts
44+
index 5f289e0..e157b3f 100644
45+
--- a/src/main/proxy/adapters/qwen.ts
46+
+++ b/src/main/proxy/adapters/qwen.ts
47+
@@ -20,6 +20,7 @@ import {
48+
createBaseChunk,
49+
ToolCallState
50+
} from '../utils/streamToolHandler'
51+
+import { messagesToPrompt, extractTextContent as extractTextContentUtil } from '../utils/messageToPrompt'
52+
53+
/**
54+
* Check if content contains tool calls (both bracket and XML formats)
55+
@@ -70,7 +71,8 @@ interface ChatCompletionRequest {
56+
web_search?: boolean
57+
reasoning_effort?: 'low' | 'medium' | 'high'
58+
enableThinking?: boolean
59+
- enableWebSearch?: boolean
60+
+ enableWeb?: boolean
61+
+ originalModel?: string
62+
}
63+
64+
function uuid(separator: boolean = true): string {
65+
@@ -152,15 +154,16 @@ export class QwenAdapter {
66+
const modelLower = modelForDetection.toLowerCase()
67+
68+
let enableThinking = request.enableThinking ?? false
69+
- let enableWebSearch = request.enableWebSearch ?? false
70+
+ // Use web_search from request (OpenAI standard) and fallback to model name detection
71+
+ let enableWeb = request.web_search ?? false
72+
73+
// Auto-enable based on model name (if not explicitly set)
74+
if (!enableThinking && (modelLower.includes('think') || modelLower.includes('r1'))) {
75+
enableThinking = true
76+
console.log('[Qwen] Thinking mode enabled (from model name)')
77+
}
78+
- if (!enableWebSearch && modelLower.includes('search')) {
79+
- enableWebSearch = true
80+
+ if (!enableWeb && modelLower.includes('')) {
81+
+ enableWeb = true
82+
console.log('[Qwen] Web search enabled (from model name)')
83+
}
84+
85+
@@ -179,38 +182,21 @@ export class QwenAdapter {
86+
})
87+
console.log('[Qwen] Using model:', actualModel)
88+
89+
- // Find system message and user message
90+
- let systemPrompt = ''
91+
- let userContent = ''
92+
-
93+
- for (const msg of request.messages) {
94+
- if (msg.role === 'system') {
95+
- systemPrompt = extractTextContent(msg.content)
96+
- } else if (msg.role === 'user') {
97+
- userContent = extractTextContent(msg.content)
98+
- }
99+
- }
100+
+ // Convert entire conversation history to a single prompt string
101+
+ // This ensures multi-turn context is preserved across requests
102+
+ let finalContent = messagesToPrompt(request.messages as any)
103+
104+
// Inject tools prompt if tools are provided and not already injected by client
105+
if (request.tools && request.tools.length > 0 && !hasToolPromptInjected(request.messages)) {
106+
const toolsPrompt = toolsToSystemPrompt(request.tools)
107+
- systemPrompt = systemPrompt
108+
- ? systemPrompt + '\n\n' + toolsPrompt
109+
- : toolsPrompt
110+
- // Add tool wrap hint to user content
111+
- userContent = userContent + TOOL_WRAP_HINT
112+
+ finalContent = toolsPrompt + '\n\n' + finalContent + TOOL_WRAP_HINT
113+
}
114+
115+
- // If system prompt exists, prepend it to user content
116+
- const finalContent = systemPrompt
117+
- ? `${systemPrompt}\n\nUser: ${userContent}`
118+
- : userContent
119+
-
120+
const timestamp = Date.now()
121+
const nonce = generateNonce()
122+
123+
const requestBody = {
124+
- deep_search: (enableWebSearch || enableThinking) ? '1' : '0',
125+
+ deep_search: (enableWeb || enableThinking) ? '1' : '0',
126+
req_id: reqId,
127+
model: actualModel,
128+
scene: 'chat',
129+
@@ -228,7 +214,7 @@ export class QwenAdapter {
130+
],
131+
from: 'default',
132+
parent_req_id: '0',
133+
- enable_search: enableWebSearch,
134+
+ enable_search: enableWeb,
135+
biz_data: '{"entryPoint":"tongyigw"}',
136+
scene_param: 'first_turn',
137+
chat_client: 'h5',
138+
@@ -398,7 +384,9 @@ export class QwenStreamHandler {
139+
140+
console.log('[Qwen] Starting stream handler...')
141+
142+
- const contentEncoding = response?.headers?.['content-encoding']
143+
+ const contentEncoding = typeof response?.headers?.['content-encoding'] === 'string'
144+
+ ? response.headers['content-encoding'] as string
145+
+ : undefined
146+
console.log('[Qwen] Content-Encoding:', contentEncoding)
147+
148+
let buffer = ''
149+
@@ -899,7 +887,8 @@ export class QwenStreamHandler {
150+
151+
let decompressStream: any = stream
152+
153+
- const contentEncoding = response?.headers?.['content-encoding']?.toLowerCase()
154+
+ const rawEncoding = response?.headers?.['content-encoding']
155+
+ const contentEncoding = typeof rawEncoding === 'string' ? rawEncoding.toLowerCase() : undefined
156+
if (contentEncoding === 'gzip') {
157+
console.log('[Qwen] Decompressing gzip stream...')
158+
decompressStream = stream.pipe(createGunzip())
159+
diff --git a/src/main/proxy/adapters/zai.ts b/src/main/proxy/adapters/zai.ts
160+
index 967b77b..d8e91fc 100644
161+
--- a/src/main/proxy/adapters/zai.ts
162+
+++ b/src/main/proxy/adapters/zai.ts
163+
@@ -107,6 +107,8 @@ function uuid(separator: boolean = true): string {
164+
}
165+
166+
export class ZaiAdapter {
167+
+ private static chatIdCache: Map<string, string> = new Map()
168+
+
169+
private provider: Provider
170+
private account: Account
171+
private token: string | null = null
172+
@@ -391,12 +393,41 @@ export class ZaiAdapter {
173+
174+
const signaturePrompt = this.extractLastUserMessage(processedMessages)
175+
176+
- // Always create a new chat (single-turn mode only)
177+
- const chatResult = await this.createChat(mappedModel, signaturePrompt)
178+
- const chatId = chatResult.chatId
179+
- const messageId = chatResult.messageId
180+
- const parentMessageId = null
181+
- console.log('[Z.ai] Created new chat:', chatId)
182+
+ // Reuse chat ID across requests for the same account when possible
183+
+ let chatId: string
184+
+ let messageId: string
185+
+ let parentMessageId: string | null = null
186+
+
187+
+ // If client provides an existing chat ID, use it (multi-turn support)
188+
+ if (request.chatId) {
189+
+ chatId = request.chatId
190+
+ // For existing chat, we still need to generate a new message ID.
191+
+ // The API expects a new message ID for each turn, and optionally a parent message ID.
192+
+ messageId = uuid()
193+
+ // We don't know the parent message ID for existing chat, but we can set it to null.
194+
+ parentMessageId = null
195+
+ console.log('[Z.ai] Using existing chat ID from request:', chatId)
196+
+ } else {
197+
+ // Check cache for this account
198+
+ const cachedChatId = ZaiAdapter.chatIdCache.get(this.account.id)
199+
+ if (cachedChatId) {
200+
+ chatId = cachedChatId
201+
+ messageId = uuid()
202+
+ parentMessageId = null
203+
+ console.log('[Z.ai] Reusing cached chat ID for account:', chatId)
204+
+ } else {
205+
+ // Create a new chat
206+
+ const chatResult = await this.createChat(mappedModel, signaturePrompt)
207+
+ chatId = chatResult.chatId
208+
+ messageId = chatResult.messageId
209+
+ // Cache it for future requests from this account
210+
+ ZaiAdapter.chatIdCache.set(this.account.id, chatId)
211+
+ console.log('[Z.ai] Created new chat and cached:', chatId)
212+
+ }
213+
+ }
214+
+
215+
+ // Note: The processedMessages array already contains the full conversation history,
216+
+ // so multi-turn context is preserved even when reusing the same chat_id.
217+
218+
const requestId = uuid()
219+
const timestamp = Date.now()
220+
diff --git a/src/main/proxy/utils/messageToPrompt.ts b/src/main/proxy/utils/messageToPrompt.ts
221+
new file mode 100644
222+
index 0000000..e60ba7c
223+
--- /dev/null
224+
+++ b/src/main/proxy/utils/messageToPrompt.ts
225+
@@ -0,0 +1,113 @@
226+
+/**
227+
+ * Utility to convert an array of chat messages into a single prompt string
228+
+ * that can be sent to any API, preserving full conversation context.
229+
+ *
230+
+ * This approach ensures multi-turn conversation works across all providers
231+
+ * without relying on provider-specific session management.
232+
+ */
233+
+
234+
+import type { ChatMessage } from '../types'
235+
+
236+
+export interface MessageContent {
237+
+ type: string
238+
+ text?: string
239+
+}
240+
+
241+
+/**
242+
+ * Extract plain text content from a message, regardless of format
243+
+ */
244+
+export function extractTextContent(content: string | MessageContent[] | null | undefined): string {
245+
+ if (!content) return ''
246+
+ if (typeof content === 'string') return content
247+
+ if (Array.isArray(content)) {
248+
+ return content
249+
+ .filter(part => part.type === 'text' && part.text)
250+
+ .map(part => part.text)
251+
+ .join(' ')
252+
+ }
253+
+ return ''
254+
+}
255+
+
256+
+/**
257+
+ * Convert an array of messages into a single prompt string.
258+
+ * Format:
259+
+ * System: <system message>
260+
+ *
261+
+ * User: <user message>
262+
+ * Assistant: <assistant response>
263+
+ * User: <next user message>
264+
+ * ...
265+
+ */
266+
+export function messagesToPrompt(messages: ChatMessage[]): string {
267+
+ const parts: string[] = []
268+
+
269+
+ for (const msg of messages) {
270+
+ const content = extractTextContent(msg.content)
271+
+ if (!content) continue
272+
+
273+
+ switch (msg.role) {
274+
+ case 'system':
275+
+ parts.push(`System: ${content}`)
276+
+ break
277+
+ case 'user':
278+
+ parts.push(`User: ${content}`)
279+
+ break
280+
+ case 'assistant':
281+
+ parts.push(`Assistant: ${content}`)
282+
+ break
283+
+ case 'tool':
284+
+ // Tool responses are typically handled separately; include as user context
285+
+ parts.push(`Tool result: ${content}`)
286+
+ break
287+
+ default:
288+
+ // Unknown role, treat as user message to be safe
289+
+ parts.push(`User: ${content}`)
290+
+ break
291+
+ }
292+
+ }
293+
+
294+
+ // Join with double newline for clear separation
295+
+ return parts.join('\n\n')
296+
+}
297+
+
298+
+/**
299+
+ * Convert messages to prompt but preserve a system message separately
300+
+ * for providers that have a dedicated system prompt field.
301+
+ * Returns { systemPrompt: string, userPrompt: string }
302+
+ */
303+
+export function splitSystemAndUserMessages(messages: ChatMessage[]): {
304+
+ systemPrompt: string
305+
+ userPrompt: string
306+
+} {
307+
+ let systemPrompt = ''
308+
+ const userParts: string[] = []
309+
+
310+
+ for (const msg of messages) {
311+
+ const content = extractTextContent(msg.content)
312+
+ if (!content) continue
313+
+
314+
+ if (msg.role === 'system') {
315+
+ systemPrompt = systemPrompt ? `${systemPrompt}\n\n${content}` : content
316+
+ } else if (msg.role === 'user') {
317+
+ userParts.push(`User: ${content}`)
318+
+ } else if (msg.role === 'assistant') {
319+
+ userParts.push(`Assistant: ${content}`)
320+
+ } else if (msg.role === 'tool') {
321+
+ userParts.push(`Tool result: ${content}`)
322+
+ } else {
323+
+ userParts.push(`User: ${content}`)
324+
+ }
325+
+ }
326+
+
327+
+ return {
328+
+ systemPrompt,
329+
+ userPrompt: userParts.join('\n\n'),
330+
+ }
331+
+}
332+
+
333+
+/**
334+
+ * Estimate token count for a prompt string (rough approximation: 3 chars per token)
335+
+ */
336+
+export function estimatePromptTokens(prompt: string): number {
337+
+ return Math.ceil(prompt.length / 3)
338+
+}

0 commit comments

Comments
 (0)