@ytchuan/flowlink 1.0.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.
Files changed (35) hide show
  1. package/README.md +176 -0
  2. package/app-ui/dist/assets/BaseCard-BquKu8iW.js +1 -0
  3. package/app-ui/dist/assets/BaseCard-vDGVmnfA.css +1 -0
  4. package/app-ui/dist/assets/ConsumptionView-Bv1BmzJZ.css +1 -0
  5. package/app-ui/dist/assets/ConsumptionView-CKpT-R7X.js +1 -0
  6. package/app-ui/dist/assets/HomeView-BXZYBUAv.js +1 -0
  7. package/app-ui/dist/assets/HomeView-C0c8S1Ss.css +1 -0
  8. package/app-ui/dist/assets/MetricCard-CIRjcKHO.css +1 -0
  9. package/app-ui/dist/assets/MetricCard-irffmzJu.js +1 -0
  10. package/app-ui/dist/assets/PackageView-C0KTgqoW.css +1 -0
  11. package/app-ui/dist/assets/PackageView-DzGcYqiI.js +1 -0
  12. package/app-ui/dist/assets/SettingsView-CKxlifru.js +2 -0
  13. package/app-ui/dist/assets/SettingsView-Co_5arPi.css +1 -0
  14. package/app-ui/dist/assets/StatusBadge-BeZad7-E.js +1 -0
  15. package/app-ui/dist/assets/StatusBadge-jZK8Ckfn.css +1 -0
  16. package/app-ui/dist/assets/index-C2wBeAgU.js +2269 -0
  17. package/app-ui/dist/assets/index-EXys6EPN.css +1 -0
  18. package/app-ui/dist/index.html +13 -0
  19. package/assets/icon.icns +0 -0
  20. package/assets/icon.ico +0 -0
  21. package/assets/logo256.png +0 -0
  22. package/assets/logo512.png +0 -0
  23. package/bin/flowlink.js +24 -0
  24. package/main.js +315 -0
  25. package/package.json +88 -0
  26. package/preload.js +103 -0
  27. package/resources/tray-icon.png +0 -0
  28. package/services/config-writer.js +415 -0
  29. package/services/device.js +104 -0
  30. package/services/scanner.js +254 -0
  31. package/services/scheduler.js +197 -0
  32. package/services/selfcheck.js +154 -0
  33. package/services/settings.js +58 -0
  34. package/services/tray.js +110 -0
  35. package/services/updater.js +26 -0
@@ -0,0 +1,415 @@
1
+ /**
2
+ * 客户端配置写入/备份/删除
3
+ * 向 WorkBuddy / CodeBuddy 写入 API 配置文件
4
+ */
5
+ const fs = require('fs')
6
+ const path = require('path')
7
+ const os = require('os')
8
+ const { app, shell } = require('electron')
9
+ const scanner = require('./scanner')
10
+
11
+ /** 备份目录 */
12
+ function getBackupDir() {
13
+ return path.join(app.getPath('userData'), 'config-backups')
14
+ }
15
+
16
+ /** 客户端配置文件路径映射 */
17
+ function getClientConfigPath(targetKey) {
18
+ const configPath = scanner.getConfigPath(targetKey)
19
+ if (configPath) return configPath
20
+
21
+ // 降级:使用默认路径
22
+ const home = os.homedir()
23
+ const map = {
24
+ workbuddy: path.join(home, '.workbuddy', 'models.json'),
25
+ codebuddy: path.join(home, '.codebuddy', 'models.json'),
26
+ }
27
+ return map[targetKey] || null
28
+ }
29
+
30
+ /**
31
+ * 生成 WorkBuddy 自定义模型条目
32
+ * @param {{ apiEndpoint: string, apiKey: string }} config
33
+ * @param {Array<{ code: string }>} models - app.ts modelPage 返回的模型列表
34
+ * @returns {Array<object>}
35
+ */
36
+ function buildWorkBuddyModels(config, models) {
37
+ return models.map((model) => ({
38
+ id: model.code,
39
+ name: model.code,
40
+ vendor: 'Custom',
41
+ url: config.apiEndpoint,
42
+ apiKey: config.apiKey,
43
+ supportsToolCall: true,
44
+ supportsImages: true,
45
+ supportsReasoning: true,
46
+ useCustomProtocol: false,
47
+ reasoning: {
48
+ supportedEfforts: ['low', 'medium', 'high', 'xhigh', 'max'],
49
+ },
50
+ }))
51
+ }
52
+
53
+ /**
54
+ * 生成 CodeBuddy 自定义模型条目
55
+ * CodeBuddy 格式:vendor 为 'user',无 reasoning/useCustomProtocol 字段
56
+ * @param {{ apiEndpoint: string, apiKey: string }} config
57
+ * @param {Array<{ code: string }>} models
58
+ * @returns {Array<object>}
59
+ */
60
+ function buildCodeBuddyModels(config, models) {
61
+ return models.map((model) => ({
62
+ id: model.code,
63
+ name: model.code,
64
+ vendor: 'user',
65
+ url: config.apiEndpoint,
66
+ apiKey: config.apiKey,
67
+ supportsToolCall: true,
68
+ supportsImages: true,
69
+ supportsReasoning: true,
70
+ }))
71
+ }
72
+
73
+ /** 读取 JSON 数组文件(不存在或格式异常时返回空数组) */
74
+ function readJsonArray(filePath) {
75
+ try {
76
+ if (fs.existsSync(filePath)) {
77
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'))
78
+ if (Array.isArray(data)) return data
79
+ }
80
+ } catch {
81
+ // 文件损坏视为无内容
82
+ }
83
+ return []
84
+ }
85
+
86
+ /**
87
+ * 读取 { "models": [...] } 格式 JSON 文件(不存在或格式异常时返回空数组)
88
+ * @param {string} filePath
89
+ * @returns {Array<object>}
90
+ */
91
+ function readJsonModelsWrapper(filePath) {
92
+ try {
93
+ if (fs.existsSync(filePath)) {
94
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'))
95
+ if (data && Array.isArray(data.models)) return data.models
96
+ }
97
+ } catch {
98
+ // 文件损坏视为无内容
99
+ }
100
+ return []
101
+ }
102
+
103
+ /** 备份文件到备份目录 */
104
+ function backupFile(filePath, targetKey, suffix = '') {
105
+ if (!fs.existsSync(filePath)) return
106
+ const backupDir = getBackupDir()
107
+ const backupName = `${targetKey}${suffix}-${Date.now()}.json`
108
+ fs.mkdirSync(backupDir, { recursive: true })
109
+ fs.copyFileSync(filePath, path.join(backupDir, backupName))
110
+ }
111
+
112
+ /**
113
+ * 写入 WorkBuddy 模型配置(合并写入:保留用户其他模型,替换本软件管理的模型)
114
+ * @param {string} configPath
115
+ * @param {boolean} backup
116
+ * @param {{ apiEndpoint: string, apiKey: string }} config
117
+ * @param {Array<{ code: string }>} models
118
+ */
119
+ function applyWorkBuddyConfig(configPath, backup, config, models) {
120
+ if (backup) {
121
+ backupFile(configPath, 'workbuddy')
122
+ }
123
+
124
+ const managedIds = models.map((m) => m.code)
125
+ const existing = readJsonArray(configPath)
126
+ // 移除同 ID 旧条目后追加最新配置
127
+ const others = existing.filter((m) => !managedIds.includes(m && m.id))
128
+ const merged = [...others, ...buildWorkBuddyModels(config, models)]
129
+
130
+ fs.mkdirSync(path.dirname(configPath), { recursive: true })
131
+ fs.writeFileSync(configPath, JSON.stringify(merged, null, 2), 'utf-8')
132
+ }
133
+
134
+ /**
135
+ * 删除 WorkBuddy 模型配置(仅移除本软件管理的模型条目)
136
+ * @param {string} configPath
137
+ * @param {Array<{ code: string }>} models
138
+ * @returns {boolean} 是否有文件被处理
139
+ */
140
+ function deleteWorkBuddyConfig(configPath, models) {
141
+ if (!fs.existsSync(configPath)) return false
142
+
143
+ const managedIds = models.map((m) => m.code)
144
+ // 无托管模型信息时跳过,避免把用户配置原样重写却误报「已删除」
145
+ if (managedIds.length === 0) return false
146
+
147
+ backupFile(configPath, 'workbuddy', '-delete')
148
+
149
+ const existing = readJsonArray(configPath)
150
+ const remaining = existing.filter((m) => !managedIds.includes(m && m.id))
151
+
152
+ if (remaining.length === 0) {
153
+ fs.unlinkSync(configPath)
154
+ } else {
155
+ fs.writeFileSync(configPath, JSON.stringify(remaining, null, 2), 'utf-8')
156
+ }
157
+ return true
158
+ }
159
+
160
+ /**
161
+ * 写入 CodeBuddy 模型配置
162
+ * CodeBuddy 格式:{ "models": [...] },合并写入:保留用户其他模型,替换本软件管理的模型
163
+ * @param {string} configPath
164
+ * @param {boolean} backup
165
+ * @param {{ apiEndpoint: string, apiKey: string }} config
166
+ * @param {Array<{ code: string }>} models
167
+ */
168
+ function applyCodeBuddyConfig(configPath, backup, config, models) {
169
+ if (backup) {
170
+ backupFile(configPath, 'codebuddy')
171
+ }
172
+
173
+ const managedIds = models.map((m) => m.code)
174
+ const existing = readJsonModelsWrapper(configPath)
175
+ // 移除同 ID 旧条目后追加最新配置
176
+ const others = existing.filter((m) => !managedIds.includes(m && m.id))
177
+ const merged = [...others, ...buildCodeBuddyModels(config, models)]
178
+
179
+ fs.mkdirSync(path.dirname(configPath), { recursive: true })
180
+ fs.writeFileSync(configPath, JSON.stringify({ models: merged }, null, 2), 'utf-8')
181
+ }
182
+
183
+ /**
184
+ * 删除 CodeBuddy 模型配置(仅移除本软件管理的模型条目)
185
+ * @param {string} configPath
186
+ * @param {Array<{ code: string }>} models
187
+ * @returns {boolean} 是否有文件被处理
188
+ */
189
+ function deleteCodeBuddyConfig(configPath, models) {
190
+ if (!fs.existsSync(configPath)) return false
191
+
192
+ const managedIds = models.map((m) => m.code)
193
+ // 无托管模型信息时跳过,避免把用户配置原样重写却误报「已删除」
194
+ if (managedIds.length === 0) return false
195
+
196
+ backupFile(configPath, 'codebuddy', '-delete')
197
+
198
+ const existing = readJsonModelsWrapper(configPath)
199
+ const remaining = existing.filter((m) => !managedIds.includes(m && m.id))
200
+
201
+ if (remaining.length === 0) {
202
+ fs.unlinkSync(configPath)
203
+ } else {
204
+ fs.writeFileSync(configPath, JSON.stringify({ models: remaining }, null, 2), 'utf-8')
205
+ }
206
+ return true
207
+ }
208
+
209
+ /** 生成通用客户端配置内容(CodeBuddy 等) */
210
+ function generateConfigContent(apiEndpoint, apiKey) {
211
+ return JSON.stringify(
212
+ {
213
+ apiEndpoint,
214
+ apiKey,
215
+ updatedAt: new Date().toISOString(),
216
+ },
217
+ null,
218
+ 2
219
+ )
220
+ }
221
+
222
+ /**
223
+ * 写入客户端配置
224
+ * @param {string[]} targets - 目标客户端 key 数组
225
+ * @param {boolean} backup - 是否备份原文件
226
+ * @param {{ apiEndpoint: string, apiKey: string }} config - 配置内容
227
+ * @param {Array<{ code: string }>} models - app.ts modelPage 返回的模型列表
228
+ * @returns {Array<{ target: string, success: boolean, message: string }>}
229
+ */
230
+ function applyConfig(targets, backup, config, models = []) {
231
+ const results = []
232
+
233
+ for (const targetKey of targets) {
234
+ try {
235
+ const configPath = getClientConfigPath(targetKey)
236
+ if (!configPath) {
237
+ results.push({ target: targetKey, success: false, message: '未知的客户端' })
238
+ continue
239
+ }
240
+
241
+ // 检查客户端是否已安装
242
+ const clients = scanner.scan()
243
+ const client = clients.find((c) => c.key === targetKey)
244
+ if (client && !client.installed) {
245
+ results.push({
246
+ target: targetKey,
247
+ success: false,
248
+ message: `${client.name} 未安装,无法配置`,
249
+ })
250
+ continue
251
+ }
252
+
253
+ if (targetKey === 'workbuddy') {
254
+ // WorkBuddy:写入 ~/.workbuddy/models.json 自定义模型列表(顶层数组格式)
255
+ applyWorkBuddyConfig(configPath, backup, config, models)
256
+ } else if (targetKey === 'codebuddy') {
257
+ // CodeBuddy:写入 ~/.codebuddy/models.json({ "models": [...] } 包装格式)
258
+ applyCodeBuddyConfig(configPath, backup, config, models)
259
+ } else {
260
+ // 其他客户端:写入通用配置格式
261
+ if (backup) {
262
+ backupFile(configPath, targetKey)
263
+ }
264
+ fs.mkdirSync(path.dirname(configPath), { recursive: true })
265
+ fs.writeFileSync(configPath, generateConfigContent(config.apiEndpoint, config.apiKey), 'utf-8')
266
+ }
267
+
268
+ results.push({
269
+ target: targetKey,
270
+ success: true,
271
+ message: `${client?.name || targetKey} 配置成功`,
272
+ })
273
+ } catch (err) {
274
+ results.push({
275
+ target: targetKey,
276
+ success: false,
277
+ message: `配置失败:${err.message}`,
278
+ })
279
+ }
280
+ }
281
+
282
+ return results
283
+ }
284
+
285
+ /**
286
+ * 删除客户端配置
287
+ * @param {string[]} targets - 目标客户端 key 数组
288
+ * @param {Array<{ code: string }>} models - app.ts modelPage 返回的模型列表
289
+ * @returns {Array<{ target: string, success: boolean, message: string }>}
290
+ */
291
+ function deleteConfig(targets, models = []) {
292
+ const results = []
293
+
294
+ for (const targetKey of targets) {
295
+ try {
296
+ const configPath = getClientConfigPath(targetKey)
297
+ if (!configPath) {
298
+ results.push({ target: targetKey, success: false, message: '未知的客户端' })
299
+ continue
300
+ }
301
+
302
+ if (targetKey === 'workbuddy') {
303
+ // WorkBuddy:仅清除本软件写入的模型条目
304
+ const handled = deleteWorkBuddyConfig(configPath, models)
305
+ results.push({
306
+ target: targetKey,
307
+ success: true,
308
+ message: handled ? '配置已删除' : '无托管模型配置,跳过',
309
+ })
310
+ continue
311
+ }
312
+
313
+ if (targetKey === 'codebuddy') {
314
+ // CodeBuddy:仅清除本软件写入的模型条目
315
+ const handled = deleteCodeBuddyConfig(configPath, models)
316
+ results.push({
317
+ target: targetKey,
318
+ success: true,
319
+ message: handled ? '配置已删除' : '无托管模型配置,跳过',
320
+ })
321
+ continue
322
+ }
323
+
324
+ if (fs.existsSync(configPath)) {
325
+ // 备份后删除
326
+ backupFile(configPath, targetKey, '-delete')
327
+ fs.unlinkSync(configPath)
328
+ results.push({ target: targetKey, success: true, message: '配置已删除' })
329
+ } else {
330
+ results.push({ target: targetKey, success: true, message: '无配置文件,跳过' })
331
+ }
332
+ } catch (err) {
333
+ results.push({
334
+ target: targetKey,
335
+ success: false,
336
+ message: `删除失败:${err.message}`,
337
+ })
338
+ }
339
+ }
340
+
341
+ return results
342
+ }
343
+
344
+ /**
345
+ * 获取目标客户端的最新一次备份文件路径
346
+ * 仅匹配「配置前备份」(如 workbuddy-1787847532450.json);
347
+ * 排除删除备份(workbuddy-delete-xxx.json)——它是删除前的快照(含托管配置),
348
+ * 还原它会把已删除的托管配置写回,导致「删除后配置复活」
349
+ * @param {string} targetKey
350
+ * @returns {string|null}
351
+ */
352
+ function getLatestBackup(targetKey) {
353
+ const backupDir = getBackupDir()
354
+ if (!fs.existsSync(backupDir)) return null
355
+
356
+ // 严格匹配 `${targetKey}-时间戳.json`,排除 -delete 后缀备份
357
+ const backupPattern = new RegExp(`^${targetKey}-(\\d+)\\.json$`)
358
+ const files = fs
359
+ .readdirSync(backupDir)
360
+ .filter((f) => backupPattern.test(f))
361
+ .sort((a, b) => {
362
+ const tsA = parseInt(a.match(backupPattern)?.[1] || '0', 10)
363
+ const tsB = parseInt(b.match(backupPattern)?.[1] || '0', 10)
364
+ return tsB - tsA
365
+ })
366
+
367
+ return files.length > 0 ? path.join(backupDir, files[0]) : null
368
+ }
369
+
370
+ /**
371
+ * 还原客户端配置(将最后一次自动备份的原文件复制回配置路径)
372
+ * @param {string[]} targets - 目标客户端 key 数组
373
+ * @returns {Array<{ target: string, success: boolean, message: string }>}
374
+ */
375
+ function restoreConfig(targets) {
376
+ const results = []
377
+
378
+ for (const targetKey of targets) {
379
+ try {
380
+ const configPath = getClientConfigPath(targetKey)
381
+ if (!configPath) {
382
+ results.push({ target: targetKey, success: false, message: '未知的客户端' })
383
+ continue
384
+ }
385
+
386
+ const backupPath = getLatestBackup(targetKey)
387
+ if (!backupPath) {
388
+ // 无备份时降级为删除当前配置
389
+ if (fs.existsSync(configPath)) {
390
+ fs.unlinkSync(configPath)
391
+ results.push({ target: targetKey, success: true, message: '无备份,已删除当前配置' })
392
+ } else {
393
+ results.push({ target: targetKey, success: true, message: '无配置文件,跳过' })
394
+ }
395
+ continue
396
+ }
397
+
398
+ fs.copyFileSync(backupPath, configPath)
399
+ results.push({ target: targetKey, success: true, message: '配置已还原' })
400
+ } catch (err) {
401
+ results.push({ target: targetKey, success: false, message: `还原失败:${err.message}` })
402
+ }
403
+ }
404
+
405
+ return results
406
+ }
407
+
408
+ /** 打开备份目录 */
409
+ async function openBackupDir() {
410
+ const backupDir = getBackupDir()
411
+ fs.mkdirSync(backupDir, { recursive: true })
412
+ await shell.openPath(backupDir)
413
+ }
414
+
415
+ module.exports = { applyConfig, deleteConfig, restoreConfig, openBackupDir, getBackupDir }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * 设备标识生成(硬件指纹)
3
+ * 格式:ST-xxxxxxxx-xxxxxxxx-xxxxxxxx-xxxxxxxx
4
+ * 持久化到 userData/device-id.json
5
+ */
6
+ const { app } = require('electron')
7
+ const fs = require('fs')
8
+ const path = require('path')
9
+ const crypto = require('crypto')
10
+ const os = require('os')
11
+ const { execSync } = require('child_process')
12
+
13
+ /** 获取硬件 UUID */
14
+ function getHardwareUUID() {
15
+ try {
16
+ if (process.platform === 'darwin') {
17
+ return execSync(
18
+ 'ioreg -d 2 -c IOPlatformExpertDevice | awk -F\\" \'/IOPlatformUUID/{print $4}\'',
19
+ { encoding: 'utf-8' }
20
+ ).trim()
21
+ } else if (process.platform === 'win32') {
22
+ // 优先 wmic(老版本 Windows)
23
+ try {
24
+ const wmicResult = execSync('wmic csproduct get UUID', { encoding: 'utf-8', timeout: 5000 })
25
+ .split('\n')
26
+ .filter((l) => l.trim() && !l.includes('UUID'))[0]
27
+ ?.trim()
28
+ if (wmicResult) return wmicResult
29
+ } catch {
30
+ // wmic 不可用(Windows 11 25H2+ 已移除),降级到 PowerShell
31
+ }
32
+ return execSync(
33
+ 'powershell -NoProfile -Command "(Get-CimInstance -ClassName Win32_ComputerSystemProduct).UUID"',
34
+ { encoding: 'utf-8', timeout: 10000 }
35
+ ).trim()
36
+ } else if (process.platform === 'linux') {
37
+ return (
38
+ fs.readFileSync('/etc/machine-id', 'utf-8').trim() ||
39
+ fs.readFileSync('/var/lib/dbus/machine-id', 'utf-8').trim()
40
+ )
41
+ }
42
+ } catch {
43
+ // 降级:使用 hostname + networkInterfaces
44
+ }
45
+ return ''
46
+ }
47
+
48
+ /** 生成设备标识 */
49
+ function generateDeviceId() {
50
+ const uuid = getHardwareUUID()
51
+ const hostname = os.hostname()
52
+ const platform = process.platform
53
+ const nics = Object.values(os.networkInterfaces())
54
+ .flat()
55
+ .filter((ni) => ni && !ni.internal && ni.mac !== '00:00:00:00:00:00')
56
+ .map((ni) => ni.mac)
57
+ .join(',')
58
+
59
+ const raw = `${uuid}|${hostname}|${platform}|${nics}`
60
+ const hash = crypto.createHash('sha256').update(raw).digest('hex')
61
+
62
+ // 格式化为 ST-xxxxxxxx-xxxxxxxx-xxxxxxxx-xxxxxxxx
63
+ const parts = [
64
+ hash.slice(0, 8),
65
+ hash.slice(8, 16),
66
+ hash.slice(16, 24),
67
+ hash.slice(24, 32),
68
+ ]
69
+ return `ST-${parts.join('-')}`
70
+ }
71
+
72
+ /** 持久化文件路径 */
73
+ function getDeviceIdFilePath() {
74
+ return path.join(app.getPath('userData'), 'device-id.json')
75
+ }
76
+
77
+ /** 获取设备 ID(不存在则生成并持久化) */
78
+ function getDeviceId() {
79
+ const filePath = getDeviceIdFilePath()
80
+
81
+ // 尝试读取已保存的设备 ID
82
+ try {
83
+ if (fs.existsSync(filePath)) {
84
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'))
85
+ if (data.deviceId && data.deviceId.startsWith('ST-')) {
86
+ return data.deviceId
87
+ }
88
+ }
89
+ } catch {
90
+ // 文件损坏,重新生成
91
+ }
92
+
93
+ // 生成新设备 ID 并保存
94
+ const deviceId = generateDeviceId()
95
+ try {
96
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
97
+ fs.writeFileSync(filePath, JSON.stringify({ deviceId, createdAt: new Date().toISOString() }, null, 2))
98
+ } catch {
99
+ // 保存失败也返回设备 ID(内存中可用)
100
+ }
101
+ return deviceId
102
+ }
103
+
104
+ module.exports = { getDeviceId, generateDeviceId }