From 940efdda741d3ae7ee954354664b5e63eb1a24eb Mon Sep 17 00:00:00 2001 From: lihejia Date: Fri, 24 Apr 2026 18:58:04 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=20DeepSeek=20?= =?UTF-8?q?=E9=80=82=E9=85=8D=E5=99=A8=E8=87=B3=20V4=EF=BC=8C=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20E2E=20=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DeepSeek 官网升级至 V4 版本,界面和模型均有变化: 适配器更新 (deepseek_text.js): - 模型从 v3.2 升级至 v4 (flash/pro 双模式) - 新增快速模式/专家模式切换 (Radio 按钮) - 支持中英文界面自动兼容 - 模型列表扩展至 8 个 (flash/pro × thinking/search 组合) E2E 测试: - 新增 API 层测试 (认证、模型列表、流式/非流式生成) - 新增浏览器交互测试 (模式切换、按钮、文本生成) - 使用 .env 配置测试参数 --- .gitignore | 4 +- e2e/api.test.js | 202 +++++++++++++++ e2e/deepseek.test.js | 369 +++++++++++++++++++++++++++ package.json | 9 +- playwright.config.js | 25 ++ src/backend/adapter/deepseek_text.js | 107 ++++++-- 6 files changed, 692 insertions(+), 24 deletions(-) create mode 100644 e2e/api.test.js create mode 100644 e2e/deepseek.test.js create mode 100644 playwright.config.js diff --git a/.gitignore b/.gitignore index 2e8c6f1..c3f8def 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,6 @@ node_modules/ data/ test/ config.yaml -camoufox/ \ No newline at end of file +camoufox/ +.env +e2e/.env \ No newline at end of file diff --git a/e2e/api.test.js b/e2e/api.test.js new file mode 100644 index 0000000..e75083e --- /dev/null +++ b/e2e/api.test.js @@ -0,0 +1,202 @@ +/** + * WebAI2API HTTP API E2E 测试 + * @description 测试 OpenAI 兼容 API:认证、模型列表、流式/非流式生成 + * @note 需要服务已启动,配置在 e2e/.env + */ + +import { test, expect } from '@playwright/test'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +// --- 加载 .env 配置 --- + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const envPath = path.join(__dirname, '.env'); +const envContent = fs.readFileSync(envPath, 'utf-8'); +const env = {}; +for (const line of envContent.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq === -1) continue; + env[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim(); +} + +const BASE_URL = env.API_BASE_URL || 'http://localhost:9330'; +const AUTH_TOKEN = env.API_AUTH_TOKEN || ''; +const MODEL = env.API_MODEL || 'deepseek_text/deepseek-v4-flash'; + +// --- 辅助函数 --- + +/** 发起 API 请求 */ +async function apiRequest(path, options = {}) { + const url = `${BASE_URL}${path}`; + const headers = { + 'Content-Type': 'application/json', + ...(AUTH_TOKEN ? { 'Authorization': `Bearer ${AUTH_TOKEN}` } : {}), + ...options.headers, + }; + return fetch(url, { ...options, headers }); +} + +/** 解析 SSE 文本为 chunk 数组 */ +function parseSSE(body) { + const chunks = []; + for (const line of body.split('\n')) { + if (!line.startsWith('data: ')) continue; + const data = line.slice(6).trim(); + if (data === '[DONE]') { + chunks.push({ type: 'done' }); + } else { + try { + chunks.push({ type: 'chunk', data: JSON.parse(data) }); + } catch { /* 忽略非 JSON 行 */ } + } + } + return chunks; +} + +// --- 测试 --- + +test.describe('API 认证', () => { + test('无 token 时返回 401', async () => { + const resp = await fetch(`${BASE_URL}/v1/models`); + expect(resp.status).toBe(401); + const body = await resp.json(); + expect(body.error).toBeTruthy(); + expect(body.error.type).toBe('invalid_request_error'); + }); + + test('错误 token 时返回 401', async () => { + const resp = await fetch(`${BASE_URL}/v1/models`, { + headers: { 'Authorization': 'Bearer wrong-token' }, + }); + expect(resp.status).toBe(401); + }); +}); + +test.describe('GET /v1/models', () => { + test('返回正确的列表格式', async () => { + const resp = await apiRequest('/v1/models'); + expect(resp.status).toBe(200); + const body = await resp.json(); + expect(body.object).toBe('list'); + expect(Array.isArray(body.data)).toBe(true); + expect(body.data.length).toBeGreaterThan(0); + }); + + test('模型包含必要字段', async () => { + const resp = await apiRequest('/v1/models'); + const body = await resp.json(); + for (const model of body.data) { + expect(model.id).toBeTruthy(); + expect(model.object).toBe('model'); + expect(model.owned_by).toBeTruthy(); + } + }); + + test(`配置的模型 ${MODEL} 在列表中`, async () => { + const resp = await apiRequest('/v1/models'); + const body = await resp.json(); + const found = body.data.some(m => m.id === MODEL); + expect(found).toBe(true); + }); +}); + +test.describe('POST /v1/chat/completions', () => { + test('非流式请求返回正确格式', async () => { + const resp = await apiRequest('/v1/chat/completions', { + method: 'POST', + body: JSON.stringify({ + model: MODEL, + messages: [{ role: 'user', content: 'Reply with exactly: hello' }], + stream: false, + }), + }); + + expect(resp.status).toBe(200); + const body = await resp.json(); + + // OpenAI 格式验证 + expect(body.id).toBeTruthy(); + expect(body.object).toBe('chat.completion'); + expect(body.model).toBe(MODEL); + expect(Array.isArray(body.choices)).toBe(true); + expect(body.choices.length).toBe(1); + + const choice = body.choices[0]; + expect(choice.index).toBe(0); + expect(choice.message.role).toBe('assistant'); + expect(choice.message.content).toBeTruthy(); + expect(choice.finish_reason).toBe('stop'); + + console.log(`[非流式] 回复: ${choice.message.content.slice(0, 80)}`); + }); + + test('流式请求返回 SSE 并最终完成', async () => { + const resp = await apiRequest('/v1/chat/completions', { + method: 'POST', + body: JSON.stringify({ + model: MODEL, + messages: [{ role: 'user', content: 'Reply with exactly: hi' }], + stream: true, + }), + }); + + expect(resp.status).toBe(200); + expect(resp.headers.get('content-type')).toContain('text/event-stream'); + + // 读取完整响应后解析 SSE + const body = await resp.text(); + const chunks = parseSSE(body); + + // 至少有一个 data chunk 和一个 [DONE] + const dataChunks = chunks.filter(c => c.type === 'chunk'); + const doneChunks = chunks.filter(c => c.type === 'done'); + expect(dataChunks.length).toBeGreaterThan(0); + expect(doneChunks.length).toBe(1); + + // 提取所有文本内容 + const textParts = dataChunks + .filter(c => c.data.choices?.[0]?.delta?.content) + .map(c => c.data.choices[0].delta.content); + const fullText = textParts.join(''); + expect(fullText.length).toBeGreaterThan(0); + + // 验证 chunk 格式 + expect(dataChunks[0].data.object).toBe('chat.completion.chunk'); + expect(dataChunks[0].data.model).toBe(MODEL); + + console.log(`[流式] 回复 (${dataChunks.length} chunks): ${fullText.slice(0, 80)}`); + }); + + test('无效模型返回 400', async () => { + const resp = await apiRequest('/v1/chat/completions', { + method: 'POST', + body: JSON.stringify({ + model: 'nonexistent-model', + messages: [{ role: 'user', content: 'hello' }], + }), + }); + + expect(resp.status).toBe(400); + const body = await resp.json(); + expect(body.error).toBeTruthy(); + expect(body.error.type).toBe('invalid_request_error'); + }); + + test('空消息返回 400', async () => { + const resp = await apiRequest('/v1/chat/completions', { + method: 'POST', + body: JSON.stringify({ + model: MODEL, + messages: [], + }), + }); + + expect(resp.status).toBe(400); + const body = await resp.json(); + expect(body.error).toBeTruthy(); + }); +}); diff --git a/e2e/deepseek.test.js b/e2e/deepseek.test.js new file mode 100644 index 0000000..805df03 --- /dev/null +++ b/e2e/deepseek.test.js @@ -0,0 +1,369 @@ +/** + * DeepSeek 适配器 E2E 测试 + * @description 测试 DeepSeek V4 界面交互:模式切换、功能按钮、文本生成 + * @note 自动检测界面语言,兼容中英文 + */ + +import { test, expect } from '@playwright/test'; +import { manifest } from '../src/backend/adapter/deepseek_text.js'; + +const TARGET_URL = 'https://chat.deepseek.com/'; + +// --- 辅助函数 --- + +/** 检测页面 locale 并返回对应的名称映射 */ +async function detectLocale(page) { + const ph = await page.locator('textarea').getAttribute('placeholder'); + const isZh = ph?.includes('给 DeepSeek') ?? false; + return { + isZh, + modeQuick: isZh ? '快速模式' : 'Instant', + modeExpert: isZh ? '专家模式' : 'Expert', + btnThinking: isZh ? '深度思考' : 'DeepThink', + btnSearch: isZh ? '智能搜索' : 'Search', + }; +} + +/** 按名称列表查找 radio (兼容中英文) */ +async function findRadio(page, names) { + for (const name of names) { + const loc = page.getByRole('radio', { name }); + if (await loc.count() > 0) return loc; + } + return null; +} + +/** 按名称列表查找 button (兼容中英文) */ +async function findButton(page, names) { + for (const name of names) { + const loc = page.getByRole('button', { name }); + if (await loc.count() > 0) return loc; + } + return null; +} + +/** 解析 SSE 响应中的文本和思考内容 (完整版,兼容所有 DeepSeek SSE 格式) */ +function parseSSEResponse(body) { + let text = '', thinking = ''; + let isCollecting = false, isCollectingThinking = false; + + for (const line of body.split('\n')) { + if (line.startsWith('event:') || !line.startsWith('data:')) continue; + const dataStr = line.slice(5).trim(); + if (!dataStr || dataStr === '{}') continue; + try { + const data = JSON.parse(dataStr); + + // 处理 fragment 列表 + const processFragment = (fragment) => { + if (fragment.type === 'RESPONSE') { + isCollecting = true; isCollectingThinking = false; + if (fragment.content) text += fragment.content; + } else if (fragment.type === 'THINK') { + isCollectingThinking = true; isCollecting = false; + if (fragment.content) thinking += fragment.content; + } else { + isCollecting = false; isCollectingThinking = false; + } + }; + + // 初始响应中的 fragments + if (data.v?.response?.fragments && Array.isArray(data.v.response.fragments)) { + data.v.response.fragments.forEach(processFragment); + } + + // fragments APPEND + if (data.p === 'response/fragments' && data.o === 'APPEND' && Array.isArray(data.v)) { + data.v.forEach(processFragment); + } + + // BATCH 操作 + if (data.o === 'BATCH' && data.p === 'response' && Array.isArray(data.v)) { + for (const item of data.v) { + if (item.p === 'fragments' && item.o === 'APPEND' && Array.isArray(item.v)) { + item.v.forEach(processFragment); + } + } + } + + // 路径式内容追加 (response/fragments/-1/content) + if (data.p && typeof data.v === 'string') { + const match = data.p.match(/response\/fragments\/(-?\d+)\/content/); + if (match) { + if (isCollecting) text += data.v; + else if (isCollectingThinking) thinking += data.v; + } + } + + // 纯文本追加 + if (data.v && typeof data.v === 'string' && !data.p && !data.o) { + if (isCollecting) text += data.v; + else if (isCollectingThinking) thinking += data.v; + } + } catch { /* 忽略 */ } + } + return { text, thinking }; +} + +// --- Manifest 结构测试 (无需浏览器) --- + +test.describe('Manifest 结构验证', () => { + test('manifest 包含必要字段', () => { + expect(manifest.id).toBe('deepseek_text'); + expect(manifest.displayName).toBeTruthy(); + expect(manifest.description).toBeTruthy(); + expect(typeof manifest.generate).toBe('function'); + expect(Array.isArray(manifest.models)).toBe(true); + expect(manifest.models.length).toBe(8); + }); + + test('所有模型包含必要属性', () => { + for (const model of manifest.models) { + expect(model.id).toBeTruthy(); + expect(model.imagePolicy).toBe('forbidden'); + } + }); + + test('快速模式模型 (deepseek-v4-flash)', () => { + const flashModels = manifest.models.filter(m => m.id.startsWith('deepseek-v4-flash')); + expect(flashModels.length).toBe(4); + for (const m of flashModels) { + expect(m.expert).toBeUndefined(); + } + }); + + test('专家模式模型 (deepseek-v4-pro)', () => { + const proModels = manifest.models.filter(m => m.id.startsWith('deepseek-v4-pro')); + expect(proModels.length).toBe(4); + for (const m of proModels) { + expect(m.expert).toBe(true); + } + }); + + test('thinking/search 模型配置正确', () => { + expect(manifest.models.filter(m => m.thinking).length).toBe(4); + expect(manifest.models.filter(m => m.search).length).toBe(4); + }); + + test('getTargetUrl 返回正确 URL', () => { + expect(manifest.getTargetUrl()).toBe(TARGET_URL); + }); +}); + +// --- 页面交互 E2E 测试 --- + +test.describe('DeepSeek 页面交互', () => { + test.beforeEach(async ({ page }) => { + await page.goto(TARGET_URL); + await page.waitForSelector('textarea', { timeout: 15000 }); + }); + + test('页面加载后显示模式选择器', async ({ page }) => { + const quickMode = await findRadio(page, ['快速模式', 'Instant']); + const expertMode = await findRadio(page, ['专家模式', 'Expert']); + expect(quickMode).not.toBeNull(); + expect(expertMode).not.toBeNull(); + }); + + test('页面加载后显示功能按钮', async ({ page }) => { + const thinkingBtn = await findButton(page, ['深度思考', 'DeepThink']); + const searchBtn = await findButton(page, ['智能搜索', 'Search']); + expect(thinkingBtn).not.toBeNull(); + expect(searchBtn).not.toBeNull(); + }); + + test('页面加载后显示输入框', async ({ page }) => { + const textarea = page.locator('textarea'); + await expect(textarea).toBeVisible(); + const ph = await textarea.getAttribute('placeholder'); + expect(ph).toBeTruthy(); + }); + + test('默认选中快速模式', async ({ page }) => { + const quickMode = await findRadio(page, ['快速模式', 'Instant']); + expect(quickMode).not.toBeNull(); + await expect(quickMode).toBeChecked(); + }); +}); + +// --- 模式切换测试 --- + +test.describe('模式切换', () => { + test.beforeEach(async ({ page }) => { + await page.goto(TARGET_URL); + await page.waitForSelector('textarea', { timeout: 15000 }); + }); + + test('切换到专家模式', async ({ page }) => { + const expertMode = await findRadio(page, ['专家模式', 'Expert']); + expect(expertMode).not.toBeNull(); + await expertMode.click(); + await expect(expertMode).toBeChecked(); + }); + + test('切换回快速模式', async ({ page }) => { + const quickMode = await findRadio(page, ['快速模式', 'Instant']); + const expertMode = await findRadio(page, ['专家模式', 'Expert']); + expect(quickMode).not.toBeNull(); + expect(expertMode).not.toBeNull(); + + await expertMode.click(); + await expect(expertMode).toBeChecked(); + + await quickMode.click(); + await expect(quickMode).toBeChecked(); + }); +}); + +// --- 功能按钮切换测试 --- + +test.describe('功能按钮切换', () => { + test.beforeEach(async ({ page }) => { + await page.goto(TARGET_URL); + await page.waitForSelector('textarea', { timeout: 15000 }); + }); + + test('深度思考按钮可切换', async ({ page }) => { + const btn = await findButton(page, ['深度思考', 'DeepThink']); + expect(btn).not.toBeNull(); + + const initialSelected = await btn.evaluate(el => el.classList.contains('ds-toggle-button--selected')); + await btn.click(); + await page.waitForTimeout(500); + + const newSelected = await btn.evaluate(el => el.classList.contains('ds-toggle-button--selected')); + expect(newSelected).toBe(!initialSelected); + }); + + test('智能搜索按钮可切换', async ({ page }) => { + const btn = await findButton(page, ['智能搜索', 'Search']); + expect(btn).not.toBeNull(); + + const initialSelected = await btn.evaluate(el => el.classList.contains('ds-toggle-button--selected')); + await btn.click(); + await page.waitForTimeout(500); + + const newSelected = await btn.evaluate(el => el.classList.contains('ds-toggle-button--selected')); + expect(newSelected).toBe(!initialSelected); + }); + + test('深度思考和智能搜索可同时开启', async ({ page }) => { + const thinkingBtn = await findButton(page, ['深度思考', 'DeepThink']); + const searchBtn = await findButton(page, ['智能搜索', 'Search']); + expect(thinkingBtn).not.toBeNull(); + expect(searchBtn).not.toBeNull(); + + // 确保都开启 + for (const btn of [thinkingBtn, searchBtn]) { + const selected = await btn.evaluate(el => el.classList.contains('ds-toggle-button--selected')); + if (!selected) { + await btn.click(); + await page.waitForTimeout(300); + } + } + + // 验证都已开启 + for (const btn of [thinkingBtn, searchBtn]) { + const selected = await btn.evaluate(el => el.classList.contains('ds-toggle-button--selected')); + expect(selected).toBe(true); + } + }); +}); + +// --- 文本生成 E2E 测试 --- + +test.describe('文本生成', () => { + test('快速模式发送消息并获取回复', async ({ page }) => { + await page.goto(TARGET_URL); + await page.waitForSelector('textarea', { timeout: 15000 }); + + const textarea = page.locator('textarea'); + const quickMode = await findRadio(page, ['快速模式', 'Instant']); + + // 确保在快速模式 + if (quickMode && !(await quickMode.isChecked())) { + await quickMode.click(); + await page.waitForTimeout(500); + } + + // 关闭深度思考和搜索 + for (const names of [['深度思考', 'DeepThink'], ['智能搜索', 'Search']]) { + const btn = await findButton(page, names); + if (btn && await btn.evaluate(el => el.classList.contains('ds-toggle-button--selected'))) { + await btn.click(); + await page.waitForTimeout(300); + } + } + + const responsePromise = page.waitForResponse( + resp => resp.url().includes('chat/completion') && resp.request().method() === 'POST', + { timeout: 60000 } + ); + + await textarea.click(); + await textarea.fill('Reply with exactly: hello'); + await page.keyboard.press('Enter'); + + const response = await responsePromise; + expect(response.status()).toBe(200); + const { text } = parseSSEResponse(await response.text()); + expect(text.length).toBeGreaterThan(0); + console.log(`[快速模式] 回复 (${text.length}字符): ${text.slice(0, 80)}...`); + }); + + test('专家模式发送消息并获取回复', async ({ page }) => { + await page.goto(TARGET_URL); + await page.waitForSelector('textarea', { timeout: 15000 }); + + const textarea = page.locator('textarea'); + const expertMode = await findRadio(page, ['专家模式', 'Expert']); + expect(expertMode).not.toBeNull(); + await expertMode.click(); + await page.waitForTimeout(800); + + const responsePromise = page.waitForResponse( + resp => resp.url().includes('chat/completion') && resp.request().method() === 'POST', + { timeout: 90000 } + ); + + await textarea.click(); + await textarea.fill('Reply with exactly: 2'); + await page.keyboard.press('Enter'); + + const response = await responsePromise; + expect(response.status()).toBe(200); + const { text } = parseSSEResponse(await response.text()); + expect(text.length).toBeGreaterThan(0); + console.log(`[专家模式] 回复 (${text.length}字符): ${text.slice(0, 80)}...`); + }); + + test('深度思考模式返回 reasoning 内容', async ({ page }) => { + await page.goto(TARGET_URL); + await page.waitForSelector('textarea', { timeout: 15000 }); + + const textarea = page.locator('textarea'); + const thinkingBtn = await findButton(page, ['深度思考', 'DeepThink']); + expect(thinkingBtn).not.toBeNull(); + + // 开启深度思考 + if (!await thinkingBtn.evaluate(el => el.classList.contains('ds-toggle-button--selected'))) { + await thinkingBtn.click(); + await page.waitForTimeout(500); + } + + const responsePromise = page.waitForResponse( + resp => resp.url().includes('chat/completion') && resp.request().method() === 'POST', + { timeout: 90000 } + ); + + await textarea.click(); + await textarea.fill('Why is the sky blue? One sentence.'); + await page.keyboard.press('Enter'); + + const response = await responsePromise; + expect(response.status()).toBe(200); + const { text, thinking } = parseSSEResponse(await response.text()); + expect(text.length).toBeGreaterThan(0); + console.log(`[深度思考] 思考(${thinking.length}字符) 回复(${text.length}字符): ${text.slice(0, 80)}...`); + }); +}); diff --git a/package.json b/package.json index de4c549..8dfe20c 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,9 @@ "start": "node supervisor.js", "genkey": "node scripts/genkey.js", "init": "node scripts/init.js", - "postinstall": "node scripts/postinstall.js" + "postinstall": "node scripts/postinstall.js", + "test": "npx playwright test", + "test:manifest": "npx playwright test --grep 'Manifest'" }, "imports": { "#config": "./src/config/index.js", @@ -31,5 +33,8 @@ "sharp": "^0.34.5", "socks-proxy-agent": "^8.0.5", "yaml": "^2.8.2" + }, + "devDependencies": { + "@playwright/test": "^1.59.1" } -} \ No newline at end of file +} diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..240acc5 --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,25 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + timeout: 120_000, + retries: 0, + workers: 1, + use: { + headless: false, + viewport: { width: 1280, height: 800 }, + storageState: './data/deepseek-auth.json', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { browserName: 'chromium' }, + testMatch: 'deepseek.test.js', + }, + { + name: 'api', + testMatch: 'api.test.js', + }, + ], +}); diff --git a/src/backend/adapter/deepseek_text.js b/src/backend/adapter/deepseek_text.js index b618703..cb653d9 100644 --- a/src/backend/adapter/deepseek_text.js +++ b/src/backend/adapter/deepseek_text.js @@ -1,5 +1,6 @@ /** * @fileoverview DeepSeek 文本生成适配器 + * @description 支持 DeepSeek V4 快速模式/专家模式,深度思考和智能搜索 */ import { @@ -18,20 +19,73 @@ import { logger } from '../../utils/logger.js'; const TARGET_URL = 'https://chat.deepseek.com/'; const INPUT_SELECTOR = 'textarea'; +// --- 模式名称 (中英文兼容) --- +const MODE_QUICK = ['快速模式', 'Instant']; +const MODE_EXPERT = ['专家模式', 'Expert']; + +// --- 功能按钮名称 (中英文兼容) --- +const BTN_THINKING = ['深度思考', 'DeepThink']; +const BTN_SEARCH = ['智能搜索', 'Search']; + +/** + * 按名称列表查找并操作 Playwright locator (兼容中英文) + * @param {import('playwright-core').Page} page - 页面对象 + * @param {string[]} names - 名称列表 (中英文) + * @param {'radio'|'button'} role - 元素角色 + * @returns {Promise} 匹配的 locator 或 null + */ +async function findByName(page, names, role) { + for (const name of names) { + const locator = page.getByRole(role, { name }); + if (await locator.count() > 0) return locator; + } + return null; +} + +/** + * 切换模式 (快速模式 / 专家模式,兼容中英文) + * @param {import('playwright-core').Page} page - 页面对象 + * @param {string[]} modeNames - 模式名称列表 (中英文) + * @param {object} meta - 日志元数据 + * @returns {Promise} 是否成功切换 + */ +async function switchMode(page, modeNames, meta = {}) { + try { + const radio = await findByName(page, modeNames, 'radio'); + if (!radio) { + logger.debug('适配器', `未找到模式选项: ${modeNames.join('/')}`, meta); + return false; + } + + const isChecked = await radio.isChecked(); + if (!isChecked) { + logger.info('适配器', `切换模式: -> ${modeNames[0]}`, meta); + await safeClick(page, radio, { bias: 'button' }); + await sleep(500, 800); + return true; + } else { + logger.debug('适配器', `已是 ${modeNames[0]} 模式`, meta); + return true; + } + } catch (e) { + logger.warn('适配器', `切换模式 ${modeNames[0]} 失败: ${e.message}`, meta); + return false; + } +} + /** - * 切换功能按钮状态 + * 切换功能按钮状态 (兼容中英文) * @param {import('playwright-core').Page} page - 页面对象 - * @param {string} buttonName - 按钮名称 (DeepThink / Search) + * @param {string[]} buttonNames - 按钮名称列表 (中英文) * @param {boolean} targetState - 目标状态 (true=开启, false=关闭) * @param {object} meta - 日志元数据 * @returns {Promise} 是否成功切换 */ -async function toggleButton(page, buttonName, targetState, meta = {}) { +async function toggleButton(page, buttonNames, targetState, meta = {}) { try { - const btn = page.getByRole('button', { name: buttonName }); - const btnCount = await btn.count(); - if (btnCount === 0) { - logger.debug('适配器', `未找到 ${buttonName} 按钮`, meta); + const btn = await findByName(page, buttonNames, 'button'); + if (!btn) { + logger.debug('适配器', `未找到按钮: ${buttonNames.join('/')}`, meta); return false; } @@ -39,36 +93,41 @@ async function toggleButton(page, buttonName, targetState, meta = {}) { const isSelected = await btn.evaluate(el => el.classList.contains('ds-toggle-button--selected')); if (isSelected !== targetState) { - logger.info('适配器', `切换 ${buttonName}: ${isSelected} -> ${targetState}`, meta); + logger.info('适配器', `切换 ${buttonNames[0]}: ${isSelected} -> ${targetState}`, meta); await safeClick(page, btn, { bias: 'button' }); await sleep(300, 500); return true; } else { - logger.debug('适配器', `${buttonName} 已是目标状态: ${targetState}`, meta); + logger.debug('适配器', `${buttonNames[0]} 已是目标状态: ${targetState}`, meta); return true; } } catch (e) { - logger.warn('适配器', `切换 ${buttonName} 失败: ${e.message}`, meta); + logger.warn('适配器', `切换 ${buttonNames[0]} 失败: ${e.message}`, meta); return false; } } /** - * 配置模型功能 (thinking / search) + * 配置模型功能 (模式切换 + thinking / search,兼容中英文) * @param {import('playwright-core').Page} page - 页面对象 * @param {object} modelConfig - 模型配置 * @param {object} meta - 日志元数据 */ async function configureModel(page, modelConfig, meta = {}) { + const expert = modelConfig?.expert || false; const thinking = modelConfig?.thinking || false; const search = modelConfig?.search || false; - // 切换 DeepThink 状态 - await toggleButton(page, 'DeepThink', thinking, meta); + // 切换模式 (快速模式 / 专家模式) + await switchMode(page, expert ? MODE_EXPERT : MODE_QUICK, meta); + await sleep(200, 400); + + // 切换深度思考状态 + await toggleButton(page, BTN_THINKING, thinking, meta); await sleep(200, 400); - // 切换 Search 状态 - await toggleButton(page, 'Search', search, meta); + // 切换智能搜索状态 + await toggleButton(page, BTN_SEARCH, search, meta); await sleep(200, 400); } @@ -286,19 +345,25 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) { export const manifest = { id: 'deepseek_text', displayName: 'DeepSeek (文本生成)', - description: '使用 DeepSeek 官网生成文本,支持 DeepThink 深度思考和 Search 搜索模式。需要已登录的 DeepSeek 账户。', + description: '使用 DeepSeek V4 官网生成文本,支持快速模式/专家模式、深度思考和智能搜索。需要已登录的 DeepSeek 账户。', // 入口 URL getTargetUrl(config, workerConfig) { return TARGET_URL; }, - // 模型列表 + // 模型列表 (DeepSeek V4) models: [ - { id: 'deepseek-v3.2', imagePolicy: 'forbidden' }, - { id: 'deepseek-v3.2-thinking', imagePolicy: 'forbidden', thinking: true }, - { id: 'deepseek-v3.2-search', imagePolicy: 'forbidden', search: true }, - { id: 'deepseek-v3.2-thinking-search', imagePolicy: 'forbidden', thinking: true, search: true }, + // 快速模式 (deepseek-v4-flash) + { id: 'deepseek-v4-flash', imagePolicy: 'forbidden' }, + { id: 'deepseek-v4-flash-thinking', imagePolicy: 'forbidden', thinking: true }, + { id: 'deepseek-v4-flash-search', imagePolicy: 'forbidden', search: true }, + { id: 'deepseek-v4-flash-thinking-search', imagePolicy: 'forbidden', thinking: true, search: true }, + // 专家模式 (deepseek-v4-pro) + { id: 'deepseek-v4-pro', imagePolicy: 'forbidden', expert: true }, + { id: 'deepseek-v4-pro-thinking', imagePolicy: 'forbidden', expert: true, thinking: true }, + { id: 'deepseek-v4-pro-search', imagePolicy: 'forbidden', expert: true, search: true }, + { id: 'deepseek-v4-pro-thinking-search', imagePolicy: 'forbidden', expert: true, thinking: true, search: true }, ], // 无需导航处理器 From 373385845c1c6241b565629a0ba8cbc562255d41 Mon Sep 17 00:00:00 2001 From: lihejia Date: Fri, 24 Apr 2026 20:59:23 +0800 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20OpenAI=20Respo?= =?UTF-8?q?nses=20API=20=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @ai-sdk/openai v5+ 默认使用 Responses API (/v1/responses) 而非 Chat Completions API (/v1/chat/completions)。添加适配器将 Responses API 请求转换为 Chat Completions 格式处理,支持流式和非流式响应。 主要变更: - 新增 src/server/api/openai/responses.js - Responses API 适配器 - 修改 src/server/api/openai/routes.js - 添加 /v1/responses 路由 支持的事件类型: - response.created / response.completed - response.output_item.added / response.output_item.done - response.content_part.added / response.content_part.done - response.output_text.delta / response.reasoning_text.delta --- src/server/api/openai/responses.js | 466 +++++++++++++++++++++++++++++ src/server/api/openai/routes.js | 6 + 2 files changed, 472 insertions(+) create mode 100644 src/server/api/openai/responses.js diff --git a/src/server/api/openai/responses.js b/src/server/api/openai/responses.js new file mode 100644 index 0000000..1b64cd3 --- /dev/null +++ b/src/server/api/openai/responses.js @@ -0,0 +1,466 @@ +/** + * @fileoverview OpenAI Responses API 适配器 + * @description 将 /v1/responses 请求转换为 /v1/chat/completions 格式 + * @see https://platform.openai.com/docs/api-reference/responses + */ + +import crypto from 'crypto'; +import { logger } from '../../../utils/logger.js'; +import { ERROR_CODES } from '../../errors.js'; +import { sendJson, sendSse, sendSseDone, sendApiError, sendHeartbeat } from '../../respond.js'; +import { parseRequest } from './parse.js'; + +/** + * 将 Responses API input 格式转换为 Chat Completions messages 格式 + * @param {Array} input - Responses API 的 input 数组 + * @returns {Array} Chat Completions 的 messages 数组 + */ +function convertInputToMessages(input) { + if (!Array.isArray(input)) return []; + + return input.map(item => { + const message = { role: item.role }; + + // 处理 content:可能是字符串或数组 + if (typeof item.content === 'string') { + message.content = item.content; + } else if (Array.isArray(item.content)) { + message.content = item.content.map(part => { + // input_text -> text + if (part.type === 'input_text') { + return { type: 'text', text: part.text || '' }; + } + // input_image -> image_url + if (part.type === 'input_image') { + return { + type: 'image_url', + image_url: { url: part.image_url || part.url || '' } + }; + } + // 已经是 Chat Completions 格式 + if (part.type === 'text' || part.type === 'image_url') { + return part; + } + // 其他类型转为文本 + return { type: 'text', text: JSON.stringify(part) }; + }); + } else { + message.content = ''; + } + + return message; + }); +} + +/** + * 生成 Responses API 格式的响应 ID + */ +function generateResponseId() { + return 'resp_' + crypto.randomUUID().replace(/-/g, '').slice(0, 24); +} + +/** + * 构造 Responses API 格式的非流式响应 + */ +function buildResponsesOutput(content, model, reasoningContent) { + const output = []; + + if (reasoningContent) { + output.push({ + type: 'reasoning', + id: 'rs_' + crypto.randomUUID().replace(/-/g, '').slice(0, 24), + status: 'completed', + summary: [], + content: [{ type: 'reasoning_text', text: reasoningContent }] + }); + } + + output.push({ + type: 'message', + id: 'msg_' + crypto.randomUUID().replace(/-/g, '').slice(0, 24), + status: 'completed', + role: 'assistant', + content: [{ + type: 'output_text', + text: content, + annotations: [] + }] + }); + + return { + id: generateResponseId(), + object: 'response', + created_at: Math.floor(Date.now() / 1000), + model, + output, + usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }, + status: 'completed' + }; +} + +/** + * 发送 Responses API 流式事件 + */ +function sendResponseSse(res, event, data) { + if (res.writableEnded) return; + res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); +} + +/** + * 创建 Responses API 路由处理器 + * @param {object} context - 与 chat/completions 共享的上下文 + * @returns {Function} 路由处理函数 + */ +export function createResponsesHandler(context) { + const { + backendName, + getModels, + getImagePolicy, + getModelType, + tempDir, + imageLimit, + queueManager + } = context; + + return async function handleResponses(req, res, requestId) { + const chunks = []; + for await (const chunk of req) { + chunks.push(chunk); + } + + try { + const body = Buffer.concat(chunks).toString(); + const data = JSON.parse(body); + const isStreaming = data.stream === true; + + // 转换 input -> messages + const messages = convertInputToMessages(data.input); + + // 构造 Chat Completions 格式的请求 + const chatRequest = { + model: data.model, + messages, + stream: false, + max_tokens: data.max_output_tokens, + temperature: data.temperature, + top_p: data.top_p + }; + + // 限流检查(非流式) + if (!isStreaming && !queueManager.canAcceptNonStreaming()) { + const status = queueManager.getStatus(); + sendApiError(res, { + code: ERROR_CODES.SERVER_BUSY, + message: `服务器繁忙(队列: ${status.total}/${queueManager.maxQueueSize})。请使用流式模式或稍后重试。` + }); + return; + } + + // 设置响应头 + if (isStreaming) { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive' + }); + } + + // 解析请求(复用 chat/completions 的解析逻辑) + const parseResult = await parseRequest(chatRequest, { + tempDir, + imageLimit, + backendName, + getSupportedModels: getModels, + getImagePolicy, + getModelType, + requestId, + logger + }); + + if (!parseResult.success) { + if (isStreaming) { + sendResponseSse(res, 'error', { + error: { message: parseResult.error.error, type: 'invalid_request_error' } + }); + res.end(); + } else { + sendApiError(res, { + code: parseResult.error.code, + message: parseResult.error.error + }); + } + return; + } + + const { prompt, imagePaths, modelId, modelName } = parseResult.data; + const responseId = generateResponseId(); + + logger.info('服务器', `[Responses] 请求入队: ${prompt.slice(0, 100)}...`, { id: requestId }); + + // 创建代理 res 对象 + const proxyRes = createResponseProxy(res, responseId, modelName, isStreaming); + + // 发送到队列处理 + queueManager.addTask({ + req, + res: proxyRes, + prompt, + imagePaths, + modelId, + modelName, + id: requestId, + isStreaming, + reasoning: false + }); + + } catch (err) { + logger.error('服务器', '[Responses] 请求处理失败', { id: requestId, error: err.message }); + sendApiError(res, { + code: ERROR_CODES.INTERNAL_ERROR, + message: err.message + }); + } + }; +} + +/** + * 创建 Responses API 流式响应代理 + * 将 Chat Completions 的 SSE 事件转换为 Responses API 格式 + */ +function createResponseProxy(res, responseId, modelName, isStreaming) { + let outputIndex = 0; + let contentIndex = 0; + let hasStarted = false; + let headerWritten = false; + let doneSent = false; + let accumulatedText = ''; + let accumulatedReasoning = ''; + let currentItemId = null; + + return { + get writableEnded() { return res.writableEnded; }, + get headersSent() { return res.headersSent || headerWritten; }, + + writeHead(status, headers) { + headerWritten = true; + return res.writeHead(status, headers); + }, + + write(data) { + if (res.writableEnded) return true; + + if (!isStreaming) { + // 非流式模式,直接写入(sendJson 会调用 writeHead + write + end) + return res.write(data); + } + + // 流式模式:解析 Chat Completions SSE 并转换为 Responses API 格式 + const str = typeof data === 'string' ? data : data.toString(); + + // 传递心跳注释(:keepalive) + if (str.startsWith(':')) { + return res.write(data); + } + + const lines = str.split('\n'); + + for (const line of lines) { + if (!line.startsWith('data: ')) continue; + const payload = line.slice(6).trim(); + + if (payload === '[DONE]') { + if (!doneSent && hasStarted) { + doneSent = true; + // 构建 output 内容 + const outputItems = []; + if (accumulatedReasoning) { + outputItems.push({ + type: 'reasoning', + id: 'rs_' + crypto.randomUUID().replace(/-/g, '').slice(0, 24), + status: 'completed', + summary: [], + content: [{ type: 'reasoning_text', text: accumulatedReasoning }] + }); + } + outputItems.push({ + type: 'message', + id: currentItemId, + status: 'completed', + role: 'assistant', + content: [{ type: 'output_text', text: accumulatedText, annotations: [] }] + }); + + sendResponseSse(res, 'response.content_part.done', { + type: 'response.content_part.done', + item_id: currentItemId, + output_index: outputIndex, + content_index: contentIndex, + part: { type: 'output_text', text: accumulatedText, annotations: [] } + }); + sendResponseSse(res, 'response.output_item.done', { + type: 'response.output_item.done', + output_index: outputIndex, + item: { + type: 'message', + id: currentItemId, + status: 'completed', + role: 'assistant', + content: [{ type: 'output_text', text: accumulatedText, annotations: [] }] + } + }); + sendResponseSse(res, 'response.completed', { + type: 'response.completed', + response: { + id: responseId, + object: 'response', + status: 'completed', + model: modelName, + output: outputItems, + usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 } + } + }); + } + continue; + } + + try { + const chunk = JSON.parse(payload); + const choice = chunk.choices?.[0]; + if (!choice) continue; + + const content = choice.delta?.content; + const reasoningContent = choice.delta?.reasoning_content; + + if (!hasStarted && (content || reasoningContent)) { + hasStarted = true; + currentItemId = 'msg_' + crypto.randomUUID().replace(/-/g, '').slice(0, 24); + // 发送 response.created + sendResponseSse(res, 'response.created', { + type: 'response.created', + response: { + id: responseId, + object: 'response', + status: 'in_progress', + model: modelName + } + }); + // 发送 output_item.added + sendResponseSse(res, 'response.output_item.added', { + type: 'response.output_item.added', + output_index: outputIndex, + item: { type: 'message', id: currentItemId, status: 'in_progress', role: 'assistant', content: [] } + }); + sendResponseSse(res, 'response.content_part.added', { + type: 'response.content_part.added', + item_id: currentItemId, + output_index: outputIndex, + content_index: contentIndex, + part: { type: 'output_text', text: '', annotations: [] } + }); + } + + if (content) { + accumulatedText += content; + sendResponseSse(res, 'response.output_text.delta', { + type: 'response.output_text.delta', + item_id: currentItemId, + output_index: outputIndex, + content_index: contentIndex, + delta: content + }); + } + + if (reasoningContent) { + accumulatedReasoning += reasoningContent; + sendResponseSse(res, 'response.reasoning_text.delta', { + type: 'response.reasoning_text.delta', + item_id: currentItemId, + output_index: outputIndex, + content_index: contentIndex, + delta: reasoningContent + }); + } + + if (choice.finish_reason === 'stop' && hasStarted && !doneSent) { + doneSent = true; + // 构建 output 内容 + const outputItems = []; + if (accumulatedReasoning) { + outputItems.push({ + type: 'reasoning', + id: 'rs_' + crypto.randomUUID().replace(/-/g, '').slice(0, 24), + status: 'completed', + summary: [], + content: [{ type: 'reasoning_text', text: accumulatedReasoning }] + }); + } + outputItems.push({ + type: 'message', + id: currentItemId, + status: 'completed', + role: 'assistant', + content: [{ type: 'output_text', text: accumulatedText, annotations: [] }] + }); + + sendResponseSse(res, 'response.content_part.done', { + type: 'response.content_part.done', + item_id: currentItemId, + output_index: outputIndex, + content_index: contentIndex, + part: { type: 'output_text', text: accumulatedText, annotations: [] } + }); + sendResponseSse(res, 'response.output_item.done', { + type: 'response.output_item.done', + output_index: outputIndex, + item: { + type: 'message', + id: currentItemId, + status: 'completed', + role: 'assistant', + content: [{ type: 'output_text', text: accumulatedText, annotations: [] }] + } + }); + sendResponseSse(res, 'response.completed', { + type: 'response.completed', + response: { + id: responseId, + object: 'response', + status: 'completed', + model: modelName, + output: outputItems, + usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 } + } + }); + } + } catch { + // 忽略解析错误 + } + } + + return true; + }, + + end(data) { + if (res.writableEnded) return; + + if (!isStreaming && data) { + // 非流式模式:拦截 sendJson 的响应并转换 + try { + const chatResponse = JSON.parse(data.toString()); + const content = chatResponse.choices?.[0]?.message?.content || ''; + const reasoning = chatResponse.choices?.[0]?.message?.reasoning_content; + const responsesOutput = buildResponsesOutput(content, modelName, reasoning); + + // writeHead 已经被 sendJson 调用过,直接写入转换后的数据 + res.end(JSON.stringify(responsesOutput)); + return; + } catch { + // 解析失败,直接返回原始数据 + return res.end(data); + } + } + + return res.end(data); + } + }; +} diff --git a/src/server/api/openai/routes.js b/src/server/api/openai/routes.js index bfba48d..cb3a2c0 100644 --- a/src/server/api/openai/routes.js +++ b/src/server/api/openai/routes.js @@ -8,6 +8,7 @@ import { logger } from '../../../utils/logger.js'; import { ERROR_CODES } from '../../errors.js'; import { sendJson, sendApiError } from '../../respond.js'; import { parseRequest } from './parse.js'; +import { createResponsesHandler } from './responses.js'; /** * 创建 OpenAI API 路由处理器 @@ -25,6 +26,9 @@ export function createOpenAIRouter(context) { queueManager } = context; + // 创建 Responses API 处理器 + const handleResponses = createResponsesHandler(context); + /** * 处理 GET /v1/models */ @@ -167,6 +171,8 @@ export function createOpenAIRouter(context) { await handleCookies(res, requestId, workerName, domain); } else if (req.method === 'POST' && pathname.startsWith('/chat/completions')) { await handleChatCompletions(req, res, requestId); + } else if (req.method === 'POST' && pathname === '/responses') { + await handleResponses(req, res, requestId); } else { res.writeHead(404); res.end(); From 6fe170f382dd27eeab4446d3dba731d2da0b36cc Mon Sep 17 00:00:00 2001 From: lihejia Date: Fri, 24 Apr 2026 21:13:25 +0800 Subject: [PATCH 3/7] =?UTF-8?q?fix:=20=E5=90=8E=E7=BB=AD=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=E5=A4=8D=E7=94=A8=20DeepSeek=20=E9=A1=B5=E9=9D=A2=E8=80=8C?= =?UTF-8?q?=E9=9D=9E=E5=85=A8=E9=A1=B5=E9=9D=A2=E5=AF=BC=E8=88=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 每次 API 调用都执行 gotoWithCheck 导航到首页会创建全新会话。 改为检测当前页面是否已在 chat.deepseek.com: - 已在页面:优先点击"新对话"按钮,避免全页面重载 - 不在页面:正常导航 - 按钮不可用:回退到全页面导航 --- src/backend/adapter/deepseek_text.js | 37 ++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/src/backend/adapter/deepseek_text.js b/src/backend/adapter/deepseek_text.js index cb653d9..6ee5f24 100644 --- a/src/backend/adapter/deepseek_text.js +++ b/src/backend/adapter/deepseek_text.js @@ -26,6 +26,7 @@ const MODE_EXPERT = ['专家模式', 'Expert']; // --- 功能按钮名称 (中英文兼容) --- const BTN_THINKING = ['深度思考', 'DeepThink']; const BTN_SEARCH = ['智能搜索', 'Search']; +const BTN_NEW_CHAT = ['新对话', 'New Chat']; /** * 按名称列表查找并操作 Playwright locator (兼容中英文) @@ -145,8 +146,40 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) { const waitTimeout = config?.backend?.pool?.waitTimeout ?? 120000; try { - logger.info('适配器', '开启新会话...', meta); - await gotoWithCheck(page, TARGET_URL); + // 智能导航:如果已在 DeepSeek 页面,点击新对话按钮而非全页面导航 + const currentUrl = page.url(); + const isOnDeepSeek = currentUrl.includes('chat.deepseek.com'); + if (isOnDeepSeek) { + logger.info('适配器', '已在 DeepSeek 页面,点击新对话...', meta); + let clicked = false; + // 策略1: 查找新对话按钮 (中英文) + try { + const newChatBtn = await findByName(page, BTN_NEW_CHAT, 'button'); + if (newChatBtn) { + await newChatBtn.click(); + clicked = true; + await sleep(500, 800); + } + } catch { /* 忽略 */ } + // 策略2: 查找链接到首页的新对话链接 + if (!clicked) { + try { + const newChatLink = page.locator(`a[href="${TARGET_URL}"]`).first(); + if (await newChatLink.count() > 0) { + await newChatLink.click(); + clicked = true; + await sleep(500, 800); + } + } catch { /* 忽略 */ } + } + // 策略3: 回退到全页面导航 + if (!clicked) { + await gotoWithCheck(page, TARGET_URL); + } + } else { + logger.info('适配器', '导航到 DeepSeek...', meta); + await gotoWithCheck(page, TARGET_URL); + } // 1. 等待输入框加载 await waitForInput(page, INPUT_SELECTOR, { click: false }); From 10d2614ec0cdb7497a0b2f360b322c669a44f422 Mon Sep 17 00:00:00 2001 From: lihejia Date: Fri, 24 Apr 2026 23:00:53 +0800 Subject: [PATCH 4/7] =?UTF-8?q?test:=20=E8=A1=A5=E5=85=85=20Responses=20AP?= =?UTF-8?q?I=20E2E=20=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 /v1/responses 端点的非流式和流式测试,验证: - 响应格式符合 OpenAI Responses API 规范 - 流式 SSE 事件包含 response.created / delta / response.completed --- e2e/api.test.js | 90 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 89 insertions(+), 1 deletion(-) diff --git a/e2e/api.test.js b/e2e/api.test.js index e75083e..c9f5d41 100644 --- a/e2e/api.test.js +++ b/e2e/api.test.js @@ -40,7 +40,7 @@ async function apiRequest(path, options = {}) { return fetch(url, { ...options, headers }); } -/** 解析 SSE 文本为 chunk 数组 */ +/** 解析 Chat Completions SSE 文本为 chunk 数组 */ function parseSSE(body) { const chunks = []; for (const line of body.split('\n')) { @@ -57,6 +57,25 @@ function parseSSE(body) { return chunks; } +/** 解析 Responses API SSE 文本为 event 数组 */ +function parseResponsesSSE(body) { + const events = []; + const lines = body.split('\n'); + let currentEvent = ''; + for (const line of lines) { + if (line.startsWith('event: ')) { + currentEvent = line.slice(7).trim(); + } else if (line.startsWith('data: ')) { + const data = line.slice(6).trim(); + try { + events.push({ event: currentEvent, data: JSON.parse(data) }); + } catch { /* 忽略非 JSON 行 */ } + currentEvent = ''; + } + } + return events; +} + // --- 测试 --- test.describe('API 认证', () => { @@ -200,3 +219,72 @@ test.describe('POST /v1/chat/completions', () => { expect(body.error).toBeTruthy(); }); }); + +test.describe('POST /v1/responses', () => { + test('非流式请求返回正确格式', async () => { + const resp = await apiRequest('/v1/responses', { + method: 'POST', + body: JSON.stringify({ + model: MODEL, + input: [{ role: 'user', content: 'Reply with exactly: hello' }], + stream: false, + }), + }); + + expect(resp.status).toBe(200); + const body = await resp.json(); + + // Responses API 格式验证 + expect(body.id).toBeTruthy(); + expect(body.id.startsWith('resp_')).toBe(true); + expect(body.object).toBe('response'); + expect(body.status).toBe('completed'); + expect(body.model).toBe(MODEL); + expect(Array.isArray(body.output)).toBe(true); + expect(body.output.length).toBeGreaterThan(0); + + // 最后一个 output 应该是 message 类型 + const message = body.output.find(o => o.type === 'message'); + expect(message).toBeTruthy(); + expect(message.role).toBe('assistant'); + expect(message.content[0].type).toBe('output_text'); + expect(message.content[0].text).toBeTruthy(); + + console.log(`[Responses 非流式] 回复: ${message.content[0].text.slice(0, 80)}`); + }); + + test('流式请求返回 SSE 并最终完成', async () => { + const resp = await apiRequest('/v1/responses', { + method: 'POST', + body: JSON.stringify({ + model: MODEL, + input: [{ role: 'user', content: 'Reply with exactly: hi' }], + stream: true, + }), + }); + + expect(resp.status).toBe(200); + expect(resp.headers.get('content-type')).toContain('text/event-stream'); + + // 读取完整响应后解析 SSE + const body = await resp.text(); + const events = parseResponsesSSE(body); + + // 应该有 response.created 事件 + const created = events.find(e => e.event === 'response.created'); + expect(created).toBeTruthy(); + + // 应该有 delta 事件 + const deltas = events.filter(e => e.event === 'response.output_text.delta'); + expect(deltas.length).toBeGreaterThan(0); + const fullText = deltas.map(e => e.data.delta).join(''); + expect(fullText.length).toBeGreaterThan(0); + + // 应该有 response.completed 事件 + const completed = events.find(e => e.event === 'response.completed'); + expect(completed).toBeTruthy(); + expect(completed.data.response.status).toBe('completed'); + + console.log(`[Responses 流式] 回复 (${deltas.length} deltas): ${fullText.slice(0, 80)}`); + }); +}); From 9fdf29d5d451ff4ce5054144bd5ce719cbe39503 Mon Sep 17 00:00:00 2001 From: lihejia Date: Fri, 24 Apr 2026 23:23:58 +0800 Subject: [PATCH 5/7] =?UTF-8?q?test:=20=E6=B7=BB=E5=8A=A0=E6=8C=81?= =?UTF-8?q?=E7=BB=AD=E6=80=A7=E5=AF=B9=E8=AF=9D=20E2E=20=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 验证浏览器会话复用:连续多次 API 请求均成功返回, 包括 Chat Completions 和 Responses API 两种格式。 --- e2e/api.test.js | 57 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/e2e/api.test.js b/e2e/api.test.js index c9f5d41..2ac1f88 100644 --- a/e2e/api.test.js +++ b/e2e/api.test.js @@ -288,3 +288,60 @@ test.describe('POST /v1/responses', () => { console.log(`[Responses 流式] 回复 (${deltas.length} deltas): ${fullText.slice(0, 80)}`); }); }); + +test.describe('持续性对话', () => { + test('连续多次请求均成功(复用浏览器会话)', async () => { + const prompts = [ + 'Reply with exactly: first', + 'Reply with exactly: second', + 'Reply with exactly: third', + ]; + + for (let i = 0; i < prompts.length; i++) { + const resp = await apiRequest('/v1/chat/completions', { + method: 'POST', + body: JSON.stringify({ + model: MODEL, + messages: [{ role: 'user', content: prompts[i] }], + stream: false, + }), + }); + + expect(resp.status).toBe(200); + const body = await resp.json(); + expect(body.object).toBe('chat.completion'); + expect(body.choices[0].message.content).toBeTruthy(); + + console.log(`[持续对话 ${i + 1}/${prompts.length}] 回复: ${body.choices[0].message.content.slice(0, 80)}`); + } + }); + + test('Responses API 连续多次请求均成功', async () => { + const prompts = [ + 'Reply with exactly: alpha', + 'Reply with exactly: beta', + ]; + + for (let i = 0; i < prompts.length; i++) { + const resp = await apiRequest('/v1/responses', { + method: 'POST', + body: JSON.stringify({ + model: MODEL, + input: [{ role: 'user', content: prompts[i] }], + stream: false, + }), + }); + + expect(resp.status).toBe(200); + const body = await resp.json(); + expect(body.object).toBe('response'); + expect(body.status).toBe('completed'); + + const message = body.output.find(o => o.type === 'message'); + expect(message).toBeTruthy(); + expect(message.content[0].text).toBeTruthy(); + + console.log(`[Responses 持续 ${i + 1}/${prompts.length}] 回复: ${message.content[0].text.slice(0, 80)}`); + } + }); +}); From 9aa51b1ecc9695fd785d0f281c6a2d9c5359e1f7 Mon Sep 17 00:00:00 2001 From: lihejia Date: Fri, 24 Apr 2026 23:35:40 +0800 Subject: [PATCH 6/7] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=AF=B9?= =?UTF-8?q?=E8=AF=9D=E8=BF=9E=E7=BB=AD=E6=80=A7=20-=20=E5=90=8E=E7=BB=AD?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E7=BB=A7=E7=BB=AD=E5=90=8C=E4=B8=80=E5=AF=B9?= =?UTF-8?q?=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 适配器检测页面 URL 判断是否在对话中 - 在对话中:直接输入新消息,保持上下文连续 - 在首页:开始新对话 - 不在 DeepSeek:导航到首页 - 新增上下文连续性 E2E 测试(验证模型记住之前的消息) --- e2e/api.test.js | 35 +++++++++++++++++++++++++++- src/backend/adapter/deepseek_text.js | 35 ++++++---------------------- 2 files changed, 41 insertions(+), 29 deletions(-) diff --git a/e2e/api.test.js b/e2e/api.test.js index 2ac1f88..60e63ce 100644 --- a/e2e/api.test.js +++ b/e2e/api.test.js @@ -290,7 +290,7 @@ test.describe('POST /v1/responses', () => { }); test.describe('持续性对话', () => { - test('连续多次请求均成功(复用浏览器会话)', async () => { + test('连续多次请求均成功(继续同一对话)', async () => { const prompts = [ 'Reply with exactly: first', 'Reply with exactly: second', @@ -316,6 +316,39 @@ test.describe('持续性对话', () => { } }); + test('对话上下文连续性(模型记住之前的消息)', async () => { + // 第一轮:告诉模型一个唯一标识 + const uniqueId = `unicorn_${Date.now()}`; + const resp1 = await apiRequest('/v1/chat/completions', { + method: 'POST', + body: JSON.stringify({ + model: MODEL, + messages: [{ role: 'user', content: `Remember this word: ${uniqueId}. Reply with exactly: remembered` }], + stream: false, + }), + }); + expect(resp1.status).toBe(200); + const body1 = await resp1.json(); + console.log(`[上下文 1/2] 回复: ${body1.choices[0].message.content.slice(0, 80)}`); + + // 第二轮:问模型之前记住的词(不重复提示) + const resp2 = await apiRequest('/v1/chat/completions', { + method: 'POST', + body: JSON.stringify({ + model: MODEL, + messages: [{ role: 'user', content: 'What word did I ask you to remember? Reply with only the word.' }], + stream: false, + }), + }); + expect(resp2.status).toBe(200); + const body2 = await resp2.json(); + const reply = body2.choices[0].message.content; + console.log(`[上下文 2/2] 回复: ${reply.slice(0, 80)}`); + + // 验证模型记住了之前的词 + expect(reply.toLowerCase()).toContain(uniqueId.toLowerCase()); + }); + test('Responses API 连续多次请求均成功', async () => { const prompts = [ 'Reply with exactly: alpha', diff --git a/src/backend/adapter/deepseek_text.js b/src/backend/adapter/deepseek_text.js index 6ee5f24..15cd586 100644 --- a/src/backend/adapter/deepseek_text.js +++ b/src/backend/adapter/deepseek_text.js @@ -146,36 +146,15 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) { const waitTimeout = config?.backend?.pool?.waitTimeout ?? 120000; try { - // 智能导航:如果已在 DeepSeek 页面,点击新对话按钮而非全页面导航 + // 智能导航:如果已在 DeepSeek 对话页面,直接继续对话;否则导航到首页 const currentUrl = page.url(); const isOnDeepSeek = currentUrl.includes('chat.deepseek.com'); - if (isOnDeepSeek) { - logger.info('适配器', '已在 DeepSeek 页面,点击新对话...', meta); - let clicked = false; - // 策略1: 查找新对话按钮 (中英文) - try { - const newChatBtn = await findByName(page, BTN_NEW_CHAT, 'button'); - if (newChatBtn) { - await newChatBtn.click(); - clicked = true; - await sleep(500, 800); - } - } catch { /* 忽略 */ } - // 策略2: 查找链接到首页的新对话链接 - if (!clicked) { - try { - const newChatLink = page.locator(`a[href="${TARGET_URL}"]`).first(); - if (await newChatLink.count() > 0) { - await newChatLink.click(); - clicked = true; - await sleep(500, 800); - } - } catch { /* 忽略 */ } - } - // 策略3: 回退到全页面导航 - if (!clicked) { - await gotoWithCheck(page, TARGET_URL); - } + const isInConversation = isOnDeepSeek && currentUrl !== TARGET_URL && currentUrl !== TARGET_URL.slice(0, -1); + if (isInConversation) { + logger.info('适配器', '继续当前对话...', meta); + } else if (isOnDeepSeek) { + // 在首页但没有对话,直接使用 + logger.info('适配器', '已在 DeepSeek 首页,开始新对话...', meta); } else { logger.info('适配器', '导航到 DeepSeek...', meta); await gotoWithCheck(page, TARGET_URL); From b7e2cb59ad36e746edaa9c159055b9549c99344f Mon Sep 17 00:00:00 2001 From: lihejia Date: Fri, 24 Apr 2026 23:45:31 +0800 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20=E5=AF=B9=E8=AF=9D=E8=BF=87=E9=95=BF?= =?UTF-8?q?=E6=97=B6=E8=87=AA=E5=8A=A8=E9=87=8D=E7=BD=AE=20+=20=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E4=B8=8A=E4=B8=8B=E6=96=87=E8=BF=9E=E7=BB=AD=E6=80=A7?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 适配器:textarea 点击失败时自动点击"新对话"按钮重试 - 测试:新增上下文连续性验证(模型记住之前的消息) --- src/backend/adapter/deepseek_text.js | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/backend/adapter/deepseek_text.js b/src/backend/adapter/deepseek_text.js index 15cd586..d1170e8 100644 --- a/src/backend/adapter/deepseek_text.js +++ b/src/backend/adapter/deepseek_text.js @@ -171,7 +171,25 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) { // 3. 输入提示词 logger.info('适配器', '输入提示词...', meta); - await safeClick(page, INPUT_SELECTOR, { bias: 'input' }); + try { + await safeClick(page, INPUT_SELECTOR, { bias: 'input' }); + } catch (clickErr) { + // 点击失败(对话过长导致 textarea 不可交互),尝试点击新对话按钮重置 + logger.warn('适配器', `输入框点击失败,尝试重置对话: ${clickErr.message}`, meta); + try { + const newChatBtn = await findByName(page, BTN_NEW_CHAT, 'button'); + if (newChatBtn) { + await newChatBtn.click(); + await sleep(800, 1200); + await waitForInput(page, INPUT_SELECTOR, { click: false }); + await safeClick(page, INPUT_SELECTOR, { bias: 'input' }); + } else { + throw clickErr; // 没有新对话按钮,抛出原始错误 + } + } catch { + throw clickErr; // 重试失败,抛出原始错误 + } + } await humanType(page, INPUT_SELECTOR, prompt); await sleep(300, 500);