weflow-cli 1.7.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +3 -2
- package/CHANGELOG.md +1949 -1
- package/OPERATIONS.md +774 -1
- package/README.en.md +93 -7
- package/README.md +31 -6
- package/bin/weflow-cli.ts +934 -27
- package/dist/bin/weflow-cli.js +952 -27
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/mcp-server/index.js +39 -13
- package/dist/mcp-server/index.js.map +1 -1
- package/dist/src/core/appMsgFormat.d.ts +37 -0
- package/dist/src/core/appMsgFormat.d.ts.map +1 -0
- package/dist/src/core/appMsgFormat.js +106 -0
- package/dist/src/core/appMsgFormat.js.map +1 -0
- package/dist/src/core/ntCore.d.ts +19 -2
- package/dist/src/core/ntCore.d.ts.map +1 -1
- package/dist/src/core/ntCore.js +11 -1
- package/dist/src/core/ntCore.js.map +1 -1
- package/dist/src/core/sqlcipherCore.d.ts +0 -12
- package/dist/src/core/sqlcipherCore.d.ts.map +1 -1
- package/dist/src/core/sqlcipherCore.js +5 -64
- package/dist/src/core/sqlcipherCore.js.map +1 -1
- package/dist/src/core/wcdbCore.d.ts +1 -1
- package/dist/src/core/wcdbCore.d.ts.map +1 -1
- package/dist/src/core/wcdbCore.js +5 -1
- package/dist/src/core/wcdbCore.js.map +1 -1
- package/dist/src/panel/client.d.ts +25 -0
- package/dist/src/panel/client.d.ts.map +1 -0
- package/dist/src/panel/client.js +34 -0
- package/dist/src/panel/client.js.map +1 -0
- package/dist/src/panel/endpoint.d.ts +45 -0
- package/dist/src/panel/endpoint.d.ts.map +1 -0
- package/dist/src/panel/endpoint.js +132 -0
- package/dist/src/panel/endpoint.js.map +1 -0
- package/dist/src/panel/launch.d.ts +50 -0
- package/dist/src/panel/launch.d.ts.map +1 -0
- package/dist/src/panel/launch.js +74 -0
- package/dist/src/panel/launch.js.map +1 -0
- package/dist/src/panel/server.d.ts +23 -0
- package/dist/src/panel/server.d.ts.map +1 -0
- package/dist/src/panel/server.js +413 -0
- package/dist/src/panel/server.js.map +1 -0
- package/dist/src/panel/userId.d.ts +37 -0
- package/dist/src/panel/userId.d.ts.map +1 -0
- package/dist/src/panel/userId.js +56 -0
- package/dist/src/panel/userId.js.map +1 -0
- package/dist/src/services/assistantDaemon.d.ts +20 -3
- package/dist/src/services/assistantDaemon.d.ts.map +1 -1
- package/dist/src/services/assistantDaemon.js +46 -9
- package/dist/src/services/assistantDaemon.js.map +1 -1
- package/dist/src/services/assistantEval.d.ts +141 -0
- package/dist/src/services/assistantEval.d.ts.map +1 -0
- package/dist/src/services/assistantEval.js +497 -0
- package/dist/src/services/assistantEval.js.map +1 -0
- package/dist/src/services/assistantMemory.d.ts +106 -4
- package/dist/src/services/assistantMemory.d.ts.map +1 -1
- package/dist/src/services/assistantMemory.js +396 -61
- package/dist/src/services/assistantMemory.js.map +1 -1
- package/dist/src/services/assistantPrivacy.d.ts +15 -2
- package/dist/src/services/assistantPrivacy.d.ts.map +1 -1
- package/dist/src/services/assistantPrivacy.js +18 -4
- package/dist/src/services/assistantPrivacy.js.map +1 -1
- package/dist/src/services/assistantRouter.d.ts +41 -0
- package/dist/src/services/assistantRouter.d.ts.map +1 -0
- package/dist/src/services/assistantRouter.js +151 -0
- package/dist/src/services/assistantRouter.js.map +1 -0
- package/dist/src/services/assistantService.d.ts +209 -3
- package/dist/src/services/assistantService.d.ts.map +1 -1
- package/dist/src/services/assistantService.js +590 -75
- package/dist/src/services/assistantService.js.map +1 -1
- package/dist/src/services/assistantTools.d.ts +205 -1
- package/dist/src/services/assistantTools.d.ts.map +1 -1
- package/dist/src/services/assistantTools.js +1089 -58
- package/dist/src/services/assistantTools.js.map +1 -1
- package/dist/src/services/assistantTrace.d.ts +47 -0
- package/dist/src/services/assistantTrace.d.ts.map +1 -0
- package/dist/src/services/assistantTrace.js +150 -0
- package/dist/src/services/assistantTrace.js.map +1 -0
- package/dist/src/services/chatService.d.ts +1 -1
- package/dist/src/services/chatService.d.ts.map +1 -1
- package/dist/src/services/chatService.js +5 -2
- package/dist/src/services/chatService.js.map +1 -1
- package/dist/src/services/configService.d.ts +9 -0
- package/dist/src/services/configService.d.ts.map +1 -1
- package/dist/src/services/configService.js +15 -8
- package/dist/src/services/configService.js.map +1 -1
- package/dist/src/services/pythonBridge.d.ts +41 -0
- package/dist/src/services/pythonBridge.d.ts.map +1 -0
- package/dist/src/services/pythonBridge.js +119 -0
- package/dist/src/services/pythonBridge.js.map +1 -0
- package/dist/src/services/syncService.d.ts +8 -2
- package/dist/src/services/syncService.d.ts.map +1 -1
- package/dist/src/services/syncService.js +26 -7
- package/dist/src/services/syncService.js.map +1 -1
- package/dist/src/services/wechatMessageService.d.ts +25 -0
- package/dist/src/services/wechatMessageService.d.ts.map +1 -1
- package/dist/src/services/wechatMessageService.js +69 -7
- package/dist/src/services/wechatMessageService.js.map +1 -1
- package/dist/src/utils/atomicWrite.d.ts +4 -0
- package/dist/src/utils/atomicWrite.d.ts.map +1 -0
- package/dist/src/utils/atomicWrite.js +60 -0
- package/dist/src/utils/atomicWrite.js.map +1 -0
- package/dist/src/utils/dateRange.d.ts +15 -0
- package/dist/src/utils/dateRange.d.ts.map +1 -1
- package/dist/src/utils/dateRange.js +47 -0
- package/dist/src/utils/dateRange.js.map +1 -1
- package/dist/src/utils/text.d.ts +18 -0
- package/dist/src/utils/text.d.ts.map +1 -0
- package/dist/src/utils/text.js +20 -0
- package/dist/src/utils/text.js.map +1 -0
- package/docs/AI_INTERFACE.md +2 -0
- package/docs/DECISIONS.md +1424 -0
- package/docs/EXTENDING.md +187 -0
- package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -0
- package/docs/MCP.md +23 -4
- package/docs/PROJECT_STATE.md +299 -98
- package/docs/SETUP.md +22 -0
- package/docs/SYNC_CONTRACT.md +31 -3
- package/mcp-server/index.ts +681 -657
- package/package.json +84 -83
- package/resources/panel/ball-position.cjs +153 -0
- package/resources/panel/index.html +46 -0
- package/resources/panel/main.cjs +447 -0
- package/resources/panel/mascot-focus.png +0 -0
- package/resources/panel/mascot-happy.png +0 -0
- package/resources/panel/mascot-sorry.png +0 -0
- package/resources/panel/mascot-tired.png +0 -0
- package/resources/panel/mascot.png +0 -0
- package/resources/panel/package.json +7 -0
- package/resources/panel/panel.css +407 -0
- package/resources/panel/preload.cjs +72 -0
- package/resources/panel/quick-menu.cjs +197 -0
- package/resources/panel/renderer.js +441 -0
- package/resources/panel/tray-menu.cjs +40 -0
- package/resources/panel/tray.png +0 -0
- package/scripts/_utils.py +403 -54
- package/scripts/annual_report.py +4 -2
- package/scripts/article_notes.py +423 -0
- package/scripts/backfill_articles.py +438 -0
- package/scripts/biz_daily.py +590 -105
- package/scripts/chat_notes.py +470 -0
- package/scripts/classify_daily.py +10 -18
- package/scripts/compile_wiki.py +1059 -46
- package/scripts/create_reading_notes.py +199 -14
- package/scripts/decide.py +253 -0
- package/scripts/draft_reply.py +671 -0
- package/scripts/export_chat_html.py +35 -33
- package/scripts/extract_todos.py +31 -5
- package/scripts/fav_notes.py +318 -0
- package/scripts/fix_topics.py +2 -1
- package/scripts/generate_ai_report.py +214 -9
- package/scripts/generate_html.py +18 -3
- package/scripts/generate_review.py +2 -2
- package/scripts/jev_client.py +294 -0
- package/scripts/jev_probe.py +440 -0
- package/scripts/nt_common.py +82 -0
- package/scripts/nt_decrypt.py +1755 -1460
- package/scripts/pipeline.py +27 -2
- package/scripts/promote_ideas.py +19 -4
- package/scripts/quality_eval.py +707 -0
- package/scripts/rag_chat.py +4 -4
- package/scripts/read_image.py +187 -0
- package/scripts/reply_debt.py +488 -0
- package/scripts/route_cards.py +453 -0
- package/scripts/semantic_search.py +208 -49
- package/scripts/user_notes.py +315 -0
- package/scripts/vault_rag.py +13 -7
- package/scripts/vault_search.py +21 -4
- package/scripts/wiki_lint.py +397 -0
- package/src/core/appMsgFormat.ts +117 -0
- package/src/core/ntCore.ts +27 -2
- package/src/core/sqlcipherCore.ts +5 -61
- package/src/core/wcdbCore.ts +6 -2
- package/src/panel/client.ts +49 -0
- package/src/panel/endpoint.ts +144 -0
- package/src/panel/launch.ts +101 -0
- package/src/panel/server.ts +424 -0
- package/src/panel/userId.ts +72 -0
- package/src/services/assistantDaemon.ts +57 -9
- package/src/services/assistantEval.ts +611 -0
- package/src/services/assistantMemory.ts +413 -57
- package/src/services/assistantPrivacy.ts +18 -4
- package/src/services/assistantRouter.ts +191 -0
- package/src/services/assistantService.ts +671 -74
- package/src/services/assistantTools.ts +1118 -57
- package/src/services/assistantTrace.ts +175 -0
- package/src/services/chatService.ts +6 -2
- package/src/services/configService.ts +403 -385
- package/src/services/pythonBridge.ts +139 -0
- package/src/services/syncService.ts +37 -9
- package/src/services/wechatMessageService.ts +73 -7
- package/src/utils/atomicWrite.ts +56 -0
- package/src/utils/dateRange.ts +46 -0
- package/src/utils/text.ts +20 -0
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 悬浮球的 Electron 外壳。**这里只做窗口与托盘**——所有"用哪个二进制、传什么参数"的决策
|
|
3
|
+
* 都在 `src/panel/launch.ts`(纯函数、能进 CI),所有对话逻辑都在守护进程那边。
|
|
4
|
+
*
|
|
5
|
+
* 三条纪律:
|
|
6
|
+
*
|
|
7
|
+
* 1. **凭据不进 argv**。这个进程是被 `weflow-cli panel` 用 `electron <panel目录>` 拉起来的,
|
|
8
|
+
* 命令行里一个参数都没有。端口与 token 由它**自己读** `~/.weflow-cli/assistant_endpoint.json`
|
|
9
|
+
* (Windows 上同机任何进程都能读命令行,把 token 放进去就是一次看不出破绽的泄漏)。
|
|
10
|
+
* 2. **token 不进渲染进程**。读出来之后立刻种成一个 `HttpOnly` cookie,然后 `loadURL` 那个
|
|
11
|
+
* 相对地址。页面脚本永远拿不到它,但它照常能发 `/api/...` 请求。
|
|
12
|
+
* 3. **不加载任何远程内容,不许导航**。窗口里显示的是助手回复,而回复里有用户的聊天内容。
|
|
13
|
+
*/
|
|
14
|
+
const { app, BrowserWindow, Tray, Menu, globalShortcut, ipcMain, session, shell, screen, nativeImage } = require('electron')
|
|
15
|
+
const { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } = require('node:fs')
|
|
16
|
+
const { join } = require('node:path')
|
|
17
|
+
const { spawn } = require('node:child_process')
|
|
18
|
+
const { pathToFileURL } = require('node:url')
|
|
19
|
+
const os = require('node:os')
|
|
20
|
+
const { trayMenuTemplate } = require('./tray-menu.cjs')
|
|
21
|
+
const { quickMenuTemplate } = require('./quick-menu.cjs')
|
|
22
|
+
|
|
23
|
+
const ENDPOINT_FILE = join(os.homedir(), '.weflow-cli', 'assistant_endpoint.json')
|
|
24
|
+
const POSITION_FILE = join(os.homedir(), '.weflow-cli', 'panel_position.json')
|
|
25
|
+
const COOKIE_NAME = 'weflow_panel'
|
|
26
|
+
/** 收起时气泡淡出多久。页面照着淡、主进程照着等——**只此一处**,两边不会走散。
|
|
27
|
+
* 另外它还是缩窗口的**下限延迟**:页面得先收到通知把气泡摘掉,窗口再缩,否则会闪出一条边。 */
|
|
28
|
+
const CLOSE_FADE_MS = 130
|
|
29
|
+
|
|
30
|
+
let win = null
|
|
31
|
+
let tray = null
|
|
32
|
+
let ballMode = true
|
|
33
|
+
let shortCircuitFailures = 0
|
|
34
|
+
/**
|
|
35
|
+
* 球挂在窗口的哪一侧、竖向离窗口顶多远。展开时由 `bubbleLayout` 定下来(页面照它把球钉住),
|
|
36
|
+
* 收起时用它把球从窗口里算回来——**不能假定是窗口的左上角**。
|
|
37
|
+
*/
|
|
38
|
+
let ballAnchor = { side: 'left', anchorY: 'bottom' }
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 跑一条 CLI 命令(目前只用来停助手)。**这里踩过一个坑,写法不能再简化**:
|
|
42
|
+
*
|
|
43
|
+
* 直接把脚本路径当参数传(`spawn(process.execPath, [cliEntry, 'assistant', 'stop', …])`,
|
|
44
|
+
* 配 `ELECTRON_RUN_AS_NODE=1`)会得到 `error: unknown command '…\cli.cjs'` ——
|
|
45
|
+
* Electron 的 Node 模式里 commander **不会跳过 `process.argv[1]`**,于是它把脚本路径当成了未知命令。
|
|
46
|
+
* 仓库里 `bin/weflow-cli-electron.cjs` 早就记着这个坑,解法是
|
|
47
|
+
* `-e "import('file:///…')" -- <参数>`。实测两种写法:前者报 unknown command,后者正常。
|
|
48
|
+
*
|
|
49
|
+
* 路径要过 `pathToFileURL`:仓库目录**可能是中文/带空格的**(本机就是),
|
|
50
|
+
* `file:///` 后面直接拼原始字符不是合法 URL。它同时负责百分号编码。
|
|
51
|
+
*/
|
|
52
|
+
function spawnCli(cliArgs) {
|
|
53
|
+
try {
|
|
54
|
+
const entry = join(__dirname, '..', '..', 'cli.cjs')
|
|
55
|
+
if (!existsSync(entry)) return false
|
|
56
|
+
const script = `import(${JSON.stringify(pathToFileURL(entry).href)})`
|
|
57
|
+
spawn(process.execPath, ['-e', script, '--', ...cliArgs], {
|
|
58
|
+
detached: true, stdio: 'ignore', windowsHide: true,
|
|
59
|
+
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
|
|
60
|
+
}).unref()
|
|
61
|
+
return true
|
|
62
|
+
} catch (error) {
|
|
63
|
+
console.error('[panel] 调 CLI 失败:', error.message)
|
|
64
|
+
return false
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 位置算术在 `ball-position.cjs`(纯函数、能被 CI 用合成布局测);
|
|
69
|
+
// 这里只负责"问 Electron 要显示器列表",然后把真实的显示器喂给它。
|
|
70
|
+
// 球的尺寸与气泡的尺寸/间距**只有一份**(就在那里)。这里原本又写了一遍 `76` 和一个
|
|
71
|
+
// `CHAT_SIZE`——每样东西两处各写一个数字,改一处就等着它们分家;`panel.css` 那边有对应的
|
|
72
|
+
// `--ball-size` / `--bubble-width` / `--bubble-gap`(CSS 拿不到 JS 的值),那几份由
|
|
73
|
+
// `test/panel-packaging.test.ts` 断言与本模块的这些值相等。
|
|
74
|
+
const { BALL_SIZE, EDGE_MARGIN, BUBBLE_SIZE, bubbleLayout, ballRectInWindow,
|
|
75
|
+
defaultBallPosition: defaultBall, clampInto, resolveStartPosition } = require('./ball-position.cjs')
|
|
76
|
+
|
|
77
|
+
function workAreas() { return screen.getAllDisplays().map((d) => d.workArea) }
|
|
78
|
+
function primaryWorkArea() { return screen.getPrimaryDisplay().workArea }
|
|
79
|
+
|
|
80
|
+
/** 小球该在哪儿。**默认放右下角**——从前不给 `x/y`,于是它落在 Windows 顺手给的位置
|
|
81
|
+
* (本机实测 815,418,屏幕中偏左),那不叫"屏幕角落一个球"。 */
|
|
82
|
+
function defaultBallPosition() { return defaultBall(primaryWorkArea(), BALL_SIZE, EDGE_MARGIN) }
|
|
83
|
+
|
|
84
|
+
/** 这个点落在哪块屏的可见区域里。**问 Electron 的那一半只此一处**。 */
|
|
85
|
+
function workAreaAt(x, y) {
|
|
86
|
+
return screen.getDisplayNearestPoint({ x, y }).workArea
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 把一个矩形挪进它所在那块屏的可见区域(不缩尺寸,只挪)。
|
|
90
|
+
* 收回球形态时要用:气泡拖到屏幕角落,直接从那儿缩回球那么大(96x96)会有一半在屏幕外。 */
|
|
91
|
+
function fitIntoWorkArea(x, y, width, height) {
|
|
92
|
+
return clampInto(x, y, width, workAreaAt(x, y))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 记住的位置还能不能用(不可达就回默认角落) */
|
|
96
|
+
function clampToVisible(pos) {
|
|
97
|
+
return resolveStartPosition(pos, workAreas(), primaryWorkArea(), BALL_SIZE, EDGE_MARGIN)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 位置落盘(原子写:先写 `.tmp` 再改名,同 `configService.save()` 的手法)。
|
|
102
|
+
* 记不住位置不是故障——下次回默认角落就是了,所以失败不抛。
|
|
103
|
+
*/
|
|
104
|
+
function savePosition(x, y) {
|
|
105
|
+
if (!Number.isInteger(x) || !Number.isInteger(y)) return
|
|
106
|
+
try {
|
|
107
|
+
mkdirSync(join(os.homedir(), '.weflow-cli'), { recursive: true })
|
|
108
|
+
const tmp = POSITION_FILE + '.tmp'
|
|
109
|
+
writeFileSync(tmp, JSON.stringify({ x, y }), 'utf8')
|
|
110
|
+
renameSync(tmp, POSITION_FILE)
|
|
111
|
+
} catch { /* 见上 */ }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 读回记住的位置。不存在 / JSON 坏了 / 字段不对 —— 一律当没存过(可达性另判) */
|
|
115
|
+
function readSavedPosition() {
|
|
116
|
+
try {
|
|
117
|
+
const parsed = JSON.parse(readFileSync(POSITION_FILE, 'utf8'))
|
|
118
|
+
if (Number.isInteger(parsed?.x) && Number.isInteger(parsed?.y)) return { x: parsed.x, y: parsed.y }
|
|
119
|
+
} catch { /* 见上 */ }
|
|
120
|
+
return null
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 拖动时记位置。**监听 `move` 而不是 `moved`**:`moved` 靠 `WM_EXITSIZEMOVE` 触发,
|
|
125
|
+
* 而程序化的 `SetWindowPos` **不产生**它——实测"窗口确实挪到了 400,300,位置文件却没写出来"。
|
|
126
|
+
* 真人的拖动会发 `WM_MOVE`,`move` 两边都收;代价是拖动过程中会连续触发,所以加防抖。
|
|
127
|
+
*/
|
|
128
|
+
let saveTimer = null
|
|
129
|
+
function flushPosition() {
|
|
130
|
+
saveTimer = null
|
|
131
|
+
if (!ballMode || !win || win.isDestroyed()) return
|
|
132
|
+
const b = win.getBounds()
|
|
133
|
+
savePosition(b.x, b.y)
|
|
134
|
+
}
|
|
135
|
+
function schedulePositionSave() {
|
|
136
|
+
if (!ballMode) return
|
|
137
|
+
if (saveTimer) clearTimeout(saveTimer)
|
|
138
|
+
saveTimer = setTimeout(flushPosition, 400)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** 读端点文件。**任何一种不可信都当没读出来**(同 `src/panel/endpoint.ts` 的纪律) */
|
|
142
|
+
function readEndpoint() {
|
|
143
|
+
try {
|
|
144
|
+
if (!existsSync(ENDPOINT_FILE)) return null
|
|
145
|
+
const parsed = JSON.parse(readFileSync(ENDPOINT_FILE, 'utf8'))
|
|
146
|
+
if (parsed?.version !== 1 || parsed?.service !== 'weflow-assistant') return null
|
|
147
|
+
if (typeof parsed.port !== 'number' || typeof parsed.token !== 'string') return null
|
|
148
|
+
return parsed
|
|
149
|
+
} catch {
|
|
150
|
+
return null
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function errorPage(message, hint) {
|
|
155
|
+
const html = `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">
|
|
156
|
+
<style>body{margin:0;height:100vh;display:flex;flex-direction:column;justify-content:center;
|
|
157
|
+
padding:18px;background:#17181c;color:#e6e7ea;font:14px/1.6 "Microsoft YaHei",system-ui;
|
|
158
|
+
-webkit-app-region:drag}h2{font-size:15px;margin:0 0 8px}p{margin:0 0 6px;color:#9aa0aa}
|
|
159
|
+
code{color:#4c8bf5;font-size:12px}</style></head><body>
|
|
160
|
+
<h2>${message}</h2><p>${hint}</p></body></html>`
|
|
161
|
+
return 'data:text/html;charset=utf-8,' + encodeURIComponent(html)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function buildWindow() {
|
|
165
|
+
// 上次拖到的位置(取不到或不可达就回右下角)
|
|
166
|
+
const start = clampToVisible(readSavedPosition() ?? defaultBallPosition())
|
|
167
|
+
win = new BrowserWindow({
|
|
168
|
+
x: start.x,
|
|
169
|
+
y: start.y,
|
|
170
|
+
width: BALL_SIZE,
|
|
171
|
+
height: BALL_SIZE,
|
|
172
|
+
frame: false,
|
|
173
|
+
transparent: true,
|
|
174
|
+
resizable: false,
|
|
175
|
+
// 悬浮球不进任务栏;展开成对话窗时再让它进去(见 setMode)
|
|
176
|
+
skipTaskbar: true,
|
|
177
|
+
alwaysOnTop: true,
|
|
178
|
+
fullscreenable: false,
|
|
179
|
+
maximizable: false,
|
|
180
|
+
minimizable: false,
|
|
181
|
+
show: false,
|
|
182
|
+
webPreferences: {
|
|
183
|
+
preload: join(__dirname, 'preload.cjs'),
|
|
184
|
+
contextIsolation: true,
|
|
185
|
+
nodeIntegration: false,
|
|
186
|
+
sandbox: true,
|
|
187
|
+
webviewTag: false,
|
|
188
|
+
// 页面来自 http://127.0.0.1:<port>,不是本地文件
|
|
189
|
+
webSecurity: true,
|
|
190
|
+
},
|
|
191
|
+
})
|
|
192
|
+
win.setAlwaysOnTop(true, 'floating')
|
|
193
|
+
|
|
194
|
+
// **监听要在 `loadURL` 之前挂**:`ready-to-show` 在加载过程中就可能触发(透明窗口上确实会),
|
|
195
|
+
// 而它**不会重放**——挂晚了窗口就永远不显示。第一版是 `await loadURL` 之后才挂的,
|
|
196
|
+
// 于是"有时候看得到球、有时候看不到",而窗口的尺寸与样式全都正常(实测 `vis=False`、
|
|
197
|
+
// 几何无一处可疑:无边框、置顶,尺寸就是**当时**那个 `BALL_SIZE`),光看几何看不出来。
|
|
198
|
+
win.once('ready-to-show', () => { if (!win.isVisible()) win.show() })
|
|
199
|
+
|
|
200
|
+
const endpoint = readEndpoint()
|
|
201
|
+
if (!endpoint) {
|
|
202
|
+
await win.loadURL(errorPage('助手没在运行', '先在终端跑一次 weflow-cli assistant start,再打开本机面板。'))
|
|
203
|
+
} else {
|
|
204
|
+
// token → HttpOnly cookie → 然后才 loadURL。**token 不进 URL、不进 argv、不进渲染进程。**
|
|
205
|
+
await session.defaultSession.cookies.set({
|
|
206
|
+
url: `http://127.0.0.1:${endpoint.port}`,
|
|
207
|
+
name: COOKIE_NAME,
|
|
208
|
+
value: endpoint.token,
|
|
209
|
+
httpOnly: true,
|
|
210
|
+
sameSite: 'strict',
|
|
211
|
+
})
|
|
212
|
+
await win.loadURL(`http://127.0.0.1:${endpoint.port}/panel`)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// 不许导航、不许开新窗口:这个窗口没有地址栏,导航走了用户看不出来
|
|
216
|
+
win.webContents.setWindowOpenHandler(({ url }) => {
|
|
217
|
+
if (/^https?:\/\//.test(url)) shell.openExternal(url) // 外链交给系统浏览器
|
|
218
|
+
return { action: 'deny' }
|
|
219
|
+
})
|
|
220
|
+
win.webContents.on('will-navigate', (event, url) => {
|
|
221
|
+
if (!url.startsWith('http://127.0.0.1:')) {
|
|
222
|
+
event.preventDefault()
|
|
223
|
+
shell.openExternal(url)
|
|
224
|
+
}
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
// 拖动时记住位置。**只记球形态的**:球是锚点,对话窗的尺寸与位置是临时的。
|
|
228
|
+
win.on('move', schedulePositionSave)
|
|
229
|
+
|
|
230
|
+
// 关窗 = 收进托盘,**不**顺手杀掉助手(用户在微信那边可能还要用)
|
|
231
|
+
win.on('close', (event) => {
|
|
232
|
+
if (!app.isQuitting) {
|
|
233
|
+
event.preventDefault()
|
|
234
|
+
win.hide()
|
|
235
|
+
}
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
// 兜底:`ready-to-show` 可能在我们挂上监听之前就已经触发过(透明窗口上确实会),
|
|
239
|
+
// 那时它不会重放,窗口就永远不显示。所以加载完成后再显式确认一次(幂等)。
|
|
240
|
+
if (!win.isVisible()) win.show()
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* 展开/收起。三件事决定了它的形状:
|
|
245
|
+
*
|
|
246
|
+
* 1. **球不能撤**(用户原话:"气泡在它旁边展开,像这个图标在说话,点击收起");
|
|
247
|
+
* 2. **球一个像素都不许动**——所以窗口的矩形由 `bubbleLayout`(`ball-position.cjs`,
|
|
248
|
+
* 有测试)算成"球 + 空隙 + 气泡"的并集,并把球在窗口里的偏移一并返回,页面照着钉;
|
|
249
|
+
* 3. **窗口只改一次大小**。从前那版是逐帧补间窗口几何,用户实测"闪好多才弹出"——
|
|
250
|
+
* 透明窗口每 resize 一次 DWM 就要重新合成一次,一秒钟里改七八次就是七八次闪。
|
|
251
|
+
* 现在:窗口一次到位,**动效全交给页面那边**(气泡的淡入与缩放是合成器做的,不重新布局)。
|
|
252
|
+
*
|
|
253
|
+
* 两次 resize 都发生在**看不见的时刻**:展开时窗口先长出来(那一大片是透明的,页面上
|
|
254
|
+
* 什么都还没显示,随后气泡淡入);收起时页面先把气泡淡掉,淡完这里才缩窗口。所以任何一帧
|
|
255
|
+
* 里"可见的东西"都没有跟着窗口跳过。
|
|
256
|
+
*
|
|
257
|
+
* **形态与方位由这里说了算**:页面只请求,照着 `panel:mode` 做。收起分两步
|
|
258
|
+
* (`fade` → 页面开始淡出;`done` → 页面换成球形态),因为窗口要等淡完才能缩,而页面
|
|
259
|
+
* 要等窗口缩完才能把气泡摘掉——晚一帧就会在球那 96x96 的窗口里看见一条气泡的边。
|
|
260
|
+
*/
|
|
261
|
+
function setMode(mode, opts) {
|
|
262
|
+
if (!win) return
|
|
263
|
+
const chat = mode === 'chat'
|
|
264
|
+
|
|
265
|
+
// **顺序是有讲究的**:Windows 上不可调整大小的窗口会**忽略 `setSize`**。
|
|
266
|
+
// 第一版先 `setResizable(false)` 再 `setSize(76,76)`,结果是"收起"以后页面回到球形态、
|
|
267
|
+
// 置顶也恢复了,窗口却停在对话窗的大小(实测 421x561)——屏幕上就是一个巨大的球。
|
|
268
|
+
// 所以:先解锁 → 改尺寸 → 最后再锁上。
|
|
269
|
+
// 尺寸与位置**一起**走 setContentBounds(详见 `panel:dragMove` 那条注释)。
|
|
270
|
+
// 顺带更正一句旧注释:它把"展开后量到 421x561 而不是 420x560"记成了 setContentBounds 的漂移。
|
|
271
|
+
// 2026-09-24 用探针分开量过(显示缩放 150%):**紧挨着 setContentBounds 调 getContentBounds,
|
|
272
|
+
// 读回来可能是上一拍的值**(实测要 420x560 读回 421x560,只差宽 1px),隔一拍再读就是
|
|
273
|
+
// 420x560,20 次往返也没有任何累积漂移。也就是说那是**读得早**,不是它漂。
|
|
274
|
+
win.setResizable(true)
|
|
275
|
+
const from = win.getContentBounds()
|
|
276
|
+
|
|
277
|
+
if (chat) {
|
|
278
|
+
const layout = bubbleLayout(from, BUBBLE_SIZE, workAreaAt(from.x, from.y))
|
|
279
|
+
ballAnchor = { side: layout.side, anchorY: layout.anchorY }
|
|
280
|
+
win.setAlwaysOnTop(false) // 对话时不必压着别的窗口
|
|
281
|
+
win.setSkipTaskbar(false)
|
|
282
|
+
win.setContentBounds(layout.window)
|
|
283
|
+
win.setResizable(true) // 展开后可调大小
|
|
284
|
+
// **展开之后要自己走到前面来。** 对话形态按设计不置顶,而"谁在最前面"此前完全靠运气:
|
|
285
|
+
// 右键那条路尤其明显——球是置顶的、看得见,可原生菜单一关,Windows 可能把焦点还给了
|
|
286
|
+
// 别的窗口,于是候选出在一个被压在后面的窗口里,用户的原话是"不知道消息返回到哪里了"。
|
|
287
|
+
if (!win.isVisible()) win.show()
|
|
288
|
+
win.moveTop() // 先抬到 z 序顶(这一步不依赖前台锁)
|
|
289
|
+
win.focus() // 再夺焦点:Windows 偶尔会拒绝它,所以上面那步不能省
|
|
290
|
+
ballMode = false
|
|
291
|
+
win.webContents.send('panel:mode', {
|
|
292
|
+
mode: 'chat', side: layout.side, anchorY: layout.anchorY, bubbleHeight: layout.bubbleHeight,
|
|
293
|
+
})
|
|
294
|
+
return { mode: 'chat' }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// 收起:页面先把气泡淡掉(窗口这会儿还是大的,所以淡出真看得见),淡完再缩。
|
|
298
|
+
const fadeMs = opts && opts.animate === false ? 0 : CLOSE_FADE_MS
|
|
299
|
+
const shrink = () => {
|
|
300
|
+
if (!win) return
|
|
301
|
+
// **球的落点从当前窗口与其锚反推**,不是窗口的左上角(球在侧边上)
|
|
302
|
+
const ball = ballRectInWindow(win.getContentBounds(), ballAnchor, BALL_SIZE)
|
|
303
|
+
const fitted = fitIntoWorkArea(ball.x, ball.y, BALL_SIZE, BALL_SIZE)
|
|
304
|
+
savePosition(fitted.x, fitted.y)
|
|
305
|
+
win.setContentBounds({ x: fitted.x, y: fitted.y, width: BALL_SIZE, height: BALL_SIZE })
|
|
306
|
+
win.setAlwaysOnTop(true, 'floating')
|
|
307
|
+
win.setSkipTaskbar(true)
|
|
308
|
+
win.setResizable(false)
|
|
309
|
+
ballMode = true
|
|
310
|
+
win.webContents.send('panel:mode', { mode: 'ball', done: true })
|
|
311
|
+
}
|
|
312
|
+
win.webContents.send('panel:mode', { mode: 'ball', fadeMs })
|
|
313
|
+
// 至少等一帧:页面先把气泡摘掉,这里再缩窗口。反过来那一帧里 76x76 的窗口会露出气泡的一条边
|
|
314
|
+
setTimeout(shrink, Math.max(fadeMs, 16))
|
|
315
|
+
return { mode: 'ball' }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function toggleVisible() {
|
|
319
|
+
if (!win) return
|
|
320
|
+
if (win.isVisible()) win.hide()
|
|
321
|
+
else { win.show(); win.focus() }
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function buildTray() {
|
|
325
|
+
try {
|
|
326
|
+
// 托盘用**单独一张** `tray.png`(64x64:圆盘 + 吉祥物),不是拿 mascot.png 缩:
|
|
327
|
+
// mascot.png 的背景是透明的,深色的猫直接放到深色任务栏上会糊掉——
|
|
328
|
+
// 这和球面那次是**同一个问题**(当时在浅/中/深三种底上并排比过)。
|
|
329
|
+
// 两张图确实要分开维护,但它们的取景本来就不同:一个是球面,一个是托盘图标。
|
|
330
|
+
const icon = nativeImage.createFromPath(join(__dirname, 'tray.png'))
|
|
331
|
+
if (icon.isEmpty()) throw new Error('托盘图标没读出来(tray.png 缺失或损坏)')
|
|
332
|
+
tray = new Tray(icon)
|
|
333
|
+
} catch (error) {
|
|
334
|
+
// 图标缺失不能静默:托盘没了,用户就只剩快捷键和窗口本身
|
|
335
|
+
console.error('[panel] 托盘图标加载失败:', error.message)
|
|
336
|
+
return
|
|
337
|
+
}
|
|
338
|
+
tray.setToolTip('第二大脑')
|
|
339
|
+
// 菜单的**内容**在 `tray-menu.cjs` 里(纯数据、不 require electron),所以它能被 CI 直接测;
|
|
340
|
+
// 这里只负责把真实动作接上去。
|
|
341
|
+
tray.setContextMenu(Menu.buildFromTemplate(trayMenuTemplate({
|
|
342
|
+
toggleVisible,
|
|
343
|
+
// 形态通知由 `setMode` 自己发(不再由调用方发一遍:两处发同一个事件,早晚有一处忘)
|
|
344
|
+
expandToChat: () => { win?.show(); setMode('chat') },
|
|
345
|
+
quitPanel: () => { app.isQuitting = true; app.quit() },
|
|
346
|
+
quitAndStopAssistant: () => {
|
|
347
|
+
app.isQuitting = true
|
|
348
|
+
// 走 CLI 而不是自己 kill:pid 文件与端点文件的清理都在那儿(见 spawnCli 的注释)。
|
|
349
|
+
// **先起进程再退出**:`app.quit()` 之后本进程就没机会 spawn 了。
|
|
350
|
+
spawnCli(['assistant', 'stop', '--yes', '--json'])
|
|
351
|
+
app.quit()
|
|
352
|
+
},
|
|
353
|
+
})))
|
|
354
|
+
tray.on('click', () => toggleVisible())
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
app.isQuitting = false
|
|
358
|
+
|
|
359
|
+
// **单实例**:没有它,启动两次就有两个球、两个托盘,而第二次 `globalShortcut.register` 会静默失败
|
|
360
|
+
if (!app.requestSingleInstanceLock()) {
|
|
361
|
+
app.quit()
|
|
362
|
+
} else {
|
|
363
|
+
app.on('second-instance', () => { win?.show(); win?.focus() })
|
|
364
|
+
|
|
365
|
+
app.whenReady().then(async () => {
|
|
366
|
+
ipcMain.handle('panel:setMode', (_event, mode, opts) => setMode(mode, opts))
|
|
367
|
+
|
|
368
|
+
// 右键"快速回复"。**用原生菜单**:它画在窗口外面,所以球那个小窗口不用先展开
|
|
369
|
+
// (页内菜单做不到这一点——它会被窗口裁掉)。选中回一个名字,关掉没选回 null。
|
|
370
|
+
ipcMain.handle('panel:quickMenu', (_event, labels) => new Promise((resolve) => {
|
|
371
|
+
if (!win || win.isDestroyed()) { resolve(null); return }
|
|
372
|
+
let picked = null
|
|
373
|
+
// 先记下来、关菜单时再 resolve:`click` 与 `popup` 的 callback 谁先到不该决定结果
|
|
374
|
+
const menu = Menu.buildFromTemplate(quickMenuTemplate(labels, {
|
|
375
|
+
// 回传的是**结构化的一项**({kind:'contact'|'action', ...}),不是光一个名字:
|
|
376
|
+
// 菜单现在有两段,"点的是哪一段"必须跟着回来,否则页面只能靠"名字长得像不像动作"来猜。
|
|
377
|
+
// 动作的话术(`prompt`)也由菜单项带回来——id→话术的映射只存一份,不存两处。
|
|
378
|
+
pick: (item) => { picked = item },
|
|
379
|
+
// 菜单最后那一项「关闭悬浮球」:**只把窗口收起来**,不退出(退出那一类在托盘菜单里)。
|
|
380
|
+
// 收起来是可逆的——托盘图标、托盘菜单的「显示 / 收起」、Ctrl+Shift+W 都叫得回来,
|
|
381
|
+
// 所以标签里写明了从哪叫回来。收起之后 `picked` 仍是 null,页面那边什么都不会发。
|
|
382
|
+
close: () => { picked = null; if (win && !win.isDestroyed()) win.hide() },
|
|
383
|
+
}))
|
|
384
|
+
menu.popup({ window: win, callback: () => resolve(picked) })
|
|
385
|
+
}))
|
|
386
|
+
|
|
387
|
+
// 拖拽:按下时记下"窗口位置 + 指针位置",移动时按差值挪窗口。
|
|
388
|
+
// 用差值而不是绝对值,是为了不受 DPI 缩放与多屏坐标原点的影响。
|
|
389
|
+
let dragOrigin = null
|
|
390
|
+
ipcMain.handle('panel:dragStart', (_event, point) => {
|
|
391
|
+
if (!win || win.isDestroyed()) return null
|
|
392
|
+
const from = win.getContentBounds()
|
|
393
|
+
// **尺寸也在这里记一次**(不只是位置)。理由见 dragMove 那段注释:
|
|
394
|
+
// 每次移动去读"当前尺寸"会在**可缩放**的窗口上让尺寸跟着位移一起长。
|
|
395
|
+
dragOrigin = {
|
|
396
|
+
pointerX: point.x, pointerY: point.y, winX: from.x, winY: from.y,
|
|
397
|
+
width: from.width, height: from.height,
|
|
398
|
+
}
|
|
399
|
+
return { x: from.x, y: from.y }
|
|
400
|
+
})
|
|
401
|
+
ipcMain.handle('panel:dragMove', (_event, point) => {
|
|
402
|
+
if (!win || win.isDestroyed() || !dragOrigin) return null
|
|
403
|
+
// **用 setContentBounds 而不是 setPosition**:实测这个窗口(无边框 + 透明)上
|
|
404
|
+
// `setPosition` 会**把窗口一点点撑大**——每次调用宽 +2 左右,连续拖 8 次之后
|
|
405
|
+
// 76x76 变成 108x84(隔离验证:完全不碰鼠标、只调这两个 IPC 也能复现)。
|
|
406
|
+
// 显式把尺寸一起传进去就不会。
|
|
407
|
+
//
|
|
408
|
+
// **尺寸必须在 dragStart 记一次、之后一直用它,不能在每次移动时读"当前尺寸"。**
|
|
409
|
+
// 那是个读-改-写的坑,只有在窗口**可缩放**时才露出来:实测同一个拖动(30 步、位移
|
|
410
|
+
// 30x24)在 `resizable: false` 下尺寸纹丝不动,在 `resizable: true`(对话形态就是)下
|
|
411
|
+
// **尺寸漂了 30x24——正好等于这次位移**。于是"在对话形态下拖球"会让窗口每拖一次大一圈,
|
|
412
|
+
// 而窗口比"气泡 + 间距 + 球"宽出来的部分,全都变成气泡与球之间的那段空档:
|
|
413
|
+
// 用户的原话是"挪动悬浮气泡时气泡和窗口之间的间距越来越远"(重新展开/收起会精确设回
|
|
414
|
+
// 尺寸,所以他看到"点一下又复位了")。
|
|
415
|
+
win.setContentBounds({
|
|
416
|
+
x: Math.round(dragOrigin.winX + point.x - dragOrigin.pointerX),
|
|
417
|
+
y: Math.round(dragOrigin.winY + point.y - dragOrigin.pointerY),
|
|
418
|
+
width: dragOrigin.width,
|
|
419
|
+
height: dragOrigin.height,
|
|
420
|
+
})
|
|
421
|
+
return null
|
|
422
|
+
})
|
|
423
|
+
ipcMain.handle('panel:dragEnd', () => { dragOrigin = null; return null })
|
|
424
|
+
ipcMain.handle('panel:info', () => ({ daemonRunning: !!readEndpoint(), endpointFile: ENDPOINT_FILE }))
|
|
425
|
+
ipcMain.handle('panel:quit', (_event, what) => {
|
|
426
|
+
app.isQuitting = true
|
|
427
|
+
if (what === 'assistant') spawnCli(['assistant', 'stop', '--yes', '--json'])
|
|
428
|
+
app.quit()
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
await buildWindow()
|
|
432
|
+
buildTray()
|
|
433
|
+
|
|
434
|
+
// 全局快捷键。**必须看返回值**:组合键被别的程序占了时 register 只是返回 false,
|
|
435
|
+
// 而用户会以为"按了没反应"。
|
|
436
|
+
const ok = globalShortcut.register('CommandOrControl+Shift+W', () => toggleVisible())
|
|
437
|
+
if (!ok) {
|
|
438
|
+
shortCircuitFailures++
|
|
439
|
+
console.error('[panel] 全局快捷键 Ctrl+Shift+W 注册失败(可能被别的程序占用)')
|
|
440
|
+
}
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
app.on('before-quit', () => { if (saveTimer) flushPosition() })
|
|
444
|
+
app.on('will-quit', () => globalShortcut.unregisterAll())
|
|
445
|
+
// 全部窗口关掉也不退出:这是个托盘常驻应用
|
|
446
|
+
app.on('window-all-closed', () => { /* 故意留空 */ })
|
|
447
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|