Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions .github/workflows/ai-cross-platform.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ on:
- 'tools/build_ai_smoke.cjs'
- 'frontend/components/**'
- 'frontend/composables/useAiApi.js'
- 'frontend/composables/useApi.js'
- 'frontend/composables/chat/useChatSearch.js'
- 'frontend/tests/chat-calendar.test.js'
- 'src/wechat_decrypt_tool/chat_realtime_reader.py'
- 'src/wechat_decrypt_tool/routers/chat.py'
- 'tests/test_chat_calendar_queries.py'
- 'tests/test_chat_message_calendar_heatmap.py'
- 'tests/test_ai*.py'
- 'tests/test_local_search*.py'
- 'pyproject.toml'
Expand All @@ -36,7 +43,8 @@ jobs:
matrix:
os: [windows-2022, macos-14]
runs-on: ${{ matrix.os }}
timeout-minutes: 30
# Windows 的真实 SQLite 持久化测试耗时波动较大,为后续构建预留时间。
timeout-minutes: 60
env:
# macOS setup-python builds may lack SQLite loadable-extension support.
# Use uv-managed CPython for both source checks and the frozen runtime.
Expand All @@ -50,14 +58,25 @@ jobs:
with:
node-version: 22
- run: python -m pip install uv
# macOS 的部分 Python 构建不支持 SQLite 扩展;统一使用 uv 托管解释器。
- name: Install Python with SQLite extension support
run: uv python install 3.11
- run: uv sync --locked --extra build
- run: uv sync --locked --extra build --python 3.11 --managed-python
- name: AI runtime and media (synthetic, no API calls)
run: uv run python tools/verify_ai_runtime.py
- name: AI and local search regression
shell: bash
run: uv run pytest -q tests/test_ai*.py tests/test_local_search*.py
# 首次失败立即输出具体断言,避免超时取消后只留下进度标记。
run: uv run pytest -x -vv --tb=short --durations=15 tests/test_ai*.py tests/test_local_search*.py
- name: Chat calendar and date navigation regression
run: >-
uv run pytest -q
tests/test_chat_calendar_queries.py
tests/test_chat_message_calendar_heatmap.py
tests/test_native_core_realtime.py
tests/test_chat_export_targets.py
tests/test_chat_source_auto.py
tests/test_chat_request_perf.py
- run: npm ci
working-directory: frontend
- run: npx vitest run
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,8 @@ jobs:
- name: Run focused desktop release tests
working-directory: desktop
shell: pwsh
env:
WDA_REQUIRE_NSIS_TEST: "1"
run: |
node --test `
tests/desktop-settings.test.cjs `
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,6 @@ pnpm-lock.yaml

# 高级版演示走片的默认输出目录(website/dev/shot.mjs),只是本地调试产物
pro-shots/

# 隔离验收数据库、真实聊天资料和本地状态快照不得进入版本库。
/tmp/deepagents-migration/
242 changes: 242 additions & 0 deletions design-qa.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"main": "src/main.cjs",
"scripts": {
"dev": "node scripts/dev.cjs",
"dev:static": "npm --prefix ../frontend run generate && cross-env ELECTRON_START_URL=http://127.0.0.1:10392 electron .",
"dev:static": "npm --prefix ../frontend run generate && cross-env WECHAT_TOOL_STATIC_UI=1 electron .",
"build:ui": "npm --prefix ../frontend run generate && node scripts/copy-ui.cjs",
"build:backend": "uv sync --no-editable --extra build --extra voice-transcription && node scripts/build-backend.cjs",
"build:icon": "node scripts/build-icon.cjs",
Expand Down
2 changes: 1 addition & 1 deletion desktop/scripts/ai-packaging.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const { spawnSync } = require('node:child_process');

// AI 的动态导入和清单统一收集,源码与冻结程序使用同一组资源。
function aiPackagingArgs(root, platform = process.platform) {
const packages = ['langchain_core', 'langchain_openai', 'langchain_anthropic', 'langgraph', 'langsmith',
const packages = ['deepagents', 'langchain', 'langchain_core', 'langchain_openai', 'langchain_anthropic', 'langchain_google_genai', 'langgraph', 'langsmith', 'wcmatch', 'bracex',
'pypdf', 'pypdfium2', 'pypdfium2_raw', 'tiktoken', 'docx', 'pptx', 'openpyxl',
'onnxruntime', 'tokenizers', 'sqlite_vec', 'huggingface_hub'];
const args = packages.flatMap(name => ['--collect-all', name]);
Expand Down
37 changes: 26 additions & 11 deletions desktop/src/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ const {
shouldRetryBackendOnDifferentPort,
} = require("./backend-startup.cjs");
const { applyNativeCoreRuntimePolicy } = require("./native-core-runtime.cjs");
const { loadWithRedirect } = require("./renderer-startup.cjs");
const { loadWithRedirect, resolveDesktopUiUrl } = require("./renderer-startup.cjs");
const {
ENV_INTEGRITY_NATIVE_PATH,
ENV_MACOS_DB_KEY_BUNDLE,
Expand Down Expand Up @@ -307,9 +307,12 @@ function getBackendUiUrl() {
}

function getDesktopUiUrl() {
const explicit = String(process.env.ELECTRON_START_URL || "").trim();
if (explicit) return explicit;
return app.isPackaged ? getBackendUiUrl() : "http://localhost:3000";
return resolveDesktopUiUrl({
startUrl: process.env.ELECTRON_START_URL,
backendUrl: getBackendUiUrl(),
isPackaged: app.isPackaged,
staticUi: process.env.WECHAT_TOOL_STATIC_UI === "1",
});
}

function isPortAvailable(port, host) {
Expand Down Expand Up @@ -863,9 +866,12 @@ function getDesktopSettingsPath() {
}

function getPackagedUiDir() {
if (!app.isPackaged) return null;
// 静态开发入口也加载生成文件,重建后必须失效旧页面缓存。
if (!app.isPackaged && process.env.WECHAT_TOOL_STATIC_UI !== "1") return null;
try {
return path.join(process.resourcesPath, "ui");
return process.env.WECHAT_TOOL_UI_DIR?.trim() || (app.isPackaged
? path.join(process.resourcesPath, "ui")
: path.join(__dirname, "..", "..", "frontend", ".output", "public"));
} catch {
return null;
}
Expand Down Expand Up @@ -1303,7 +1309,7 @@ async function applyPendingOutputDirOnStartup() {
}

async function refreshRendererCacheForPackagedUi() {
if (!app.isPackaged) return;
if (!app.isPackaged && process.env.WECHAT_TOOL_STATIC_UI !== "1") return;

const nextBuildId = readPackagedUiBuildId();
if (!nextBuildId) return;
Expand All @@ -1322,6 +1328,7 @@ async function refreshRendererCacheForPackagedUi() {
logMain(`[main] cleared renderer cache for UI build change: ${prevBuildId || "(none)"} -> ${nextBuildId}`);
} catch (err) {
logMain(`[main] failed to clear renderer cache for UI build change: ${err?.message || err}`);
return;
}

loadDesktopSettings();
Expand Down Expand Up @@ -2460,6 +2467,8 @@ function setupRendererLifecycleLogging(win) {
const logRendererLifecycle = (message) => {
logMain(`[renderer] ${message}`);
};
win.on('show', () => logRendererLifecycle('window-show'));
win.on('hide', () => logRendererLifecycle('window-hide'));

logRendererLifecycle(`window-created id=${win.id}`);

Expand Down Expand Up @@ -2568,14 +2577,15 @@ async function loadWithRetry(win, url) {
attempt += 1;
logMain(`[main] loadWithRetry attempt=${attempt} url=${url}`);
try {
await loadWithRedirect(win, url);
const remaining = Math.max(1, 60_000 - (Date.now() - startedAt));
await loadWithRedirect(win, url, Math.min(5000, remaining), remaining);
logMain(`[main] loadWithRetry success attempt=${attempt} elapsedMs=${Date.now() - startedAt} url=${url}`);
return;
} catch (err) {
logMain(
`[main] loadWithRetry failure attempt=${attempt} elapsedMs=${Date.now() - startedAt} url=${url} error=${err?.message || err}`
);
if (Date.now() - startedAt > 60_000) throw new Error(`Failed to load URL in time: ${url}`);
if (Date.now() - startedAt >= 60_000) throw new Error(`Failed to load URL in time: ${url}`);
await new Promise((r) => setTimeout(r, 500));
}
}
Expand Down Expand Up @@ -3469,9 +3479,14 @@ async function ensureMainWindowReady() {
logMain(`[main] debugEnabled=${debugEnabled()} startUrl=${startUrl}`);
await loadWithRetry(win, startUrl);

if (debugEnabled()) {
// 首次创建不能只依赖构造器的显示行为;隐藏启动标志可能留下不可见主窗口。
if (mainWindow === win && !win.isDestroyed()) showMainWindow();

// 常规开发版启动也先显示应用;仅显式调试启动自动打开工具窗口。
if (debugEnabled() && (process.env.WECHAT_DESKTOP_DEBUG === "1" || process.argv.includes("--debug") || process.argv.includes("--devtools"))) {
try {
win.webContents.openDevTools({ mode: "detach" });
// 调试窗口不抢走主窗口焦点,启动后用户能直接看到应用。
win.webContents.openDevTools({ mode: "detach", activate: false });
} catch {}
}

Expand Down
42 changes: 34 additions & 8 deletions desktop/src/renderer-startup.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ function isInternalRedirect(start, destination) {
}

// 首次使用页会中止初始导航;仅在同源目标实际完成加载后接受该跳转。
async function loadWithRedirect(win, url, timeoutMs = 5000) {
async function loadWithRedirect(win, url, timeoutMs = 5000, navigationTimeoutMs = 60000) {
const contents = win.webContents;
let finished = false;
let disposed = false;
let resolveFinished;
let timer;
let deadlineTimer;
const completion = new Promise(resolve => { resolveFinished = resolve; });
const onFinish = () => {
if (isInternalRedirect(url, contents.getURL())) {
Expand All @@ -26,17 +28,41 @@ async function loadWithRedirect(win, url, timeoutMs = 5000) {
}
};
contents.on('did-finish-load', onFinish);
const navigate = async () => {
try {
await win.loadURL(url);
} catch (error) {
if (disposed) throw error;
if (error?.code !== 'ERR_ABORTED' && error?.errno !== -3) throw error;
if (finished) return;
timer = setTimeout(() => resolveFinished(false), timeoutMs);
if (!await completion) throw error;
}
};
const deadline = new Promise((_, reject) => {
deadlineTimer = setTimeout(() => {
// loadURL 自身可能一直不返回;重试循环外的计时不能中断这种挂起。
const error = Object.assign(new Error(`页面加载超时:${url}`), { code: 'ERR_NAVIGATION_TIMEOUT' });
reject(error);
try { if (!contents.isDestroyed?.()) contents.stop?.(); } catch {}
}, Math.max(1, navigationTimeoutMs));
});
try {
await win.loadURL(url);
} catch (error) {
if (error?.code !== 'ERR_ABORTED' && error?.errno !== -3) throw error;
if (finished) return;
timer = setTimeout(() => resolveFinished(false), timeoutMs);
if (!await completion) throw error;
await Promise.race([navigate(), deadline]);
} finally {
disposed = true;
clearTimeout(deadlineTimer);
clearTimeout(timer);
resolveFinished(false);
contents.removeListener('did-finish-load', onFinish);
}
}

module.exports = { loadWithRedirect, isInternalRedirect };
function resolveDesktopUiUrl({ startUrl, backendUrl, isPackaged, staticUi }) {
// 静态模式跟随已经就绪的后端端口,不继承失效的开发服务器地址。
if (staticUi) return backendUrl;
const explicit = String(startUrl || '').trim();
return explicit || (isPackaged ? backendUrl : 'http://localhost:3000');
}

module.exports = { loadWithRedirect, isInternalRedirect, resolveDesktopUiUrl };
80 changes: 80 additions & 0 deletions desktop/tests/acceptance-stream-proxy.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const http = require('node:http')
const { createStreamProxy } = require('../../tools/ai_acceptance_stream_proxy.cjs')

test('图片故障仅影响指定 MD5 的图片接口,恢复后继续读取原内容', async () => {
let upstream = 0
const backend = http.createServer((req, res) => { upstream++; res.end('原内容') })
await new Promise(resolve => backend.listen(0, '127.0.0.1', resolve))
const proxy = await createStreamProxy(`http://127.0.0.1:${backend.address().port}`, { passthrough: true })
const md5 = 'a'.repeat(32)
const path = '/api/chat/media/image?md5=' + md5
try {
assert.throws(() => proxy.setMissingImage('*', true))
proxy.setMissingImage(md5, true)
const missing = await fetch(proxy.url + path)
assert.equal(missing.status, 404)
assert.equal(missing.headers.get('cache-control'), 'no-store')
assert.equal(upstream, 0)
for (const url of ['/api/other?md5=' + md5, '/api/chat/media/image?md5=' + 'b'.repeat(32)]) {
assert.equal(await (await fetch(proxy.url + url)).text(), '原内容')
}
proxy.setMissingImage(md5, false)
assert.equal(await (await fetch(proxy.url + path)).text(), '原内容')
assert.equal(upstream, 3)
assert.deepEqual(proxy.images.map(x => x.missing), [true, false, false])
} finally {
await proxy.close()
backend.closeAllConnections()
await new Promise(resolve => backend.close(resolve))
}
})

test('验收代理切断真实流连接,重连保留 Last-Event-ID,后端保持可用', async () => {
const received = []
const backend = http.createServer((req, res) => {
received.push(req.headers['last-event-id'] || '')
res.writeHead(200, { 'content-type': 'text/event-stream' })
res.write('id: 7\ndata: {"version":1}\n\n')
})
await new Promise(resolve => backend.listen(0, '127.0.0.1', resolve))
const proxy = await createStreamProxy(`http://127.0.0.1:${backend.address().port}`)
try {
const response = await fetch(proxy.url + '/api/ai/agent/events?account=test')
const reader = response.body.getReader()
assert.match(new TextDecoder().decode((await reader.read()).value), /id: 7/)
proxy.drop()
await assert.rejects(reader.read())
proxy.resume()
const again = await fetch(proxy.url + '/api/ai/agent/events?account=test', { headers: { 'Last-Event-ID': '7' } })
await again.body.cancel()
assert.deepEqual(received, ['', '7'])
assert.equal(proxy.requests[1].last_event_id, '7')
} finally {
await proxy.close()
backend.closeAllConnections()
await new Promise(resolve => backend.close(resolve))
}
})

test('同源转发保留普通请求方法和正文,断流不影响其他 API', async () => {
const backend = http.createServer(async (req, res) => {
let body = ''
for await (const chunk of req) body += chunk
res.writeHead(200, { 'content-type':'application/json' })
res.end(JSON.stringify({ method:req.method, body, host:req.headers.host }))
})
await new Promise(resolve=>backend.listen(0,'127.0.0.1',resolve))
const proxy = await createStreamProxy(`http://127.0.0.1:${backend.address().port}`, { passthrough:true })
try {
proxy.drop()
const response=await fetch(proxy.url+'/api/test', { method:'POST',body:'消息正文' })
assert.deepEqual(await response.json(), { method:'POST',body:'消息正文',host:new URL(proxy.url).host })
assert.equal(proxy.requests.length,0)
} finally {
await proxy.close()
backend.closeAllConnections()
await new Promise(resolve=>backend.close(resolve))
}
})
32 changes: 32 additions & 0 deletions desktop/tests/renderer-cache.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');

const source = fs.readFileSync(path.join(__dirname, '../src/main.cjs'), 'utf8');
const cacheCode = source.slice(source.indexOf('async function refreshRendererCacheForPackagedUi()'), source.indexOf('function parseEnvBool('));
function setup({ staticUi = '1', fail = false, previous = 'old' } = {}) {
const calls = [], settings = { lastSeenUiBuildId: previous };
const context = { app: { isPackaged: false }, process: { env: { WECHAT_TOOL_STATIC_UI: staticUi } },
readPackagedUiBuildId: () => 'new', loadDesktopSettings: () => settings, desktopSettings: settings,
persistDesktopSettings: () => calls.push('persist'), logMain: () => {},
session: { defaultSession: { clearCache: async () => { calls.push('cache'); if (fail) throw new Error('locked'); },
clearStorageData: async options => calls.push(options.storages) } } };
vm.createContext(context); vm.runInContext(cacheCode, context);
return { calls, settings, run: () => context.refreshRendererCacheForPackagedUi() };
}
test('静态开发重建后清理 HTTP 缓存,保留草稿和账号存储', async () => {
const state = setup(); await state.run();
assert.deepEqual(JSON.parse(JSON.stringify(state.calls)), ['cache', ['serviceworkers'], 'persist']);
assert.equal(state.settings.lastSeenUiBuildId, 'new');
});
test('缓存清理失败时保留旧版本标记,下次启动仍可重试', async () => {
const state = setup({ fail: true }); await state.run();
assert.deepEqual(state.calls, ['cache']); assert.equal(state.settings.lastSeenUiBuildId, 'old');
});
test('相同构建和普通开发服务器不触发缓存清理', async () => {
for (const options of [{ previous: 'new' }, { staticUi: '' }]) {
const state = setup(options); await state.run(); assert.deepEqual(state.calls, []);
}
});
Loading
Loading