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
|
@@ -8,20 +8,81 @@
|
|
|
8
8
|
* └─ 隐私关卡: 工具结果脱敏后才出境到云端 LLM (本地引擎则完全不出境)
|
|
9
9
|
*/
|
|
10
10
|
import { WechatMessageService } from './wechatMessageService.js'
|
|
11
|
+
import { decideRoute, MIN_NEEDS_TOOL, type FastRouteMode } from './assistantRouter.js'
|
|
12
|
+
import { selectFactsForInjection, frameLocalData } from './assistantMemory.js'
|
|
11
13
|
import { configService } from './configService.js'
|
|
12
14
|
import { AssistantMemory, type ChatTurn } from './assistantMemory.js'
|
|
13
15
|
import { privacyGate } from './assistantPrivacy.js'
|
|
14
|
-
import { TOOL_DEFS, executeTool } from './assistantTools.js'
|
|
16
|
+
import { TOOL_DEFS, availableToolDefs, unavailableToolReason, executeTool } from './assistantTools.js'
|
|
17
|
+
import type { AttachedImage, ToolContext } from './assistantTools.js'
|
|
18
|
+
import { producedContent } from './assistantTools.js'
|
|
19
|
+
import { recordTurn, describeForChat, summarizeArgs, clipReasoning } from './assistantTrace.js'
|
|
20
|
+
import type { StopReason, TurnTrace } from './assistantTrace.js'
|
|
15
21
|
import { appendLog } from './assistantDaemon.js'
|
|
16
22
|
import type { Message, WechatInboundMessage } from '../types.js'
|
|
17
23
|
import { buildEvidenceReviewInput } from './evidenceService.js'
|
|
18
24
|
import { evaluateAssistantAccess } from './assistantRouting.js'
|
|
25
|
+
import { nowLine } from '../utils/dateRange.js'
|
|
26
|
+
import { resolvePanelUserId, PANEL_FALLBACK_BUCKET } from '../panel/userId.js'
|
|
27
|
+
import { startPanelServer, type PanelServer } from '../panel/server.js'
|
|
28
|
+
import { clearEndpoint } from '../panel/endpoint.js'
|
|
19
29
|
|
|
20
30
|
const MAX_TOOL_ROUNDS = 6
|
|
21
31
|
/** 每日 LLM 处理上限 (护栏: 防 bug 死循环/异常流量烧钱; 0 = 不限制) */
|
|
22
32
|
const DAILY_LIMIT = 100
|
|
23
33
|
|
|
24
|
-
const
|
|
34
|
+
const SEP = String.fromCharCode(10) // 提示词里的换行。写成常量,省得在每种写入路径上各自操心转义
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 消息通道。**不是新抽象,是把既有的隐式契约写出来**——`AssistantService` 一直只用
|
|
38
|
+
* `WechatMessageService` 的这四个方法,测试也是靠替换这四个方法驱动整条循环的
|
|
39
|
+
* (`test/assistant-loop.test.ts` 的 `installFakeChannel`)。写成接口是为了让"通道可以不是微信"
|
|
40
|
+
* 这件事在类型上成立,而不是靠"反正 `svc` 可空"。
|
|
41
|
+
*
|
|
42
|
+
* `stop` 写成 `Promise<void> | void`:`WechatMessageService.stop()` 返回 Promise,
|
|
43
|
+
* 而 `AssistantService.stop()` 并不 await 它(`:668`),两边都收。
|
|
44
|
+
*/
|
|
45
|
+
export interface AssistantChannel {
|
|
46
|
+
onMessage(cb: (msg: WechatInboundMessage) => void): void
|
|
47
|
+
startPolling(): Promise<void>
|
|
48
|
+
sendText(conversationId: string, text: string): Promise<boolean>
|
|
49
|
+
stop(): Promise<void> | void
|
|
50
|
+
/**
|
|
51
|
+
* 服务端是否已判这个 token 失效。**可选**:它是"通道实况"的一部分,而通道是可以被替身实现的
|
|
52
|
+
* (测试里的假通道、将来的别的入口),没这个方法的通道一律当成没过期。
|
|
53
|
+
*/
|
|
54
|
+
isTokenExpired?(): boolean
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 一轮从哪来。**决定要不要过白名单**,见 `runTurn` */
|
|
58
|
+
export type TurnOrigin = 'wechat' | 'panel'
|
|
59
|
+
|
|
60
|
+
export interface TurnInput {
|
|
61
|
+
sessionId: string
|
|
62
|
+
text: string
|
|
63
|
+
kind: string
|
|
64
|
+
origin: TurnOrigin
|
|
65
|
+
/** 微信来源才有:白名单判定要它的 senderId / conversationType / mentionedBot */
|
|
66
|
+
message?: WechatInboundMessage
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 一轮的结果。**"回什么"与"往哪回"分开**:这里只说回什么,往哪回由调用方的 `deliver` 决定。
|
|
71
|
+
*
|
|
72
|
+
* 这么分的直接原因是修一个真 bug:泵里原先是 `this.svc!.sendText(...)`,而 `svc` 在没有
|
|
73
|
+
* 微信通道时是 `null`——那个 `!` 只是给 TS 看的。于是"配额用尽"那一次会抛 TypeError,
|
|
74
|
+
* 调用方永远收不到响应(挂到超时)。任何"没有通道的宿主"都会踩到。
|
|
75
|
+
*/
|
|
76
|
+
export type TurnOutcome =
|
|
77
|
+
| { status: 'replied'; text: string }
|
|
78
|
+
/** 微信来源且不在白名单里。**故意不回复**(回一句就等于告诉陌生人这号是活的) */
|
|
79
|
+
| { status: 'denied'; reason: string }
|
|
80
|
+
| { status: 'ignored'; reason: string }
|
|
81
|
+
| { status: 'quota-exceeded'; text: string }
|
|
82
|
+
| { status: 'not-running' }
|
|
83
|
+
| { status: 'error'; message: string }
|
|
84
|
+
|
|
85
|
+
const BASE_PROMPT = `你是"第二大脑", 运行在用户自己的电脑上, 通过微信或本机面板与用户对话。
|
|
25
86
|
你可以调用工具查询用户本地微信数据(会话/聊天记录/收藏), 以及读写关于用户的长期记忆。
|
|
26
87
|
|
|
27
88
|
行为准则:
|
|
@@ -29,17 +90,72 @@ const BASE_PROMPT = `你是"第二大脑", 运行在用户自己的电脑上,
|
|
|
29
90
|
- 用户让你记住某事时, 用 save_memory 工具保存
|
|
30
91
|
- 回复用微信聊天风格, 简洁, 不用 markdown 符号
|
|
31
92
|
- 数据不足时直说需要什么, 不要瞎猜
|
|
32
|
-
- 回复控制在 300 字内,
|
|
93
|
+
- 回复控制在 300 字内, 列表类可放宽
|
|
94
|
+
- **必须真的调用过工具之后**才能对结果下结论。没调工具就说「内容被屏蔽了」「我查到了」
|
|
95
|
+
都是编造, 审计里会留下 tools=0。
|
|
96
|
+
- 工具**失败**时(结果以括号起头, 如「(起草没跑通: …)」), 只许转述工具给出的那个原因。
|
|
97
|
+
工具没写原因、或只给了退出码, 就直说「工具没给出原因」并给下一步(重试一次 / 换个问法),
|
|
98
|
+
**不许替它推测**一个听起来合理的原因——「大概是模型判断不一致」「可能是网络」这类
|
|
99
|
+
都是编的, 用户会当成事实。也不许把工具失败说成"查不到"、把"查不到"说成工具失败。`
|
|
33
100
|
|
|
34
101
|
interface ApiMessage {
|
|
35
102
|
role: 'system' | 'user' | 'assistant' | 'tool'
|
|
36
103
|
content: string
|
|
37
104
|
tool_calls?: any[]
|
|
38
105
|
tool_call_id?: string
|
|
106
|
+
/** 多模态附件:只会由 `look_at_image` 产生,展开见 `toApiMessages` */
|
|
107
|
+
images?: AttachedImage[]
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 把内部消息展开成请求要的形状——**图片只在这一处**变成多模态数组。
|
|
112
|
+
*
|
|
113
|
+
* 其余所有环节(记忆、脱敏、审计、配额)继续按文本 `content` 工作,所以加图片没有把
|
|
114
|
+
* `ApiMessage.content` 变成一个到处都要判类型的联合体。
|
|
115
|
+
*
|
|
116
|
+
* `allowImages` 为假时图片**不发**,并在正文里留下一句说明:静默少发一张图,和"模型
|
|
117
|
+
* 看不见"在下游无法区分——这是这个仓库反复踩过的那类坑。
|
|
118
|
+
*
|
|
119
|
+
* 纯函数,不读配置。是否允许由调用方(`callLLM`)按隐私模式判定。
|
|
120
|
+
*/
|
|
121
|
+
export function toApiMessages(messages: ApiMessage[], allowImages: boolean):
|
|
122
|
+
{ messages: ApiMessage[]; imagesSent: number; imagesDropped: number } {
|
|
123
|
+
let imagesSent = 0
|
|
124
|
+
let imagesDropped = 0
|
|
125
|
+
|
|
126
|
+
const expanded = messages.map(m => {
|
|
127
|
+
const images = m.images ?? []
|
|
128
|
+
if (!images.length) return m
|
|
129
|
+
|
|
130
|
+
if (!allowImages) {
|
|
131
|
+
imagesDropped += images.length
|
|
132
|
+
return { ...m, images: undefined,
|
|
133
|
+
content: `${m.content}\n(另有 ${images.length} 张图片未随本轮发出:当前隐私模式不允许图片出境)` }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
imagesSent += images.length
|
|
137
|
+
return {
|
|
138
|
+
...m,
|
|
139
|
+
images: undefined,
|
|
140
|
+
content: [
|
|
141
|
+
{ type: 'text', text: m.content },
|
|
142
|
+
...images.map(img => ({
|
|
143
|
+
type: 'image_url',
|
|
144
|
+
image_url: { url: `data:${img.mime};base64,${img.b64}` },
|
|
145
|
+
})),
|
|
146
|
+
] as unknown as string,
|
|
147
|
+
}
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
return { messages: expanded, imagesSent, imagesDropped }
|
|
39
151
|
}
|
|
40
152
|
|
|
41
153
|
export class AssistantService {
|
|
42
|
-
|
|
154
|
+
constructor(options: { routeDecider?: (request: unknown) => Promise<any> } = {}) {
|
|
155
|
+
this.routeDecider = options.routeDecider
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private svc: AssistantChannel | null = null
|
|
43
159
|
private memory = new AssistantMemory()
|
|
44
160
|
private running = false
|
|
45
161
|
/** 消息串行队列: 保证 handleMessage 不并发交错 (记忆窗口一致性) */
|
|
@@ -47,6 +163,23 @@ export class AssistantService {
|
|
|
47
163
|
/** 每日用量计数 (内存态, 重启重置 — 配额护栏防烧钱, 无需持久精确) */
|
|
48
164
|
private dailyCount = 0
|
|
49
165
|
private dailyDate = new Date().toDateString()
|
|
166
|
+
/** 快路径的判断调用。留出注入点:测试用假判断层驱动,不联网、不 spawn Python */
|
|
167
|
+
private routeDecider: ((request: unknown) => Promise<any>) | undefined
|
|
168
|
+
/**
|
|
169
|
+
* `handleMessage` 的重入护栏。
|
|
170
|
+
*
|
|
171
|
+
* `turnCalls` / `lastReasoning` / `lastNeedsLocalData` 是**实例字段**,它们的正确性只依赖
|
|
172
|
+
* "handleMessage 被串行调用"这个不变式——而那个不变式今天只写在注释里。第二个入口
|
|
173
|
+
* (本机面板)一出现,早晚有人想加一条直连的快速路径。重入就直接抛,
|
|
174
|
+
* 把"记忆窗口悄悄错乱"换成一声巨响。
|
|
175
|
+
*/
|
|
176
|
+
private inFlight = false
|
|
177
|
+
/** `start()` 给的日志出口。本机入口不经过那个调用点传参,所以存一份 */
|
|
178
|
+
private logSink: ((line: string) => void) | undefined
|
|
179
|
+
/** 本机入口的回环端点(见 src/panel/server.ts 的信任边界) */
|
|
180
|
+
private panelServer: PanelServer | null = null
|
|
181
|
+
/** 本机入口用哪个记忆桶。与微信直聊同一个 id 时才是"共用一个大脑" */
|
|
182
|
+
private memoryBucket: string = PANEL_FALLBACK_BUCKET
|
|
50
183
|
|
|
51
184
|
private engineConfig(): { url: string; model: string; key: string | null; local: boolean } {
|
|
52
185
|
const engine = String(configService.get('aiEngine') || 'deepseek')
|
|
@@ -89,7 +222,15 @@ export class AssistantService {
|
|
|
89
222
|
if (!key && !local) {
|
|
90
223
|
throw new Error('未配置 LLM (config set deepseekApiKey 或切换 ollama)')
|
|
91
224
|
}
|
|
92
|
-
|
|
225
|
+
// 图片出境与正文出境不是一回事:strict 模式(第三方聊天正文不出境)下图片一律不发。
|
|
226
|
+
// `look_at_image` 自己也会拒绝取图,但"工具肯给"与"这道门肯放"是两件事——漏一层就是
|
|
227
|
+
// 聊天图片出境。本地引擎不出机器,不受这条限制。
|
|
228
|
+
const allowImages = privacyGate.mode() !== 'strict' || privacyGate.isLocalInference()
|
|
229
|
+
const { messages: expanded, imagesSent, imagesDropped } = toApiMessages(messages, allowImages)
|
|
230
|
+
// `images` 不是 API 的字段,展平后要去掉;其余字段原样透传。
|
|
231
|
+
const payload = expanded.map(({ images, ...rest }) => rest)
|
|
232
|
+
|
|
233
|
+
const body: Record<string, unknown> = { model, messages: payload, max_tokens: maxTokens, temperature: 0.4 }
|
|
93
234
|
if (tools?.length) { body.tools = tools; body.tool_choice = 'auto' }
|
|
94
235
|
|
|
95
236
|
const res = await fetch(`${url}/chat/completions`, {
|
|
@@ -103,11 +244,23 @@ export class AssistantService {
|
|
|
103
244
|
})
|
|
104
245
|
privacyGate.audit('CLOUD_CALL', JSON.stringify(body).length,
|
|
105
246
|
local ? 'local' : `${url.split('//')[1].split('/')[0]}`)
|
|
247
|
+
// 只记"真的出机器"的那种。本地引擎收图片时没有东西离开本机,记成 SENT 是错的说法。
|
|
248
|
+
if (!local && imagesSent) {
|
|
249
|
+
const bytes = messages.reduce((n, m) => n + (m.images ?? []).reduce((b, i) => b + i.b64.length, 0), 0)
|
|
250
|
+
const ids = messages.flatMap(m => (m.images ?? []).map(i => i.localId)).filter(Boolean).join(',')
|
|
251
|
+
privacyGate.audit('IMAGE_SENT', bytes, `n=${imagesSent}` + (ids ? ` ids=${ids}` : ''))
|
|
252
|
+
}
|
|
253
|
+
if (imagesDropped) privacyGate.audit('IMAGE_HELD', imagesDropped, 'strict')
|
|
106
254
|
if (!res.ok) {
|
|
107
255
|
const t = await res.text().catch(() => '')
|
|
108
256
|
throw new Error(`LLM ${res.status}: ${t.slice(0, 120)}`)
|
|
109
257
|
}
|
|
110
|
-
|
|
258
|
+
// 推理内容(如果这个模型给的话)单独挂在 message.reasoning_content 上,不混进 content。
|
|
259
|
+
// **只在这里接住**:不把它塞回对话(那是模型的独白,不是给用户的内容),只留给轨迹。
|
|
260
|
+
const envelope: any = await res.json()
|
|
261
|
+
const reasoning = envelope?.choices?.[0]?.message?.reasoning_content
|
|
262
|
+
this.lastReasoning = typeof reasoning === 'string' ? reasoning : ''
|
|
263
|
+
return envelope
|
|
111
264
|
}
|
|
112
265
|
|
|
113
266
|
/** 在明确授权后分析本地聊天,输出证据线索而非法律结论。 */
|
|
@@ -141,23 +294,287 @@ export class AssistantService {
|
|
|
141
294
|
}
|
|
142
295
|
}
|
|
143
296
|
|
|
144
|
-
/**
|
|
145
|
-
|
|
146
|
-
|
|
297
|
+
/** 当前的隐私状态,作为**事实**写进系统提示。
|
|
298
|
+
*
|
|
299
|
+
* 曾经这里是一段写死的假设句(「若因严格模式读不到正文…」),实测撞了车:模型把它当成
|
|
300
|
+
* 当前状态,于是**一次工具都没调**就回用户"我调了工具,但内容被严格模式挡掉了"——
|
|
301
|
+
* 审计里 `tools=0`,是编造。现在按实际档位说,且与"没调工具不许下结论"那条配套。
|
|
302
|
+
*/
|
|
303
|
+
private privacyStateLine(): string {
|
|
304
|
+
const mode = privacyGate.mode()
|
|
305
|
+
const local = privacyGate.isLocalInference()
|
|
306
|
+
if (local) {
|
|
307
|
+
return '[隐私] 本地推理:数据不出这台机器,工具结果里的聊天正文是原文,可以如实引用。'
|
|
308
|
+
}
|
|
309
|
+
if (mode === 'strict') {
|
|
310
|
+
return '[隐私] 当前 strict 模式:第三方聊天正文会以「[内容N字已按严格模式屏蔽]」的形式出现在'
|
|
311
|
+
+ '工具结果里,那时你只能看到时间与字数。遇到这种情况如实说明,并给出三条路:'
|
|
312
|
+
+ '①改 balanced(正文发给当前模型,电话/证件/邮箱/密钥/链接仍打码);'
|
|
313
|
+
+ '②改用本地模型 aiEngine=ollama/lmstudio(数据不出机器,屏蔽自动不生效);③保持现状。'
|
|
314
|
+
}
|
|
315
|
+
return `[隐私] 当前 ${mode} 模式:工具结果里的聊天正文是原文(电话/证件/邮箱/密钥/链接已打成`
|
|
316
|
+
+ '占位符),可以直接引用,不必声称被屏蔽。'
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** 组装系统提示: 基础人格 + 隐私状态 + L2 摘要 + L3 事实。
|
|
320
|
+
*
|
|
321
|
+
* 两个纪律:
|
|
322
|
+
* 1. **事实按相关度取一部分**,而不是 30 条全塞——无关的那些是噪声,不只是花钱;
|
|
323
|
+
* 2. 本地数据(摘要、事实)一律**加帧 + 转义框标签**:它们的内容里完全可能写着
|
|
324
|
+
* `</weflow-local-data>` 再跟一段像系统指令的话,不转义就等于让数据自己把框关上。
|
|
325
|
+
*/
|
|
326
|
+
private buildSystemPrompt(userId: string, question = ''): string {
|
|
327
|
+
// 当前时间:**没有它,任何相对时间都是猜**。"上周三""昨天""这周"要变成工具能用的
|
|
328
|
+
// 日期,模型得先知道今天是几号(`get_messages` 的 since/until 就是这么用的)。
|
|
329
|
+
const parts = [BASE_PROMPT, `[当前时间] ${nowLine()}(本机时区)`, this.privacyStateLine()]
|
|
147
330
|
const summary = this.memory.summary(userId)
|
|
148
|
-
if (summary)
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
331
|
+
if (summary) {
|
|
332
|
+
parts.push('[此前对话摘要]' + SEP + frameLocalData('memory.summary', summary))
|
|
333
|
+
}
|
|
334
|
+
const { selected, withheld } = selectFactsForInjection(this.memory.facts(userId), question)
|
|
335
|
+
if (selected.length) {
|
|
336
|
+
const lines = selected.map(f => `· ${f.content}`).join(SEP)
|
|
337
|
+
// 少给了几条要**如实说**:谎报「以下是全部记忆」比少给更糟——模型会以为自己看到了全部。
|
|
338
|
+
const note = withheld > 0 ? `${SEP}(另有 ${withheld} 条与这次问题关系较远,未列出)` : ''
|
|
339
|
+
parts.push('[关于用户的长期记忆]' + SEP + frameLocalData('memory.facts', lines + note))
|
|
340
|
+
}
|
|
341
|
+
return parts.join(SEP)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** 白名单为空的首次配置提示是否已经打过了(只打一次,别把日志刷满) */
|
|
345
|
+
private firstRunHintShown = false
|
|
346
|
+
/** 最近一次路由认为「这条消息需要查本机数据」的概率(0 = 没问过) */
|
|
347
|
+
private lastNeedsLocalData = 0
|
|
348
|
+
/** 最近一次模型调用里**真返回**的推理内容(`reasoning_content`)。默认模型不返回,所以常是空串 */
|
|
349
|
+
private lastReasoning = ''
|
|
350
|
+
/** 每个用户最近一轮的轨迹:微信里发「轨迹」看的就是它(跨进程的历史在文件里,见 assistantTrace) */
|
|
351
|
+
private traces = new Map<string, TurnTrace>()
|
|
352
|
+
/**
|
|
353
|
+
* 本轮已经调过的「工具名 + 参数」。同一条消息里重复调用**同样的参数**不会得到新信息,
|
|
354
|
+
* 而实测(评测的 ambiguous-contact 用例)模型会连着调三次 list_sessions。
|
|
355
|
+
* 消息是串行处理的(queue),所以实例字段在这里是安全的。
|
|
356
|
+
*/
|
|
357
|
+
private turnCalls = new Set<string>()
|
|
358
|
+
|
|
359
|
+
/** 白名单为空时,把"该把谁加进去"连同**完整**的发送者 ID 打一行。
|
|
360
|
+
*
|
|
361
|
+
* 为什么可以打完整的 ID:填白名单用的就是这个值,而它只在入站消息里出现——登录响应给的
|
|
362
|
+
* `ilink_user_id` 与它是不是同一个值**没有验证过**,所以不能拿登录来猜(猜错的后果是
|
|
363
|
+
* "白名单非空、看着配好了、却仍然拒你")。启发式在这里正好用得上:真值自己会来。
|
|
364
|
+
*
|
|
365
|
+
* 三条自我约束:白名单非空时不提示(那时人已经配过了)、只对**直聊**提示(群里的
|
|
366
|
+
* sender_id 是群成员,把它加进白名单是错的)、只提示一次。
|
|
367
|
+
* `assistant log --json` 照旧只回元数据,不返回日志内容。
|
|
368
|
+
*/
|
|
369
|
+
private maybeAnnounceWhitelistBootstrap(access: { reason: string }, msg: WechatInboundMessage,
|
|
370
|
+
onLog?: (line: string) => void): void {
|
|
371
|
+
if (this.firstRunHintShown) return
|
|
372
|
+
if (String(configService.get('assistantWhitelist') || '').trim()) return
|
|
373
|
+
if (access.reason !== 'direct-not-whitelisted') return
|
|
374
|
+
this.firstRunHintShown = true
|
|
375
|
+
const line = '[首次配置] 白名单为空,所以谁都没回。若刚才这条是你本人发的,执行:'
|
|
376
|
+
+ ` weflow-cli config set assistantWhitelist "${msg.senderId}"`
|
|
377
|
+
onLog?.(line)
|
|
378
|
+
appendLog(line)
|
|
379
|
+
privacyGate.audit('WHITELIST_BOOTSTRAP_HINT', 0, 'whitelist-empty')
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** 快路径开关。默认 `off`:不改行为,直到有人愿意盯着它(D-035) */
|
|
383
|
+
private fastRouteMode(): FastRouteMode {
|
|
384
|
+
const raw = String(configService.get('assistantFastRoute') || '').trim().toLowerCase()
|
|
385
|
+
return raw === 'on' || raw === 'log' ? raw : 'off'
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** ReAct 主循环:一轮一轮问模型,直到它给出不带工具调用的答复。
|
|
389
|
+
*
|
|
390
|
+
* 抽成方法是因为守卫(见 handleMessage)要在"顶回去一次"之后**再跑一遍同一条循环**——
|
|
391
|
+
* 两处各写一份,早晚有一处会漏掉既有的容错(参数解析、工具审计、轮数上限)。
|
|
392
|
+
*/
|
|
393
|
+
private async runReactLoop(userId: string, messages: ApiMessage[],
|
|
394
|
+
trace: TurnTrace): Promise<{ reply: string; toolCalls: number }> {
|
|
395
|
+
let reply = ''
|
|
396
|
+
let toolCalls = 0
|
|
397
|
+
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
|
|
398
|
+
// 只摆这台机器**跑得了**的工具:摆了跑不了的,模型会去试、拿一个错回来
|
|
399
|
+
const data = await this.callLLM(messages, availableToolDefs())
|
|
400
|
+
const msg = data.choices?.[0]?.message
|
|
401
|
+
if (!msg) throw new Error('LLM 返回为空')
|
|
402
|
+
trace.rounds += 1
|
|
403
|
+
// 模型这次真返回了推理内容的话,留一份(截断)。当前默认模型不返回,那就是空。
|
|
404
|
+
if (this.lastReasoning) {
|
|
405
|
+
trace.reasoningChars += this.lastReasoning.length
|
|
406
|
+
if (!trace.reasoning) trace.reasoning = clipReasoning(this.lastReasoning)
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (msg.tool_calls?.length) {
|
|
410
|
+
messages.push({ role: 'assistant', content: msg.content || '', tool_calls: msg.tool_calls })
|
|
411
|
+
for (const tc of msg.tool_calls) {
|
|
412
|
+
toolCalls++
|
|
413
|
+
let args: Record<string, any> = {}
|
|
414
|
+
try { args = JSON.parse(tc.function?.arguments || '{}') } catch { /* 参数容错 */ }
|
|
415
|
+
await this.runToolCall(userId, messages, tc.id, tc.function?.name || '', args, trace)
|
|
416
|
+
}
|
|
417
|
+
continue
|
|
418
|
+
}
|
|
419
|
+
reply = (msg.content || '').trim() || '(空回复)'
|
|
420
|
+
trace.steps.push({ kind: 'note', detail: `第 ${round + 1} 轮往返后给出答复` })
|
|
421
|
+
break
|
|
422
|
+
}
|
|
423
|
+
if (!reply) {
|
|
424
|
+
reply = '(这轮处理太复杂了, 换个问法试试?)'
|
|
425
|
+
// 别把它记成"答完了":这是撞上限,不是回答
|
|
426
|
+
trace.stop = 'rounds-exhausted'
|
|
427
|
+
}
|
|
428
|
+
return { reply, toolCalls }
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** 执行一次工具调用:脱敏、审计、把结果塞回对话。
|
|
432
|
+
*
|
|
433
|
+
* 快路径与 ReAct 循环**共用这一份**:D-035 里那条不可回归的安全属性是「没有审计行就不得
|
|
434
|
+
* 派发工具」,两条路各写一遍,早晚有一条会漏。
|
|
435
|
+
*/
|
|
436
|
+
private async runToolCall(userId: string, messages: ApiMessage[], callId: string,
|
|
437
|
+
name: string, args: Record<string, any>,
|
|
438
|
+
trace: TurnTrace): Promise<void> {
|
|
439
|
+
const callKey = `${name}:${JSON.stringify(args ?? {})}`
|
|
440
|
+
if (this.turnCalls.has(callKey)) {
|
|
441
|
+
// 同样的参数再来一次不会得到新东西。**顶回去说清楚**,而不是默默重跑一遍——
|
|
442
|
+
// 实测模型会连着调三次 list_sessions,既慢又把它自己的上下文刷满。
|
|
443
|
+
trace.steps.push({ kind: 'note', detail: `重复调用 ${name}(参数相同)→ 跳过` })
|
|
444
|
+
messages.push({ role: 'tool', tool_call_id: callId,
|
|
445
|
+
content: `(这一步与前面某次调用完全相同,结果不会变。结果已经在上面的对话里,`
|
|
446
|
+
+ `直接用它回答,不要再重复调用同一个工具。)` })
|
|
447
|
+
return
|
|
448
|
+
}
|
|
449
|
+
this.turnCalls.add(callKey)
|
|
450
|
+
const ctx: ToolContext = { userId, memory: this.memory }
|
|
451
|
+
const raw = await executeTool(name, args, ctx)
|
|
452
|
+
trace.toolCalls += 1
|
|
453
|
+
trace.steps.push({
|
|
454
|
+
kind: 'tool', name, args: summarizeArgs(args), bytes: raw.length,
|
|
455
|
+
produced: producedContent(raw),
|
|
456
|
+
})
|
|
457
|
+
const { safe, redactions } = privacyGate.redact(raw)
|
|
458
|
+
privacyGate.audit(`TOOL:${name}`, raw.length, redactions ? `redacted=${redactions}` : '')
|
|
459
|
+
messages.push({ role: 'tool', tool_call_id: callId, content: safe })
|
|
460
|
+
|
|
461
|
+
// 工具取来的图片作为**紧随其后的一条 user 消息**附上:`tool` 消息的 content 只能是
|
|
462
|
+
// 字符串(OpenAI 的形状),塞不进多模态数组。图片本身不过 redact(它不是文本),
|
|
463
|
+
// 出不出境由 `toApiMessages` 按隐私模式判定——那是唯一一道门。
|
|
464
|
+
if (ctx.pendingImages?.length) {
|
|
465
|
+
const n = ctx.pendingImages.length
|
|
466
|
+
messages.push({
|
|
467
|
+
role: 'user',
|
|
468
|
+
content: `(这是你刚取来的图片${n > 1 ? `,共 ${n} 张` : ''})`,
|
|
469
|
+
images: ctx.pendingImages,
|
|
470
|
+
})
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** 快路径:先问一次「该查哪个能力」,把这一个工具执行掉,于是循环第一轮就看得到结果。
|
|
475
|
+
*
|
|
476
|
+
* 返回派发了几个工具(0 = 回退)。**任何不确定都回退**,而回退就是原样跑循环——不是
|
|
477
|
+
* 「另一个更差的兜底」。`log` 模式下只算不派发,用来在真实流量上观察它本来会怎么走。
|
|
478
|
+
*/
|
|
479
|
+
private async maybeFastRoute(userId: string, text: string, messages: ApiMessage[],
|
|
480
|
+
trace: TurnTrace): Promise<number> {
|
|
481
|
+
const mode = this.fastRouteMode()
|
|
482
|
+
if (mode === 'off') return 0
|
|
483
|
+
|
|
484
|
+
let decision
|
|
485
|
+
try {
|
|
486
|
+
decision = await decideRoute(text, { runDecide: this.routeDecider })
|
|
487
|
+
} catch (error: any) {
|
|
488
|
+
appendLog(`[快路径] 路由异常,按原样回退: ${error?.message ?? error}`)
|
|
489
|
+
trace.steps.push({ kind: 'route', detail: '判断层异常,直接走循环' })
|
|
490
|
+
return 0
|
|
152
491
|
}
|
|
153
|
-
|
|
492
|
+
|
|
493
|
+
this.lastNeedsLocalData = decision.needsTool
|
|
494
|
+
if (!decision.capability) {
|
|
495
|
+
appendLog(`[快路径] 回退: ${decision.reason}`)
|
|
496
|
+
privacyGate.audit('FASTROUTE_SKIP', 0, decision.reason.slice(0, 120))
|
|
497
|
+
trace.steps.push({ kind: 'route', detail: `判断层: ${decision.reason}` })
|
|
498
|
+
return 0
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (mode === 'log') {
|
|
502
|
+
// 灰度期:只记「本来会走哪条」,行为一个字不改
|
|
503
|
+
appendLog(`[快路径/只记] ${decision.reason}`)
|
|
504
|
+
privacyGate.audit('FASTROUTE_WOULD', 0, `${decision.capability.name} ${decision.model}`)
|
|
505
|
+
trace.steps.push({ kind: 'route', detail: `判断层(只记,未派发): ${decision.reason}` })
|
|
506
|
+
return 0
|
|
507
|
+
}
|
|
508
|
+
trace.steps.push({ kind: 'route', detail: `判断层: ${decision.reason}` })
|
|
509
|
+
// 快路径是**直接派发**的(不经工具表),所以这里要自己过同一道判据
|
|
510
|
+
const blocked = unavailableToolReason(decision.capability.tool)
|
|
511
|
+
if (blocked) {
|
|
512
|
+
trace.steps.push({ kind: 'route', detail: `快路径想派发 ${decision.capability.tool},但${blocked};回退成循环` })
|
|
513
|
+
return 0
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const callId = `fastroute-${Date.now()}`
|
|
517
|
+
appendLog(`[快路径] ${decision.reason}`)
|
|
518
|
+
privacyGate.audit('FASTROUTE_HIT', 0, `${decision.capability.name} ${decision.model}`)
|
|
519
|
+
messages.push({
|
|
520
|
+
role: 'assistant',
|
|
521
|
+
content: '',
|
|
522
|
+
tool_calls: [{
|
|
523
|
+
id: callId,
|
|
524
|
+
type: 'function',
|
|
525
|
+
function: {
|
|
526
|
+
name: decision.capability.tool,
|
|
527
|
+
arguments: JSON.stringify(decision.capability.args),
|
|
528
|
+
},
|
|
529
|
+
}],
|
|
530
|
+
})
|
|
531
|
+
await this.runToolCall(userId, messages, callId, decision.capability.tool,
|
|
532
|
+
decision.capability.args, trace)
|
|
533
|
+
return 1
|
|
154
534
|
}
|
|
155
535
|
|
|
156
|
-
/**
|
|
536
|
+
/**
|
|
537
|
+
* 单条消息处理: 指令路由 → ReAct 循环 → 记忆更新。
|
|
538
|
+
*
|
|
539
|
+
* **外部只许经由 `enqueueTurn` 调用**(它保证串行);直接调会被 `inFlight` 挡下——
|
|
540
|
+
* 见那个字段的注释。
|
|
541
|
+
*/
|
|
157
542
|
async handleMessage(userId: string, text: string, kind: string): Promise<string> {
|
|
543
|
+
if (this.inFlight) {
|
|
544
|
+
throw new Error('handleMessage 重入:它依赖串行调用(记忆窗口、turnCalls 都是实例字段)')
|
|
545
|
+
}
|
|
546
|
+
this.inFlight = true
|
|
547
|
+
try {
|
|
548
|
+
return await this.handleMessageInner(userId, text, kind)
|
|
549
|
+
} finally {
|
|
550
|
+
this.inFlight = false
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
private async handleMessageInner(userId: string, text: string, kind: string): Promise<string> {
|
|
158
555
|
if (kind !== 'text') return '目前只支持文字消息哦'
|
|
159
556
|
|
|
160
557
|
const t = text.trim()
|
|
558
|
+
const startedAt = Date.now()
|
|
559
|
+
this.turnCalls.clear()
|
|
560
|
+
// 一轮的轨迹:判断了什么、调了什么、几轮、为什么停。**内置指令也要记**——
|
|
561
|
+
// 不然"我发了「轨迹」却看不到刚才那一轮"这种事自己就会发生(它本身也是一轮)。
|
|
562
|
+
const trace: TurnTrace = {
|
|
563
|
+
at: new Date(startedAt).toISOString(), userId, questionChars: t.length, steps: [],
|
|
564
|
+
rounds: 0, toolCalls: 0, reasoning: '', reasoningChars: 0, answerChars: 0,
|
|
565
|
+
stop: 'answered', elapsedMs: 0,
|
|
566
|
+
}
|
|
567
|
+
// `stop` 不给就保留循环/异常那边已经定好的结束原因(主路径走这里收尾)。
|
|
568
|
+
const finish = (reply: string, stop?: StopReason): string => {
|
|
569
|
+
trace.answerChars = reply.length
|
|
570
|
+
trace.elapsedMs = Date.now() - startedAt
|
|
571
|
+
if (stop) trace.stop = stop
|
|
572
|
+
// 内置指令**不占**内存里那一份"上一轮":`轨迹` 本身就是一轮,占了它之后连着问两次
|
|
573
|
+
// 第二次就什么都看不到了。文件里照样留档(每轮都记)。
|
|
574
|
+
if (trace.stop !== 'builtin') this.traces.set(userId, trace)
|
|
575
|
+
recordTurn(trace)
|
|
576
|
+
return reply
|
|
577
|
+
}
|
|
161
578
|
|
|
162
579
|
if (t === '帮助' || t.toLowerCase() === 'help') {
|
|
163
580
|
return ['🧠 第二大脑 Agent', '',
|
|
@@ -165,15 +582,41 @@ export class AssistantService {
|
|
|
165
582
|
'「我最近都在忙什么?」',
|
|
166
583
|
'「总结我和XX的聊天」',
|
|
167
584
|
'「收藏里有哪些AI文章?」',
|
|
585
|
+
'「我最近都在读什么?」',
|
|
168
586
|
'「记住: 我的项目叫weflow-cli」',
|
|
169
587
|
'', '记忆: 三层 (窗口/摘要/长期事实), 重启不丢',
|
|
170
588
|
'隐私: 数据库不出本机, 出境内容自动脱敏',
|
|
171
|
-
'', '指令: 记忆 | 清空记忆'].join('\n')
|
|
589
|
+
'', '指令: 记忆 | 隐私 | 清空记忆 | 轨迹'].join('\n')
|
|
590
|
+
}
|
|
591
|
+
if (t === '轨迹' || t === '过程' || t === '思考过程') {
|
|
592
|
+
// 上一轮**怎么走到那个答复的**。看的是本地那份轨迹,不是模型的自述——
|
|
593
|
+
// 模型说"我查了"与它真的查了,是两件事(审计里那条 TOOL_GUARD_PUSHBACK 就是这么来的)。
|
|
594
|
+
return finish(describeForChat(this.traces.get(userId)), 'builtin')
|
|
172
595
|
}
|
|
173
596
|
if (t === '清空记忆' || t === '重置') {
|
|
174
597
|
this.memory.reset(userId)
|
|
175
598
|
privacyGate.audit('MEMORY_RESET', 0, userId.slice(0, 8))
|
|
176
|
-
return '✓ 对话记忆已清空, 重新开始'
|
|
599
|
+
return finish('✓ 对话记忆已清空, 重新开始', 'builtin')
|
|
600
|
+
}
|
|
601
|
+
if (t === '隐私' || t === 'privacy') {
|
|
602
|
+
// 只读:把当前档位和改法说清楚。**不让一条微信消息直接改隐私档位**——
|
|
603
|
+
// 那等于把隐私开关搬进对话里,而配置本来就是用户在自己电脑上显式设定的东西。
|
|
604
|
+
const mode = privacyGate.mode()
|
|
605
|
+
const local = privacyGate.isLocalInference()
|
|
606
|
+
const lines = [`隐私模式: ${mode}${local ? '(本地推理, 数据不出机器)' : '(云端推理)'}`]
|
|
607
|
+
lines.push(`工具拿到的聊天正文: ${local ? '原文(不出机器)'
|
|
608
|
+
: mode === 'strict' ? '被屏蔽, 只剩时间与字数'
|
|
609
|
+
: mode === 'open' ? '原文, 不脱敏'
|
|
610
|
+
: '原文, 但电话/证件/邮箱/密钥/链接会打码'}`)
|
|
611
|
+
lines.push('在电脑上改(改完要重启助手):')
|
|
612
|
+
lines.push('weflow-cli config set assistantPrivacy balanced # 正文出境, PII 打码')
|
|
613
|
+
lines.push('weflow-cli config set assistantPrivacy open # 全不打码')
|
|
614
|
+
lines.push('weflow-cli config set assistantPrivacy strict # 正文不出境')
|
|
615
|
+
if (!local) {
|
|
616
|
+
lines.push('或者换本地模型, 内容根本不出机器:')
|
|
617
|
+
lines.push('weflow-cli config set aiEngine ollama')
|
|
618
|
+
}
|
|
619
|
+
return finish(lines.join(String.fromCharCode(10)), 'builtin')
|
|
177
620
|
}
|
|
178
621
|
if (t === '记忆') {
|
|
179
622
|
const facts = this.memory.facts(userId)
|
|
@@ -183,6 +626,11 @@ export class AssistantService {
|
|
|
183
626
|
lines.push(`长期事实: ${facts.length} 条`)
|
|
184
627
|
if (facts.length) lines.push(facts.slice(-5).map(f => `· ${f.content}`).join('\n'))
|
|
185
628
|
lines.push(`隐私模式: ${privacyGate.mode()}${privacyGate.isLocalInference() ? ' (本地推理, 不出境)' : ''}`)
|
|
629
|
+
// 落盘失败要说出来:用户在这里问"你记住了什么",如果上一次根本没存上,
|
|
630
|
+
// 那份答案就是假的。`save()` 不抛异常是刻意的,代价是必须有地方把它讲出来。
|
|
631
|
+
if (this.memory.lastSaveError) {
|
|
632
|
+
lines.push(`⚠ 上次保存失败: ${this.memory.lastSaveError}`)
|
|
633
|
+
}
|
|
186
634
|
lines.push(`今日用量: ${this.dailyCount}/${DAILY_LIMIT}`)
|
|
187
635
|
return lines.join('\n')
|
|
188
636
|
}
|
|
@@ -190,37 +638,45 @@ export class AssistantService {
|
|
|
190
638
|
// === ReAct 主循环 ===
|
|
191
639
|
this.memory.addTurn(userId, 'user', t)
|
|
192
640
|
const messages: ApiMessage[] = [
|
|
193
|
-
{ role: 'system', content: this.buildSystemPrompt(userId) },
|
|
641
|
+
{ role: 'system', content: this.buildSystemPrompt(userId, t) },
|
|
194
642
|
...this.memory.workingWindow(userId).map(turn => ({ role: turn.role, content: turn.content })),
|
|
195
643
|
]
|
|
196
644
|
|
|
197
645
|
let reply = ''
|
|
198
646
|
let toolCalls = 0
|
|
199
647
|
try {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
648
|
+
toolCalls += await this.maybeFastRoute(userId, t, messages, trace)
|
|
649
|
+
const first = await this.runReactLoop(userId, messages, trace)
|
|
650
|
+
reply = first.reply
|
|
651
|
+
toolCalls += first.toolCalls
|
|
652
|
+
|
|
653
|
+
// 守卫:路由说「这条消息需要查本机数据」,而这一轮**一次工具都没调** —— 两者自相矛盾。
|
|
654
|
+
// 实测撞到过两次:它回"我确实调了工具查了",而审计里 `tools=0`、没有任何 TOOL: 行。
|
|
655
|
+
// 这里不去解释原因,只把矛盾顶回去**一次**:让模型看见"你手上没有工具结果"再答。
|
|
656
|
+
// 只在路由判过"需要查本机数据"时触发(那时才有这个信号),且补问失败不许把原答复弄丢。
|
|
657
|
+
if (toolCalls === 0 && this.lastNeedsLocalData >= MIN_NEEDS_TOOL) {
|
|
658
|
+
privacyGate.audit('TOOL_GUARD_PUSHBACK', 0,
|
|
659
|
+
`needs_local_data=${this.lastNeedsLocalData.toFixed(2)}`)
|
|
660
|
+
appendLog('[守卫] 路由说需要查本机数据,但这一轮没调任何工具;顶回去一次')
|
|
661
|
+
try {
|
|
662
|
+
messages.push({
|
|
663
|
+
role: 'system',
|
|
664
|
+
content: '注意:你刚才的回答没有调用任何工具,因此你手上并没有本机数据。'
|
|
665
|
+
+ '这个问题需要本机的真实数据。请现在就调用合适的工具;'
|
|
666
|
+
+ '若确实查不到,说明你调用了哪个工具、它返回了什么。',
|
|
667
|
+
})
|
|
668
|
+
trace.steps.push({ kind: 'note', detail: '守卫:路由说需要本机数据而这一轮没调工具,顶回去一次' })
|
|
669
|
+
const second = await this.runReactLoop(userId, messages, trace)
|
|
670
|
+
reply = second.reply
|
|
671
|
+
toolCalls += second.toolCalls
|
|
672
|
+
} catch (e: any) {
|
|
673
|
+
appendLog(`[守卫] 补问失败,保留原答复: ${e?.message ?? e}`)
|
|
217
674
|
}
|
|
218
|
-
reply = (msg.content || '').trim() || '(空回复)'
|
|
219
|
-
break
|
|
220
675
|
}
|
|
221
|
-
if (!reply) reply = '(这轮处理太复杂了, 换个问法试试?)'
|
|
222
676
|
} catch (e: any) {
|
|
223
677
|
reply = `❌ 大脑暂时离线: ${e.message?.slice(0, 100)}\n(本地指令仍可用: 发「帮助」)`
|
|
678
|
+
trace.stop = 'llm-error'
|
|
679
|
+
trace.error = String(e?.message ?? e).slice(0, 120)
|
|
224
680
|
}
|
|
225
681
|
|
|
226
682
|
// === 记忆更新 (L2 压缩 + L3 提取) ===
|
|
@@ -232,64 +688,205 @@ export class AssistantService {
|
|
|
232
688
|
try { await this.memory.extractFactsIfNeeded(userId, llm) } catch { /* 提取失败不阻断 */ }
|
|
233
689
|
this.memory.save()
|
|
234
690
|
privacyGate.audit('TURN_DONE', reply.length, `tools=${toolCalls}`)
|
|
691
|
+
finish(reply)
|
|
235
692
|
return reply
|
|
236
693
|
}
|
|
237
694
|
|
|
695
|
+
/**
|
|
696
|
+
* 一轮的处理。**这是唯一一处**——微信通道与本机面板都走这里。
|
|
697
|
+
*
|
|
698
|
+
* 抽出来的理由是这个仓库付过学费的老毛病:判定逻辑两处各写一份,早晚有一处漏掉既有的容错
|
|
699
|
+
* (同样的判词写在本文件 `:319` 与 `:361` 附近的注释里)。配额、忽略非文本、异常兜底
|
|
700
|
+
* 这些行为**只该有一份**。
|
|
701
|
+
*
|
|
702
|
+
* `origin` 决定要不要过白名单:微信来的必须过;**本机入口的门是端点 token,不是白名单**。
|
|
703
|
+
* 把白名单套到"坐在机器前的人"身上是错的——空名单时会把面板自己拒掉,而且
|
|
704
|
+
* `maybeAnnounceWhitelistBootstrap` 还会打出一条**教用户把自己的面板 id 加进微信白名单**的错提示。
|
|
705
|
+
*
|
|
706
|
+
* 本方法**不负责"往哪回"**:结果交给调用方(微信是 `sendText`,面板是 HTTP 响应)。
|
|
707
|
+
*/
|
|
708
|
+
private async runTurn(input: TurnInput, onLog?: (line: string) => void): Promise<TurnOutcome> {
|
|
709
|
+
if (!this.running) return { status: 'not-running' }
|
|
710
|
+
const { sessionId, text, kind, origin } = input
|
|
711
|
+
|
|
712
|
+
if (origin === 'wechat') {
|
|
713
|
+
const msg = input.message!
|
|
714
|
+
const access = this.accessDecision(msg)
|
|
715
|
+
if (!access.allowed) {
|
|
716
|
+
privacyGate.audit(`DENY_${access.reason.toUpperCase().replace(/-/g, '_')}`, 0, sessionId.slice(0, 12))
|
|
717
|
+
onLog?.(` → 拒绝: ${sessionId.slice(0, 12)}… ${access.reason} (未回复, 不耗 LLM)`)
|
|
718
|
+
this.maybeAnnounceWhitelistBootstrap(access, msg, onLog)
|
|
719
|
+
return { status: 'denied', reason: access.reason }
|
|
720
|
+
}
|
|
721
|
+
if (kind !== 'text') {
|
|
722
|
+
onLog?.(` → 忽略非文本消息 (${kind})`)
|
|
723
|
+
return { status: 'ignored', reason: 'non-text' }
|
|
724
|
+
}
|
|
725
|
+
onLog?.(`[${new Date().toLocaleTimeString('zh-CN')}] ${sessionId.slice(0, 12)}…: ${text.slice(0, 40)}`)
|
|
726
|
+
} else {
|
|
727
|
+
// 本机入口**不往日志里写内容**,这是有意的不对称:微信那边没有别的界面,
|
|
728
|
+
// 日志是唯一线索;面板有界面,日志里留长度就够查问题了。别当成不一致"修"回去。
|
|
729
|
+
onLog?.(`[panel] ${text.length}字`)
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
if (this.dailyQuotaLeft() <= 0) {
|
|
733
|
+
privacyGate.audit('DENY_DAILY_LIMIT', this.dailyCount)
|
|
734
|
+
appendLog(`[配额] 今日 ${DAILY_LIMIT} 条上限已用尽, 拒绝 (via=${origin})`)
|
|
735
|
+
return { status: 'quota-exceeded', text: '今日额度已用完, 明天再来吧' }
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
try {
|
|
739
|
+
const reply = await this.handleMessage(sessionId, text, kind)
|
|
740
|
+
this.dailyCount++
|
|
741
|
+
// **这里不报"已回复"**:回没回得出去是投递方才知道的事(微信的 sendText 会失败)。
|
|
742
|
+
// 两处都报的话,微信那条路会在日志里出现两遍"已回复",看着像处理了两条消息。
|
|
743
|
+
return { status: 'replied', text: reply }
|
|
744
|
+
} catch (e: any) {
|
|
745
|
+
onLog?.(` → 处理异常: ${e.message}`)
|
|
746
|
+
appendLog(`[${new Date().toLocaleTimeString('zh-CN')}] 异常: ${e.message}`)
|
|
747
|
+
return { status: 'error', message: String(e?.message ?? e) }
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* 串行入队 + 投递。**投递在队列里做**,不在队列外——微信那边"先回复完再处理下一条"
|
|
753
|
+
* 是既有行为,把它挪到队列外会让两条消息的回复有乱序的可能(前一条发出慢一点就反超)。
|
|
754
|
+
*
|
|
755
|
+
* 返回的 Promise 在这个请求**处理且投递完**之后 resolve,调用方可以直接 await 它去写响应。
|
|
756
|
+
*/
|
|
757
|
+
private enqueueTurn(input: TurnInput, deliver: (outcome: TurnOutcome) => Promise<void> | void,
|
|
758
|
+
onLog?: (line: string) => void): Promise<void> {
|
|
759
|
+
// 没给日志出口就用 `start()` 时那个:本机入口不经过 `start()` 的调用点传参,
|
|
760
|
+
// 漏了这一步会让面板那一侧**一行日志都不留**(第一版就是这样,测试抓到了)。
|
|
761
|
+
const log = onLog ?? this.logSink
|
|
762
|
+
this.queue = this.queue.then(async () => {
|
|
763
|
+
const outcome = await this.runTurn(input, log)
|
|
764
|
+
await deliver(outcome)
|
|
765
|
+
}).catch((e: any) => appendLog(`队列异常: ${e.message}`))
|
|
766
|
+
return this.queue
|
|
767
|
+
}
|
|
768
|
+
|
|
238
769
|
/** 启动常驻监听 */
|
|
239
770
|
async start(onLog?: (line: string) => void): Promise<void> {
|
|
240
771
|
const token = configService.get('wechatOcToken')
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
this.svc = new WechatMessageService({ token })
|
|
772
|
+
this.logSink = onLog
|
|
244
773
|
this.running = true
|
|
774
|
+
if (token) {
|
|
775
|
+
this.svc = new WechatMessageService({ token })
|
|
776
|
+
} else {
|
|
777
|
+
// **降级启动**:没有消息通道也要起得来。
|
|
778
|
+
//
|
|
779
|
+
// 从前这里直接抛("未登录消息通道, 先运行 weflow-cli login-wechat"),于是"没登录微信"
|
|
780
|
+
// == 助手整个起不来——本机的实地记录就是这样(docs/PROJECT_STATE.md 那条)。
|
|
781
|
+
// 代价要说清:`assistant start` 从此可能"报成功但通道是空的",所以 status 必须能把
|
|
782
|
+
// 这两者分开(`messageChannelLoggedIn` 是"配了 token 吗",它回答不了这个问题)。
|
|
783
|
+
appendLog('[通道] 未登录消息通道,以本机入口模式启动')
|
|
784
|
+
onLog?.('⚠ 消息通道未登录:微信里暂时说不了话;本机面板仍然可用')
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// 记忆加载出过事(版本不认识 / 文件坏了)要说出来:否则用户面对一份空记忆,
|
|
788
|
+
// 只会以为「它忘了我」。原文件此时已经留档,所以这句话里带着文件名。
|
|
789
|
+
if (this.memory.problem) {
|
|
790
|
+
appendLog(`[记忆] ${this.memory.problem}`)
|
|
791
|
+
privacyGate.audit('MEMORY_LOAD_ISSUE', 0, this.memory.problem.slice(0, 80))
|
|
792
|
+
onLog?.(`⚠ 记忆: ${this.memory.problem}`)
|
|
793
|
+
}
|
|
245
794
|
const { local } = this.engineConfig()
|
|
246
|
-
|
|
795
|
+
// bot 那一段只在真有通道时报:没有通道还报一个 bot 账号,是让人以为它在收消息
|
|
796
|
+
onLog?.('助手已启动 (' + (token ? `bot: ${configService.get('wechatOcAccountId')}, ` : '本机入口模式, ') +
|
|
247
797
|
`引擎: ${local ? '本地' : '云端'}, 记忆用户数: ${this.memory.userCount()})`)
|
|
248
798
|
|
|
799
|
+
// 本机入口:**只绑回环** + 每次启动随机口令。悬浮窗与 `weflow-cli panel` 都走它,
|
|
800
|
+
// 于是两个入口落在**同一个进程**里(配额、串行队列、记忆都只有一份)。
|
|
801
|
+
const bucket = resolvePanelUserId({
|
|
802
|
+
whitelist: configService.get('assistantWhitelist'),
|
|
803
|
+
configured: configService.get('assistantPanelUser'),
|
|
804
|
+
})
|
|
805
|
+
this.memoryBucket = bucket.userId
|
|
806
|
+
try {
|
|
807
|
+
this.panelServer = await startPanelServer({
|
|
808
|
+
service: this,
|
|
809
|
+
memoryBucket: this.memoryBucket,
|
|
810
|
+
memoryNote: bucket.note,
|
|
811
|
+
channel: token ? 'wechat' : 'local',
|
|
812
|
+
onLog,
|
|
813
|
+
})
|
|
814
|
+
appendLog(`[本机入口] 监听 127.0.0.1:${this.panelServer.port}`
|
|
815
|
+
+ ` channel=${token ? 'wechat' : 'local'} bucket=${this.memoryBucket}`)
|
|
816
|
+
onLog?.(`本机入口: http://127.0.0.1:${this.panelServer.port}(记忆桶 ${this.memoryBucket})`)
|
|
817
|
+
} catch (e: any) {
|
|
818
|
+
// 端点起不来不该把助手一起弄死:微信那边还能用(端口被占是唯一常见的失败)
|
|
819
|
+
appendLog(`[本机入口] 启动失败: ${e.message}`)
|
|
820
|
+
onLog?.(`⚠ 本机入口启动失败: ${e.message}`)
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
if (!this.svc) return
|
|
824
|
+
|
|
249
825
|
this.svc.onMessage((msg) => {
|
|
250
826
|
// 串行入队: 并发到达的消息按顺序处理, 记忆窗口不交错
|
|
251
|
-
this.
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
}
|
|
265
|
-
if (this.dailyQuotaLeft() <= 0) {
|
|
266
|
-
privacyGate.audit('DENY_DAILY_LIMIT', this.dailyCount)
|
|
267
|
-
appendLog(`[配额] 今日 ${DAILY_LIMIT} 条上限已用尽, 拒绝: ${msg.messageStr.slice(0, 30)}`)
|
|
268
|
-
await this.svc!.sendText(sessionId, '今日额度已用完, 明天再来吧').catch(() => {})
|
|
269
|
-
return
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
onLog?.(`[${new Date().toLocaleTimeString('zh-CN')}] ${sessionId.slice(0, 12)}…: ${msg.messageStr.slice(0, 40)}`)
|
|
273
|
-
try {
|
|
274
|
-
const reply = await this.handleMessage(sessionId, msg.messageStr, msg.messageKind)
|
|
275
|
-
this.dailyCount++
|
|
276
|
-
const ok = await this.svc!.sendText(sessionId, reply)
|
|
277
|
-
onLog?.(` → ${ok ? `已回复 (${reply.length}字, 今日 ${this.dailyCount}/${DAILY_LIMIT})` : '回复失败'}`)
|
|
278
|
-
} catch (e: any) {
|
|
279
|
-
onLog?.(` → 处理异常: ${e.message}`)
|
|
280
|
-
appendLog(`[${new Date().toLocaleTimeString('zh-CN')}] 异常: ${e.message}`)
|
|
281
|
-
}
|
|
282
|
-
}).catch((e: any) => appendLog(`队列异常: ${e.message}`))
|
|
827
|
+
this.enqueueTurn(
|
|
828
|
+
{ sessionId: msg.conversationId, text: msg.messageStr, kind: msg.messageKind, origin: 'wechat', message: msg },
|
|
829
|
+
async (outcome) => {
|
|
830
|
+
if (!('text' in outcome) || !outcome.text) return
|
|
831
|
+
const ok = await this.svc?.sendText(msg.conversationId, outcome.text)
|
|
832
|
+
if (outcome.status === 'replied') {
|
|
833
|
+
onLog?.(` → ${ok ? `已回复 (${outcome.text.length}字, 今日 ${this.dailyCount}/${DAILY_LIMIT})` : '回复失败'}`)
|
|
834
|
+
} else {
|
|
835
|
+
onLog?.(` → ${ok ? '已回复额度提示' : '回复失败'}`)
|
|
836
|
+
}
|
|
837
|
+
},
|
|
838
|
+
onLog,
|
|
839
|
+
).catch(() => { /* enqueueTurn 内部已经把异常落进日志 */ })
|
|
283
840
|
})
|
|
284
841
|
|
|
285
842
|
await this.svc.startPolling()
|
|
286
843
|
}
|
|
287
844
|
|
|
845
|
+
/** 本机入口用:投一条消息进同一条队列,返回这一轮的结果 */
|
|
846
|
+
ask(sessionId: string, text: string, onLog?: (line: string) => void): Promise<TurnOutcome> {
|
|
847
|
+
let outcome: TurnOutcome = { status: 'error', message: '未取得结果' }
|
|
848
|
+
return this.enqueueTurn({ sessionId, text, kind: 'text', origin: 'panel' }, (o) => {
|
|
849
|
+
outcome = o
|
|
850
|
+
// 投递结果由这一侧报(`runTurn` 不知道投递成没成)
|
|
851
|
+
if (o.status === 'replied') {
|
|
852
|
+
(onLog ?? this.logSink)?.(` → 已回复 (${o.text.length}字, 今日 ${this.dailyCount}/${DAILY_LIMIT}, via=panel)`)
|
|
853
|
+
}
|
|
854
|
+
}, onLog).then(() => outcome)
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/** 消息通道接上了吗(未登录时是 false)。status 要靠它区分两种"没通道" */
|
|
858
|
+
/**
|
|
859
|
+
* 微信通道**现在**是否可用。
|
|
860
|
+
*
|
|
861
|
+
* 原来是 `this.svc !== null`——那只是"启动时配了 token",于是 token 失效之后它照样报 true,
|
|
862
|
+
* 面板与状态页都在说"微信 + 本机"而通道其实已经死了(用户看不到任何异常)。
|
|
863
|
+
* 现在把长轮询那边的实况算进来:服务端判过期时,这里如实变 false(面板就会显示"仅本机入口")。
|
|
864
|
+
*/
|
|
865
|
+
isChannelActive(): boolean { return this.svc !== null && !this.svc.isTokenExpired?.() }
|
|
866
|
+
|
|
867
|
+
/** 配额用量。**内存态**(重启归零),所以它不是"今天一共花了多少"的账本,是护栏的余量 */
|
|
868
|
+
quotaState(): { used: number; limit: number } {
|
|
869
|
+
this.dailyQuotaLeft() // 先让它处理日期翻转,否则跨零点后会报昨天的数
|
|
870
|
+
return { used: this.dailyCount, limit: DAILY_LIMIT }
|
|
871
|
+
}
|
|
872
|
+
|
|
288
873
|
stop(): void {
|
|
289
874
|
this.running = false
|
|
290
875
|
this.svc?.stop()
|
|
876
|
+
// 关掉本机入口,并**主动删掉端点文件**——这一条与"崩溃时不删"是两回事:
|
|
877
|
+
// 走 `stop()` 的是有序关闭,删掉能让面板立刻知道;崩溃/SIGTERM 残留的那种,
|
|
878
|
+
// 由读取端探活(`readEndpoint()` 会检查 pid)兜住。
|
|
879
|
+
const closing = this.panelServer
|
|
880
|
+
this.panelServer = null
|
|
881
|
+
if (closing) {
|
|
882
|
+
clearEndpoint()
|
|
883
|
+
void closing.close()
|
|
884
|
+
}
|
|
291
885
|
}
|
|
292
886
|
|
|
887
|
+
/** 本机入口的实际监听端口;没起来就是 null */
|
|
888
|
+
panelPort(): number | null { return this.panelServer ? this.panelServer.port : null }
|
|
889
|
+
|
|
293
890
|
isRunning(): boolean { return this.running }
|
|
294
891
|
memoryUserCount(): number { return this.memory.userCount() }
|
|
295
892
|
}
|