fr-cli 2.1.0__py3-none-any.whl
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.
- fr_cli/README.md +148 -0
- fr_cli/WEAPON.MD +186 -0
- fr_cli/__init__.py +4 -0
- fr_cli/addon/plugin.py +69 -0
- fr_cli/agent/__init__.py +9 -0
- fr_cli/agent/builtins/__init__.py +4 -0
- fr_cli/agent/builtins/_utils.py +48 -0
- fr_cli/agent/builtins/db.py +269 -0
- fr_cli/agent/builtins/local.py +105 -0
- fr_cli/agent/builtins/rag.py +652 -0
- fr_cli/agent/builtins/rag_watcher_daemon.py +156 -0
- fr_cli/agent/builtins/remote.py +214 -0
- fr_cli/agent/builtins/spider.py +247 -0
- fr_cli/agent/client.py +164 -0
- fr_cli/agent/executor.py +86 -0
- fr_cli/agent/generator.py +104 -0
- fr_cli/agent/manager.py +193 -0
- fr_cli/agent/master.py +604 -0
- fr_cli/agent/master_prompt.py +118 -0
- fr_cli/agent/remote.py +70 -0
- fr_cli/agent/server.py +279 -0
- fr_cli/agent/workflow.py +164 -0
- fr_cli/breakthrough/update.py +154 -0
- fr_cli/command/__init__.py +4 -0
- fr_cli/command/executor.py +276 -0
- fr_cli/command/registry.py +1034 -0
- fr_cli/command/security.py +30 -0
- fr_cli/conf/config.py +126 -0
- fr_cli/conf/wizard.py +172 -0
- fr_cli/core/chat.py +280 -0
- fr_cli/core/core.py +111 -0
- fr_cli/core/intent.py +129 -0
- fr_cli/core/recommender.py +71 -0
- fr_cli/core/stream.py +83 -0
- fr_cli/core/sysmon.py +117 -0
- fr_cli/core/thinking.py +215 -0
- fr_cli/gatekeeper/__init__.py +7 -0
- fr_cli/gatekeeper/daemon.py +216 -0
- fr_cli/gatekeeper/manager.py +218 -0
- fr_cli/lang/i18n.py +827 -0
- fr_cli/main.py +329 -0
- fr_cli/memory/context.py +119 -0
- fr_cli/memory/history.py +96 -0
- fr_cli/memory/session.py +134 -0
- fr_cli/repl/__init__.py +0 -0
- fr_cli/repl/commands.py +1098 -0
- fr_cli/security/security.py +46 -0
- fr_cli/ui/ui.py +116 -0
- fr_cli/weapon/cron.py +217 -0
- fr_cli/weapon/dataframe.py +97 -0
- fr_cli/weapon/disk.py +141 -0
- fr_cli/weapon/fs.py +206 -0
- fr_cli/weapon/launcher.py +249 -0
- fr_cli/weapon/loader.py +98 -0
- fr_cli/weapon/mail.py +227 -0
- fr_cli/weapon/mcp.py +204 -0
- fr_cli/weapon/vision.py +74 -0
- fr_cli/weapon/web.py +88 -0
- fr_cli-2.1.0.dist-info/METADATA +227 -0
- fr_cli-2.1.0.dist-info/RECORD +64 -0
- fr_cli-2.1.0.dist-info/WHEEL +5 -0
- fr_cli-2.1.0.dist-info/entry_points.txt +2 -0
- fr_cli-2.1.0.dist-info/licenses/LICENSE +21 -0
- fr_cli-2.1.0.dist-info/top_level.txt +1 -0
fr_cli/main.py
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""
|
|
2
|
+
凡人打字机 - 主脑控制台
|
|
3
|
+
负责状态初始化、命令路由与 AI 交互循环
|
|
4
|
+
"""
|
|
5
|
+
import sys, os, subprocess, platform, shutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
# 添加项目根目录到 Python 路径
|
|
9
|
+
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
10
|
+
if project_root not in sys.path:
|
|
11
|
+
sys.path.insert(0, project_root)
|
|
12
|
+
|
|
13
|
+
from fr_cli.conf.config import init_config, ConfigError
|
|
14
|
+
from fr_cli.lang.i18n import T
|
|
15
|
+
from fr_cli.ui.ui import enable_win_ansi, print_banner, print_bye, CYAN, RED, YELLOW, DIM, RESET
|
|
16
|
+
from fr_cli.core.stream import stream_cnt
|
|
17
|
+
from fr_cli.memory.history import load_sess
|
|
18
|
+
from fr_cli.memory.context import load_context
|
|
19
|
+
from fr_cli.core.core import AppState
|
|
20
|
+
from fr_cli.core.chat import handle_ai_chat
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _sync_manual_to_workspace(vfs):
|
|
24
|
+
"""将项目根目录的 MANUAL.md 复制到工作空间,使 AI 可通过 read_file 读取"""
|
|
25
|
+
if not vfs.cwd:
|
|
26
|
+
return
|
|
27
|
+
try:
|
|
28
|
+
manual_src = Path(__file__).parent.parent / "MANUAL.md"
|
|
29
|
+
if not manual_src.exists():
|
|
30
|
+
return
|
|
31
|
+
manual_dst = Path(vfs.cwd) / "MANUAL.md"
|
|
32
|
+
if not manual_dst.exists():
|
|
33
|
+
import shutil
|
|
34
|
+
shutil.copy2(manual_src, manual_dst)
|
|
35
|
+
except Exception:
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ------------------------------------------------------------------
|
|
40
|
+
# 命令路由函数(将 main() 中巨大的 if-elif 链提取为字典映射)
|
|
41
|
+
# 返回 True 表示应退出主循环
|
|
42
|
+
# ------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
from fr_cli.repl.commands import (
|
|
45
|
+
_cmd_exit,
|
|
46
|
+
_cmd_help,
|
|
47
|
+
_cmd_model,
|
|
48
|
+
_cmd_key,
|
|
49
|
+
_cmd_limit,
|
|
50
|
+
_cmd_lang,
|
|
51
|
+
_cmd_dir,
|
|
52
|
+
_cmd_dirs,
|
|
53
|
+
_cmd_rmdir,
|
|
54
|
+
_cmd_save,
|
|
55
|
+
_cmd_load,
|
|
56
|
+
_cmd_del,
|
|
57
|
+
_cmd_session_list,
|
|
58
|
+
_cmd_session_load,
|
|
59
|
+
_cmd_session_del,
|
|
60
|
+
_cmd_see,
|
|
61
|
+
_cmd_update,
|
|
62
|
+
_cmd_agent_server,
|
|
63
|
+
_cmd_mode,
|
|
64
|
+
_cmd_gatekeeper,
|
|
65
|
+
_cmd_open,
|
|
66
|
+
_cmd_launch,
|
|
67
|
+
_cmd_apps,
|
|
68
|
+
_cmd_agent_create,
|
|
69
|
+
_cmd_agent_list,
|
|
70
|
+
_cmd_agent_delete,
|
|
71
|
+
_cmd_agent_show,
|
|
72
|
+
_cmd_agent_run,
|
|
73
|
+
_cmd_agent_edit,
|
|
74
|
+
_cmd_agent_forge,
|
|
75
|
+
_cmd_remote_agent_add,
|
|
76
|
+
_cmd_remote_agent_list,
|
|
77
|
+
_cmd_remote_agent_del,
|
|
78
|
+
_cmd_agent_publish,
|
|
79
|
+
_cmd_remote_agent_scan,
|
|
80
|
+
_cmd_remote_agent_import,
|
|
81
|
+
_cmd_remote_setup,
|
|
82
|
+
_cmd_db_setup,
|
|
83
|
+
_cmd_agent_cron_add,
|
|
84
|
+
_cmd_agent_cron_list,
|
|
85
|
+
_cmd_agent_cron_del,
|
|
86
|
+
_cmd_rag_dir,
|
|
87
|
+
_cmd_rag_watch,
|
|
88
|
+
_cmd_rag_sync,
|
|
89
|
+
_cmd_read_excel,
|
|
90
|
+
_cmd_read_csv,
|
|
91
|
+
_cmd_master,
|
|
92
|
+
_cmd_mcp_list,
|
|
93
|
+
_cmd_mcp_add,
|
|
94
|
+
_cmd_mcp_del,
|
|
95
|
+
_cmd_mcp_enable,
|
|
96
|
+
_cmd_mcp_disable,
|
|
97
|
+
_cmd_mcp_refresh,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
_COMMAND_ROUTES = {
|
|
102
|
+
"/exit": _cmd_exit,
|
|
103
|
+
"/quit": _cmd_exit,
|
|
104
|
+
"/help": _cmd_help,
|
|
105
|
+
"/model": _cmd_model,
|
|
106
|
+
"/key": _cmd_key,
|
|
107
|
+
"/limit": _cmd_limit,
|
|
108
|
+
"/lang": _cmd_lang,
|
|
109
|
+
"/mode": _cmd_mode,
|
|
110
|
+
"/dir": _cmd_dir,
|
|
111
|
+
"/dirs": _cmd_dirs,
|
|
112
|
+
"/rmdir": _cmd_rmdir,
|
|
113
|
+
"/save": _cmd_save,
|
|
114
|
+
"/load": _cmd_load,
|
|
115
|
+
"/del": _cmd_del,
|
|
116
|
+
"/session_list": _cmd_session_list,
|
|
117
|
+
"/session_load": _cmd_session_load,
|
|
118
|
+
"/session_del": _cmd_session_del,
|
|
119
|
+
"/see": _cmd_see,
|
|
120
|
+
"/update": _cmd_update,
|
|
121
|
+
"/agent_server": _cmd_agent_server,
|
|
122
|
+
"/gatekeeper": _cmd_gatekeeper,
|
|
123
|
+
"/open": _cmd_open,
|
|
124
|
+
"/launch": _cmd_launch,
|
|
125
|
+
"/apps": _cmd_apps,
|
|
126
|
+
"/agent_create": _cmd_agent_create,
|
|
127
|
+
"/agent_list": _cmd_agent_list,
|
|
128
|
+
"/agent_delete": _cmd_agent_delete,
|
|
129
|
+
"/agent_show": _cmd_agent_show,
|
|
130
|
+
"/agent_run": _cmd_agent_run,
|
|
131
|
+
"/agent_edit": _cmd_agent_edit,
|
|
132
|
+
"/agent_forge": _cmd_agent_forge,
|
|
133
|
+
"/remote_agent_add": _cmd_remote_agent_add,
|
|
134
|
+
"/remote_agent_list": _cmd_remote_agent_list,
|
|
135
|
+
"/remote_agent_del": _cmd_remote_agent_del,
|
|
136
|
+
"/agent_publish": _cmd_agent_publish,
|
|
137
|
+
"/remote_agent_scan": _cmd_remote_agent_scan,
|
|
138
|
+
"/remote_agent_import": _cmd_remote_agent_import,
|
|
139
|
+
"/remote_setup": _cmd_remote_setup,
|
|
140
|
+
"/db_setup": _cmd_db_setup,
|
|
141
|
+
"/agent_cron_add": _cmd_agent_cron_add,
|
|
142
|
+
"/agent_cron_list": _cmd_agent_cron_list,
|
|
143
|
+
"/agent_cron_del": _cmd_agent_cron_del,
|
|
144
|
+
"/rag_dir": _cmd_rag_dir,
|
|
145
|
+
"/rag_watch": _cmd_rag_watch,
|
|
146
|
+
"/rag_sync": _cmd_rag_sync,
|
|
147
|
+
"/read_excel": _cmd_read_excel,
|
|
148
|
+
"/read_csv": _cmd_read_csv,
|
|
149
|
+
"/master": _cmd_master,
|
|
150
|
+
"/mcp_list": _cmd_mcp_list,
|
|
151
|
+
"/mcp_add": _cmd_mcp_add,
|
|
152
|
+
"/mcp_del": _cmd_mcp_del,
|
|
153
|
+
"/mcp_enable": _cmd_mcp_enable,
|
|
154
|
+
"/mcp_disable": _cmd_mcp_disable,
|
|
155
|
+
"/mcp_refresh": _cmd_mcp_refresh,
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def main():
|
|
160
|
+
enable_win_ansi()
|
|
161
|
+
try:
|
|
162
|
+
cfg = init_config()
|
|
163
|
+
except ConfigError:
|
|
164
|
+
print(f"{RED}配置初始化失败,退出。{RESET}")
|
|
165
|
+
return
|
|
166
|
+
state = AppState(cfg)
|
|
167
|
+
|
|
168
|
+
# 将 MANUAL.md 同步到工作空间
|
|
169
|
+
_sync_manual_to_workspace(state.vfs)
|
|
170
|
+
|
|
171
|
+
# 加载历史会话或初始化系统提示词
|
|
172
|
+
sp = T("sys_prompt", state.lang)
|
|
173
|
+
if state.sn:
|
|
174
|
+
ok, m, _ = load_sess(0, sp)
|
|
175
|
+
if ok:
|
|
176
|
+
state.messages = m
|
|
177
|
+
if not state.messages:
|
|
178
|
+
state.messages = [{"role": "system", "content": sp}]
|
|
179
|
+
|
|
180
|
+
# 加载当前会话的记忆上下文
|
|
181
|
+
state.context_summary = load_context(state.sn)
|
|
182
|
+
|
|
183
|
+
# 启动动画
|
|
184
|
+
print_banner(state.model_name, state.limit, cfg.get("allowed_dirs", [""]), state.sn, state.lang)
|
|
185
|
+
|
|
186
|
+
# ================= 主循环 =================
|
|
187
|
+
while True:
|
|
188
|
+
try:
|
|
189
|
+
u = input(f"{CYAN}>>> {RESET}").strip()
|
|
190
|
+
except (EOFError, KeyboardInterrupt):
|
|
191
|
+
print_bye()
|
|
192
|
+
break
|
|
193
|
+
|
|
194
|
+
if not u:
|
|
195
|
+
continue
|
|
196
|
+
|
|
197
|
+
# 替换别名
|
|
198
|
+
if u in state.aliases:
|
|
199
|
+
u = state.aliases[u]
|
|
200
|
+
|
|
201
|
+
# ----------------- 内置指令路由 -----------------
|
|
202
|
+
if u.startswith("/"):
|
|
203
|
+
parts = u.split()
|
|
204
|
+
cmd = parts[0].lower()
|
|
205
|
+
arg1 = parts[1] if len(parts) > 1 else ""
|
|
206
|
+
|
|
207
|
+
if cmd in _COMMAND_ROUTES:
|
|
208
|
+
if _COMMAND_ROUTES[cmd](state, parts):
|
|
209
|
+
break
|
|
210
|
+
else:
|
|
211
|
+
# 其余命令统一委托给执行引擎
|
|
212
|
+
result, error = state.executor.execute(u, state.messages)
|
|
213
|
+
if error:
|
|
214
|
+
print(f"{RED}{error}{RESET}")
|
|
215
|
+
elif result is not None:
|
|
216
|
+
# 根据命令类型做简单格式化
|
|
217
|
+
if cmd == "/cat" and arg1:
|
|
218
|
+
print(f"\n{DIM}--- {arg1} ---{RESET}\n{result}\n{DIM}--- EOF ---{RESET}")
|
|
219
|
+
elif cmd == "/fetch" and arg1:
|
|
220
|
+
print(f"{DIM}--- Fetch ---{RESET}\n{result}\n{DIM}--- EOF ---{RESET}")
|
|
221
|
+
elif cmd == "/skills":
|
|
222
|
+
print("\n".join([f"{CYAN}{line}{RESET}" for line in result.split("\n")]))
|
|
223
|
+
else:
|
|
224
|
+
print(result)
|
|
225
|
+
|
|
226
|
+
# ----------------- 破壁指令 -----------------
|
|
227
|
+
elif u.startswith("!"):
|
|
228
|
+
is_pipe = "|" in u
|
|
229
|
+
shell_cmd = u[1:].split("|")[0].strip()
|
|
230
|
+
|
|
231
|
+
if state.security.check("sec_shell", shell_cmd):
|
|
232
|
+
try:
|
|
233
|
+
if platform.system() == "Windows":
|
|
234
|
+
ps_exe = shutil.which("pwsh") or shutil.which("powershell")
|
|
235
|
+
if ps_exe:
|
|
236
|
+
res = subprocess.run([ps_exe, "-Command", shell_cmd], capture_output=True, text=True, timeout=15)
|
|
237
|
+
else:
|
|
238
|
+
res = subprocess.run(shell_cmd, shell=True, capture_output=True, text=True, timeout=15)
|
|
239
|
+
else:
|
|
240
|
+
res = subprocess.run(shell_cmd, shell=True, capture_output=True, text=True, timeout=15)
|
|
241
|
+
out = res.stdout + res.stderr
|
|
242
|
+
if is_pipe:
|
|
243
|
+
pipe_prompt = u.split("|", 1)[1].strip()
|
|
244
|
+
final_prompt = f"{T('pipe_prefix', state.lang)}{out}\n\n{pipe_prompt}"
|
|
245
|
+
if state.vfs.cwd:
|
|
246
|
+
final_prompt += T("ctx_dir", state.lang, state.vfs.cwd)
|
|
247
|
+
state.messages.append({"role": "user", "content": final_prompt})
|
|
248
|
+
txt, _, response_time = stream_cnt(
|
|
249
|
+
state.client, state.model_name, state.messages, state.lang,
|
|
250
|
+
max_tokens=state.limit
|
|
251
|
+
)
|
|
252
|
+
state.messages.append({"role": "assistant", "content": txt})
|
|
253
|
+
|
|
254
|
+
# 自动执行 AI 响应中的命令(与 handle_ai_chat 保持一致)
|
|
255
|
+
clean_txt, cmd_results = state.executor.process_ai_commands(txt, state.messages)
|
|
256
|
+
if cmd_results:
|
|
257
|
+
print(f"\n{CYAN}🤖 自动执行命令:{RESET}")
|
|
258
|
+
for result in cmd_results:
|
|
259
|
+
print(f"{DIM}{result}{RESET}")
|
|
260
|
+
state.messages[-1]["content"] = clean_txt if clean_txt.strip() else "[已执行命令]"
|
|
261
|
+
state.messages.append({
|
|
262
|
+
"role": "system",
|
|
263
|
+
"content": f"命令执行结果:\n" + "\n".join(cmd_results)
|
|
264
|
+
})
|
|
265
|
+
sys.stdout.write(f"{CYAN}{T('prompt_ai', state.lang)}{RESET} ")
|
|
266
|
+
sys.stdout.flush()
|
|
267
|
+
final_txt, _, final_response_time = stream_cnt(
|
|
268
|
+
state.client, state.model_name, state.messages, state.lang,
|
|
269
|
+
custom_prefix="", max_tokens=state.limit
|
|
270
|
+
)
|
|
271
|
+
state.messages.append({"role": "assistant", "content": final_txt})
|
|
272
|
+
response_time += final_response_time
|
|
273
|
+
|
|
274
|
+
print(f"{DIM}📊 {T('stats_model', state.lang)}: {state.model_name} | {T('stats_time', state.lang)}: {response_time:.2f}{T('stats_seconds', state.lang)}{RESET}")
|
|
275
|
+
else:
|
|
276
|
+
if out.strip():
|
|
277
|
+
print(out.strip()[:2000])
|
|
278
|
+
except subprocess.TimeoutExpired:
|
|
279
|
+
print(f"{RED}Timeout{RESET}")
|
|
280
|
+
except Exception as e:
|
|
281
|
+
print(f"{RED}{e}{RESET}")
|
|
282
|
+
|
|
283
|
+
# ----------------- 内置 Agent 前缀拦截 -----------------
|
|
284
|
+
elif u.startswith("@local "):
|
|
285
|
+
try:
|
|
286
|
+
from fr_cli.agent.builtins.local import handle_local
|
|
287
|
+
handle_local(u, state)
|
|
288
|
+
except Exception as e:
|
|
289
|
+
print(f"{RED}@local Agent 执行失败: {e}{RESET}")
|
|
290
|
+
|
|
291
|
+
elif u.startswith("@remote "):
|
|
292
|
+
try:
|
|
293
|
+
from fr_cli.agent.builtins.remote import handle_remote
|
|
294
|
+
handle_remote(u, state)
|
|
295
|
+
except Exception as e:
|
|
296
|
+
print(f"{RED}@remote Agent 执行失败: {e}{RESET}")
|
|
297
|
+
|
|
298
|
+
elif u.startswith("@spider "):
|
|
299
|
+
try:
|
|
300
|
+
from fr_cli.agent.builtins.spider import handle_spider
|
|
301
|
+
handle_spider(u, state)
|
|
302
|
+
except Exception as e:
|
|
303
|
+
print(f"{RED}@spider Agent 执行失败: {e}{RESET}")
|
|
304
|
+
|
|
305
|
+
elif u.startswith("@db "):
|
|
306
|
+
try:
|
|
307
|
+
from fr_cli.agent.builtins.db import handle_db
|
|
308
|
+
handle_db(u, state)
|
|
309
|
+
except Exception as e:
|
|
310
|
+
print(f"{RED}@db Agent 执行失败: {e}{RESET}")
|
|
311
|
+
|
|
312
|
+
elif u.startswith("@RAG ") or u.startswith("@rag "):
|
|
313
|
+
try:
|
|
314
|
+
from fr_cli.agent.builtins.rag import handle_rag
|
|
315
|
+
handle_rag(u, state)
|
|
316
|
+
except Exception as e:
|
|
317
|
+
print(f"{RED}@RAG Agent 执行失败: {e}{RESET}")
|
|
318
|
+
|
|
319
|
+
# ----------------- AI 正常对话 -----------------
|
|
320
|
+
else:
|
|
321
|
+
if state.master_agent.is_enabled():
|
|
322
|
+
reply, _ = state.master_agent.handle(u)
|
|
323
|
+
print(f"\n{CYAN}{reply}{RESET}")
|
|
324
|
+
else:
|
|
325
|
+
handle_ai_chat(state, u)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
if __name__ == "__main__":
|
|
329
|
+
main()
|
fr_cli/memory/context.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""
|
|
2
|
+
记忆上下文引擎
|
|
3
|
+
保存并注入最近 N 轮对话的总结,作为 system prompt 的上下文
|
|
4
|
+
让每个会话拥有独立的短期记忆
|
|
5
|
+
"""
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
|
|
11
|
+
CONTEXT_FILE = Path.home() / ".zhipu_cli_context.json"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def extract_recent_turns(messages, n=5):
|
|
15
|
+
"""
|
|
16
|
+
从消息列表中提取最近 n 轮 user/assistant 对话
|
|
17
|
+
:param messages: 完整消息历史
|
|
18
|
+
:param n: 轮数(每轮包含 user + assistant)
|
|
19
|
+
:return: list of dict, 最多 n*2 条消息
|
|
20
|
+
"""
|
|
21
|
+
chat_msgs = [m for m in messages if m.get("role") in ("user", "assistant")]
|
|
22
|
+
return chat_msgs[-n * 2:]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_context_summary(turns, lang="zh"):
|
|
26
|
+
"""
|
|
27
|
+
将对话轮次格式化为上下文摘要文本
|
|
28
|
+
:param turns: extract_recent_turns 返回的消息列表
|
|
29
|
+
:param lang: 语言
|
|
30
|
+
:return: str 摘要文本(空字符串表示无内容)
|
|
31
|
+
"""
|
|
32
|
+
if not turns:
|
|
33
|
+
return ""
|
|
34
|
+
|
|
35
|
+
header = "\n\n[当前会话上下文摘要]\n" if lang == "zh" else "\n\n[Session Context Summary]\n"
|
|
36
|
+
lines = []
|
|
37
|
+
|
|
38
|
+
for m in turns:
|
|
39
|
+
role = "用户" if m.get("role") == "user" else "AI"
|
|
40
|
+
content = m.get("content", "")
|
|
41
|
+
|
|
42
|
+
# 处理多模态消息(图片等)
|
|
43
|
+
if isinstance(content, list):
|
|
44
|
+
content = "[图片/多模态消息]"
|
|
45
|
+
elif isinstance(content, str):
|
|
46
|
+
# 去除命令标记,避免重复噪音
|
|
47
|
+
content = re.sub(r'【命令:(.*?)】', '', content)
|
|
48
|
+
content = content.strip()
|
|
49
|
+
if not content:
|
|
50
|
+
content = "[已执行命令]"
|
|
51
|
+
elif len(content) > 200:
|
|
52
|
+
content = content[:200] + "..."
|
|
53
|
+
|
|
54
|
+
lines.append(f"{role}:{content}")
|
|
55
|
+
|
|
56
|
+
return header + "\n".join(lines) + "\n"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def save_context(session_name, summary):
|
|
60
|
+
"""
|
|
61
|
+
按会话名持久化上下文摘要
|
|
62
|
+
:param session_name: 会话名(空字符串时使用 __default__)
|
|
63
|
+
:param summary: 摘要文本
|
|
64
|
+
"""
|
|
65
|
+
data = {}
|
|
66
|
+
if CONTEXT_FILE.exists():
|
|
67
|
+
try:
|
|
68
|
+
with open(CONTEXT_FILE, 'r', encoding='utf-8') as f:
|
|
69
|
+
data = json.load(f)
|
|
70
|
+
except Exception:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
key = session_name if session_name else "__default__"
|
|
74
|
+
data[key] = {
|
|
75
|
+
"summary": summary,
|
|
76
|
+
"ts": datetime.now().isoformat()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
with open(CONTEXT_FILE, 'w', encoding='utf-8') as f:
|
|
81
|
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
82
|
+
except Exception:
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def load_context(session_name):
|
|
87
|
+
"""
|
|
88
|
+
按会话名加载上下文摘要
|
|
89
|
+
:param session_name: 会话名
|
|
90
|
+
:return: str 摘要文本(不存在时返回空字符串)
|
|
91
|
+
"""
|
|
92
|
+
if not CONTEXT_FILE.exists():
|
|
93
|
+
return ""
|
|
94
|
+
try:
|
|
95
|
+
with open(CONTEXT_FILE, 'r', encoding='utf-8') as f:
|
|
96
|
+
data = json.load(f)
|
|
97
|
+
key = session_name if session_name else "__default__"
|
|
98
|
+
return data.get(key, {}).get("summary", "")
|
|
99
|
+
except Exception:
|
|
100
|
+
return ""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def clear_context(session_name):
|
|
104
|
+
"""
|
|
105
|
+
清除指定会话的上下文摘要
|
|
106
|
+
:param session_name: 会话名
|
|
107
|
+
"""
|
|
108
|
+
if not CONTEXT_FILE.exists():
|
|
109
|
+
return
|
|
110
|
+
try:
|
|
111
|
+
with open(CONTEXT_FILE, 'r', encoding='utf-8') as f:
|
|
112
|
+
data = json.load(f)
|
|
113
|
+
key = session_name if session_name else "__default__"
|
|
114
|
+
if key in data:
|
|
115
|
+
del data[key]
|
|
116
|
+
with open(CONTEXT_FILE, 'w', encoding='utf-8') as f:
|
|
117
|
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
118
|
+
except Exception:
|
|
119
|
+
pass
|
fr_cli/memory/history.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""
|
|
2
|
+
会话轮回管理引擎
|
|
3
|
+
负责对话历史的本地保存、加载、删除与 Markdown 导出
|
|
4
|
+
"""
|
|
5
|
+
import json, os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from fr_cli.lang.i18n import T
|
|
9
|
+
from fr_cli.ui.ui import RED, RESET
|
|
10
|
+
|
|
11
|
+
HIST_DIR = Path.home() / ".zhipu_cli_history"
|
|
12
|
+
|
|
13
|
+
def init_history():
|
|
14
|
+
"""确保历史目录存在"""
|
|
15
|
+
HIST_DIR.mkdir(parents=True, exist_ok=True)
|
|
16
|
+
|
|
17
|
+
def _fpath(name):
|
|
18
|
+
return HIST_DIR / f"{name}.json"
|
|
19
|
+
|
|
20
|
+
def get_sessions():
|
|
21
|
+
"""获取所有会话列表"""
|
|
22
|
+
init_history()
|
|
23
|
+
sess = []
|
|
24
|
+
for f in sorted(HIST_DIR.glob("sess_*.json"), key=os.path.getmtime, reverse=True):
|
|
25
|
+
try:
|
|
26
|
+
with open(f, 'r', encoding='utf-8') as fp:
|
|
27
|
+
data = json.load(fp)
|
|
28
|
+
sess.append({"file": f.name, "name": data.get("name", f.stem)})
|
|
29
|
+
except Exception:
|
|
30
|
+
pass
|
|
31
|
+
return sess
|
|
32
|
+
|
|
33
|
+
def save_sess(name, msgs):
|
|
34
|
+
"""保存当前对话到轮回石"""
|
|
35
|
+
init_history()
|
|
36
|
+
safe_name = "".join(c for c in name if c.isalnum() or c in ('_', '-'))
|
|
37
|
+
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
38
|
+
fname = f"sess_{ts}_{safe_name}"
|
|
39
|
+
fp = _fpath(fname)
|
|
40
|
+
try:
|
|
41
|
+
with open(fp, 'w', encoding='utf-8') as f:
|
|
42
|
+
json.dump({"name": name, "ts": ts, "msgs": msgs}, f, ensure_ascii=False, indent=2)
|
|
43
|
+
return True
|
|
44
|
+
except Exception as e:
|
|
45
|
+
print(f"{RED}{e}{RESET}")
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
def load_sess(index, sp):
|
|
49
|
+
"""从轮回石中加载指定索引的会话"""
|
|
50
|
+
ss = get_sessions()
|
|
51
|
+
if not ss or index >= len(ss): return False, None, None
|
|
52
|
+
fp = HIST_DIR / ss[index]["file"]
|
|
53
|
+
try:
|
|
54
|
+
with open(fp, 'r', encoding='utf-8') as f:
|
|
55
|
+
data = json.load(f)
|
|
56
|
+
msgs = data.get("msgs", [])
|
|
57
|
+
# 强制覆盖第一条为最新的系统提示词
|
|
58
|
+
if msgs and msgs[0]["role"] == "system":
|
|
59
|
+
msgs[0]["content"] = sp
|
|
60
|
+
else:
|
|
61
|
+
msgs.insert(0, {"role": "system", "content": sp})
|
|
62
|
+
return True, msgs, data.get("name")
|
|
63
|
+
except Exception as e:
|
|
64
|
+
print(f"{RED}{e}{RESET}")
|
|
65
|
+
return False, None, None
|
|
66
|
+
|
|
67
|
+
def del_sess(index):
|
|
68
|
+
"""斩断一段因果(删除会话)"""
|
|
69
|
+
ss = get_sessions()
|
|
70
|
+
if not ss or index >= len(ss): return
|
|
71
|
+
fp = HIST_DIR / ss[index]["file"]
|
|
72
|
+
try:
|
|
73
|
+
os.remove(fp)
|
|
74
|
+
return True
|
|
75
|
+
except Exception:
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
def export_md(msgs, lang, out_dir=None):
|
|
79
|
+
"""将当前会话导出为 Markdown 文件
|
|
80
|
+
:param out_dir: 用户设定的工作目录,若提供且存在则保存到该目录,否则保存到当前运行目录
|
|
81
|
+
"""
|
|
82
|
+
if not msgs: return False, T("empty", lang)
|
|
83
|
+
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
84
|
+
fname = f"glm_export_{ts}.md"
|
|
85
|
+
try:
|
|
86
|
+
target_dir = out_dir if out_dir and os.path.isdir(out_dir) else "."
|
|
87
|
+
fpath = os.path.join(target_dir, fname)
|
|
88
|
+
with open(fpath, 'w', encoding='utf-8') as f:
|
|
89
|
+
for m in msgs:
|
|
90
|
+
role = m.get("role", "unknown")
|
|
91
|
+
content = m.get("content", "")
|
|
92
|
+
if role == "system": continue
|
|
93
|
+
tag = "### 🧑 凡人" if role == "user" else "### 🧙 飞书"
|
|
94
|
+
f.write(f"{tag}\n\n{content}\n\n---\n\n")
|
|
95
|
+
return True, fpath
|
|
96
|
+
except Exception as e: return False, str(e)
|
fr_cli/memory/session.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""
|
|
2
|
+
自动会话存档引擎 —— 按日期轮回
|
|
3
|
+
每次启动自动创建日期编号会话文件,实时追加对话记录。
|
|
4
|
+
"""
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
SESSION_DIR = Path.home() / ".fr_cli_sessions"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _ensure_dir():
|
|
14
|
+
SESSION_DIR.mkdir(parents=True, exist_ok=True)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _list_session_files():
|
|
18
|
+
"""返回所有会话文件路径列表(按修改时间倒序)"""
|
|
19
|
+
_ensure_dir()
|
|
20
|
+
files = list(SESSION_DIR.glob("*.json"))
|
|
21
|
+
files.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
|
22
|
+
return files
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _get_next_session_filename():
|
|
26
|
+
"""根据日期生成下一个可用会话文件名 YYYY-MM-DD_01.json, YYYY-MM-DD_02.json ..."""
|
|
27
|
+
_ensure_dir()
|
|
28
|
+
today = datetime.now().strftime("%Y-%m-%d")
|
|
29
|
+
existing = [f.stem for f in SESSION_DIR.glob(f"{today}_*.json")]
|
|
30
|
+
nums = []
|
|
31
|
+
for stem in existing:
|
|
32
|
+
parts = stem.split("_")
|
|
33
|
+
if len(parts) == 2 and parts[1].isdigit():
|
|
34
|
+
nums.append(int(parts[1]))
|
|
35
|
+
next_num = max(nums, default=0) + 1
|
|
36
|
+
return f"{today}_{next_num:02d}.json"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def create_session(messages):
|
|
40
|
+
"""创建新的自动会话文件,返回文件路径"""
|
|
41
|
+
_ensure_dir()
|
|
42
|
+
fname = _get_next_session_filename()
|
|
43
|
+
fpath = SESSION_DIR / fname
|
|
44
|
+
data = {
|
|
45
|
+
"filename": fname,
|
|
46
|
+
"created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
47
|
+
"updated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
48
|
+
"messages": messages,
|
|
49
|
+
}
|
|
50
|
+
try:
|
|
51
|
+
with open(fpath, "w", encoding="utf-8") as f:
|
|
52
|
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
53
|
+
return str(fpath)
|
|
54
|
+
except Exception:
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def update_session(fpath, messages):
|
|
59
|
+
"""更新会话文件中的消息记录"""
|
|
60
|
+
if not fpath:
|
|
61
|
+
return False
|
|
62
|
+
try:
|
|
63
|
+
path = Path(fpath)
|
|
64
|
+
data = {}
|
|
65
|
+
if path.exists():
|
|
66
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
67
|
+
data = json.load(f)
|
|
68
|
+
data["updated_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
69
|
+
data["messages"] = messages
|
|
70
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
71
|
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
72
|
+
return True
|
|
73
|
+
except Exception:
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def list_sessions():
|
|
78
|
+
"""列出所有会话,返回元数据列表"""
|
|
79
|
+
files = _list_session_files()
|
|
80
|
+
result = []
|
|
81
|
+
for idx, fpath in enumerate(files, start=1):
|
|
82
|
+
try:
|
|
83
|
+
with open(fpath, "r", encoding="utf-8") as f:
|
|
84
|
+
data = json.load(f)
|
|
85
|
+
created = data.get("created_at", "未知")
|
|
86
|
+
updated = data.get("updated_at", "未知")
|
|
87
|
+
msg_count = len(data.get("messages", []))
|
|
88
|
+
result.append({
|
|
89
|
+
"index": idx,
|
|
90
|
+
"filename": fpath.name,
|
|
91
|
+
"created_at": created,
|
|
92
|
+
"updated_at": updated,
|
|
93
|
+
"msg_count": msg_count,
|
|
94
|
+
"path": str(fpath),
|
|
95
|
+
})
|
|
96
|
+
except Exception:
|
|
97
|
+
pass
|
|
98
|
+
return result
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def load_session(index, current_system_prompt=None):
|
|
102
|
+
"""按索引加载会话消息
|
|
103
|
+
:param index: 1-based 索引
|
|
104
|
+
:param current_system_prompt: 若提供,将覆盖第一条 system prompt
|
|
105
|
+
:return: (success, messages, filename)
|
|
106
|
+
"""
|
|
107
|
+
sessions = list_sessions()
|
|
108
|
+
if not sessions or index < 1 or index > len(sessions):
|
|
109
|
+
return False, None, None
|
|
110
|
+
target = sessions[index - 1]
|
|
111
|
+
try:
|
|
112
|
+
with open(target["path"], "r", encoding="utf-8") as f:
|
|
113
|
+
data = json.load(f)
|
|
114
|
+
msgs = data.get("messages", [])
|
|
115
|
+
if current_system_prompt and msgs and msgs[0]["role"] == "system":
|
|
116
|
+
msgs[0]["content"] = current_system_prompt
|
|
117
|
+
elif current_system_prompt:
|
|
118
|
+
msgs.insert(0, {"role": "system", "content": current_system_prompt})
|
|
119
|
+
return True, msgs, target["filename"]
|
|
120
|
+
except Exception:
|
|
121
|
+
return False, None, None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def delete_session(index):
|
|
125
|
+
"""按索引删除会话文件"""
|
|
126
|
+
sessions = list_sessions()
|
|
127
|
+
if not sessions or index < 1 or index > len(sessions):
|
|
128
|
+
return False
|
|
129
|
+
target = sessions[index - 1]
|
|
130
|
+
try:
|
|
131
|
+
os.remove(target["path"])
|
|
132
|
+
return True
|
|
133
|
+
except Exception:
|
|
134
|
+
return False
|
fr_cli/repl/__init__.py
ADDED
|
File without changes
|