weflow-cli 1.9.0 → 1.10.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 +62 -26
- package/CHANGELOG.md +948 -0
- package/OPERATIONS.md +65 -11
- package/README.en.md +14 -5
- package/README.md +21 -6
- package/bin/weflow-cli.ts +348 -39
- package/dist/bin/weflow-cli.js +363 -68
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/mcp-server/index.d.ts.map +1 -1
- package/dist/mcp-server/index.js +19 -1
- package/dist/mcp-server/index.js.map +1 -1
- package/dist/src/core/dbPathService.d.ts +12 -0
- package/dist/src/core/dbPathService.d.ts.map +1 -1
- package/dist/src/core/dbPathService.js +20 -3
- package/dist/src/core/dbPathService.js.map +1 -1
- package/dist/src/panel/server.d.ts.map +1 -1
- package/dist/src/panel/server.js +35 -0
- package/dist/src/panel/server.js.map +1 -1
- package/dist/src/services/assistantService.d.ts +19 -0
- package/dist/src/services/assistantService.d.ts.map +1 -1
- package/dist/src/services/assistantService.js +20 -0
- package/dist/src/services/assistantService.js.map +1 -1
- package/dist/src/services/assistantTools.d.ts +27 -1
- package/dist/src/services/assistantTools.d.ts.map +1 -1
- package/dist/src/services/assistantTools.js +169 -35
- package/dist/src/services/assistantTools.js.map +1 -1
- package/dist/src/services/configService.d.ts +2 -0
- package/dist/src/services/configService.d.ts.map +1 -1
- package/dist/src/services/configService.js +3 -2
- package/dist/src/services/configService.js.map +1 -1
- package/dist/src/services/messageQuery.d.ts +18 -0
- package/dist/src/services/messageQuery.d.ts.map +1 -1
- package/dist/src/services/messageQuery.js +29 -0
- package/dist/src/services/messageQuery.js.map +1 -1
- package/dist/src/services/wechatClient.d.ts +125 -0
- package/dist/src/services/wechatClient.d.ts.map +1 -0
- package/dist/src/services/wechatClient.js +344 -0
- package/dist/src/services/wechatClient.js.map +1 -0
- package/dist/src/services/wechatMedia.d.ts +44 -0
- package/dist/src/services/wechatMedia.d.ts.map +1 -0
- package/dist/src/services/wechatMedia.js +155 -0
- package/dist/src/services/wechatMedia.js.map +1 -0
- package/dist/src/services/wechatMessageService.d.ts +11 -0
- package/dist/src/services/wechatMessageService.d.ts.map +1 -1
- package/dist/src/services/wechatMessageService.js +55 -11
- package/dist/src/services/wechatMessageService.js.map +1 -1
- package/dist/src/types.d.ts +4 -0
- package/dist/src/types.d.ts.map +1 -1
- package/docs/CONTACT_DB_SCHEMA.md +1085 -0
- package/docs/DECISIONS.md +1274 -0
- package/docs/EXTENDING.md +69 -7
- package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +6 -0
- package/docs/HEALTH-CHECK.md +12 -7
- package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +2 -2
- package/docs/MCP.md +2 -2
- package/docs/PROJECT_STATE.md +62 -5
- package/docs/RELEASING.md +115 -1
- package/docs/images/weflow-architecture-gpt-image-2.png +0 -0
- package/logo.png +0 -0
- package/mcp-server/index.ts +18 -3
- package/package.json +5 -3
- package/resources/js/graph3d/NOTICE.txt +35 -0
- package/resources/js/graph3d/d3-binarytree.min.js +2 -0
- package/resources/js/graph3d/d3-dispatch.min.js +2 -0
- package/resources/js/graph3d/d3-force-3d.min.js +2 -0
- package/resources/js/graph3d/d3-octree.min.js +2 -0
- package/resources/js/graph3d/d3-quadtree.min.js +2 -0
- package/resources/js/graph3d/d3-timer.min.js +2 -0
- package/resources/js/graph3d/layout.mjs +63 -0
- package/resources/js/graph3d/three.min.js +6 -0
- package/resources/js/graph3d/viewer2d.js +448 -0
- package/resources/panel/index.html +12 -0
- package/resources/panel/mascot-blink-1.png +0 -0
- package/resources/panel/mascot-blink-2.png +0 -0
- package/resources/panel/mascot-lift-down-1.png +0 -0
- package/resources/panel/mascot-lift-down-2.png +0 -0
- package/resources/panel/mascot-lift-down-3.png +0 -0
- package/resources/panel/mascot-lift-down-4.png +0 -0
- package/resources/panel/mascot-lift-down-5.png +0 -0
- package/resources/panel/mascot-lift-down-6.png +0 -0
- package/resources/panel/mascot-lift-up-1.png +0 -0
- package/resources/panel/mascot-lift-up-2.png +0 -0
- package/resources/panel/mascot-lift-up-3.png +0 -0
- package/resources/panel/mascot-lift-up-4.png +0 -0
- package/resources/panel/mascot-lift-up-5.png +0 -0
- package/resources/panel/mascot-lift-up-6.png +0 -0
- package/resources/panel/mascot-tickle-1.png +0 -0
- package/resources/panel/mascot-tickle-2.png +0 -0
- package/resources/panel/mascot-tickle-3.png +0 -0
- package/resources/panel/mascot-tickle-4.png +0 -0
- package/resources/panel/panel.css +111 -0
- package/resources/panel/renderer.js +480 -8
- package/scripts/_utils.py +1203 -977
- package/scripts/biz_daily.py +25 -10
- package/scripts/chat_notes.py +15 -5
- package/scripts/check_readme_commands.py +426 -0
- package/scripts/compile_wiki.py +163 -57
- package/scripts/contact_schema.py +339 -0
- package/scripts/data/wechat_emoji.json +225 -0
- package/scripts/fav_notes.py +4 -1
- package/scripts/graph_2d.py +151 -0
- package/scripts/graph_3d.py +646 -0
- package/scripts/health_check.py +163 -26
- package/scripts/nt_decrypt.py +3 -3
- package/scripts/panel_frames.py +683 -0
- package/scripts/pipeline.py +21 -10
- package/scripts/quality_eval.py +5 -0
- package/scripts/rag_chat.py +33 -16
- package/scripts/reply_debt.py +26 -6
- package/scripts/semantic_search.py +130 -22
- package/scripts/vault_rag.py +42 -16
- package/scripts/vault_search.py +44 -8
- package/scripts/wechat_emoticon.py +6 -0
- package/scripts/wiki_lint.py +94 -17
- package/src/core/dbPathService.ts +22 -4
- package/src/panel/server.ts +33 -0
- package/src/services/assistantService.ts +26 -0
- package/src/services/assistantTools.ts +2452 -2319
- package/src/services/configService.ts +5 -2
- package/src/services/messageQuery.ts +29 -0
- package/src/services/wechatClient.ts +380 -0
- package/src/services/wechatMedia.ts +166 -0
- package/src/services/wechatMessageService.ts +62 -11
- package/src/types.ts +4 -0
- package/dist/src/utils/wechatEmoji.d.ts +0 -5
- package/dist/src/utils/wechatEmoji.d.ts.map +0 -1
- package/dist/src/utils/wechatEmoji.js +0 -35
- package/dist/src/utils/wechatEmoji.js.map +0 -1
package/scripts/vault_rag.py
CHANGED
|
@@ -16,17 +16,35 @@ DEFAULT_VAULT = os.path.join(PROJECT_ROOT, 'output', 'wechat-vault')
|
|
|
16
16
|
DEFAULT_BIZ = os.path.join(PROJECT_ROOT, 'output', 'biz-daily')
|
|
17
17
|
|
|
18
18
|
sys.path.insert(0, SCRIPTS_DIR)
|
|
19
|
-
from _utils import CONCEPT_DIRS, call_deepseek
|
|
19
|
+
from _utils import CONCEPT_DIRS, LINE_LABELS, call_deepseek, line_for_relative_concepts
|
|
20
20
|
|
|
21
|
+
LINE_CHOICES = ('all', 'wiki', 'chat')
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
|
|
24
|
+
def line_label(line: str) -> str:
|
|
25
|
+
return '两条线' if line == 'all' else LINE_LABELS.get(line, line)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def collect_context(vault: str, biz_daily: str, question: str, top_k: int,
|
|
29
|
+
line: str = 'all') -> list[dict]:
|
|
30
|
+
"""从 Vault + biz-daily 检索相关上下文。
|
|
31
|
+
|
|
32
|
+
`line` 把**材料也分开**:概念页按目录分线,而用户的笔记层与日报文章都属于**文章线**
|
|
33
|
+
(`--line chat` 只给聊天线的概念页)。不这么分的话,"只看聊天知识库"仍然会从阅读笔记里
|
|
34
|
+
捞结果进来——那正是"两条线分开"要避免的事。
|
|
35
|
+
|
|
36
|
+
**函数默认 `all`(不缩小范围)**,用户看到的默认(文章线)在 CLI 那一层:忘了传 `line`
|
|
37
|
+
的调用方会多搜一条线,而不是静默少搜一半。
|
|
38
|
+
"""
|
|
24
39
|
results = []
|
|
25
40
|
search_terms = question.lower().split()
|
|
41
|
+
want = (lambda which: line == 'all' or line == which)
|
|
26
42
|
|
|
27
|
-
# 1.
|
|
43
|
+
# 1. 概念页(权重最高)——不传 `--line` 时两个目录都读:文章知识库与聊天知识库是分开的
|
|
28
44
|
# (`_utils.CONCEPT_DIRS`),只读一个的话分出去的那一半会静默地搜不到。
|
|
29
45
|
for relative in CONCEPT_DIRS:
|
|
46
|
+
if not want(line_for_relative_concepts(relative)):
|
|
47
|
+
continue
|
|
30
48
|
concepts_dir = Path(vault) / relative
|
|
31
49
|
if not concepts_dir.is_dir():
|
|
32
50
|
continue
|
|
@@ -41,7 +59,8 @@ def collect_context(vault: str, biz_daily: str, question: str, top_k: int) -> li
|
|
|
41
59
|
|
|
42
60
|
# 2. 阅读笔记 —— **`Notes/` 这个目录在本仓库里从来不存**(2026-09-27 实测:
|
|
43
61
|
# Vault 里没有它),所以这一段一直搜的是空气。笔记实际住在 `002_Literature`。
|
|
44
|
-
|
|
62
|
+
# 这是**用户自己的**层(文章线),所以 `--line chat` 时整段跳过。
|
|
63
|
+
for relative in (('002_Literature', '003_Ideas', '008_MOC') if want('wiki') else ()):
|
|
45
64
|
notes_dir = Path(vault) / relative
|
|
46
65
|
if not notes_dir.is_dir():
|
|
47
66
|
continue
|
|
@@ -57,9 +76,9 @@ def collect_context(vault: str, biz_daily: str, question: str, top_k: int) -> li
|
|
|
57
76
|
if score > 0:
|
|
58
77
|
results.append({'source': 'note', 'title': md.stem[:60], 'content': text, 'score': score})
|
|
59
78
|
|
|
60
|
-
# 3.
|
|
61
|
-
biz_path = Path(biz_daily)
|
|
62
|
-
for date_dir in sorted(biz_path.glob('20*'), reverse=True)[:7]: # 最近 7 天
|
|
79
|
+
# 3. 最新文章(日报 = 文章线,`--line chat` 时跳过)
|
|
80
|
+
biz_path = Path(biz_daily) if want('wiki') else None
|
|
81
|
+
for date_dir in (sorted(biz_path.glob('20*'), reverse=True)[:7] if biz_path else []): # 最近 7 天
|
|
63
82
|
for md in date_dir.rglob('*.md'):
|
|
64
83
|
if md.name == 'README.md' or 'README' in md.name:
|
|
65
84
|
continue
|
|
@@ -75,7 +94,7 @@ def collect_context(vault: str, biz_daily: str, question: str, top_k: int) -> li
|
|
|
75
94
|
return results[:top_k]
|
|
76
95
|
|
|
77
96
|
|
|
78
|
-
def build_prompt(question: str, context: list[dict]) -> str:
|
|
97
|
+
def build_prompt(question: str, context: list[dict], line: str = 'all') -> str:
|
|
79
98
|
sources_text = ''
|
|
80
99
|
for i, ctx in enumerate(context):
|
|
81
100
|
label = {'concept': '概念', 'note': '笔记', 'article': '文章'}.get(ctx['source'], ctx['source'])
|
|
@@ -83,6 +102,9 @@ def build_prompt(question: str, context: list[dict]) -> str:
|
|
|
83
102
|
|
|
84
103
|
return f'''你是个人知识助手,基于用户笔记库回答问题。
|
|
85
104
|
|
|
105
|
+
**这一趟只检索了:{line_label(line)}**(另外的范围里可能有相关内容)。没有段落时按
|
|
106
|
+
"**这个范围里**没有相关信息"说,别说成"知识库里没有"。
|
|
107
|
+
|
|
86
108
|
知识库内容:
|
|
87
109
|
{sources_text}
|
|
88
110
|
|
|
@@ -99,6 +121,8 @@ def main():
|
|
|
99
121
|
parser.add_argument('--vault', default=DEFAULT_VAULT, help='Vault 路径')
|
|
100
122
|
parser.add_argument('--biz-daily', default=DEFAULT_BIZ, help='biz-daily 路径')
|
|
101
123
|
parser.add_argument('--api-key', default='', help='DeepSeek API key')
|
|
124
|
+
parser.add_argument('--line', choices=list(LINE_CHOICES), default='wiki',
|
|
125
|
+
help='只看一条知识库:wiki=文章线(默认)、chat=聊天线、all=两条都看')
|
|
102
126
|
parser.add_argument('--json', action='store_true', help='JSON 输出')
|
|
103
127
|
args = parser.parse_args()
|
|
104
128
|
|
|
@@ -113,21 +137,23 @@ def main():
|
|
|
113
137
|
sys.exit(1)
|
|
114
138
|
|
|
115
139
|
top_k = int(args.top_k)
|
|
116
|
-
context = collect_context(args.vault, args.biz_daily, question, top_k)
|
|
140
|
+
context = collect_context(args.vault, args.biz_daily, question, top_k, line=args.line)
|
|
117
141
|
|
|
118
142
|
if args.json:
|
|
119
|
-
output = {'question': question, '
|
|
143
|
+
output = {'question': question, 'line': args.line, 'lineLabel': line_label(args.line),
|
|
144
|
+
'sources': [{'source': c['source'], 'title': c['title'], 'score': c['score']} for c in context]}
|
|
120
145
|
if context:
|
|
121
|
-
prompt = build_prompt(question, context)
|
|
146
|
+
prompt = build_prompt(question, context, args.line)
|
|
122
147
|
output['answer'] = call_deepseek(prompt, api_key, max_tokens=800)
|
|
123
148
|
else:
|
|
124
|
-
|
|
149
|
+
# **说清是哪个范围里没有**:`知识库中没有相关信息` 会被读成"整库都没有"
|
|
150
|
+
output['answer'] = '%s里没有相关信息' % line_label(args.line)
|
|
125
151
|
print(json.dumps(output, ensure_ascii=False, indent=2))
|
|
126
152
|
return
|
|
127
153
|
|
|
128
|
-
print(f'🔍 "{question}"
|
|
154
|
+
print(f'🔍 "{question}"(范围:{line_label(args.line)})\n')
|
|
129
155
|
if not context:
|
|
130
|
-
print('
|
|
156
|
+
print('%s里没有相关信息(`--line all` 两条一起看)' % line_label(args.line))
|
|
131
157
|
return
|
|
132
158
|
|
|
133
159
|
print(f'检索到 {len(context)} 条相关内容:\n')
|
|
@@ -135,7 +161,7 @@ def main():
|
|
|
135
161
|
icon = {'concept': '🧠', 'note': '📝', 'article': '📄'}.get(c['source'], '📎')
|
|
136
162
|
print(f' {i+1}. {icon} [{c["source"]}] {c["title"]}')
|
|
137
163
|
|
|
138
|
-
prompt = build_prompt(question, context)
|
|
164
|
+
prompt = build_prompt(question, context, args.line)
|
|
139
165
|
print(f'\n{"="*50}')
|
|
140
166
|
answer = call_deepseek(prompt, api_key, max_tokens=800)
|
|
141
167
|
print(answer)
|
package/scripts/vault_search.py
CHANGED
|
@@ -13,11 +13,24 @@ from datetime import datetime, timedelta
|
|
|
13
13
|
|
|
14
14
|
SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
15
15
|
sys.path.insert(0, SCRIPTS_DIR)
|
|
16
|
-
from _utils import CONCEPT_DIRS # noqa: E402
|
|
16
|
+
from _utils import (CONCEPT_DIRS, LINE_LABELS, line_for_relative_concepts) # noqa: E402
|
|
17
17
|
PROJECT_ROOT = os.path.dirname(SCRIPTS_DIR)
|
|
18
18
|
DEFAULT_VAULT = os.path.join(PROJECT_ROOT, 'output', 'wechat-vault')
|
|
19
19
|
DEFAULT_BIZ = os.path.join(PROJECT_ROOT, 'output', 'biz-daily')
|
|
20
20
|
|
|
21
|
+
LINE_CHOICES = ('all', 'wiki', 'chat')
|
|
22
|
+
|
|
23
|
+
# 笔记层里**哪些属于聊天线**。其余笔记层是用户自己写的/读的(文章线)。
|
|
24
|
+
#
|
|
25
|
+
# 两个知识库分开之后,`--line` 要能真的把材料也分开,否则"只看聊天线"仍然会从用户的
|
|
26
|
+
# 阅读笔记里捞结果——那不是聊天知识库里的东西。`Sources/Chat` 是 `chat_notes --vault-copy`
|
|
27
|
+
# 放聊天卡的地方(`scripts/chat_notes.py`),只有它是聊天线的。
|
|
28
|
+
CHAT_NOTE_DIRS = ('Sources/Chat',)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def line_label(line: str) -> str:
|
|
32
|
+
return '两条线' if line == 'all' else LINE_LABELS.get(line, line)
|
|
33
|
+
|
|
21
34
|
|
|
22
35
|
def search_text(query: str, files: list[tuple], top_k: int) -> list[dict]:
|
|
23
36
|
"""关键词搜索 + 简单排序。"""
|
|
@@ -43,10 +56,19 @@ def search_text(query: str, files: list[tuple], top_k: int) -> list[dict]:
|
|
|
43
56
|
return scored[:top_k]
|
|
44
57
|
|
|
45
58
|
|
|
46
|
-
def collect_files(vault: str, biz_daily: str, search_type: str, days: int
|
|
47
|
-
|
|
59
|
+
def collect_files(vault: str, biz_daily: str, search_type: str, days: int,
|
|
60
|
+
line: str = 'all') -> list[tuple]:
|
|
61
|
+
"""收集可搜索文件。
|
|
62
|
+
|
|
63
|
+
`line` 在**收集这一步**就过滤(而不是收完再筛):三条来源各自属于哪条线是这里才知道的
|
|
64
|
+
—— 日报文章与用户的笔记层是文章线、`Sources/Chat` 的聊天卡是聊天线、概念页按目录分。
|
|
65
|
+
|
|
66
|
+
**函数默认 `all`(不缩小范围)**,用户看到的默认(文章线)在 CLI 那一层:忘了传 `line`
|
|
67
|
+
的调用方会多搜一条线,而不是静默少搜一半。
|
|
68
|
+
"""
|
|
48
69
|
files = []
|
|
49
70
|
cutoff = datetime.now() - timedelta(days=days)
|
|
71
|
+
want = (lambda which: line == 'all' or line == which)
|
|
50
72
|
|
|
51
73
|
def parse_fm(text):
|
|
52
74
|
if not text.startswith('---'):
|
|
@@ -61,7 +83,7 @@ def collect_files(vault: str, biz_daily: str, search_type: str, days: int) -> li
|
|
|
61
83
|
fm[k.strip()] = v.strip().strip('"').strip("'")
|
|
62
84
|
return fm
|
|
63
85
|
|
|
64
|
-
if search_type in ('all', 'article'):
|
|
86
|
+
if search_type in ('all', 'article') and want('wiki'):
|
|
65
87
|
# biz-daily 文章
|
|
66
88
|
biz_dir = Path(biz_daily)
|
|
67
89
|
for date_dir in sorted(biz_dir.glob('20*'), reverse=True):
|
|
@@ -86,6 +108,10 @@ def collect_files(vault: str, biz_daily: str, search_type: str, days: int) -> li
|
|
|
86
108
|
# ——命令照样返回结果,只是少了一半。
|
|
87
109
|
vault_path = Path(vault)
|
|
88
110
|
for relative in CONCEPT_DIRS:
|
|
111
|
+
# 概念页**按目录分线**(清单里每条线一个概念目录),而不是按库根——
|
|
112
|
+
# 测试的临时库、或 `--vault` 指到别处时,库根对不上但"这串相对路径属于哪条线"仍在
|
|
113
|
+
if not want(line_for_relative_concepts(relative)):
|
|
114
|
+
continue
|
|
89
115
|
concepts_dir = vault_path / relative
|
|
90
116
|
if not concepts_dir.is_dir():
|
|
91
117
|
continue
|
|
@@ -96,7 +122,7 @@ def collect_files(vault: str, biz_daily: str, search_type: str, days: int) -> li
|
|
|
96
122
|
fm = {}
|
|
97
123
|
files.append((md, 'concept', fm))
|
|
98
124
|
|
|
99
|
-
if search_type in ('all', 'note'):
|
|
125
|
+
if search_type in ('all', 'note') and (want('wiki') or want('chat')):
|
|
100
126
|
# **`Notes/` 这个目录在本仓库里从来不存。** 2026-09-27 实测:Vault 里没有它,
|
|
101
127
|
# 于是 `--type note` 一直搜的是空气——而它本该覆盖的阅读笔记有 25,676 篇,
|
|
102
128
|
# 是最大的一层。笔记实际住在下面这几个目录里。
|
|
@@ -105,6 +131,9 @@ def collect_files(vault: str, biz_daily: str, search_type: str, days: int) -> li
|
|
|
105
131
|
# `Sources/WeChat`(两万六千篇原始文章,走 biz-daily 那条路)不是一回事:
|
|
106
132
|
# 这里面是一百多张**已经提炼过的**卡——会话里聊了什么、有哪些人、欠着什么。
|
|
107
133
|
for sub in ('002_Literature', '001_Daily', '003_Ideas', '008_MOC', 'Sources/Chat'):
|
|
134
|
+
# 笔记层也分线:`Sources/Chat` 是聊天卡(聊天线),其余是用户自己的笔记(文章线)
|
|
135
|
+
if not want('chat' if sub in CHAT_NOTE_DIRS else 'wiki'):
|
|
136
|
+
continue
|
|
108
137
|
notes_dir = vault_path / sub
|
|
109
138
|
if not notes_dir.is_dir():
|
|
110
139
|
continue
|
|
@@ -127,6 +156,8 @@ def main():
|
|
|
127
156
|
parser.add_argument('--top-k', default='10', help='返回数量')
|
|
128
157
|
parser.add_argument('--type', default='all', choices=['all', 'article', 'concept', 'note'])
|
|
129
158
|
parser.add_argument('--days', type=int, default=90, help='搜索天数范围')
|
|
159
|
+
parser.add_argument('--line', choices=list(LINE_CHOICES), default='wiki',
|
|
160
|
+
help='只看一条知识库:wiki=文章线(默认)、chat=聊天线、all=两条都看')
|
|
130
161
|
parser.add_argument('--vault', default=DEFAULT_VAULT, help='Vault 路径')
|
|
131
162
|
parser.add_argument('--biz-daily', default=DEFAULT_BIZ, help='biz-daily 路径')
|
|
132
163
|
parser.add_argument('--json', action='store_true', help='JSON 输出')
|
|
@@ -136,14 +167,19 @@ def main():
|
|
|
136
167
|
print('请提供搜索关键词')
|
|
137
168
|
sys.exit(1)
|
|
138
169
|
|
|
139
|
-
files = collect_files(args.vault, args.biz_daily, args.type, args.days)
|
|
170
|
+
files = collect_files(args.vault, args.biz_daily, args.type, args.days, line=args.line)
|
|
140
171
|
results = search_text(args.query, files, int(args.top_k))
|
|
141
172
|
|
|
142
173
|
if args.json:
|
|
143
|
-
|
|
174
|
+
# **带上范围**:`--line chat` 搜空了和"知识库里真没有"是两回事,而这一支的输出
|
|
175
|
+
# 就是 JSON 本身(CLI 直接把它打给人看),没有别的地方能写这句。
|
|
176
|
+
print(json.dumps({'line': args.line, 'lineLabel': line_label(args.line),
|
|
177
|
+
'type': args.type, 'results': results},
|
|
178
|
+
ensure_ascii=False, indent=2))
|
|
144
179
|
return
|
|
145
180
|
|
|
146
|
-
print(f'🔍 搜索 "{args.query}" 找到 {len(results)} 条
|
|
181
|
+
print(f'🔍 搜索 "{args.query}" 找到 {len(results)} 条 '
|
|
182
|
+
f'(类型: {args.type}, 范围: {line_label(args.line)}, {args.days}天内)\n')
|
|
147
183
|
for i, r in enumerate(results):
|
|
148
184
|
icon = {'article': '📄', 'concept': '🧠', 'note': '📝'}.get(r['category'], '📎')
|
|
149
185
|
num = str(i + 1).rjust(2)
|
|
@@ -20,8 +20,14 @@ import glob
|
|
|
20
20
|
import hashlib
|
|
21
21
|
import os
|
|
22
22
|
import subprocess
|
|
23
|
+
import sys
|
|
23
24
|
import tempfile
|
|
24
25
|
|
|
26
|
+
# 手跑或被重定向时 stdout 走本机编码(Windows 是 GBK):非 GBK 字符(本文件里的 `✓`)会
|
|
27
|
+
# UnicodeEncodeError、整次输出没了;桥接跑有 PYTHONIOENCODING=utf-8 所以看不出来。
|
|
28
|
+
# 这条让两条路的输出一致(同 docs/EXTENDING.md Recipe E,守它的测试是 test/script_stdout_encoding_test.py)。
|
|
29
|
+
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
|
30
|
+
|
|
25
31
|
try:
|
|
26
32
|
from cryptography.hazmat.primitives import padding
|
|
27
33
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
package/scripts/wiki_lint.py
CHANGED
|
@@ -25,12 +25,17 @@ import sys
|
|
|
25
25
|
from pathlib import Path
|
|
26
26
|
|
|
27
27
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
28
|
-
from _utils import CONCEPT_DIRS,
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
#
|
|
33
|
-
|
|
28
|
+
from _utils import (CONCEPT_DIRS, KNOWLEDGE_LINES, is_line_qualified, line_for_concept_dir, # noqa: E402
|
|
29
|
+
line_label, link_target_name, normalize_concept_name, parse_frontmatter)
|
|
30
|
+
|
|
31
|
+
# 这里原来还有一份 `DEFAULT_PAGES_DIR = 'output/wechat-vault/Wiki/Concepts'`:**没人用**,
|
|
32
|
+
# 而它是文章线概念目录的第二份拷贝 —— 谁哪天顺手用了它,聊天线就会静默地不被体检
|
|
33
|
+
# ("体检通过"于是成了假话)。默认值现在只有一个来源:`--dir` 不传就是清单里的全部线。
|
|
34
|
+
# 文章线的根(材料目录 `002_Literature`/`Sources/WeChat` 都在它下面)。取自清单的**第一条线**,
|
|
35
|
+
# 不再自己写一遍字符串 —— 两条线各自一个根之后,这里会跟着清单变。
|
|
36
|
+
VAULT_ROOT = KNOWLEDGE_LINES[0][1]
|
|
37
|
+
# 体检要看的**全部**概念页目录:由清单拼出(每条线 = 根 + 线内概念目录)。
|
|
38
|
+
VAULT_CONCEPT_DIRS = tuple(str(Path(root) / concepts) for _line, root, concepts in KNOWLEDGE_LINES)
|
|
34
39
|
# 链接可能指向的「材料」层(与卡片目录一起参与可解析性判断)
|
|
35
40
|
MATERIAL_DIRS = tuple(str(Path(VAULT_ROOT) / d) for d in
|
|
36
41
|
('002_Literature', '001_Daily', 'Sources/Chat', 'Sources/WeChat'))
|
|
@@ -69,19 +74,40 @@ def links_by_section(body: str) -> list:
|
|
|
69
74
|
才真的建了页。所以那一节里的"还没有页"是**扩张候选**,不是坏掉的东西;
|
|
70
75
|
而 `## 来源` 里的 `[[<卡片>.md]]` 断了就是**真断了**(卡片被删或改名)。
|
|
71
76
|
一视同仁地报"40 条死链",看的人只会以为这里烂掉了。
|
|
77
|
+
|
|
78
|
+
名字一律经 `link_target_name` **归一化**(去掉 `|显示文本`、去掉线的路径前缀):
|
|
79
|
+
跨线同名的链接写的是 `[[Chat/Concepts/DeepSeek|DeepSeek]]`,不归一化的话这些链接
|
|
80
|
+
会被当成"带路径的卡片链接"报成断链——而它们其实解析得好好的。
|
|
72
81
|
"""
|
|
73
82
|
section, out = '', []
|
|
74
83
|
for line in (body or '').split('\n'):
|
|
75
84
|
if line.strip().startswith('## '):
|
|
76
85
|
section = line.strip()[3:].strip()
|
|
77
86
|
continue
|
|
78
|
-
for
|
|
79
|
-
name =
|
|
87
|
+
for raw in _LINK.findall(line):
|
|
88
|
+
name = link_target_name(raw)
|
|
80
89
|
if name:
|
|
81
90
|
out.append((section, name))
|
|
82
91
|
return out
|
|
83
92
|
|
|
84
93
|
|
|
94
|
+
def raw_links_by_section(body: str) -> list:
|
|
95
|
+
"""和 `links_by_section` 一样,但**保留原始写法**:`(小节, 原文, 归一化后的名字)`。
|
|
96
|
+
|
|
97
|
+
体检要判"这条链接消歧了没有",而那是**原文**才有的信息(归一化之后两种写法一模一样)。
|
|
98
|
+
"""
|
|
99
|
+
section, out = '', []
|
|
100
|
+
for line in (body or '').split('\n'):
|
|
101
|
+
if line.strip().startswith('## '):
|
|
102
|
+
section = line.strip()[3:].strip()
|
|
103
|
+
continue
|
|
104
|
+
for raw in _LINK.findall(line):
|
|
105
|
+
raw = raw.strip()
|
|
106
|
+
if raw:
|
|
107
|
+
out.append((section, raw, link_target_name(raw)))
|
|
108
|
+
return out
|
|
109
|
+
|
|
110
|
+
|
|
85
111
|
def collect_card_stems(card_dirs) -> set:
|
|
86
112
|
"""卡片文件名(不带 `.md`)。`## 来源` 那节的链接就是这些名字。"""
|
|
87
113
|
stems = set()
|
|
@@ -138,12 +164,22 @@ def inspect(pages: list, exists, card_links=()) -> dict:
|
|
|
138
164
|
- `aspirational`:`## 相关概念` 那节里指向"还没建页"的概念——**扩张候选,不是错误**;
|
|
139
165
|
- `orphans`:连卡片都没提到过它的页面(卡片链接也算入链,否则每张页都会被误报成孤儿);
|
|
140
166
|
- `empty`:没有定义也没有要点;
|
|
141
|
-
- `duplicateTitles
|
|
167
|
+
- `duplicateTitles`:**同一条线内**撞了名字的页(跨线同名不算,另见下一条);
|
|
168
|
+
- `crossLineSameName`:两条线都有这个名字(**设计使然,不是错误**)—— 合并视图里
|
|
169
|
+
就是靠它看两条线在哪重叠;
|
|
170
|
+
- `ambiguousLinks`:**指向**"两条线都有"那个名字、又**没带路径**的链接 —— 在 Obsidian
|
|
171
|
+
里点它会命中哪一张是不确定的。这一项**能修**(`wiki compile --qualify-links`),
|
|
172
|
+
所以它和上一条不同:上一条不用改,这一条要改;
|
|
142
173
|
- `nearDuplicates`:`{'sameNode': [...], 'contained': [...]}` 两档,可信度差一个量级
|
|
143
174
|
—— 见 `near_duplicate_titles`。
|
|
144
175
|
"""
|
|
145
176
|
stems = {page['stem'] for page in pages}
|
|
146
177
|
inbound = {stem: 0 for stem in stems}
|
|
178
|
+
# 哪条线有哪些名字:下面判"跨线同名""二义链接"都要用,所以先算一次。
|
|
179
|
+
lines_of = {}
|
|
180
|
+
for page in pages:
|
|
181
|
+
lines_of.setdefault(page['stem'], set()).add(page.get('dir', ''))
|
|
182
|
+
ambiguous_names = {name for name, where in lines_of.items() if len(where) > 1}
|
|
147
183
|
# **别名要归位到它那一页,否则那张页永远被报成孤儿。** 文件名是概念名的一个有损变换
|
|
148
184
|
# (`:` `/ ? * " < > |` 换成 `_`,再截到 60 字符),而指向它的链接用的是**原名**:
|
|
149
185
|
# `resolvable_names` 早就把别名算进"存在"了(所以断链是 0),但**入链计数**这里原来只认页码
|
|
@@ -160,9 +196,13 @@ def inspect(pages: list, exists, card_links=()) -> dict:
|
|
|
160
196
|
for name in card_links: # 卡片 → 概念,这是主要入链
|
|
161
197
|
if not (name.endswith('.md') or '/' in name) and stem_of(name) in inbound:
|
|
162
198
|
inbound[stem_of(name)] += 1
|
|
163
|
-
broken, aspirational, empty = [], [], []
|
|
199
|
+
broken, aspirational, empty, ambiguous_links = [], [], [], []
|
|
164
200
|
for page in pages:
|
|
165
|
-
|
|
201
|
+
# 走**保留原文**的那一份:判"消歧了没有"只有原文里有信息(归一化之后两种写法一样)
|
|
202
|
+
for section, raw, name in raw_links_by_section(page['body']):
|
|
203
|
+
if name in ambiguous_names and not is_line_qualified(raw):
|
|
204
|
+
ambiguous_links.append({'page': page['stem'], 'dir': page.get('dir', ''),
|
|
205
|
+
'target': name})
|
|
166
206
|
if exists(name):
|
|
167
207
|
if not (name.endswith('.md') or '/' in name) and stem_of(name) in inbound:
|
|
168
208
|
inbound[stem_of(name)] += 1
|
|
@@ -177,10 +217,24 @@ def inspect(pages: list, exists, card_links=()) -> dict:
|
|
|
177
217
|
orphans = sorted(stem for stem, count in inbound.items() if count == 0)
|
|
178
218
|
titles = {}
|
|
179
219
|
for page in pages:
|
|
180
|
-
|
|
181
|
-
|
|
220
|
+
# **同名要按线分组判**。两条线各有一张同名页是设计使然(用户 2026-10-10 要求两个
|
|
221
|
+
# 概念命名空间分开),它不是"同名页"这一项要找的东西;真该报的是**同一条线里**
|
|
222
|
+
# 撞了名字 —— 那会让 `[[名字]]` 在那一棵目录里就没有确定目标。
|
|
223
|
+
titles.setdefault(page.get('dir', ''), {}).setdefault(
|
|
224
|
+
page['title'] or page['stem'], []).append(page['stem'])
|
|
225
|
+
duplicates = {}
|
|
226
|
+
for _where, by_title in sorted(titles.items()):
|
|
227
|
+
for title, stems in by_title.items():
|
|
228
|
+
if len(stems) > 1:
|
|
229
|
+
duplicates.setdefault(title, []).extend(stems)
|
|
230
|
+
# **跨线同名另立一节**:它不是错误,是"这两条线在哪些概念上有重叠"——合并视图里
|
|
231
|
+
# 唯一想看的那个数(2026-10-10 实测 36 个)。混进 `duplicateTitles` 的话,
|
|
232
|
+
# 读的人会去"修"一个设计如此的东西。
|
|
233
|
+
cross_line = sorted(ambiguous_names)
|
|
182
234
|
return {'pages': len(pages), 'broken': broken, 'aspirational': aspirational,
|
|
183
235
|
'orphans': orphans, 'empty': empty, 'duplicateTitles': duplicates,
|
|
236
|
+
'crossLineSameName': cross_line,
|
|
237
|
+
'ambiguousLinks': ambiguous_links,
|
|
184
238
|
'degenerateFields': degenerate_fields(pages),
|
|
185
239
|
'nearDuplicates': near_duplicate_titles(pages)}
|
|
186
240
|
|
|
@@ -227,7 +281,7 @@ def near_duplicate_titles(pages: list) -> dict:
|
|
|
227
281
|
# **按目录分开比。** 两个概念目录是**两个知识库**(D-051),同一个名字两边各有一张页是
|
|
228
282
|
# 设计使然、不是重复;`compile_wiki --merge-duplicates` 也是按目录跑的,永远动不了跨线的
|
|
229
283
|
# 组。2026-09-28 实测:合并跑完后还剩 2 组(`AI 工具`、`GLORIA`)全是跨线的 —— 而报告
|
|
230
|
-
# 写着"这些就是合并会合并的",是句假话。跨线同名另有 `
|
|
284
|
+
# 写着"这些就是合并会合并的",是句假话。跨线同名另有 `crossLineSameName` 那一节在报。
|
|
231
285
|
named = sorted({(p.get('dir', ''), p['title'] or p['stem']) for p in pages})
|
|
232
286
|
same, contained = [], []
|
|
233
287
|
for where in sorted({w for w, _ in named}):
|
|
@@ -393,6 +447,12 @@ def main():
|
|
|
393
447
|
report['success'] = True
|
|
394
448
|
report['pagesDir'] = existing
|
|
395
449
|
report['cardLinks'] = len(card_links)
|
|
450
|
+
# **这一趟体检的是哪条线**:读的人(助手的 `wiki_health`)要按线分栏报,而它原来是从
|
|
451
|
+
# 路径里找 `Chat` 这个词猜的 —— 两条线各自一个根之后聊天线很可能不再叫 `Chat`,
|
|
452
|
+
# 于是它会**把聊天线报成文章线**,且不报错。所以由这一侧给出(认不出线就给 null)。
|
|
453
|
+
lines = [line_for_concept_dir(d) for d in existing]
|
|
454
|
+
report['line'] = lines[0] if len(set(lines)) == 1 else None
|
|
455
|
+
report['lineLabel'] = line_label(report['line'])
|
|
396
456
|
|
|
397
457
|
if args.json:
|
|
398
458
|
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
@@ -415,9 +475,25 @@ def main():
|
|
|
415
475
|
print(' %s(%d 字)' % (item['page'], item['chars']))
|
|
416
476
|
print(' 重新跑一次 wiki compile 通常就好了(那次模型返回不完整)')
|
|
417
477
|
if report['duplicateTitles']:
|
|
418
|
-
print('\n同名页 %d
|
|
478
|
+
print('\n同名页 %d 组(**同一条线内**撞了名字):' % len(report['duplicateTitles']))
|
|
419
479
|
for title, stems in list(report['duplicateTitles'].items())[:5]:
|
|
420
480
|
print(' %s ← %s' % (title, '、'.join(stems)))
|
|
481
|
+
if report.get('crossLineSameName'):
|
|
482
|
+
# 不当成问题报:两条线各留一张同名页是设计(用户要求两个命名空间分开)。
|
|
483
|
+
# 列出来是因为"哪些概念两条线都有"本身有信息量,而它**只能从这里看到**。
|
|
484
|
+
print('\n两条线都有的概念 %d 个(**设计如此,不用改**):%s'
|
|
485
|
+
% (len(report['crossLineSameName']), '、'.join(report['crossLineSameName'][:12])))
|
|
486
|
+
print(' 在 Obsidian 里这些名字的裸链接是**二义的**(官方:可能命中的不是你想要的'
|
|
487
|
+
'那一张)。要精确指着某一条线就用带路径的链接 `[[Wiki/Concepts/名字]]`')
|
|
488
|
+
if report.get('ambiguousLinks'):
|
|
489
|
+
rows = report['ambiguousLinks']
|
|
490
|
+
pages_hit = len({r['page'] for r in rows})
|
|
491
|
+
print('\n**二义的链接 %d 条**(指向"两条线都有"的名字、又没带路径;%d 张页):'
|
|
492
|
+
% (len(rows), pages_hit))
|
|
493
|
+
for item in rows[:5]:
|
|
494
|
+
print(' %s → [[%s]]' % (item['page'], item['target']))
|
|
495
|
+
print(' 修:`weflow-cli wiki compile --qualify-links`(本地重写、不调模型;'
|
|
496
|
+
'先加 `--dry-run` 看条数)')
|
|
421
497
|
if report['aspirational']:
|
|
422
498
|
names = sorted({item['target'] for item in report['aspirational']})
|
|
423
499
|
print('\n还没建页的相关概念 %d 个(**扩张候选,不是错误**):%s'
|
|
@@ -443,8 +519,9 @@ def main():
|
|
|
443
519
|
for field, info in report['degenerateFields'].items():
|
|
444
520
|
print(' %s: %s(%s/%s 页)' % (field, info['值'], info['页数'], info.get('总页数', report['pages'])))
|
|
445
521
|
print(' 多半是上游分类器把整批语料扔进了一个桶——不改分类器的话,别信这个字段')
|
|
446
|
-
if not any((report['broken'], report['orphans'], report['empty'], report['duplicateTitles']
|
|
447
|
-
|
|
522
|
+
if not any((report['broken'], report['orphans'], report['empty'], report['duplicateTitles'],
|
|
523
|
+
report.get('ambiguousLinks'))):
|
|
524
|
+
print('\n没有需要修的问题(断链/孤儿/空页/同名/二义链接)')
|
|
448
525
|
return 0
|
|
449
526
|
|
|
450
527
|
|
|
@@ -183,7 +183,18 @@ export class DbPathService {
|
|
|
183
183
|
/**
|
|
184
184
|
* 自动检测微信数据库根目录
|
|
185
185
|
*/
|
|
186
|
-
|
|
186
|
+
/**
|
|
187
|
+
* 找一个可用的数据根。
|
|
188
|
+
*
|
|
189
|
+
* `hintedRoots` 是**调用方拿到的线索**,会被**最先**试(见 bin 里的 init:它把客户端
|
|
190
|
+
* 命令行自报的 `--wechat-files-path` 放进来)。为什么值得优先:下面那两条都是**按目录名猜**
|
|
191
|
+
* (常见路径 / 全盘扫 `xwechat_files`),而这一条是**客户端自己说的** —— 也是唯一一条
|
|
192
|
+
* 不受"目录被建在别处、名字还不一样"影响的来源。
|
|
193
|
+
*
|
|
194
|
+
* 线索**同样要过 `resolveDataRoot` 验证**(找到账户目录才算数):拿到一个路径 ≠ 它能用。
|
|
195
|
+
*/
|
|
196
|
+
async autoDetect(options: { searchDrives?: boolean; fullScan?: boolean; hintedRoots?: string[] } = {}):
|
|
197
|
+
Promise<{ success: boolean; path?: string; error?: string; source?: 'client' | 'known' | 'scan' }> {
|
|
187
198
|
try {
|
|
188
199
|
const possiblePaths: string[] = []
|
|
189
200
|
const home = homedir()
|
|
@@ -227,10 +238,17 @@ export class DbPathService {
|
|
|
227
238
|
}
|
|
228
239
|
}
|
|
229
240
|
|
|
241
|
+
// 1) **客户端自己说的**(命令行里的 `--wechat-files-path`)。取不到就走下面的老路。
|
|
242
|
+
for (const hint of options.hintedRoots ?? []) {
|
|
243
|
+
const dataRoot = this.resolveDataRoot(hint)
|
|
244
|
+
if (dataRoot) return { success: true, path: dataRoot, source: 'client' }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// 2) 常见路径(含按目录名在几个已知位置里找)
|
|
230
248
|
for (const path of possiblePaths) {
|
|
231
249
|
const dataRoot = this.resolveDataRoot(path)
|
|
232
250
|
if (dataRoot) {
|
|
233
|
-
return { success: true, path: dataRoot }
|
|
251
|
+
return { success: true, path: dataRoot, source: 'known' }
|
|
234
252
|
}
|
|
235
253
|
}
|
|
236
254
|
|
|
@@ -238,7 +256,7 @@ export class DbPathService {
|
|
|
238
256
|
for (const volumeRoot of this.getVolumeRoots()) {
|
|
239
257
|
for (const dataRootPath of this.findKnownDataRoots(volumeRoot, 12)) {
|
|
240
258
|
const dataRoot = this.resolveDataRoot(dataRootPath)
|
|
241
|
-
if (dataRoot) return { success: true, path: dataRoot }
|
|
259
|
+
if (dataRoot) return { success: true, path: dataRoot, source: 'scan' }
|
|
242
260
|
}
|
|
243
261
|
}
|
|
244
262
|
}
|
|
@@ -246,7 +264,7 @@ export class DbPathService {
|
|
|
246
264
|
if (options.fullScan) {
|
|
247
265
|
for (const volumeRoot of this.getVolumeRoots()) {
|
|
248
266
|
const accountPath = this.findAccountDescendant(volumeRoot, 32)
|
|
249
|
-
if (accountPath) return { success: true, path: dirname(accountPath) }
|
|
267
|
+
if (accountPath) return { success: true, path: dirname(accountPath), source: 'scan' }
|
|
250
268
|
}
|
|
251
269
|
}
|
|
252
270
|
|
package/src/panel/server.ts
CHANGED
|
@@ -77,6 +77,29 @@ const STATIC_FILES: Record<string, string> = {
|
|
|
77
77
|
// 它的左边**必须是一条笔直的切边**(内容贴着画布 x=0),贴上去才跟屏幕边严丝合缝——
|
|
78
78
|
// 所以半隐时球的 `border-radius` 也要去掉(圆会把那条切边剪成弧)。
|
|
79
79
|
'/panel/mascot-peek.png': 'mascot-peek.png',
|
|
80
|
+
// 被拎起来那套动作的 12 张(2026-10-02):6 帧拎起来 + 6 帧落回去。
|
|
81
|
+
// 静止帧就是 `mascot-base.png`,不新增。同上:**漏一张,那一段动作里球会闪成空图**,
|
|
82
|
+
// 而 PANEL_ASSET_MISSING 只写在响应体里、页面上看不到。
|
|
83
|
+
'/panel/mascot-lift-up-1.png': 'mascot-lift-up-1.png',
|
|
84
|
+
'/panel/mascot-lift-up-2.png': 'mascot-lift-up-2.png',
|
|
85
|
+
'/panel/mascot-lift-up-3.png': 'mascot-lift-up-3.png',
|
|
86
|
+
'/panel/mascot-lift-up-4.png': 'mascot-lift-up-4.png',
|
|
87
|
+
'/panel/mascot-lift-up-5.png': 'mascot-lift-up-5.png',
|
|
88
|
+
'/panel/mascot-lift-up-6.png': 'mascot-lift-up-6.png',
|
|
89
|
+
'/panel/mascot-lift-down-1.png': 'mascot-lift-down-1.png',
|
|
90
|
+
'/panel/mascot-lift-down-2.png': 'mascot-lift-down-2.png',
|
|
91
|
+
'/panel/mascot-lift-down-3.png': 'mascot-lift-down-3.png',
|
|
92
|
+
'/panel/mascot-lift-down-4.png': 'mascot-lift-down-4.png',
|
|
93
|
+
'/panel/mascot-lift-down-5.png': 'mascot-lift-down-5.png',
|
|
94
|
+
'/panel/mascot-lift-down-6.png': 'mascot-lift-down-6.png',
|
|
95
|
+
// 眨眼那 2 帧(2026-10-04):眼睛以外与静止帧逐像素相同,只把眼睛带贴回来。
|
|
96
|
+
'/panel/mascot-blink-1.png': 'mascot-blink-1.png',
|
|
97
|
+
'/panel/mascot-blink-2.png': 'mascot-blink-2.png',
|
|
98
|
+
// 挠痒痒那 4 张(2026-10-02):鼠标在球身上晃过时循环的姿势帧。同上:漏一张就闪空图。
|
|
99
|
+
'/panel/mascot-tickle-1.png': 'mascot-tickle-1.png',
|
|
100
|
+
'/panel/mascot-tickle-2.png': 'mascot-tickle-2.png',
|
|
101
|
+
'/panel/mascot-tickle-3.png': 'mascot-tickle-3.png',
|
|
102
|
+
'/panel/mascot-tickle-4.png': 'mascot-tickle-4.png',
|
|
80
103
|
}
|
|
81
104
|
const CONTENT_TYPES: Record<string, string> = {
|
|
82
105
|
'.html': 'text/html; charset=utf-8',
|
|
@@ -318,6 +341,16 @@ export async function startPanelServer(options: PanelServerOptions): Promise<Pan
|
|
|
318
341
|
return
|
|
319
342
|
}
|
|
320
343
|
|
|
344
|
+
if (url.pathname === '/api/memory') {
|
|
345
|
+
// **只读**,而且这份数据**不经过模型**:它是给用户在面板里审计"你记住了什么"用的,
|
|
346
|
+
// 与 `buildSystemPrompt` 里注入的那份上下文是两件事(各有各的预算与脱敏)。
|
|
347
|
+
// 桶由服务端决定(上面那个 `memoryBucket`),**不接受客户端指定** —— 否则这就是一个
|
|
348
|
+
// 能读别的记忆桶的口子。
|
|
349
|
+
if (req.method !== 'GET') { json(res, 405, { ok: false, code: 'METHOD_NOT_ALLOWED' }); return }
|
|
350
|
+
json(res, 200, { ok: true, bucket: memoryBucket, ...service.memorySnapshot(memoryBucket) })
|
|
351
|
+
return
|
|
352
|
+
}
|
|
353
|
+
|
|
321
354
|
if (url.pathname === '/api/ask') {
|
|
322
355
|
if (req.method !== 'POST') { json(res, 405, { ok: false, code: 'METHOD_NOT_ALLOWED' }); return }
|
|
323
356
|
const contentType = String(req.headers['content-type'] ?? '')
|
|
@@ -955,6 +955,32 @@ export class AssistantService {
|
|
|
955
955
|
return { used: this.dailyCount, limit: DAILY_LIMIT }
|
|
956
956
|
}
|
|
957
957
|
|
|
958
|
+
/**
|
|
959
|
+
* 面板里那个"记忆"按钮要的:**它记住了什么**。只读,且**不经过模型**。
|
|
960
|
+
*
|
|
961
|
+
* 为什么不复用微信里那条 `记忆` 指令的文本:聊天那条路给的是"最近 5 条"的摘要(那是给对话用的),
|
|
962
|
+
* 面板要按时间列全、还要看得出每条是从哪句话来的,所以带上 `sourceQuote`。两边读的是同一份 memory,
|
|
963
|
+
* 只是呈现不同 —— 不各存一份。
|
|
964
|
+
*
|
|
965
|
+
* 桶(`userId`)由**调用方**决定:面板那条路传的是服务端自己那个 `memoryBucket`,不接受客户端指定。
|
|
966
|
+
*/
|
|
967
|
+
memorySnapshot(userId: string): {
|
|
968
|
+
facts: { content: string; ts: number; sourceQuote: string }[]
|
|
969
|
+
summary: string
|
|
970
|
+
workingTurns: number
|
|
971
|
+
saveError: string
|
|
972
|
+
} {
|
|
973
|
+
return {
|
|
974
|
+
facts: this.memory.facts(userId).map(f => ({
|
|
975
|
+
content: f.content, ts: f.ts, sourceQuote: f.sourceQuote ?? '',
|
|
976
|
+
})),
|
|
977
|
+
summary: this.memory.summary(userId),
|
|
978
|
+
workingTurns: this.memory.workingWindow(userId).length,
|
|
979
|
+
// 落盘失败要传出去:用户问"你记住了什么"时,若上次根本没存上,那份答案就是假的
|
|
980
|
+
saveError: this.memory.lastSaveError ?? '',
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
958
984
|
stop(): void {
|
|
959
985
|
this.running = false
|
|
960
986
|
this.svc?.stop()
|