Compare commits
2 Commits
0ac246c22b
...
01138d0881
| Author | SHA1 | Date | |
|---|---|---|---|
| 01138d0881 | |||
| ba47147425 |
@ -1971,7 +1971,8 @@ class MainTerminal:
|
||||
task=arguments.get("task", ""),
|
||||
target_dir=arguments.get("target_dir", ""),
|
||||
reference_files=arguments.get("reference_files", []),
|
||||
timeout_seconds=arguments.get("timeout_seconds")
|
||||
timeout_seconds=arguments.get("timeout_seconds"),
|
||||
conversation_id=self.context_manager.current_conversation_id
|
||||
)
|
||||
|
||||
elif tool_name == "wait_sub_agent":
|
||||
|
||||
@ -1,3 +0,0 @@
|
||||
{
|
||||
"active_sessions": []
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
[]
|
||||
@ -1,632 +0,0 @@
|
||||
{
|
||||
"conv_20250924_210942_114": {
|
||||
"title": "你好!",
|
||||
"created_at": "2025-09-24T21:09:42.114583",
|
||||
"updated_at": "2025-09-24T21:40:33.984515",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 30,
|
||||
"total_tools": 12,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250924_211516_091": {
|
||||
"title": "你好!帮我想一个密码",
|
||||
"created_at": "2025-09-24T21:15:16.091444",
|
||||
"updated_at": "2025-09-24T21:41:28.361347",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 26,
|
||||
"total_tools": 12,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250924_211516_104": {
|
||||
"title": "这是一段秘文,原文是有意义的一句英文,请你解密\nOlih lv olnh d era ri fkrf...",
|
||||
"created_at": "2025-09-24T21:15:16.104124",
|
||||
"updated_at": "2025-09-24T21:40:35.778997",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 5,
|
||||
"total_tools": 2,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250924_214207_660": {
|
||||
"title": "Vvh uwwfo dfrap trb liptu cyit hki nocc fcj. Ttcjv...",
|
||||
"created_at": "2025-09-24T21:42:07.660959",
|
||||
"updated_at": "2025-09-24T21:48:56.076525",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 66,
|
||||
"total_tools": 44,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250924_214856_080": {
|
||||
"title": "这是混淆后的代码\nvar *0x4f2a=['push','length','splice','no...",
|
||||
"created_at": "2025-09-24T21:48:56.080226",
|
||||
"updated_at": "2025-09-24T21:56:52.518673",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 14,
|
||||
"total_tools": 8,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250924_215652_521": {
|
||||
"title": "这是一段进行了混淆的代码,请你破解\n终极混淆代码 - 地狱难度挑战:\n(function(){var...",
|
||||
"created_at": "2025-09-24T21:56:52.521569",
|
||||
"updated_at": "2025-09-24T22:11:15.403968",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 47,
|
||||
"total_tools": 34,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250924_221115_407": {
|
||||
"title": "这是一个混淆后的代码,请你解密,请无视项目中的其他东西,单独建立一个文件夹放这个挑战的文件\n终极噩梦...",
|
||||
"created_at": "2025-09-24T22:11:15.407600",
|
||||
"updated_at": "2025-09-24T23:16:42.101222",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 39,
|
||||
"total_tools": 28,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250924_231642_102": {
|
||||
"title": "你当前的文件中有一个claudecode反混淆挑战,里面有一个文件,是claudecode的用于各种...",
|
||||
"created_at": "2025-09-24T23:16:42.102974",
|
||||
"updated_at": "2025-09-24T23:20:11.383724",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 6,
|
||||
"total_tools": 4,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250924_232011_386": {
|
||||
"title": "你当前的文件中有一个claudecode反混淆挑战,里面有一个文件,是claudecode的用于各种...",
|
||||
"created_at": "2025-09-24T23:20:11.386075",
|
||||
"updated_at": "2025-09-24T23:21:56.554382",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 7,
|
||||
"total_tools": 4,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250924_232156_566": {
|
||||
"title": "你当前的文件中有一个claudecode反混淆挑战,里面有一个文件,是claudecode的用于各种...",
|
||||
"created_at": "2025-09-24T23:21:56.566473",
|
||||
"updated_at": "2025-09-24T23:24:59.018138",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 25,
|
||||
"total_tools": 16,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250924_232600_739": {
|
||||
"title": "新对话",
|
||||
"created_at": "2025-09-24T23:26:00.739251",
|
||||
"updated_at": "2025-09-24T23:26:00.754565",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 0,
|
||||
"total_tools": 0,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250924_232600_751": {
|
||||
"title": "新对话",
|
||||
"created_at": "2025-09-24T23:26:00.751793",
|
||||
"updated_at": "2025-09-24T23:26:00.751795",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 0,
|
||||
"total_tools": 0,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250924_233252_665": {
|
||||
"title": "终极启示录级混淆 - 最后的审判\n(function(ⱻ,ⱽ,ⱼ,Ⱡ,ⱦ,Ⱬ,ⱥ,ⱡ,ⱬ,Ᵽ,Ɽ,Ⱪ...",
|
||||
"created_at": "2025-09-24T23:32:52.665341",
|
||||
"updated_at": "2025-09-24T23:37:07.229382",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 6,
|
||||
"total_tools": 2,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250924_233621_342": {
|
||||
"title": "新对话",
|
||||
"created_at": "2025-09-24T23:36:21.342071",
|
||||
"updated_at": "2025-09-24T23:36:21.342076",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 0,
|
||||
"total_tools": 0,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250924_233728_734": {
|
||||
"title": "新对话",
|
||||
"created_at": "2025-09-24T23:37:28.734988",
|
||||
"updated_at": "2025-09-24T23:37:28.751203",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 0,
|
||||
"total_tools": 0,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250924_233728_748": {
|
||||
"title": "终极启示录级混淆 - 最后的审判\n(function(ⱻ,ⱽ,ⱼ,Ⱡ,ⱦ,Ⱬ,ⱥ,ⱡ,ⱬ,Ᵽ,Ɽ,Ⱪ...",
|
||||
"created_at": "2025-09-24T23:37:28.748313",
|
||||
"updated_at": "2025-09-26T16:08:59.318623",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 10,
|
||||
"total_tools": 6,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250924_234426_057": {
|
||||
"title": "当前项目里有一个经过混淆后的代码,请你进行动用你的各种工具进行反破译,加油!💪",
|
||||
"created_at": "2025-09-24T23:44:26.057604",
|
||||
"updated_at": "2025-09-25T09:27:45.086530",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 102,
|
||||
"total_tools": 66,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250925_091150_965": {
|
||||
"title": "首先取消聚焦现在这个文件\n现在你可以在路径规划这个文件夹中看到两个文件,分别是一个用于api接口的总...",
|
||||
"created_at": "2025-09-25T09:11:50.965226",
|
||||
"updated_at": "2025-09-25T09:38:38.186023",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 57,
|
||||
"total_tools": 34,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250925_093829_434": {
|
||||
"title": "取消聚焦所有文件",
|
||||
"created_at": "2025-09-25T09:38:29.434786",
|
||||
"updated_at": "2025-09-25T09:41:02.389919",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 7,
|
||||
"total_tools": 4,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250925_094102_391": {
|
||||
"title": "当前路径规划文件夹里有两文件,但都很长,我现在需要做一个新算法,你可以看到在zongti文件里不管调...",
|
||||
"created_at": "2025-09-25T09:41:02.391491",
|
||||
"updated_at": "2025-09-26T15:10:49.817102",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 59,
|
||||
"total_tools": 52,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250925_102401_519": {
|
||||
"title": "新对话",
|
||||
"created_at": "2025-09-25T10:24:01.519825",
|
||||
"updated_at": "2025-09-25T10:24:01.519833",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 0,
|
||||
"total_tools": 0,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250925_102406_275": {
|
||||
"title": "你好!",
|
||||
"created_at": "2025-09-25T10:24:06.275868",
|
||||
"updated_at": "2025-09-25T11:40:37.703991",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 191,
|
||||
"total_tools": 165,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250925_105746_128": {
|
||||
"title": "你好!",
|
||||
"created_at": "2025-09-25T10:57:46.128936",
|
||||
"updated_at": "2025-09-25T11:41:15.369991",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 98,
|
||||
"total_tools": 64,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250925_112053_581": {
|
||||
"title": "请你帮我调研一下目前市面上有哪些视频分析ai,我需要的是那种上传视频,然后就能根据视频解析出视频内容...",
|
||||
"created_at": "2025-09-25T11:20:53.581518",
|
||||
"updated_at": "2025-09-26T15:43:22.591377",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 101,
|
||||
"total_tools": 67,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250925_114123_293": {
|
||||
"title": "帮我看看视频分析文件夹里的东西",
|
||||
"created_at": "2025-09-25T11:41:23.293714",
|
||||
"updated_at": "2025-09-25T11:44:42.236381",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 2,
|
||||
"total_tools": 0,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250925_114442_238": {
|
||||
"title": "请你根据目前你项目中的agent_最新版,写一个小白向的新手教程,主要内容包括怎么运行,怎么安装py...",
|
||||
"created_at": "2025-09-25T11:44:42.238464",
|
||||
"updated_at": "2025-09-26T15:07:39.515726",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 25,
|
||||
"total_tools": 18,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250925_114918_842": {
|
||||
"title": "新对话",
|
||||
"created_at": "2025-09-25T11:49:18.842322",
|
||||
"updated_at": "2025-09-25T11:49:18.858752",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 0,
|
||||
"total_tools": 0,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250925_114918_855": {
|
||||
"title": "请你根据目前你项目中的agent_最新版,写一个小白向的新手教程,主要内容包括怎么运行,怎么安装py...",
|
||||
"created_at": "2025-09-25T11:49:18.855741",
|
||||
"updated_at": "2025-09-25T15:39:56.078028",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 207,
|
||||
"total_tools": 154,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250925_142225_210": {
|
||||
"title": "请你写一段代码,再用最阴间,最吓人,最恶心人的方式对其进行企业级的混淆",
|
||||
"created_at": "2025-09-25T14:22:25.210753",
|
||||
"updated_at": "2025-09-25T15:42:48.763883",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 12,
|
||||
"total_tools": 6,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250925_154000_043": {
|
||||
"title": "请你看看zongti文件,里面有一个不管调用什么算法都使用遗传,现在我把模拟退火写好了,请你求改总体...",
|
||||
"created_at": "2025-09-25T15:40:00.043777",
|
||||
"updated_at": "2025-09-25T15:52:06.506306",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 23,
|
||||
"total_tools": 14,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250925_155206_512": {
|
||||
"title": "如何实时查看docker日志,如何进入容器内",
|
||||
"created_at": "2025-09-25T15:52:06.512447",
|
||||
"updated_at": "2025-09-25T16:05:28.035537",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 7,
|
||||
"total_tools": 2,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250925_160528_039": {
|
||||
"title": "请你看一下那个视频分析文件夹里的东西是什么",
|
||||
"created_at": "2025-09-25T16:05:28.039652",
|
||||
"updated_at": "2025-09-26T15:07:36.011797",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 124,
|
||||
"total_tools": 98,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250925_161824_386": {
|
||||
"title": "新对话",
|
||||
"created_at": "2025-09-25T16:18:24.386332",
|
||||
"updated_at": "2025-09-25T16:18:24.386335",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 0,
|
||||
"total_tools": 0,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250925_163352_646": {
|
||||
"title": "新对话",
|
||||
"created_at": "2025-09-25T16:33:52.646643",
|
||||
"updated_at": "2025-09-25T16:33:52.663203",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 0,
|
||||
"total_tools": 0,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250925_163352_660": {
|
||||
"title": "我在当前像目有一个个视频分析和那网传透工具,当前视频分析直接传的的是base64编码,但是豆包AI ...",
|
||||
"created_at": "2025-09-25T16:33:52.660034",
|
||||
"updated_at": "2025-09-26T16:38:00.511760",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 436,
|
||||
"total_tools": 347,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250926_093524_257": {
|
||||
"title": "新对话",
|
||||
"created_at": "2025-09-26T09:35:24.257999",
|
||||
"updated_at": "2025-09-26T09:35:24.258003",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 0,
|
||||
"total_tools": 0,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250926_150349_960": {
|
||||
"title": "你好!",
|
||||
"created_at": "2025-09-26T15:03:49.960171",
|
||||
"updated_at": "2025-09-26T15:07:20.186733",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 2,
|
||||
"total_tools": 0,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250926_150646_861": {
|
||||
"title": "新对话",
|
||||
"created_at": "2025-09-26T15:06:46.861450",
|
||||
"updated_at": "2025-09-26T15:06:51.767810",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 0,
|
||||
"total_tools": 0,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250926_150646_874": {
|
||||
"title": "新对话",
|
||||
"created_at": "2025-09-26T15:06:46.875002",
|
||||
"updated_at": "2025-09-26T15:06:46.875006",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 0,
|
||||
"total_tools": 0,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250926_151049_822": {
|
||||
"title": "请你调用三个工具向我展示,每次调用前请说明",
|
||||
"created_at": "2025-09-26T15:10:49.822777",
|
||||
"updated_at": "2025-09-26T15:42:24.436910",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 11,
|
||||
"total_tools": 6,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250926_151205_306": {
|
||||
"title": "请直接给我写一首诗",
|
||||
"created_at": "2025-09-26T15:12:05.306857",
|
||||
"updated_at": "2025-09-26T15:42:47.111920",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 2,
|
||||
"total_tools": 0,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250926_153402_615": {
|
||||
"title": "你好!",
|
||||
"created_at": "2025-09-26T15:34:02.615195",
|
||||
"updated_at": "2025-09-26T15:42:48.604023",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 21,
|
||||
"total_tools": 8,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250926_153402_628": {
|
||||
"title": "新对话",
|
||||
"created_at": "2025-09-26T15:34:02.628657",
|
||||
"updated_at": "2025-09-26T15:34:02.628659",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 0,
|
||||
"total_tools": 0,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250926_154205_118": {
|
||||
"title": "新对话",
|
||||
"created_at": "2025-09-26T15:42:05.118698",
|
||||
"updated_at": "2025-09-26T15:42:05.135999",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 0,
|
||||
"total_tools": 0,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250926_154205_133": {
|
||||
"title": "新对话",
|
||||
"created_at": "2025-09-26T15:42:05.133015",
|
||||
"updated_at": "2025-09-26T15:42:05.133017",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 0,
|
||||
"total_tools": 0,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250926_155342_321": {
|
||||
"title": "新对话",
|
||||
"created_at": "2025-09-26T15:53:42.321335",
|
||||
"updated_at": "2025-09-26T15:53:42.331483",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 0,
|
||||
"total_tools": 0,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250926_155959_055": {
|
||||
"title": "新对话",
|
||||
"created_at": "2025-09-26T15:59:59.055928",
|
||||
"updated_at": "2025-09-26T15:59:59.063629",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 0,
|
||||
"total_tools": 0,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250926_160332_115": {
|
||||
"title": "新对话",
|
||||
"created_at": "2025-09-26T16:03:32.115755",
|
||||
"updated_at": "2025-09-26T16:03:32.123925",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 0,
|
||||
"total_tools": 0,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250926_160409_071": {
|
||||
"title": "新对话",
|
||||
"created_at": "2025-09-26T16:04:09.071062",
|
||||
"updated_at": "2025-09-26T16:04:09.118388",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 0,
|
||||
"total_tools": 0,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250926_160548_420": {
|
||||
"title": "新对话",
|
||||
"created_at": "2025-09-26T16:05:48.420573",
|
||||
"updated_at": "2025-09-26T16:05:48.436614",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 0,
|
||||
"total_tools": 0,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250926_160548_433": {
|
||||
"title": "新对话",
|
||||
"created_at": "2025-09-26T16:05:48.433761",
|
||||
"updated_at": "2025-09-26T16:05:48.433765",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 0,
|
||||
"total_tools": 0,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250926_160845_366": {
|
||||
"title": "新对话",
|
||||
"created_at": "2025-09-26T16:08:45.366490",
|
||||
"updated_at": "2025-09-26T16:08:45.382996",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 0,
|
||||
"total_tools": 0,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250926_160845_380": {
|
||||
"title": "新对话",
|
||||
"created_at": "2025-09-26T16:08:45.380211",
|
||||
"updated_at": "2025-09-26T16:08:45.380213",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 0,
|
||||
"total_tools": 0,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250926_160859_322": {
|
||||
"title": "请调用三个工具",
|
||||
"created_at": "2025-09-26T16:08:59.322416",
|
||||
"updated_at": "2025-09-26T16:16:18.037917",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 9,
|
||||
"total_tools": 6,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250926_161609_787": {
|
||||
"title": "新对话",
|
||||
"created_at": "2025-09-26T16:16:09.787345",
|
||||
"updated_at": "2025-09-26T16:16:09.787348",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 0,
|
||||
"total_tools": 0,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250926_161618_041": {
|
||||
"title": "请调用三个工具",
|
||||
"created_at": "2025-09-26T16:16:18.041166",
|
||||
"updated_at": "2025-09-26T16:16:51.122169",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 13,
|
||||
"total_tools": 6,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250926_162047_219": {
|
||||
"title": "你好",
|
||||
"created_at": "2025-09-26T16:20:47.219702",
|
||||
"updated_at": "2025-09-26T16:20:57.748232",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 2,
|
||||
"total_tools": 0,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250926_162148_372": {
|
||||
"title": "你好!请随便用两个工具",
|
||||
"created_at": "2025-09-26T16:21:48.372329",
|
||||
"updated_at": "2025-09-26T16:37:45.904110",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 7,
|
||||
"total_tools": 4,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250926_162756_360": {
|
||||
"title": "你好!",
|
||||
"created_at": "2025-09-26T16:27:56.360496",
|
||||
"updated_at": "2025-09-26T16:29:37.633584",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 13,
|
||||
"total_tools": 6,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250926_163044_253": {
|
||||
"title": "你好!",
|
||||
"created_at": "2025-09-26T16:30:44.253791",
|
||||
"updated_at": "2025-09-26T16:57:55.259056",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 2,
|
||||
"total_tools": 0,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250926_163151_508": {
|
||||
"title": "你好!",
|
||||
"created_at": "2025-09-26T16:31:51.508887",
|
||||
"updated_at": "2025-09-26T16:33:54.634323",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 15,
|
||||
"total_tools": 8,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250926_163354_641": {
|
||||
"title": "帮我找找京东的技术产品经理笔试题",
|
||||
"created_at": "2025-09-26T16:33:54.641518",
|
||||
"updated_at": "2025-09-26T16:37:37.394642",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 17,
|
||||
"total_tools": 10,
|
||||
"status": "active"
|
||||
},
|
||||
"conv_20250926_165757_173": {
|
||||
"title": "你好!",
|
||||
"created_at": "2025-09-26T16:57:57.173528",
|
||||
"updated_at": "2025-09-26T16:58:06.402270",
|
||||
"project_path": "/Users/jojo/Desktop/agent_kimi_源码备份/project",
|
||||
"thinking_mode": true,
|
||||
"total_messages": 2,
|
||||
"total_tools": 0,
|
||||
"status": "active"
|
||||
}
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
{
|
||||
"codes": [
|
||||
{
|
||||
"code": "invite2025",
|
||||
"remaining": 7
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -5,7 +5,7 @@ import shutil
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from typing import Dict, List, Optional, Tuple, Any
|
||||
|
||||
import httpx
|
||||
|
||||
@ -34,12 +34,15 @@ class SubAgentManager:
|
||||
self.base_dir = Path(SUB_AGENT_TASKS_BASE_DIR).resolve()
|
||||
self.results_dir = Path(SUB_AGENT_PROJECT_RESULTS_DIR).resolve()
|
||||
self.state_file = Path(SUB_AGENT_STATE_FILE).resolve()
|
||||
self.sub_agent_conversations_dir = (self.data_dir / "conversations" / "sub_agent").resolve()
|
||||
|
||||
self.base_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.results_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.state_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.sub_agent_conversations_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.tasks: Dict[str, Dict] = {}
|
||||
self.conversation_agents: Dict[str, List[int]] = {}
|
||||
self._load_state()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@ -54,6 +57,7 @@ class SubAgentManager:
|
||||
target_dir: str,
|
||||
reference_files: Optional[List[str]] = None,
|
||||
timeout_seconds: Optional[int] = None,
|
||||
conversation_id: Optional[str] = None,
|
||||
) -> Dict:
|
||||
"""创建子智能体任务并启动远端服务。"""
|
||||
reference_files = reference_files or []
|
||||
@ -61,6 +65,15 @@ class SubAgentManager:
|
||||
if validation_error:
|
||||
return {"success": False, "error": validation_error}
|
||||
|
||||
if not conversation_id:
|
||||
return {"success": False, "error": "缺少对话ID,无法创建子智能体"}
|
||||
|
||||
if not self._ensure_agent_slot_available(conversation_id, agent_id):
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"该对话已使用过编号 {agent_id},请更换新的子智能体代号。"
|
||||
}
|
||||
|
||||
if self._active_task_count() >= SUB_AGENT_MAX_ACTIVE:
|
||||
return {
|
||||
"success": False,
|
||||
@ -69,9 +82,9 @@ class SubAgentManager:
|
||||
|
||||
task_id = self._generate_task_id(agent_id)
|
||||
task_root = self.base_dir / task_id
|
||||
references_dir = task_root / "references"
|
||||
deliverables_dir = task_root / "deliverables"
|
||||
workspace_dir = task_root / "workspace"
|
||||
references_dir = workspace_dir / "references"
|
||||
deliverables_dir = workspace_dir / "deliverables"
|
||||
|
||||
for path in (task_root, references_dir, deliverables_dir, workspace_dir):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
@ -96,6 +109,10 @@ class SubAgentManager:
|
||||
"references_dir": str(references_dir),
|
||||
"deliverables_dir": str(deliverables_dir),
|
||||
"timeout_seconds": timeout_seconds,
|
||||
"parent_conversation_id": conversation_id,
|
||||
"data_dir": str(self.data_dir),
|
||||
"reference_manifest": copied_refs,
|
||||
"conversation_storage_dir": str(self.sub_agent_conversations_dir),
|
||||
}
|
||||
|
||||
service_response = self._call_service("POST", "/tasks", payload, timeout_seconds + 5)
|
||||
@ -108,6 +125,7 @@ class SubAgentManager:
|
||||
}
|
||||
|
||||
status = service_response.get("status", "pending")
|
||||
sub_conversation_id = service_response.get("sub_conversation_id")
|
||||
task_record = {
|
||||
"task_id": task_id,
|
||||
"agent_id": agent_id,
|
||||
@ -122,8 +140,12 @@ class SubAgentManager:
|
||||
"timeout_seconds": timeout_seconds,
|
||||
"service_payload": payload,
|
||||
"created_at": time.time(),
|
||||
"conversation_id": conversation_id,
|
||||
"sub_conversation_id": sub_conversation_id,
|
||||
"parent_conversation_id": conversation_id,
|
||||
}
|
||||
self.tasks[task_id] = task_record
|
||||
self._mark_agent_id_used(conversation_id, agent_id)
|
||||
self._save_state()
|
||||
|
||||
message = f"子智能体{agent_id} 已创建,任务ID: {task_id},当前状态:{status}"
|
||||
@ -137,6 +159,7 @@ class SubAgentManager:
|
||||
"message": message,
|
||||
"deliverables_dir": str(deliverables_dir),
|
||||
"copied_references": copied_refs,
|
||||
"sub_conversation_id": sub_conversation_id,
|
||||
}
|
||||
|
||||
def wait_for_completion(
|
||||
@ -191,14 +214,31 @@ class SubAgentManager:
|
||||
try:
|
||||
data = json.loads(self.state_file.read_text(encoding="utf-8"))
|
||||
self.tasks = data.get("tasks", {})
|
||||
self.conversation_agents = data.get("conversation_agents", {})
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("子智能体状态文件损坏,已忽略。")
|
||||
self.tasks = {}
|
||||
self.conversation_agents = {}
|
||||
else:
|
||||
self.tasks = {}
|
||||
self.conversation_agents = {}
|
||||
if self.tasks:
|
||||
migrated = False
|
||||
for task in self.tasks.values():
|
||||
if task.get("parent_conversation_id"):
|
||||
continue
|
||||
candidate = task.get("conversation_id") or (task.get("service_payload") or {}).get("parent_conversation_id")
|
||||
if candidate:
|
||||
task["parent_conversation_id"] = candidate
|
||||
migrated = True
|
||||
if migrated:
|
||||
self._save_state()
|
||||
|
||||
def _save_state(self):
|
||||
payload = {"tasks": self.tasks}
|
||||
payload = {
|
||||
"tasks": self.tasks,
|
||||
"conversation_agents": self.conversation_agents
|
||||
}
|
||||
self.state_file.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
def _generate_task_id(self, agent_id: int) -> str:
|
||||
@ -335,6 +375,7 @@ class SubAgentManager:
|
||||
"status": status,
|
||||
"message": message or f"子智能体状态:{status}",
|
||||
"details": service_payload,
|
||||
"sub_conversation_id": task.get("sub_conversation_id"),
|
||||
"system_message": self._build_system_message(task, status, None, message),
|
||||
}
|
||||
task["final_result"] = result
|
||||
@ -379,6 +420,7 @@ class SubAgentManager:
|
||||
"message": message or "子智能体已完成任务。",
|
||||
"deliverables_path": str(deliverables_dir),
|
||||
"copied_path": str(copied_path),
|
||||
"sub_conversation_id": task.get("sub_conversation_id"),
|
||||
"system_message": system_message,
|
||||
"details": service_payload,
|
||||
}
|
||||
@ -401,6 +443,15 @@ class SubAgentManager:
|
||||
if task_root.exists():
|
||||
shutil.rmtree(task_root, ignore_errors=True)
|
||||
|
||||
def _ensure_agent_slot_available(self, conversation_id: str, agent_id: int) -> bool:
|
||||
used = self.conversation_agents.setdefault(conversation_id, [])
|
||||
return agent_id not in used
|
||||
|
||||
def _mark_agent_id_used(self, conversation_id: str, agent_id: int):
|
||||
used = self.conversation_agents.setdefault(conversation_id, [])
|
||||
if agent_id not in used:
|
||||
used.append(agent_id)
|
||||
|
||||
def _validate_create_params(self, agent_id: Optional[int], summary: str, task: str, target_dir: str) -> Optional[str]:
|
||||
if agent_id is None:
|
||||
return "子智能体代号不能为空"
|
||||
@ -408,8 +459,8 @@ class SubAgentManager:
|
||||
agent_id = int(agent_id)
|
||||
except ValueError:
|
||||
return "子智能体代号必须是整数"
|
||||
if not (1 <= agent_id <= SUB_AGENT_MAX_ACTIVE):
|
||||
return f"子智能体代号必须在 1~{SUB_AGENT_MAX_ACTIVE} 范围内"
|
||||
if agent_id <= 0:
|
||||
return "子智能体代号必须为正整数"
|
||||
if not summary or not summary.strip():
|
||||
return "任务摘要不能为空"
|
||||
if not task or not task.strip():
|
||||
@ -441,3 +492,34 @@ class SubAgentManager:
|
||||
return f"{prefix} 执行失败:" + (extra if extra else "请检查交付目录或任务状态。")
|
||||
|
||||
return f"{prefix} 状态:{status}。" + (extra if extra else "")
|
||||
|
||||
def get_overview(self, conversation_id: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""返回子智能体任务概览,用于前端展示。"""
|
||||
overview: List[Dict[str, Any]] = []
|
||||
for task_id, task in self.tasks.items():
|
||||
if conversation_id and task.get("conversation_id") != conversation_id:
|
||||
continue
|
||||
snapshot = {
|
||||
"task_id": task_id,
|
||||
"agent_id": task.get("agent_id"),
|
||||
"summary": task.get("summary"),
|
||||
"status": task.get("status"),
|
||||
"created_at": task.get("created_at"),
|
||||
"updated_at": task.get("updated_at"),
|
||||
"target_dir": task.get("target_project_dir"),
|
||||
"last_tool": task.get("last_tool"),
|
||||
"deliverables_dir": task.get("deliverables_dir"),
|
||||
"copied_path": task.get("copied_path"),
|
||||
"conversation_id": task.get("conversation_id"),
|
||||
"sub_conversation_id": task.get("sub_conversation_id"),
|
||||
}
|
||||
# 对于运行中的任务,尝试获取最新状态
|
||||
if snapshot["status"] not in TERMINAL_STATUSES:
|
||||
remote = self._call_service("GET", f"/tasks/{task_id}", timeout=5)
|
||||
if remote.get("success"):
|
||||
snapshot["status"] = remote.get("status", snapshot["status"])
|
||||
snapshot["remote_message"] = remote.get("message")
|
||||
snapshot["last_tool"] = remote.get("last_tool")
|
||||
task["last_tool"] = snapshot["last_tool"]
|
||||
overview.append(snapshot)
|
||||
return overview
|
||||
|
||||
@ -1,18 +0,0 @@
|
||||
Traceback (most recent call last):
|
||||
File "/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/runpy.py", line 197, in _run_module_as_main
|
||||
return _run_code(code, main_globals, None,
|
||||
File "/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/runpy.py", line 87, in _run_code
|
||||
exec(code, run_globals)
|
||||
File "/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/http/server.py", line 1297, in <module>
|
||||
test(
|
||||
File "/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/http/server.py", line 1252, in test
|
||||
with ServerClass(addr, HandlerClass) as httpd:
|
||||
File "/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/socketserver.py", line 452, in __init__
|
||||
self.server_bind()
|
||||
File "/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/http/server.py", line 1295, in server_bind
|
||||
return super().server_bind()
|
||||
File "/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/http/server.py", line 138, in server_bind
|
||||
socketserver.TCPServer.server_bind(self)
|
||||
File "/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/3.9/lib/python3.9/socketserver.py", line 466, in server_bind
|
||||
self.socket.bind(self.server_address)
|
||||
OSError: [Errno 48] Address already in use
|
||||
@ -1,35 +0,0 @@
|
||||
以下为需更新的工具描述与提示词草案(供集成到 `define_tools` 及相关返回信息中使用):
|
||||
|
||||
---
|
||||
|
||||
### read_file
|
||||
- **描述**:`统一的阅读工具。通过 type 参数在 read(直接阅读)、search(全文搜索)、extract(按行抽取)之间切换,始终返回 UTF-8 文本。所有模式都会在响应前根据 max_chars 截断输出,保证不会超量。`
|
||||
- **模式提示**:
|
||||
- `read`:可选 `start_line`/`end_line`,适合一次性查看短片段。
|
||||
- `search`:需提供 `query`,并可设置 `max_matches`、`context_before`、`context_after`、`case_sensitive`,自动合并重复命中,以窗口形式返回。
|
||||
- `extract`:传入 `segments=[{start_line,end_line,label?},...]`,适合按多段行号提取关键信息。
|
||||
- **失败/限制提示**:若因编码或体积被拒绝,提醒:`文件不是 UTF-8 或体量过大,请改用 run_python(可结合 python-docx、pandas 等库)读取。` 若多次需要查看同一长文件,建议直接调用 `focus_file`。
|
||||
|
||||
### focus_file
|
||||
- **描述**:`持续在上下文中展示 UTF-8 文本文件的完整内容,适合频繁查看/修改的核心文件。文件非 UTF-8 或体积超限将直接拒绝;如需了解二进制/Office 文件,请改用 run_python。`
|
||||
|
||||
### run_python
|
||||
- **描述**:`执行一次性 Python 脚本,可用于处理二进制或非 UTF-8 文件(如 Excel、Word、PDF、图片),或进行数据分析与验证。请在代码内显式读取文件并打印必要结果,避免长时间阻塞。`
|
||||
- **成功消息补充**:鼓励记录输出摘要,提醒用户脚本已在云端运行,可复用逻辑写入脚本文件。
|
||||
|
||||
### run_command
|
||||
- **描述**:`执行一次性命令并返回结果。适合快速查看文件信息(如 file/ls/stat/iconv)、转换编码、或调用 cli 工具。禁止启动交互式程序;对已聚焦文件仅允许使用 grep -n 等定位命令。若需要解析二进制/Office 文件,可结合 run_python 或专门命令行工具(如 xlsx2csv)。`
|
||||
|
||||
### terminal_session / terminal_input / terminal_reset
|
||||
- **描述补充**:强调云端多用户环境:`请在受限工作区内使用,避免切换到未经授权的路径。禁止启动需完整 TTY 的程序;如误触,请使用 terminal_reset 恢复,并说明原因。`
|
||||
|
||||
### web_search
|
||||
- **描述**:`在外部信息确实缺失时才调用的网络搜索工具。调用前应确认问题无法通过现有上下文解决,并向用户说明搜索目的。尽量精准撰写 query,合理设置时间/主题参数,避免无意义或重复搜索。`
|
||||
- **失败/过度使用提示**:当搜索结果空或不必要时返回建议:`考虑先梳理现有信息或向用户确认,再决定是否继续搜索。`
|
||||
|
||||
### extract_webpage / save_webpage
|
||||
- **描述**:`仅在 web_search 结果不足以提供所需细节时使用。提醒会显著增加 token 消耗,提取后应考虑落地为文本文件,并告知用户整理计划。`
|
||||
- **成功消息补充**:提示已获取的内容体量及后续处理建议(如“建议整理要点写入笔记或待办”)。
|
||||
|
||||
### todo_create / todo_update_task / todo_finish
|
||||
- **描述补充**:强调沟通闭环:`建/更/结单前需先向用户同步当前理解、风险及下一步。` `todo_finish` 在仍有未完成任务时要求提供剩余事项及后续建议。***
|
||||
@ -160,7 +160,9 @@ async function bootstrapApp() {
|
||||
|
||||
// 对话历史侧边栏
|
||||
sidebarCollapsed: true, // 默认收起对话侧边栏
|
||||
showTodoList: false,
|
||||
panelMode: 'files', // files | todo | subAgents
|
||||
subAgents: [],
|
||||
subAgentPollTimer: null,
|
||||
conversations: [],
|
||||
conversationsLoading: false,
|
||||
hasMoreConversations: false,
|
||||
@ -221,7 +223,8 @@ async function bootstrapApp() {
|
||||
terminal_realtime: '🖥️',
|
||||
terminal_command: '⌨️',
|
||||
memory: '🧠',
|
||||
todo: '🗒️'
|
||||
todo: '🗒️',
|
||||
sub_agent: '🤖'
|
||||
},
|
||||
|
||||
// 右键菜单相关
|
||||
@ -277,6 +280,13 @@ async function bootstrapApp() {
|
||||
document.addEventListener('click', this.onDocumentClick);
|
||||
window.addEventListener('scroll', this.onWindowScroll, true);
|
||||
document.addEventListener('keydown', this.onKeydownListener);
|
||||
|
||||
this.fetchSubAgents();
|
||||
this.subAgentPollTimer = setInterval(() => {
|
||||
if (this.panelMode === 'subAgents') {
|
||||
this.fetchSubAgents();
|
||||
}
|
||||
}, 5000);
|
||||
},
|
||||
|
||||
beforeUnmount() {
|
||||
@ -295,6 +305,10 @@ async function bootstrapApp() {
|
||||
document.removeEventListener('keydown', this.onKeydownListener);
|
||||
this.onKeydownListener = null;
|
||||
}
|
||||
if (this.subAgentPollTimer) {
|
||||
clearInterval(this.subAgentPollTimer);
|
||||
this.subAgentPollTimer = null;
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
@ -612,6 +626,7 @@ async function bootstrapApp() {
|
||||
// 刷新对话列表
|
||||
this.loadConversationsList();
|
||||
this.fetchTodoList();
|
||||
this.fetchSubAgents();
|
||||
});
|
||||
|
||||
this.socket.on('conversation_resolved', (data) => {
|
||||
@ -1480,6 +1495,7 @@ async function bootstrapApp() {
|
||||
|
||||
// 3. 重置UI状态
|
||||
this.resetAllStates();
|
||||
this.fetchSubAgents();
|
||||
|
||||
// 4. 延迟获取并显示历史对话内容(关键功能)
|
||||
setTimeout(() => {
|
||||
@ -2056,8 +2072,13 @@ async function bootstrapApp() {
|
||||
};
|
||||
},
|
||||
|
||||
toggleTodoPanel() {
|
||||
this.showTodoList = !this.showTodoList;
|
||||
cycleSidebarPanel() {
|
||||
const order = ['files', 'todo', 'subAgents'];
|
||||
const nextIndex = (order.indexOf(this.panelMode) + 1) % order.length;
|
||||
this.panelMode = order[nextIndex];
|
||||
if (this.panelMode === 'subAgents') {
|
||||
this.fetchSubAgents();
|
||||
}
|
||||
},
|
||||
|
||||
formatTaskStatus(task) {
|
||||
@ -2073,6 +2094,37 @@ async function bootstrapApp() {
|
||||
return this.toolCategoryEmojis[categoryId] || '⚙️';
|
||||
},
|
||||
|
||||
async fetchSubAgents() {
|
||||
try {
|
||||
const resp = await fetch('/api/sub_agents');
|
||||
if (!resp.ok) {
|
||||
throw new Error(await resp.text());
|
||||
}
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
this.subAgents = Array.isArray(data.data) ? data.data : [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取子智能体列表失败:', error);
|
||||
}
|
||||
},
|
||||
|
||||
openSubAgent(agent) {
|
||||
if (!agent || !agent.task_id) {
|
||||
return;
|
||||
}
|
||||
const { protocol, hostname } = window.location;
|
||||
const parentConv = agent.conversation_id || this.currentConversationId || '';
|
||||
const convSegment = this.stripConversationPrefix(parentConv);
|
||||
const agentLabel = agent.agent_id ? `sub_agent${agent.agent_id}` : agent.task_id;
|
||||
const base = `${protocol}//${hostname}:8092`;
|
||||
const pathSuffix = convSegment
|
||||
? `/${convSegment}+${agentLabel}`
|
||||
: `/sub_agent/${agent.task_id}`;
|
||||
const url = `${base}${pathSuffix}`;
|
||||
window.open(url, '_blank');
|
||||
},
|
||||
|
||||
async fetchTodoList() {
|
||||
try {
|
||||
const response = await fetch('/api/todo-list');
|
||||
@ -2438,7 +2490,9 @@ async function bootstrapApp() {
|
||||
'todo_create': '🗒️',
|
||||
'todo_update_task': '☑️',
|
||||
'todo_finish': '🏁',
|
||||
'todo_finish_confirm': '❗'
|
||||
'todo_finish_confirm': '❗',
|
||||
'create_sub_agent': '🤖',
|
||||
'wait_sub_agent': '⏳'
|
||||
};
|
||||
return icons[toolName] || '⚙️';
|
||||
},
|
||||
@ -2474,7 +2528,9 @@ async function bootstrapApp() {
|
||||
'todo_create': 'file-animation',
|
||||
'todo_update_task': 'file-animation',
|
||||
'todo_finish': 'file-animation',
|
||||
'todo_finish_confirm': 'file-animation'
|
||||
'todo_finish_confirm': 'file-animation',
|
||||
'create_sub_agent': 'terminal-animation',
|
||||
'wait_sub_agent': 'wait-animation'
|
||||
};
|
||||
return animations[tool.name] || 'default-animation';
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,194 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Claude颜色展示 - 简化版</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--claude-bg: #eeece2;
|
||||
--claude-text: #3d3929;
|
||||
--claude-accent: #da7756;
|
||||
--claude-accent-strong: #bd5d3a;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Iowan Old Style', ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
|
||||
background-color: var(--claude-bg);
|
||||
color: var(--claude-text);
|
||||
line-height: 1.5;
|
||||
overflow-y: auto;
|
||||
min-height: 100vh;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.8em;
|
||||
margin: 15px 0;
|
||||
text-align: center;
|
||||
color: var(--claude-text);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.3em;
|
||||
margin: 20px 0 10px 0;
|
||||
color: var(--claude-text);
|
||||
border-bottom: 2px solid var(--claude-accent);
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
.color-card {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
margin: 10px 0;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.color-swatch {
|
||||
width: 60px;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin-right: 15px;
|
||||
border: 1px solid rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.color-info {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.color-name {
|
||||
font-weight: bold;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.color-hex {
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.text-sample {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
margin: 10px 0;
|
||||
border-left: 3px solid var(--claude-accent);
|
||||
}
|
||||
|
||||
.button-demo {
|
||||
background: var(--claude-accent-strong);
|
||||
color: white;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
margin: 5px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.chat-sample {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.user-msg {
|
||||
background: rgba(255,255,255,0.7);
|
||||
padding: 8px 12px;
|
||||
border-radius: 12px 12px 0 12px;
|
||||
margin: 5px 0;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.ai-msg {
|
||||
background: rgba(218, 119, 86, 0.12);
|
||||
padding: 8px 12px;
|
||||
border-radius: 12px 12px 12px 0;
|
||||
margin: 5px 0;
|
||||
margin-right: 20px;
|
||||
border-left: 2px solid var(--claude-accent-strong);
|
||||
}
|
||||
|
||||
/* 颜色定义 */
|
||||
.bg-color { background-color: var(--claude-bg); }
|
||||
.text-color { background-color: var(--claude-text); }
|
||||
.logo-color { background-color: var(--claude-accent); }
|
||||
.button-color { background-color: var(--claude-accent-strong); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Claude聊天界面颜色</h1>
|
||||
|
||||
<h2>核心颜色</h2>
|
||||
<div class="color-card">
|
||||
<div class="color-swatch bg-color"></div>
|
||||
<div class="color-info">
|
||||
<div class="color-name">背景颜色</div>
|
||||
<div class="color-hex">#eeece2</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="color-card">
|
||||
<div class="color-swatch text-color"></div>
|
||||
<div class="color-info">
|
||||
<div class="color-name">字体颜色</div>
|
||||
<div class="color-hex">#3d3929</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="color-card">
|
||||
<div class="color-swatch logo-color"></div>
|
||||
<div class="color-info">
|
||||
<div class="color-name">品牌主色</div>
|
||||
<div class="color-hex">#da7756</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="color-card">
|
||||
<div class="color-swatch button-color"></div>
|
||||
<div class="color-info">
|
||||
<div class="color-name">按钮颜色</div>
|
||||
<div class="color-hex">#bd5d3a</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>字体样式</h2>
|
||||
<div class="text-sample">
|
||||
字体栈:ui-serif, Georgia, Cambria, "Times New Roman", Times, serif
|
||||
<br><br>
|
||||
<i>斜体样式展示</i> | <b>粗体样式展示</b>
|
||||
</div>
|
||||
|
||||
<h2>界面元素</h2>
|
||||
<div class="text-sample">
|
||||
<a href="#" class="button-demo">主要按钮</a>
|
||||
<a href="#" class="button-demo" style="background: #da7756;">悬停效果</a>
|
||||
</div>
|
||||
|
||||
<h2>对话界面模拟</h2>
|
||||
<div class="chat-sample">
|
||||
<div class="user-msg">你好,Claude!今天天气怎么样?</div>
|
||||
<div class="ai-msg">你好!我无法获取实时天气信息,但你可以通过天气应用查看当地天气。</div>
|
||||
</div>
|
||||
|
||||
<div class="text-sample">
|
||||
这种配色方案创造出温暖、专业的对话环境,背景是米白色(#eeece2),文字是深棕色(#3d3929),给人舒适友好的感觉。
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,399 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AI Agent 调试监控</title>
|
||||
<script src="https://unpkg.com/vue@3.3.4/dist/vue.global.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/socket.io-client@4.6.1/dist/socket.io.min.js"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: monospace;
|
||||
background: #1a1a1a;
|
||||
color: #0f0;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.panel {
|
||||
background: #000;
|
||||
border: 1px solid #0f0;
|
||||
margin: 10px 0;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.event-log {
|
||||
height: 300px;
|
||||
overflow-y: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
.event-item {
|
||||
padding: 2px 0;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
.event-type {
|
||||
color: #ff0;
|
||||
font-weight: bold;
|
||||
}
|
||||
.timestamp {
|
||||
color: #888;
|
||||
}
|
||||
.actions-monitor {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.action-card {
|
||||
border: 1px solid #444;
|
||||
padding: 5px;
|
||||
background: #111;
|
||||
}
|
||||
.action-type {
|
||||
color: #0ff;
|
||||
}
|
||||
.tool-status {
|
||||
color: #f0f;
|
||||
}
|
||||
.streaming-true {
|
||||
background: #330;
|
||||
}
|
||||
.status-running {
|
||||
background: #003;
|
||||
}
|
||||
.status-completed {
|
||||
background: #030;
|
||||
}
|
||||
.raw-data {
|
||||
background: #222;
|
||||
padding: 5px;
|
||||
margin: 5px 0;
|
||||
white-space: pre-wrap;
|
||||
font-size: 11px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
button {
|
||||
background: #0f0;
|
||||
color: #000;
|
||||
border: none;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
margin: 5px;
|
||||
}
|
||||
button:hover {
|
||||
background: #0ff;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="debug-app" class="container">
|
||||
<h1>🔧 AI Agent 调试监控</h1>
|
||||
|
||||
<!-- 连接状态 -->
|
||||
<div class="panel">
|
||||
<h3>连接状态</h3>
|
||||
<div>Socket连接: <span :style="{color: isConnected ? '#0f0' : '#f00'}">{{ isConnected ? '已连接' : '未连接' }}</span></div>
|
||||
<div>当前消息索引: {{ currentMessageIndex }}</div>
|
||||
<div>消息总数: {{ messages.length }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 控制面板 -->
|
||||
<div class="panel">
|
||||
<h3>控制面板</h3>
|
||||
<button @click="clearLogs">清除日志</button>
|
||||
<button @click="testMessage">发送测试消息</button>
|
||||
<button @click="exportData">导出数据</button>
|
||||
<label>
|
||||
<input type="checkbox" v-model="pauseUpdates"> 暂停更新
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- 事件日志 -->
|
||||
<div class="panel">
|
||||
<h3>WebSocket事件流 (最新 {{ events.length }} 条)</h3>
|
||||
<div class="event-log">
|
||||
<div v-for="(event, idx) in events" :key="idx" class="event-item">
|
||||
<span class="timestamp">{{ event.time }}</span>
|
||||
<span class="event-type">{{ event.type }}</span>
|
||||
<span v-if="event.data">: {{ JSON.stringify(event.data).slice(0, 200) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 当前消息的Actions监控 -->
|
||||
<div class="panel" v-if="currentMessageIndex >= 0 && messages[currentMessageIndex]">
|
||||
<h3>当前消息Actions状态 (消息 #{{ currentMessageIndex }})</h3>
|
||||
<div class="actions-monitor">
|
||||
<div v-for="(action, idx) in messages[currentMessageIndex].actions"
|
||||
:key="action.id"
|
||||
class="action-card"
|
||||
:class="{
|
||||
'streaming-true': action.streaming,
|
||||
'status-running': action.tool && action.tool.status === 'running',
|
||||
'status-completed': action.tool && action.tool.status === 'completed'
|
||||
}">
|
||||
<div class="action-type">Action #{{ idx }}: {{ action.type }}</div>
|
||||
<div v-if="action.type === 'text'">
|
||||
Streaming: <b>{{ action.streaming }}</b><br>
|
||||
Content长度: {{ action.content ? action.content.length : 0 }}
|
||||
</div>
|
||||
<div v-if="action.type === 'tool'">
|
||||
工具: {{ action.tool.name }}<br>
|
||||
<span class="tool-status">状态: {{ action.tool.status }}</span><br>
|
||||
ID: {{ action.tool.id }}<br>
|
||||
有结果: {{ !!action.tool.result }}
|
||||
</div>
|
||||
<div v-if="action.type === 'thinking'">
|
||||
Streaming: <b>{{ action.streaming }}</b><br>
|
||||
Content长度: {{ action.content ? action.content.length : 0 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 原始数据查看 -->
|
||||
<div class="panel">
|
||||
<h3>最新消息原始数据</h3>
|
||||
<div class="raw-data" v-if="messages.length > 0">{{ JSON.stringify(messages[messages.length - 1], null, 2) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const { createApp } = Vue;
|
||||
|
||||
const debugApp = createApp({
|
||||
data() {
|
||||
return {
|
||||
isConnected: false,
|
||||
socket: null,
|
||||
events: [],
|
||||
messages: [],
|
||||
currentMessageIndex: -1,
|
||||
pauseUpdates: false,
|
||||
maxEvents: 100
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.initSocket();
|
||||
},
|
||||
|
||||
methods: {
|
||||
initSocket() {
|
||||
this.socket = io('/', {
|
||||
transports: ['websocket', 'polling']
|
||||
});
|
||||
|
||||
// 监听所有事件
|
||||
const events = [
|
||||
'connect', 'disconnect', 'system_ready',
|
||||
'ai_message_start', 'thinking_start', 'thinking_chunk', 'thinking_end',
|
||||
'text_start', 'text_chunk', 'text_end',
|
||||
'tool_preparing', 'tool_hint', 'tool_start', 'tool_status',
|
||||
'tool_execution_start', 'tool_execution_end', 'update_action',
|
||||
'task_complete', 'error'
|
||||
];
|
||||
|
||||
events.forEach(eventName => {
|
||||
this.socket.on(eventName, (data) => {
|
||||
this.logEvent(eventName, data);
|
||||
this.handleEvent(eventName, data);
|
||||
});
|
||||
});
|
||||
|
||||
this.socket.on('connect', () => {
|
||||
this.isConnected = true;
|
||||
});
|
||||
|
||||
this.socket.on('disconnect', () => {
|
||||
this.isConnected = false;
|
||||
});
|
||||
},
|
||||
|
||||
logEvent(type, data) {
|
||||
if (this.pauseUpdates) return;
|
||||
|
||||
const event = {
|
||||
time: new Date().toLocaleTimeString('zh-CN', { hour12: false, milliseconds: true }),
|
||||
type: type,
|
||||
data: data
|
||||
};
|
||||
|
||||
this.events.unshift(event);
|
||||
if (this.events.length > this.maxEvents) {
|
||||
this.events = this.events.slice(0, this.maxEvents);
|
||||
}
|
||||
},
|
||||
|
||||
handleEvent(eventName, data) {
|
||||
if (this.pauseUpdates) return;
|
||||
|
||||
// 处理消息相关事件
|
||||
switch(eventName) {
|
||||
case 'ai_message_start':
|
||||
this.messages.push({
|
||||
role: 'assistant',
|
||||
actions: [],
|
||||
timestamp: Date.now()
|
||||
});
|
||||
this.currentMessageIndex = this.messages.length - 1;
|
||||
break;
|
||||
|
||||
case 'thinking_start':
|
||||
if (this.currentMessageIndex >= 0) {
|
||||
const msg = this.messages[this.currentMessageIndex];
|
||||
msg.actions.push({
|
||||
id: Date.now(),
|
||||
type: 'thinking',
|
||||
content: '',
|
||||
streaming: true
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'thinking_end':
|
||||
if (this.currentMessageIndex >= 0) {
|
||||
const msg = this.messages[this.currentMessageIndex];
|
||||
const thinkingAction = msg.actions.find(a => a.type === 'thinking' && a.streaming);
|
||||
if (thinkingAction) {
|
||||
thinkingAction.streaming = false;
|
||||
console.log('思考结束,设置streaming=false');
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'text_start':
|
||||
if (this.currentMessageIndex >= 0) {
|
||||
const msg = this.messages[this.currentMessageIndex];
|
||||
msg.actions.push({
|
||||
id: Date.now(),
|
||||
type: 'text',
|
||||
content: '',
|
||||
streaming: true
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'text_chunk':
|
||||
if (this.currentMessageIndex >= 0 && data.content) {
|
||||
const msg = this.messages[this.currentMessageIndex];
|
||||
const textAction = [...msg.actions].reverse().find(a => a.type === 'text' && a.streaming);
|
||||
if (textAction) {
|
||||
textAction.content += data.content;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'text_end':
|
||||
if (this.currentMessageIndex >= 0) {
|
||||
const msg = this.messages[this.currentMessageIndex];
|
||||
const textAction = [...msg.actions].reverse().find(a => a.type === 'text' && a.streaming);
|
||||
if (textAction) {
|
||||
textAction.streaming = false;
|
||||
console.log('文本结束,设置streaming=false');
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'tool_preparing':
|
||||
case 'tool_hint':
|
||||
if (this.currentMessageIndex >= 0) {
|
||||
const msg = this.messages[this.currentMessageIndex];
|
||||
msg.actions.push({
|
||||
id: data.id,
|
||||
type: 'tool',
|
||||
tool: {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
status: 'preparing',
|
||||
arguments: {},
|
||||
result: null
|
||||
}
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'tool_start':
|
||||
if (this.currentMessageIndex >= 0) {
|
||||
const msg = this.messages[this.currentMessageIndex];
|
||||
// 查找准备中的工具或创建新的
|
||||
let tool = msg.actions.find(a =>
|
||||
a.type === 'tool' &&
|
||||
(a.id === data.preparing_id || a.tool.id === data.preparing_id)
|
||||
);
|
||||
|
||||
if (tool) {
|
||||
tool.tool.status = 'running';
|
||||
tool.tool.arguments = data.arguments;
|
||||
console.log('工具开始运行,状态改为running');
|
||||
} else {
|
||||
msg.actions.push({
|
||||
id: data.id,
|
||||
type: 'tool',
|
||||
tool: {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
status: 'running',
|
||||
arguments: data.arguments,
|
||||
result: null
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'update_action':
|
||||
if (this.currentMessageIndex >= 0) {
|
||||
const msg = this.messages[this.currentMessageIndex];
|
||||
const tool = msg.actions.find(a =>
|
||||
a.type === 'tool' &&
|
||||
(a.tool.id === data.id || a.tool.id === data.preparing_id || a.id === data.id)
|
||||
);
|
||||
|
||||
if (tool) {
|
||||
tool.tool.status = data.status || 'completed';
|
||||
tool.tool.result = data.result;
|
||||
console.log(`工具完成,状态改为${tool.tool.status}`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'task_complete':
|
||||
this.currentMessageIndex = -1;
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
clearLogs() {
|
||||
this.events = [];
|
||||
this.messages = [];
|
||||
this.currentMessageIndex = -1;
|
||||
},
|
||||
|
||||
testMessage() {
|
||||
this.socket.emit('send_message', { message: '创建一个test.txt文件' });
|
||||
},
|
||||
|
||||
exportData() {
|
||||
const data = {
|
||||
events: this.events,
|
||||
messages: this.messages,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `debug-${Date.now()}.json`;
|
||||
a.click();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
debugApp.mount('#debug-app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,591 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AI Agent System</title>
|
||||
|
||||
<!-- Vue 3 CDN -->
|
||||
<script src="https://unpkg.com/vue@3.3.4/dist/vue.global.prod.js"></script>
|
||||
|
||||
<!-- Socket.IO Client(CDN优先,JS 内有多源兜底加载) -->
|
||||
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js" defer></script>
|
||||
|
||||
<!-- Marked.js for Markdown -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked@9.1.6/marked.min.js"></script>
|
||||
|
||||
<!-- Prism.js for code highlighting -->
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css" rel="stylesheet" />
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-python.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-javascript.min.js"></script>
|
||||
|
||||
<!-- KaTeX for LaTeX (无defer,同步加载) -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/contrib/auto-render.min.js"></script>
|
||||
|
||||
<!-- Custom CSS -->
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<!-- Loading indicator -->
|
||||
<div v-if="!isConnected && messages.length === 0" style="text-align: center; padding: 50px;">
|
||||
<h2>正在连接服务器...</h2>
|
||||
<p>如果长时间无响应,请刷新页面</p>
|
||||
</div>
|
||||
|
||||
<!-- Main UI (只在连接后显示) -->
|
||||
<template v-else>
|
||||
<!-- 顶部状态栏 -->
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<span class="logo">🤖 AI Agent</span>
|
||||
<span class="project-path">{{ projectPath }}</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span class="thinking-mode">{{ thinkingMode }}</span>
|
||||
<span class="connection-status" :class="{ connected: isConnected }">
|
||||
<span class="status-dot" :class="{ active: isConnected }"></span>
|
||||
{{ isConnected ? '已连接' : '未连接' }}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="main-container">
|
||||
<!-- 新增:对话历史侧边栏(最左侧) -->
|
||||
<aside class="conversation-sidebar" :class="{ collapsed: sidebarCollapsed }">
|
||||
<div class="conversation-header">
|
||||
<button @click="createNewConversation" class="new-conversation-btn" v-if="!sidebarCollapsed">
|
||||
<span class="btn-icon">+</span>
|
||||
<span class="btn-text">新建对话</span>
|
||||
</button>
|
||||
<button @click="toggleSidebar" class="toggle-sidebar-btn">
|
||||
<span v-if="sidebarCollapsed">☰</span>
|
||||
<span v-else>←</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-if="!sidebarCollapsed">
|
||||
<div class="conversation-search">
|
||||
<input v-model="searchQuery"
|
||||
@input="searchConversations"
|
||||
placeholder="搜索对话..."
|
||||
class="search-input">
|
||||
</div>
|
||||
|
||||
<div class="conversation-list">
|
||||
<div v-if="conversationsLoading" class="loading-conversations">
|
||||
正在加载...
|
||||
</div>
|
||||
<div v-else-if="conversations.length === 0" class="no-conversations">
|
||||
暂无对话记录
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-for="conv in conversations"
|
||||
:key="conv.id"
|
||||
class="conversation-item"
|
||||
:class="{ active: conv.id === currentConversationId }"
|
||||
@click="loadConversation(conv.id)">
|
||||
<div class="conversation-title">{{ conv.title }}</div>
|
||||
<div class="conversation-meta">
|
||||
<span class="conversation-time">{{ formatTime(conv.updated_at) }}</span>
|
||||
<span class="conversation-counts">
|
||||
{{ conv.total_messages }}条消息
|
||||
<span v-if="conv.total_tools > 0"> · {{ conv.total_tools }}工具</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="conversation-actions" @click.stop>
|
||||
<button @click="deleteConversation(conv.id)"
|
||||
class="conversation-action-btn delete-btn"
|
||||
title="删除对话">
|
||||
×
|
||||
</button>
|
||||
<button @click="duplicateConversation(conv.id)"
|
||||
class="conversation-action-btn copy-btn"
|
||||
title="复制对话">
|
||||
⧉
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasMoreConversations" class="load-more">
|
||||
<button @click="loadMoreConversations"
|
||||
:disabled="loadingMoreConversations"
|
||||
class="load-more-btn">
|
||||
{{ loadingMoreConversations ? '载入中...' : '加载更多' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="conversation-collapsed-spacer"></div>
|
||||
</template>
|
||||
</aside>
|
||||
|
||||
<!-- 左侧文件树 -->
|
||||
<aside class="sidebar left-sidebar" :style="{ width: leftWidth + 'px' }">
|
||||
<div class="sidebar-header">
|
||||
<button class="sidebar-view-toggle"
|
||||
@click="toggleTodoPanel"
|
||||
:title="showTodoList ? '查看项目文件' : '查看待办列表'">
|
||||
<span v-if="showTodoList">{{ fileEmoji }}</span>
|
||||
<span v-else>{{ todoEmoji }}</span>
|
||||
</button>
|
||||
<h3>{{ showTodoList ? (todoEmoji + ' 待办列表') : (fileEmoji + ' 项目文件') }}</h3>
|
||||
</div>
|
||||
<template v-if="showTodoList">
|
||||
<div class="todo-panel">
|
||||
<div v-if="!todoList" class="todo-empty">
|
||||
暂无待办列表
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="todo-task"
|
||||
v-for="task in (todoList.tasks || [])"
|
||||
:key="task.index"
|
||||
:class="{ done: task.status === 'done' }">
|
||||
<span class="todo-task-title">task{{ task.index }}:{{ task.title }}</span>
|
||||
<span class="todo-task-status">{{ formatTaskStatus(task) }}</span>
|
||||
</div>
|
||||
<div class="todo-instruction">{{ todoList.instruction }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="file-tree" @contextmenu.prevent>
|
||||
<file-node
|
||||
v-for="node in fileTree"
|
||||
:key="node.path"
|
||||
:node="node"
|
||||
:level="0"
|
||||
:expanded-folders="expandedFolders"
|
||||
@toggle-folder="toggleFolder"
|
||||
@context-menu="showContextMenu"
|
||||
></file-node>
|
||||
</div>
|
||||
</template>
|
||||
</aside>
|
||||
|
||||
<!-- 左侧拖拽手柄 -->
|
||||
<div class="resize-handle" @mousedown="startResize('left', $event)"></div>
|
||||
|
||||
<!-- 中间聊天区域 -->
|
||||
<main class="chat-container">
|
||||
<!-- 当前对话信息栏 -->
|
||||
<div class="current-conversation-info" v-if="currentConversationTitle">
|
||||
<span class="conversation-title-display">{{ currentConversationTitle }}</span>
|
||||
<span class="conversation-stats">
|
||||
<span class="message-count">{{ messages.length }}条消息</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Token区域包装器 -->
|
||||
<div class="token-wrapper" v-if="currentConversationId">
|
||||
<!-- Token统计显示面板 -->
|
||||
<div class="token-display-panel" :class="{ collapsed: tokenPanelCollapsed }">
|
||||
<div class="token-panel-content">
|
||||
<div class="token-stats">
|
||||
<div class="token-item">
|
||||
<span class="token-label">当前上下文</span>
|
||||
<span class="token-value current">{{ formatTokenCount(currentContextTokens || 0) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="token-separator"></div>
|
||||
|
||||
<div class="token-item">
|
||||
<span class="token-label">累计输入</span>
|
||||
<span class="token-value input">{{ formatTokenCount(currentConversationTokens.cumulative_input_tokens || 0) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="token-item">
|
||||
<span class="token-label">累计输出</span>
|
||||
<span class="token-value output">{{ formatTokenCount(currentConversationTokens.cumulative_output_tokens || 0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 独立的切换按钮 -->
|
||||
<button @click="toggleTokenPanel" class="token-toggle-btn" :class="{ collapsed: tokenPanelCollapsed }">
|
||||
<span v-if="!tokenPanelCollapsed">▲</span>
|
||||
<span v-else>▼</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="messages-area" ref="messagesArea">
|
||||
<div v-for="(msg, index) in messages" :key="index" class="message-block">
|
||||
|
||||
<!-- 用户消息 -->
|
||||
<div v-if="msg.role === 'user'" class="user-message">
|
||||
<div class="message-header">👤 用户</div>
|
||||
<div class="message-text">{{ msg.content }}</div>
|
||||
</div>
|
||||
|
||||
<!-- AI消息 -->
|
||||
<div v-else-if="msg.role === 'assistant'" class="assistant-message">
|
||||
<div class="message-header">🤖 AI Assistant</div>
|
||||
|
||||
<!-- 按顺序显示所有actions -->
|
||||
<div v-for="(action, actionIndex) in msg.actions"
|
||||
:key="action.id"
|
||||
class="action-item"
|
||||
:class="{
|
||||
'streaming-content': action.streaming,
|
||||
'completed-tool': action.type === 'tool' && !action.streaming,
|
||||
'immediate-show': action.streaming || action.type === 'text'
|
||||
}">
|
||||
|
||||
<!-- 思考块 -->
|
||||
<div v-if="action.type === 'thinking'"
|
||||
class="collapsible-block thinking-block"
|
||||
:class="{ expanded: expandedBlocks.has(`${index}-thinking-${actionIndex}`) }">
|
||||
<div class="collapsible-header" @click="toggleBlock(`${index}-thinking-${actionIndex}`)">
|
||||
<div class="arrow"></div>
|
||||
<div class="status-icon">
|
||||
<span class="thinking-icon" :class="{ 'thinking-animation': action.streaming }">🧠</span>
|
||||
</div>
|
||||
<span class="status-text">{{ action.streaming ? '正在思考...' : '思考过程' }}</span>
|
||||
</div>
|
||||
<div class="collapsible-content">
|
||||
<div class="content-inner thinking-content">
|
||||
{{ action.content }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="action.streaming" class="progress-indicator"></div>
|
||||
</div>
|
||||
|
||||
<!-- 文本输出块 -->
|
||||
<div v-else-if="action.type === 'text'" class="text-output">
|
||||
<div class="text-content"
|
||||
:class="{ 'streaming-text': action.streaming }">
|
||||
<!-- 流式:实时渲染markdown但不包装代码块 -->
|
||||
<div v-if="action.streaming" v-html="renderMarkdown(action.content, true)"></div>
|
||||
<!-- 完成:完整渲染包含代码块包装 -->
|
||||
<div v-else v-html="renderMarkdown(action.content, false)"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 追加内容占位 -->
|
||||
<div v-else-if="action.type === 'append_payload'"
|
||||
class="append-placeholder"
|
||||
:class="{ 'append-error': action.append?.success === false }">
|
||||
<div class="append-placeholder-content">
|
||||
<template v-if="action.append?.success !== false">
|
||||
✏️ 已写入 {{ action.append?.path || '目标文件' }} 的追加内容(内容已保存至文件)
|
||||
</template>
|
||||
<template v-else>
|
||||
❌ 向 {{ action.append?.path || '目标文件' }} 写入失败,内容已截获供后续修复。
|
||||
</template>
|
||||
<div class="append-meta" v-if="action.append">
|
||||
<span v-if="action.append.lines !== null && action.append.lines !== undefined">
|
||||
· 行数 {{ action.append.lines }}
|
||||
</span>
|
||||
<span v-if="action.append.bytes !== null && action.append.bytes !== undefined">
|
||||
· 字节 {{ action.append.bytes }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="append-warning" v-if="action.append?.forced">
|
||||
⚠️ 未检测到结束标记,请根据提示继续补充。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 修改内容占位 -->
|
||||
<div v-else-if="action.type === 'modify_payload'" class="modify-placeholder">
|
||||
<div class="modify-placeholder-content">
|
||||
🛠️ 已对 {{ action.modify?.path || '目标文件' }} 执行补丁
|
||||
<div class="modify-meta" v-if="action.modify">
|
||||
<span v-if="action.modify.total !== null && action.modify.total !== undefined">
|
||||
· 共 {{ action.modify.total }} 处
|
||||
</span>
|
||||
<span v-if="action.modify.completed && action.modify.completed.length">
|
||||
· 已完成 {{ action.modify.completed.length }} 处
|
||||
</span>
|
||||
<span v-if="action.modify.failed && action.modify.failed.length">
|
||||
· 未完成 {{ action.modify.failed.length }} 处
|
||||
</span>
|
||||
</div>
|
||||
<div class="modify-warning" v-if="action.modify?.forced">
|
||||
⚠️ 未检测到结束标记,系统已在流结束时执行补丁。
|
||||
</div>
|
||||
<div class="modify-warning" v-if="action.modify?.failed && action.modify.failed.length">
|
||||
⚠️ 未完成的序号:{{ action.modify.failed.map(f => f.index || f).join('、') || action.modify.failed.join('、') }},请根据提示重新输出。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 工具块(修复版) -->
|
||||
<div v-else-if="action.type === 'tool'"
|
||||
class="collapsible-block tool-block"
|
||||
:class="{
|
||||
expanded: expandedBlocks.has(`${index}-tool-${actionIndex}`),
|
||||
processing: action.tool.status === 'preparing' || action.tool.status === 'running',
|
||||
completed: action.tool.status === 'completed'
|
||||
}">
|
||||
<div class="collapsible-header" @click="toggleBlock(`${index}-tool-${actionIndex}`)">
|
||||
<div class="arrow"></div>
|
||||
<div class="status-icon">
|
||||
<!-- 修复:传递完整的tool对象 -->
|
||||
<span class="tool-icon"
|
||||
:class="getToolAnimationClass(action.tool)">
|
||||
{{ getToolIcon(action.tool) }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="status-text">
|
||||
{{ getToolStatusText(action.tool) }}
|
||||
</span>
|
||||
<span class="tool-desc">{{ getToolDescription(action.tool) }}</span>
|
||||
</div>
|
||||
<div class="collapsible-content">
|
||||
<div class="content-inner">
|
||||
<!-- 根据工具类型显示不同内容 -->
|
||||
<div v-if="action.tool.name === 'web_search' && action.tool.result">
|
||||
<div class="search-meta">
|
||||
<div><strong>搜索内容:</strong>{{ action.tool.result.query || action.tool.arguments.query }}</div>
|
||||
<div><strong>主题:</strong>{{ formatSearchTopic(action.tool.result.filters || {}) }}</div>
|
||||
<div><strong>时间范围:</strong>{{ formatSearchTime(action.tool.result.filters || {}) }}</div>
|
||||
<div><strong>结果数量:</strong>{{ action.tool.result.total_results }}</div>
|
||||
</div>
|
||||
<div v-if="action.tool.result.results && action.tool.result.results.length" class="search-result-list">
|
||||
<div v-for="item in action.tool.result.results" :key="item.url || item.index" class="search-result-item">
|
||||
<div class="search-result-title">{{ item.title || '无标题' }}</div>
|
||||
<div class="search-result-url"><a v-if="item.url" :href="item.url" target="_blank">{{ item.url }}</a><span v-else>无可用链接</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="search-empty">未返回详细的搜索结果。</div>
|
||||
</div>
|
||||
<div v-else-if="action.tool.name === 'run_python' && action.tool.result">
|
||||
<div class="code-block">
|
||||
<div class="code-label">代码:</div>
|
||||
<pre><code class="language-python">{{ action.tool.result.code || action.tool.arguments.code }}</code></pre>
|
||||
</div>
|
||||
<div v-if="action.tool.result.output" class="output-block">
|
||||
<div class="output-label">输出:</div>
|
||||
<pre>{{ action.tool.result.output }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<pre>{{ JSON.stringify(action.tool.result || action.tool.arguments, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 修复:只在running状态显示进度条 -->
|
||||
<!-- 只在流式消息期间且工具运行时显示进度条 -->
|
||||
<div v-if="streamingMessage && (action.tool.status === 'preparing' || action.tool.status === 'running')"
|
||||
class="progress-indicator"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 系统消息 -->
|
||||
<div v-else class="system-message">
|
||||
{{ msg.content }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scroll-lock-toggle" :class="{ locked: autoScrollEnabled && !userScrolling }">
|
||||
<button @click="toggleScrollLock" class="scroll-lock-btn">
|
||||
<svg v-if="autoScrollEnabled && !userScrolling" viewBox="0 0 24 24" aria-hidden="true" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M8 11h8a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H8a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1Z" />
|
||||
<path d="M9 11V8a3 3 0 0 1 6 0v3" />
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 24 24" aria-hidden="true" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 5v12" />
|
||||
<path d="M7 13l5 5 5-5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 输入区域 -->
|
||||
<div class="input-area">
|
||||
<div class="input-wrapper">
|
||||
<textarea
|
||||
v-model="inputMessage"
|
||||
@keydown.enter.ctrl="sendMessage"
|
||||
placeholder="输入消息... (Ctrl+Enter 发送)"
|
||||
class="message-input"
|
||||
:disabled="!isConnected || streamingMessage"
|
||||
rows="3">
|
||||
</textarea>
|
||||
<div class="input-actions">
|
||||
<div class="upload-control">
|
||||
<input type="file"
|
||||
ref="fileUploadInput"
|
||||
class="file-input-hidden"
|
||||
@change="handleFileSelected">
|
||||
<button type="button"
|
||||
class="btn upload-btn"
|
||||
@click="triggerFileUpload"
|
||||
:disabled="!isConnected || uploading">
|
||||
{{ uploading ? '上传中...' : '上传文件' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="tool-dropdown" ref="toolDropdown">
|
||||
<button type="button"
|
||||
class="btn tool-btn"
|
||||
@click="toggleToolMenu"
|
||||
:disabled="!isConnected || toolSettingsLoading">
|
||||
工具
|
||||
</button>
|
||||
<transition name="settings-menu">
|
||||
<div class="settings-menu tool-menu" v-if="toolMenuOpen">
|
||||
<div class="tool-menu-status" v-if="toolSettingsLoading">
|
||||
正在同步工具状态...
|
||||
</div>
|
||||
<div v-else-if="toolSettings.length === 0" class="tool-menu-empty">
|
||||
暂无可控工具
|
||||
</div>
|
||||
<div v-else class="tool-menu-list">
|
||||
<div v-for="category in toolSettings"
|
||||
:key="category.id"
|
||||
class="tool-category-item"
|
||||
:class="{ disabled: !category.enabled }">
|
||||
<span class="tool-category-label">
|
||||
<span class="tool-category-icon">{{ toolCategoryEmoji(category.id) }}</span>
|
||||
{{ category.label }}
|
||||
</span>
|
||||
<button type="button"
|
||||
class="menu-btn tool-category-toggle"
|
||||
@click="updateToolCategory(category.id, !category.enabled)"
|
||||
:disabled="streamingMessage || !isConnected || toolSettingsLoading">
|
||||
{{ category.enabled ? '禁用' : '启用' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
<button @click="handleSendOrStop"
|
||||
:disabled="!isConnected || (!inputMessage.trim() && !streamingMessage)"
|
||||
:class="['btn', streamingMessage ? 'stop-btn' : 'send-btn']">
|
||||
{{ streamingMessage ? '停止' : '发送' }}
|
||||
</button>
|
||||
<div class="settings-dropdown" ref="settingsDropdown">
|
||||
<button type="button"
|
||||
class="btn settings-btn"
|
||||
@click="toggleSettings"
|
||||
:disabled="!isConnected">
|
||||
设置
|
||||
</button>
|
||||
<transition name="settings-menu">
|
||||
<div class="settings-menu" v-if="settingsOpen">
|
||||
<button type="button"
|
||||
class="menu-btn focus-entry"
|
||||
@click="toggleFocusPanel"
|
||||
:disabled="streamingMessage || !isConnected">
|
||||
{{ rightCollapsed ? '展开聚焦面板' : '折叠聚焦面板' }}
|
||||
</button>
|
||||
<button type="button"
|
||||
class="menu-btn compress-entry"
|
||||
@click="compressConversation"
|
||||
:disabled="compressing || streamingMessage || !isConnected">
|
||||
{{ compressing ? '压缩中...' : '压缩' }}
|
||||
</button>
|
||||
<button type="button"
|
||||
class="menu-btn clear-entry"
|
||||
@click="clearChat"
|
||||
:disabled="streamingMessage || !isConnected">
|
||||
清除
|
||||
</button>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 右侧拖拽手柄 -->
|
||||
<div class="resize-handle" @mousedown="startResize('right', $event)"></div>
|
||||
|
||||
<!-- 右侧聚焦文件 -->
|
||||
<aside class="sidebar right-sidebar"
|
||||
:class="{ collapsed: rightCollapsed }"
|
||||
:style="{ width: rightCollapsed ? '0px' : rightWidth + 'px' }">
|
||||
<div class="sidebar-header">
|
||||
<h3>👁️ 聚焦文件 ({{ Object.keys(focusedFiles).length }}/3)</h3>
|
||||
</div>
|
||||
<div class="focused-files" v-if="!rightCollapsed">
|
||||
<div v-if="Object.keys(focusedFiles).length === 0" class="no-files">
|
||||
暂无聚焦文件
|
||||
</div>
|
||||
<div v-else class="file-tabs">
|
||||
<div v-for="(file, path) in focusedFiles" :key="path" class="file-tab">
|
||||
<div class="tab-header">
|
||||
<span class="file-name">{{ path.split('/').pop() }}</span>
|
||||
<span class="file-size">{{ (file.size / 1024).toFixed(1) }}KB</span>
|
||||
</div>
|
||||
<div class="file-content">
|
||||
<pre><code :class="getLanguageClass(path)">{{ file.content }}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
<div class="context-menu"
|
||||
v-if="contextMenu.visible"
|
||||
:style="{ top: contextMenu.y + 'px', left: contextMenu.x + 'px' }"
|
||||
@click.stop>
|
||||
<button v-if="contextMenu.node && contextMenu.node.type === 'file'"
|
||||
@click.stop="downloadFile(contextMenu.node.path)">
|
||||
下载文件
|
||||
</button>
|
||||
<button v-if="contextMenu.node && contextMenu.node.type === 'folder'"
|
||||
:disabled="!contextMenu.node.path"
|
||||
@click.stop="downloadFolder(contextMenu.node.path)">
|
||||
下载压缩包
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
// 全局复制代码块函数
|
||||
function copyCodeBlock(blockId) {
|
||||
const codeElement = document.querySelector(`[data-code-id="${blockId}"]`);
|
||||
if (!codeElement) return;
|
||||
|
||||
const button = document.querySelector(`[data-code="${blockId}"]`);
|
||||
|
||||
// 如果正在显示"已复制"状态,忽略点击
|
||||
if (button.classList.contains('copied')) return;
|
||||
|
||||
// 使用保存的原始代码内容
|
||||
const codeContent = codeElement.dataset.originalCode || codeElement.textContent;
|
||||
|
||||
// 首次点击时保存原始图标
|
||||
if (!button.dataset.originalIcon) {
|
||||
button.dataset.originalIcon = button.textContent;
|
||||
}
|
||||
|
||||
navigator.clipboard.writeText(codeContent).then(() => {
|
||||
button.textContent = '✓';
|
||||
button.classList.add('copied');
|
||||
|
||||
setTimeout(() => {
|
||||
// 使用保存的原始图标恢复
|
||||
button.textContent = button.dataset.originalIcon || '📋';
|
||||
button.classList.remove('copied');
|
||||
}, 2000);
|
||||
}).catch(err => {
|
||||
console.error('复制失败:', err);
|
||||
// 即使失败也要恢复状态
|
||||
button.textContent = button.dataset.originalIcon || '📋';
|
||||
button.classList.remove('copied');
|
||||
});
|
||||
}
|
||||
// 使用事件委托处理复制按钮点击
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.classList.contains('copy-code-btn')) {
|
||||
const blockId = e.target.getAttribute('data-code');
|
||||
if (blockId) copyCodeBlock(blockId);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<!-- 加载应用脚本 -->
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,136 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>登录 - AI Agent</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: #f8f2ec;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
}
|
||||
.login-card {
|
||||
width: 360px;
|
||||
padding: 32px 28px;
|
||||
background: #fffefc;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 16px 45px rgba(118, 103, 84, 0.18);
|
||||
text-align: center;
|
||||
}
|
||||
h1 {
|
||||
margin-top: 0;
|
||||
font-size: 1.5rem;
|
||||
color: #4b392c;
|
||||
}
|
||||
.form-group {
|
||||
text-align: left;
|
||||
margin-top: 18px;
|
||||
}
|
||||
label {
|
||||
font-size: 0.85rem;
|
||||
color: #6c5a4a;
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
input[type="text"], input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(118, 103, 84, 0.3);
|
||||
font-size: 0.95rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
button {
|
||||
margin-top: 22px;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: #d8894c;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.error {
|
||||
margin-top: 14px;
|
||||
color: #c0392b;
|
||||
font-size: 0.85rem;
|
||||
min-height: 1em;
|
||||
}
|
||||
.link {
|
||||
margin-top: 18px;
|
||||
font-size: 0.85rem;
|
||||
color: #6c5a4a;
|
||||
}
|
||||
.link a {
|
||||
color: #d8894c;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-card">
|
||||
<h1>AI Agent 登录</h1>
|
||||
<div class="form-group">
|
||||
<label for="email">邮箱</label>
|
||||
<input type="email" id="email" autocomplete="email" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">密码</label>
|
||||
<input type="password" id="password" autocomplete="current-password" />
|
||||
</div>
|
||||
<button id="login-btn">登录</button>
|
||||
<div class="error" id="error"></div>
|
||||
<div class="link">
|
||||
还没有账号?<a href="/register">点击注册</a>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const btn = document.getElementById('login-btn');
|
||||
const errorEl = document.getElementById('error');
|
||||
|
||||
btn.addEventListener('click', async () => {
|
||||
const email = document.getElementById('email').value.trim();
|
||||
const password = document.getElementById('password').value;
|
||||
if (!email || !password) {
|
||||
errorEl.textContent = '请输入邮箱和密码';
|
||||
return;
|
||||
}
|
||||
btn.disabled = true;
|
||||
errorEl.textContent = '';
|
||||
try {
|
||||
const resp = await fetch('/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
errorEl.textContent = data.error || '登录失败';
|
||||
}
|
||||
} catch (err) {
|
||||
errorEl.textContent = '网络错误,请重试';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
btn.click();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,149 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>注册 - AI Agent</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: #f5f0eb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.register-card {
|
||||
width: 380px;
|
||||
padding: 32px 30px;
|
||||
background: #fffefc;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 16px 45px rgba(118, 103, 84, 0.18);
|
||||
text-align: center;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.4rem;
|
||||
color: #4b392c;
|
||||
}
|
||||
.form-group {
|
||||
text-align: left;
|
||||
margin-top: 16px;
|
||||
}
|
||||
label {
|
||||
font-size: 0.85rem;
|
||||
color: #6c5a4a;
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(118, 103, 84, 0.3);
|
||||
font-size: 0.95rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
button {
|
||||
margin-top: 22px;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: #d8894c;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.error {
|
||||
margin-top: 14px;
|
||||
color: #c0392b;
|
||||
font-size: 0.85rem;
|
||||
min-height: 1em;
|
||||
}
|
||||
.link {
|
||||
margin-top: 18px;
|
||||
font-size: 0.85rem;
|
||||
color: #6c5a4a;
|
||||
}
|
||||
.link a {
|
||||
color: #d8894c;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="register-card">
|
||||
<h1>创建账号</h1>
|
||||
<div class="form-group">
|
||||
<label for="username">用户名</label>
|
||||
<input type="text" id="username" placeholder="仅限小写字母/数字/下划线" autocomplete="username" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="email">邮箱</label>
|
||||
<input type="email" id="email" autocomplete="email" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">密码</label>
|
||||
<input type="password" id="password" autocomplete="new-password" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="invite">邀请码</label>
|
||||
<input type="text" id="invite" placeholder="必填" autocomplete="off" />
|
||||
</div>
|
||||
<button id="register-btn">注册</button>
|
||||
<div class="error" id="error"></div>
|
||||
<div class="link">
|
||||
已有账号?<a href="/login">返回登录</a>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const btn = document.getElementById('register-btn');
|
||||
const errorEl = document.getElementById('error');
|
||||
|
||||
async function register() {
|
||||
const username = document.getElementById('username').value.trim();
|
||||
const email = document.getElementById('email').value.trim();
|
||||
const password = document.getElementById('password').value;
|
||||
const invite = document.getElementById('invite').value.trim();
|
||||
|
||||
if (!username || !email || !password || !invite) {
|
||||
errorEl.textContent = '请完整填写所有字段';
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
errorEl.textContent = '';
|
||||
try {
|
||||
const resp = await fetch('/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, email, password, invite_code: invite })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
window.location.href = '/login';
|
||||
} else {
|
||||
errorEl.textContent = data.error || '注册失败';
|
||||
}
|
||||
} catch (err) {
|
||||
errorEl.textContent = '网络错误,请稍后再试';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
btn.addEventListener('click', register);
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
register();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,803 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AI Terminal Monitor - 实时终端查看器</title>
|
||||
|
||||
<!-- xterm.js 终端模拟器 -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" />
|
||||
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 头部区域 */
|
||||
.header {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 15px 20px;
|
||||
backdrop-filter: blur(10px);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #4ade80;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(74, 222, 128, 0.7); }
|
||||
70% { box-shadow: 0 0 0 10px rgba(74, 222, 128, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(74, 222, 128, 0); }
|
||||
}
|
||||
|
||||
.status-indicator.disconnected {
|
||||
background: #ef4444;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* 会话标签栏 */
|
||||
.session-tabs {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
padding: 10px 20px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
overflow-x: auto;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 8px 16px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: #007acc;
|
||||
border-color: #007acc;
|
||||
box-shadow: 0 4px 12px rgba(0, 122, 204, 0.3);
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
margin-left: 8px;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.tab-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 主要内容区域 */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
padding: 20px;
|
||||
gap: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 终端容器 */
|
||||
.terminal-wrapper {
|
||||
flex: 1;
|
||||
background: #1e1e1e;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.terminal-header {
|
||||
background: #2d2d2d;
|
||||
padding: 10px 15px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #444;
|
||||
}
|
||||
|
||||
.terminal-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.terminal-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.control-btn.close { background: #ff5f56; }
|
||||
.control-btn.minimize { background: #ffbd2e; }
|
||||
.control-btn.maximize { background: #27c93f; }
|
||||
|
||||
#terminal {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
padding-bottom: 30px; /* 增加底部内边距 */
|
||||
overflow-y: auto; /* 确保可以滚动 */
|
||||
}
|
||||
|
||||
/* 侧边栏信息 */
|
||||
.sidebar {
|
||||
width: 300px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
backdrop-filter: blur(10px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.info-section h3 {
|
||||
font-size: 14px;
|
||||
margin-bottom: 10px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 命令历史 */
|
||||
.command-history {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.command-item {
|
||||
padding: 5px;
|
||||
margin: 2px 0;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: #4ade80;
|
||||
border-left: 2px solid #4ade80;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
/* 底部状态栏 */
|
||||
.status-bar {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 10px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.status-left {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.main-content {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
.loader {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 20px auto;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 提示信息 */
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: #fff;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 头部 -->
|
||||
<div class="header">
|
||||
<h1>
|
||||
🖥️ AI Terminal Monitor
|
||||
<span class="status-indicator" id="connectionStatus"></span>
|
||||
<span style="font-size: 14px; font-weight: normal; margin-left: 10px;" id="connectionText">连接中...</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- 会话标签 -->
|
||||
<div class="session-tabs" id="sessionTabs">
|
||||
<div class="tab" onclick="createNewSession()">
|
||||
<span class="tab-icon">➕</span>
|
||||
<span>等待终端会话...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<div class="main-content">
|
||||
<!-- 终端容器 -->
|
||||
<div class="terminal-wrapper">
|
||||
<div class="terminal-header">
|
||||
<div class="terminal-title">
|
||||
<span id="terminalSession">无活动会话</span>
|
||||
<span id="terminalPath" style="color: #666;">-</span>
|
||||
</div>
|
||||
<div class="terminal-controls">
|
||||
<div class="control-btn close" title="关闭"></div>
|
||||
<div class="control-btn minimize" title="最小化"></div>
|
||||
<div class="control-btn maximize" title="最大化"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="terminal"></div>
|
||||
</div>
|
||||
|
||||
<!-- 侧边栏 -->
|
||||
<div class="sidebar">
|
||||
<!-- 会话信息 -->
|
||||
<div class="info-section">
|
||||
<h3>📊 会话信息</h3>
|
||||
<div class="info-item">
|
||||
<span class="info-label">会话名称</span>
|
||||
<span class="info-value" id="sessionName">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">工作目录</span>
|
||||
<span class="info-value" id="workingDir">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Shell类型</span>
|
||||
<span class="info-value" id="shellType">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">运行时间</span>
|
||||
<span class="info-value" id="uptime">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">缓冲区</span>
|
||||
<span class="info-value" id="bufferSize">0 KB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 命令历史 -->
|
||||
<div class="info-section">
|
||||
<h3>📝 最近命令</h3>
|
||||
<div class="command-history" id="commandHistory">
|
||||
<div style="color: #666; text-align: center;">暂无命令</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<div class="info-section">
|
||||
<h3>📈 统计</h3>
|
||||
<div class="info-item">
|
||||
<span class="info-label">总命令数</span>
|
||||
<span class="info-value" id="commandCount">0</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">输出行数</span>
|
||||
<span class="info-value" id="outputLines">0</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">活动会话</span>
|
||||
<span class="info-value" id="activeCount">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 状态栏 -->
|
||||
<div class="status-bar">
|
||||
<div class="status-left">
|
||||
<div class="status-item">
|
||||
<span>⚡</span>
|
||||
<span id="latency">延迟: 0ms</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span>📡</span>
|
||||
<span id="dataRate">0 KB/s</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span>🕐</span>
|
||||
<span id="currentTime"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-right">
|
||||
<span style="opacity: 0.6;">AI Agent Terminal Monitor v1.0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提示框 -->
|
||||
<div class="tooltip" id="tooltip"></div>
|
||||
|
||||
<!-- 引入依赖 -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.js"></script>
|
||||
<script src="https://cdn.socket.io/4.0.0/socket.io.min.js"></script>
|
||||
|
||||
<script>
|
||||
// 全局变量
|
||||
let term = null;
|
||||
let fitAddon = null;
|
||||
let socket = null;
|
||||
let currentSession = null;
|
||||
let sessions = {};
|
||||
let commandHistory = [];
|
||||
let stats = {
|
||||
commandCount: 0,
|
||||
outputLines: 0,
|
||||
dataReceived: 0,
|
||||
startTime: Date.now()
|
||||
};
|
||||
|
||||
// 初始化终端
|
||||
function initTerminal() {
|
||||
term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
|
||||
theme: {
|
||||
background: '#1e1e1e',
|
||||
foreground: '#d4d4d4',
|
||||
cursor: '#aeafad',
|
||||
black: '#000000',
|
||||
red: '#cd3131',
|
||||
green: '#0dbc79',
|
||||
yellow: '#e5e510',
|
||||
blue: '#2472c8',
|
||||
magenta: '#bc3fbc',
|
||||
cyan: '#11a8cd',
|
||||
white: '#e5e5e5'
|
||||
},
|
||||
scrollback: 10000,
|
||||
cols: 120, // 设置固定列数
|
||||
rows: 30, // 设置固定行数
|
||||
convertEol: true, // 转换行尾
|
||||
wordSeparator: ' ()[]{}\'"', // 单词分隔符
|
||||
rendererType: 'canvas', // 使用canvas渲染器
|
||||
allowTransparency: false
|
||||
});
|
||||
|
||||
// 添加插件
|
||||
fitAddon = new FitAddon.FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
|
||||
const webLinksAddon = new WebLinksAddon.WebLinksAddon();
|
||||
term.loadAddon(webLinksAddon);
|
||||
|
||||
// 打开终端
|
||||
term.open(document.getElementById('terminal'));
|
||||
|
||||
// 延迟执行fit以确保正确计算尺寸
|
||||
setTimeout(() => {
|
||||
fitAddon.fit();
|
||||
console.log(`终端尺寸: ${term.cols}x${term.rows}`);
|
||||
}, 100);
|
||||
|
||||
// 显示欢迎信息
|
||||
term.writeln('\x1b[1;36m╔════════════════════════════════════════╗\x1b[0m');
|
||||
term.writeln('\x1b[1;36m║ 🤖 AI Terminal Monitor v1.0 ║\x1b[0m');
|
||||
term.writeln('\x1b[1;36m╚════════════════════════════════════════╝\x1b[0m');
|
||||
term.writeln('');
|
||||
term.writeln('\x1b[33m正在连接到AI Agent...\x1b[0m');
|
||||
term.writeln('');
|
||||
}
|
||||
|
||||
// 初始化WebSocket连接
|
||||
function initWebSocket() {
|
||||
socket = io();
|
||||
|
||||
// 连接成功
|
||||
socket.on('connect', () => {
|
||||
console.log('WebSocket连接成功');
|
||||
updateConnectionStatus(true);
|
||||
term.writeln('\x1b[32m✓ 已连接到服务器\x1b[0m');
|
||||
|
||||
// 订阅所有终端事件
|
||||
socket.emit('terminal_subscribe', { all: true });
|
||||
console.log('已发送终端订阅请求');
|
||||
});
|
||||
|
||||
// 连接断开
|
||||
socket.on('disconnect', () => {
|
||||
console.log('WebSocket连接断开');
|
||||
updateConnectionStatus(false);
|
||||
term.writeln('\x1b[31m✗ 与服务器断开连接\x1b[0m');
|
||||
});
|
||||
|
||||
// 订阅成功确认
|
||||
socket.on('terminal_subscribed', (data) => {
|
||||
console.log('终端订阅成功:', data);
|
||||
term.writeln('\x1b[32m✓ 已订阅终端事件\x1b[0m');
|
||||
});
|
||||
|
||||
// 终端启动事件
|
||||
socket.on('terminal_started', (data) => {
|
||||
console.log('终端启动:', data);
|
||||
addSession(data.session, data);
|
||||
term.writeln(`\x1b[32m[终端启动]\x1b[0m ${data.session} - ${data.working_dir}`);
|
||||
});
|
||||
|
||||
// 终端列表更新
|
||||
socket.on('terminal_list_update', (data) => {
|
||||
console.log('终端列表更新:', data);
|
||||
if (data.terminals && data.terminals.length > 0) {
|
||||
// 清除所有会话并重新添加
|
||||
sessions = {};
|
||||
for (const terminal of data.terminals) {
|
||||
sessions[terminal.name] = {
|
||||
working_dir: terminal.working_dir,
|
||||
is_running: terminal.is_running,
|
||||
shell: 'bash'
|
||||
};
|
||||
}
|
||||
updateSessionTabs();
|
||||
if (data.active && !currentSession) {
|
||||
switchToSession(data.active);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 终端输出事件
|
||||
socket.on('terminal_output', (data) => {
|
||||
console.log('收到终端输出:', data.session, data.data.length + '字节');
|
||||
if (data.session === currentSession || !currentSession) {
|
||||
// 如果没有当前会话,自动切换到这个会话
|
||||
if (!currentSession && data.session) {
|
||||
if (!sessions[data.session]) {
|
||||
addSession(data.session, {
|
||||
session: data.session,
|
||||
working_dir: 'unknown'
|
||||
});
|
||||
}
|
||||
switchToSession(data.session);
|
||||
}
|
||||
// 直接写入原始输出
|
||||
term.write(data.data);
|
||||
stats.outputLines++;
|
||||
stats.dataReceived += data.data.length;
|
||||
updateStats();
|
||||
}
|
||||
});
|
||||
|
||||
// 终端输入事件(AI发送的命令)
|
||||
socket.on('terminal_input', (data) => {
|
||||
console.log('收到终端输入:', data.session, data.data);
|
||||
if (data.session === currentSession || !currentSession) {
|
||||
// 如果没有当前会话,自动切换到这个会话
|
||||
if (!currentSession && data.session) {
|
||||
if (!sessions[data.session]) {
|
||||
addSession(data.session, {
|
||||
session: data.session,
|
||||
working_dir: 'unknown'
|
||||
});
|
||||
}
|
||||
switchToSession(data.session);
|
||||
}
|
||||
// 用绿色显示输入的命令
|
||||
term.write(`\x1b[1;32m➜ ${data.data}\x1b[0m`);
|
||||
|
||||
// 添加到命令历史
|
||||
addCommandToHistory(data.data.trim());
|
||||
stats.commandCount++;
|
||||
updateStats();
|
||||
}
|
||||
});
|
||||
|
||||
// 终端关闭事件
|
||||
socket.on('terminal_closed', (data) => {
|
||||
console.log('终端关闭:', data);
|
||||
removeSession(data.session);
|
||||
term.writeln(`\x1b[31m[终端关闭]\x1b[0m ${data.session}`);
|
||||
});
|
||||
|
||||
// 终端重置事件
|
||||
socket.on('terminal_reset', (data) => {
|
||||
console.log('终端重置:', data);
|
||||
if (data.session) {
|
||||
if (!sessions[data.session]) {
|
||||
addSession(data.session, {
|
||||
session: data.session,
|
||||
working_dir: data.working_dir || 'unknown'
|
||||
});
|
||||
} else {
|
||||
sessions[data.session].working_dir = data.working_dir || sessions[data.session].working_dir;
|
||||
}
|
||||
switchToSession(data.session);
|
||||
}
|
||||
term.writeln(`\x1b[33m[终端已重置]\x1b[0m ${data.session || ''}`);
|
||||
resetCommandHistory();
|
||||
stats.commandCount = 0;
|
||||
stats.outputLines = 0;
|
||||
stats.dataReceived = 0;
|
||||
stats.startTime = Date.now();
|
||||
updateStats();
|
||||
});
|
||||
|
||||
// 终端切换事件
|
||||
socket.on('terminal_switched', (data) => {
|
||||
console.log('终端切换:', data);
|
||||
switchToSession(data.current);
|
||||
});
|
||||
|
||||
// 调试:监听所有事件
|
||||
const onevent = socket.onevent;
|
||||
socket.onevent = function (packet) {
|
||||
const args = packet.data || [];
|
||||
console.log('Socket事件:', args[0], args[1]);
|
||||
onevent.call(this, packet);
|
||||
};
|
||||
}
|
||||
|
||||
// 添加会话
|
||||
function addSession(name, info) {
|
||||
sessions[name] = info;
|
||||
updateSessionTabs();
|
||||
|
||||
// 如果是第一个会话,自动切换
|
||||
if (!currentSession) {
|
||||
switchToSession(name);
|
||||
}
|
||||
}
|
||||
|
||||
// 移除会话
|
||||
function removeSession(name) {
|
||||
delete sessions[name];
|
||||
updateSessionTabs();
|
||||
|
||||
// 如果是当前会话,切换到其他会话
|
||||
if (currentSession === name) {
|
||||
const remaining = Object.keys(sessions);
|
||||
if (remaining.length > 0) {
|
||||
switchToSession(remaining[0]);
|
||||
} else {
|
||||
currentSession = null;
|
||||
updateSessionInfo();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 切换会话
|
||||
function switchToSession(name) {
|
||||
currentSession = name;
|
||||
updateSessionTabs();
|
||||
updateSessionInfo();
|
||||
|
||||
// 清空终端并显示切换信息
|
||||
term.clear();
|
||||
term.writeln(`\x1b[36m[切换到终端: ${name}]\x1b[0m`);
|
||||
term.writeln('');
|
||||
|
||||
// 请求该会话的历史输出
|
||||
socket.emit('get_terminal_output', { session: name });
|
||||
}
|
||||
|
||||
// 更新会话标签
|
||||
function updateSessionTabs() {
|
||||
const container = document.getElementById('sessionTabs');
|
||||
container.innerHTML = '';
|
||||
|
||||
if (Object.keys(sessions).length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="tab">
|
||||
<span class="tab-icon">⏳</span>
|
||||
<span>等待终端会话...</span>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [name, info] of Object.entries(sessions)) {
|
||||
const tab = document.createElement('div');
|
||||
tab.className = `tab ${name === currentSession ? 'active' : ''}`;
|
||||
tab.innerHTML = `
|
||||
<span class="tab-icon">📟</span>
|
||||
<span>${name}</span>
|
||||
`;
|
||||
tab.onclick = () => switchToSession(name);
|
||||
container.appendChild(tab);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新会话信息
|
||||
function updateSessionInfo() {
|
||||
if (!currentSession || !sessions[currentSession]) {
|
||||
document.getElementById('sessionName').textContent = '-';
|
||||
document.getElementById('workingDir').textContent = '-';
|
||||
document.getElementById('shellType').textContent = '-';
|
||||
document.getElementById('terminalSession').textContent = '无活动会话';
|
||||
document.getElementById('terminalPath').textContent = '-';
|
||||
return;
|
||||
}
|
||||
|
||||
const info = sessions[currentSession];
|
||||
document.getElementById('sessionName').textContent = currentSession;
|
||||
document.getElementById('workingDir').textContent = info.working_dir || '-';
|
||||
document.getElementById('shellType').textContent = info.shell || '-';
|
||||
document.getElementById('terminalSession').textContent = currentSession;
|
||||
document.getElementById('terminalPath').textContent = info.working_dir || '-';
|
||||
}
|
||||
|
||||
// 添加命令到历史
|
||||
function addCommandToHistory(command) {
|
||||
commandHistory.unshift(command);
|
||||
if (commandHistory.length > 20) {
|
||||
commandHistory.pop();
|
||||
}
|
||||
updateCommandHistory();
|
||||
}
|
||||
|
||||
function resetCommandHistory() {
|
||||
commandHistory = [];
|
||||
updateCommandHistory();
|
||||
}
|
||||
|
||||
// 更新命令历史显示
|
||||
function updateCommandHistory() {
|
||||
const container = document.getElementById('commandHistory');
|
||||
if (commandHistory.length === 0) {
|
||||
container.innerHTML = '<div style="color: #666; text-align: center;">暂无命令</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = commandHistory
|
||||
.map(cmd => `<div class="command-item">${cmd}</div>`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
// 更新统计信息
|
||||
function updateStats() {
|
||||
document.getElementById('commandCount').textContent = stats.commandCount;
|
||||
document.getElementById('outputLines').textContent = stats.outputLines;
|
||||
document.getElementById('activeCount').textContent = Object.keys(sessions).length;
|
||||
document.getElementById('bufferSize').textContent = (stats.dataReceived / 1024).toFixed(1) + ' KB';
|
||||
|
||||
// 更新运行时间
|
||||
const uptime = Math.floor((Date.now() - stats.startTime) / 1000);
|
||||
const hours = Math.floor(uptime / 3600);
|
||||
const minutes = Math.floor((uptime % 3600) / 60);
|
||||
const seconds = uptime % 60;
|
||||
document.getElementById('uptime').textContent =
|
||||
`${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// 更新连接状态
|
||||
function updateConnectionStatus(connected) {
|
||||
const indicator = document.getElementById('connectionStatus');
|
||||
const text = document.getElementById('connectionText');
|
||||
|
||||
if (connected) {
|
||||
indicator.classList.remove('disconnected');
|
||||
text.textContent = '已连接';
|
||||
text.style.color = '#4ade80';
|
||||
} else {
|
||||
indicator.classList.add('disconnected');
|
||||
text.textContent = '未连接';
|
||||
text.style.color = '#ef4444';
|
||||
}
|
||||
}
|
||||
|
||||
// 更新当前时间
|
||||
function updateCurrentTime() {
|
||||
const now = new Date();
|
||||
document.getElementById('currentTime').textContent =
|
||||
now.toLocaleTimeString('zh-CN');
|
||||
}
|
||||
|
||||
// 窗口大小调整
|
||||
window.addEventListener('resize', () => {
|
||||
if (fitAddon) {
|
||||
fitAddon.fit();
|
||||
}
|
||||
});
|
||||
|
||||
// 初始化
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initTerminal();
|
||||
initWebSocket();
|
||||
|
||||
// 定时更新
|
||||
setInterval(updateCurrentTime, 1000);
|
||||
setInterval(updateStats, 1000);
|
||||
updateCurrentTime();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -129,19 +129,24 @@
|
||||
<aside class="sidebar left-sidebar" :style="{ width: leftWidth + 'px' }">
|
||||
<div class="sidebar-header">
|
||||
<button class="sidebar-view-toggle"
|
||||
@click="toggleTodoPanel"
|
||||
:title="showTodoList ? '查看项目文件' : '查看待办列表'">
|
||||
<span v-if="showTodoList">{{ fileEmoji }}</span>
|
||||
<span v-else>{{ todoEmoji }}</span>
|
||||
@click="cycleSidebarPanel"
|
||||
:title="panelMode === 'files' ? '查看待办列表' : (panelMode === 'todo' ? '查看子智能体' : '查看项目文件')">
|
||||
<span v-if="panelMode === 'files'">{{ todoEmoji }}</span>
|
||||
<span v-else-if="panelMode === 'todo'">🤖</span>
|
||||
<span v-else>{{ fileEmoji }}</span>
|
||||
</button>
|
||||
<button class="sidebar-manage-btn"
|
||||
@click="openGuiFileManager"
|
||||
title="打开桌面式文件管理器">
|
||||
管理
|
||||
</button>
|
||||
<h3>{{ showTodoList ? (todoEmoji + ' 待办列表') : (fileEmoji + ' 项目文件') }}</h3>
|
||||
<h3>
|
||||
<span v-if="panelMode === 'files'">{{ fileEmoji }} 项目文件</span>
|
||||
<span v-else-if="panelMode === 'todo'">{{ todoEmoji }} 待办列表</span>
|
||||
<span v-else>🤖 子智能体</span>
|
||||
</h3>
|
||||
</div>
|
||||
<template v-if="showTodoList">
|
||||
<template v-if="panelMode === 'todo'">
|
||||
<div class="todo-panel">
|
||||
<div v-if="!todoList" class="todo-empty">
|
||||
暂无待办列表
|
||||
@ -158,6 +163,29 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="panelMode === 'subAgents'">
|
||||
<div class="sub-agent-panel">
|
||||
<div v-if="!subAgents.length" class="sub-agent-empty">
|
||||
暂无运行中的子智能体
|
||||
</div>
|
||||
<div v-else class="sub-agent-cards">
|
||||
<div class="sub-agent-card"
|
||||
v-for="agent in subAgents"
|
||||
:key="agent.task_id"
|
||||
@click="openSubAgent(agent)">
|
||||
<div class="sub-agent-header">
|
||||
<span class="sub-agent-id">#{{
|
||||
agent.agent_id }}</span>
|
||||
<span class="sub-agent-status" :class="agent.status">{{ agent.status }}</span>
|
||||
</div>
|
||||
<div class="sub-agent-summary">{{ agent.summary }}</div>
|
||||
<div class="sub-agent-tool" v-if="agent.last_tool">
|
||||
当前:{{ agent.last_tool }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="file-tree" @contextmenu.prevent>
|
||||
<file-node
|
||||
|
||||
@ -517,6 +517,67 @@ body {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sub-agent-panel {
|
||||
padding: 16px 16px 24px;
|
||||
}
|
||||
|
||||
.sub-agent-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sub-agent-card {
|
||||
border: 1px solid rgba(118, 103, 84, 0.2);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.sub-agent-card:hover {
|
||||
border-color: #6c5ce7;
|
||||
box-shadow: 0 6px 16px rgba(108, 92, 231, 0.15);
|
||||
}
|
||||
|
||||
.sub-agent-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.sub-agent-status {
|
||||
text-transform: capitalize;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sub-agent-status.running {
|
||||
color: #0984e3;
|
||||
}
|
||||
|
||||
.sub-agent-status.completed {
|
||||
color: #00b894;
|
||||
}
|
||||
|
||||
.sub-agent-status.failed,
|
||||
.sub-agent-status.timeout {
|
||||
color: #d63031;
|
||||
}
|
||||
|
||||
.sub-agent-summary {
|
||||
font-size: 13px;
|
||||
color: var(--claude-text);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.sub-agent-tool {
|
||||
font-size: 12px;
|
||||
color: var(--claude-text-secondary);
|
||||
}
|
||||
|
||||
.todo-empty {
|
||||
font-size: 14px;
|
||||
color: var(--claude-text-secondary);
|
||||
|
||||
31
sub_agent/config/__init__.py
Normal file
31
sub_agent/config/__init__.py
Normal file
@ -0,0 +1,31 @@
|
||||
"""Config package initializer,保持对旧 `from config import ...` 的兼容。"""
|
||||
|
||||
from . import api as _api
|
||||
from . import paths as _paths
|
||||
from . import limits as _limits
|
||||
from . import terminal as _terminal
|
||||
from . import conversation as _conversation
|
||||
from . import security as _security
|
||||
from . import ui as _ui
|
||||
from . import memory as _memory
|
||||
from . import todo as _todo
|
||||
from . import auth as _auth
|
||||
from . import sub_agent as _sub_agent
|
||||
|
||||
from .api import *
|
||||
from .paths import *
|
||||
from .limits import *
|
||||
from .terminal import *
|
||||
from .conversation import *
|
||||
from .security import *
|
||||
from .ui import *
|
||||
from .memory import *
|
||||
from .todo import *
|
||||
from .auth import *
|
||||
from .sub_agent import *
|
||||
|
||||
__all__ = []
|
||||
for module in (_api, _paths, _limits, _terminal, _conversation, _security, _ui, _memory, _todo, _auth, _sub_agent):
|
||||
__all__ += getattr(module, "__all__", [])
|
||||
|
||||
del _api, _paths, _limits, _terminal, _conversation, _security, _ui, _memory, _todo, _auth, _sub_agent
|
||||
25
sub_agent/config/api.py
Normal file
25
sub_agent/config/api.py
Normal file
@ -0,0 +1,25 @@
|
||||
"""API 和外部服务配置。"""
|
||||
|
||||
API_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3"
|
||||
API_KEY = "3e96a682-919d-45c1-acb2-53bc4e9660d3"
|
||||
MODEL_ID = "kimi-k2-250905"
|
||||
|
||||
# Tavily 搜索
|
||||
TAVILY_API_KEY = "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"
|
||||
|
||||
# 默认响应 token 限制
|
||||
DEFAULT_RESPONSE_MAX_TOKENS = 32768
|
||||
|
||||
__all__ = [
|
||||
"API_BASE_URL",
|
||||
"API_KEY",
|
||||
"MODEL_ID",
|
||||
"TAVILY_API_KEY",
|
||||
"DEFAULT_RESPONSE_MAX_TOKENS",
|
||||
]
|
||||
|
||||
'''
|
||||
API_BASE_URL = "https://api.moonshot.cn/v1",
|
||||
API_KEY = "sk-xW0xjfQM6Mp9ZCWMLlnHiRJcpEOIZPTkXcN0dQ15xpZSuw2y",
|
||||
MODEL_ID = "kimi-k2-0905-preview"
|
||||
'''
|
||||
9
sub_agent/config/auth.py
Normal file
9
sub_agent/config/auth.py
Normal file
@ -0,0 +1,9 @@
|
||||
"""认证与后台账户配置。"""
|
||||
|
||||
ADMIN_USERNAME = "jojo"
|
||||
ADMIN_PASSWORD_HASH = "pbkdf2:sha256:600000$FSNAVncPXW6CBtfj$b7f093f4256de9d1a16d588565d4b1e108a9c66b2901884dd118c515258d78c7"
|
||||
|
||||
__all__ = [
|
||||
"ADMIN_USERNAME",
|
||||
"ADMIN_PASSWORD_HASH",
|
||||
]
|
||||
52
sub_agent/config/conversation.py
Normal file
52
sub_agent/config/conversation.py
Normal file
@ -0,0 +1,52 @@
|
||||
"""对话持久化与索引配置。"""
|
||||
|
||||
from .paths import DATA_DIR
|
||||
|
||||
CONVERSATION_HISTORY_FILE = f"{DATA_DIR}/conversation_history.json"
|
||||
CONVERSATIONS_DIR = f"{DATA_DIR}/conversations"
|
||||
CONVERSATION_INDEX_FILE = "index.json"
|
||||
CONVERSATION_FILE_PREFIX = "conv_"
|
||||
|
||||
DEFAULT_CONVERSATIONS_LIMIT = 20
|
||||
MAX_CONVERSATIONS_LIMIT = 100
|
||||
CONVERSATION_TITLE_MAX_LENGTH = 100
|
||||
CONVERSATION_SEARCH_MAX_RESULTS = 50
|
||||
|
||||
CONVERSATION_AUTO_CLEANUP_ENABLED = False
|
||||
CONVERSATION_RETENTION_DAYS = 30
|
||||
CONVERSATION_MAX_TOTAL = 1000
|
||||
|
||||
CONVERSATION_BACKUP_ENABLED = True
|
||||
CONVERSATION_BACKUP_INTERVAL_HOURS = 24
|
||||
CONVERSATION_BACKUP_MAX_COUNT = 7
|
||||
|
||||
CONVERSATION_MAX_MESSAGE_SIZE = 50000
|
||||
CONVERSATION_MAX_MESSAGES_PER_CONVERSATION = 10000
|
||||
CONVERSATION_EXPORT_MAX_SIZE = 10 * 1024 * 1024
|
||||
|
||||
CONVERSATION_LAZY_LOADING = True
|
||||
CONVERSATION_CACHE_SIZE = 50
|
||||
CONVERSATION_INDEX_UPDATE_BATCH_SIZE = 100
|
||||
|
||||
__all__ = [
|
||||
"CONVERSATION_HISTORY_FILE",
|
||||
"CONVERSATIONS_DIR",
|
||||
"CONVERSATION_INDEX_FILE",
|
||||
"CONVERSATION_FILE_PREFIX",
|
||||
"DEFAULT_CONVERSATIONS_LIMIT",
|
||||
"MAX_CONVERSATIONS_LIMIT",
|
||||
"CONVERSATION_TITLE_MAX_LENGTH",
|
||||
"CONVERSATION_SEARCH_MAX_RESULTS",
|
||||
"CONVERSATION_AUTO_CLEANUP_ENABLED",
|
||||
"CONVERSATION_RETENTION_DAYS",
|
||||
"CONVERSATION_MAX_TOTAL",
|
||||
"CONVERSATION_BACKUP_ENABLED",
|
||||
"CONVERSATION_BACKUP_INTERVAL_HOURS",
|
||||
"CONVERSATION_BACKUP_MAX_COUNT",
|
||||
"CONVERSATION_MAX_MESSAGE_SIZE",
|
||||
"CONVERSATION_MAX_MESSAGES_PER_CONVERSATION",
|
||||
"CONVERSATION_EXPORT_MAX_SIZE",
|
||||
"CONVERSATION_LAZY_LOADING",
|
||||
"CONVERSATION_CACHE_SIZE",
|
||||
"CONVERSATION_INDEX_UPDATE_BATCH_SIZE",
|
||||
]
|
||||
64
sub_agent/config/limits.py
Normal file
64
sub_agent/config/limits.py
Normal file
@ -0,0 +1,64 @@
|
||||
"""全局额度与工具限制配置。"""
|
||||
|
||||
# 上下文与文件
|
||||
MAX_CONTEXT_SIZE = 100000
|
||||
MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||
MAX_OPEN_FILES = 20
|
||||
MAX_UPLOAD_SIZE = 50 * 1024 * 1024
|
||||
|
||||
# 执行超时
|
||||
CODE_EXECUTION_TIMEOUT = 60
|
||||
TERMINAL_COMMAND_TIMEOUT = 30
|
||||
SEARCH_MAX_RESULTS = 10
|
||||
|
||||
# 自动修复与工具调用限制
|
||||
AUTO_FIX_TOOL_CALL = False
|
||||
AUTO_FIX_MAX_ATTEMPTS = 3
|
||||
MAX_ITERATIONS_PER_TASK = 100
|
||||
MAX_CONSECUTIVE_SAME_TOOL = 50
|
||||
MAX_TOTAL_TOOL_CALLS = 100
|
||||
TOOL_CALL_COOLDOWN = 0.5
|
||||
|
||||
# 工具字符/体积限制
|
||||
MAX_READ_FILE_CHARS = 30000
|
||||
MAX_FOCUS_FILE_CHARS = 30000
|
||||
MAX_RUN_COMMAND_CHARS = 10000
|
||||
MAX_EXTRACT_WEBPAGE_CHARS = 80000
|
||||
|
||||
# read_file 子配置
|
||||
READ_TOOL_MAX_FILE_SIZE = 100 * 1024 * 1024
|
||||
READ_TOOL_DEFAULT_MAX_CHARS = MAX_READ_FILE_CHARS
|
||||
READ_TOOL_DEFAULT_CONTEXT_BEFORE = 1
|
||||
READ_TOOL_DEFAULT_CONTEXT_AFTER = 1
|
||||
READ_TOOL_MAX_CONTEXT_BEFORE = 3
|
||||
READ_TOOL_MAX_CONTEXT_AFTER = 5
|
||||
READ_TOOL_DEFAULT_MAX_MATCHES = 5
|
||||
READ_TOOL_MAX_MATCHES = 50
|
||||
|
||||
__all__ = [
|
||||
"MAX_CONTEXT_SIZE",
|
||||
"MAX_FILE_SIZE",
|
||||
"MAX_OPEN_FILES",
|
||||
"MAX_UPLOAD_SIZE",
|
||||
"CODE_EXECUTION_TIMEOUT",
|
||||
"TERMINAL_COMMAND_TIMEOUT",
|
||||
"SEARCH_MAX_RESULTS",
|
||||
"AUTO_FIX_TOOL_CALL",
|
||||
"AUTO_FIX_MAX_ATTEMPTS",
|
||||
"MAX_ITERATIONS_PER_TASK",
|
||||
"MAX_CONSECUTIVE_SAME_TOOL",
|
||||
"MAX_TOTAL_TOOL_CALLS",
|
||||
"TOOL_CALL_COOLDOWN",
|
||||
"MAX_READ_FILE_CHARS",
|
||||
"MAX_FOCUS_FILE_CHARS",
|
||||
"MAX_RUN_COMMAND_CHARS",
|
||||
"MAX_EXTRACT_WEBPAGE_CHARS",
|
||||
"READ_TOOL_MAX_FILE_SIZE",
|
||||
"READ_TOOL_DEFAULT_MAX_CHARS",
|
||||
"READ_TOOL_DEFAULT_CONTEXT_BEFORE",
|
||||
"READ_TOOL_DEFAULT_CONTEXT_AFTER",
|
||||
"READ_TOOL_MAX_CONTEXT_BEFORE",
|
||||
"READ_TOOL_MAX_CONTEXT_AFTER",
|
||||
"READ_TOOL_DEFAULT_MAX_MATCHES",
|
||||
"READ_TOOL_MAX_MATCHES",
|
||||
]
|
||||
11
sub_agent/config/memory.py
Normal file
11
sub_agent/config/memory.py
Normal file
@ -0,0 +1,11 @@
|
||||
"""记忆文件配置。"""
|
||||
|
||||
from .paths import DATA_DIR
|
||||
|
||||
MAIN_MEMORY_FILE = f"{DATA_DIR}/memory.md"
|
||||
TASK_MEMORY_FILE = f"{DATA_DIR}/task_memory.md"
|
||||
|
||||
__all__ = [
|
||||
"MAIN_MEMORY_FILE",
|
||||
"TASK_MEMORY_FILE",
|
||||
]
|
||||
21
sub_agent/config/paths.py
Normal file
21
sub_agent/config/paths.py
Normal file
@ -0,0 +1,21 @@
|
||||
"""项目路径与目录配置。"""
|
||||
|
||||
DEFAULT_PROJECT_PATH = "./project"
|
||||
PROMPTS_DIR = "./prompts"
|
||||
DATA_DIR = "./data"
|
||||
LOGS_DIR = "./logs"
|
||||
|
||||
# 多用户空间
|
||||
USER_SPACE_DIR = "./users"
|
||||
USERS_DB_FILE = f"{DATA_DIR}/users.json"
|
||||
INVITE_CODES_FILE = f"{DATA_DIR}/invite_codes.json"
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_PROJECT_PATH",
|
||||
"PROMPTS_DIR",
|
||||
"DATA_DIR",
|
||||
"LOGS_DIR",
|
||||
"USER_SPACE_DIR",
|
||||
"USERS_DB_FILE",
|
||||
"INVITE_CODES_FILE",
|
||||
]
|
||||
48
sub_agent/config/security.py
Normal file
48
sub_agent/config/security.py
Normal file
@ -0,0 +1,48 @@
|
||||
"""安全与确认策略配置。"""
|
||||
|
||||
FORBIDDEN_COMMANDS = [
|
||||
"rm -rf /",
|
||||
"rm -rf ~",
|
||||
"format",
|
||||
"shutdown",
|
||||
"reboot",
|
||||
"kill -9",
|
||||
"dd if=",
|
||||
]
|
||||
|
||||
FORBIDDEN_PATHS = [
|
||||
"/System",
|
||||
"/usr",
|
||||
"/bin",
|
||||
"/sbin",
|
||||
"/etc",
|
||||
"/var",
|
||||
"/tmp",
|
||||
"/Applications",
|
||||
"/Library",
|
||||
"C:\\Windows",
|
||||
"C:\\Program Files",
|
||||
"C:\\Program Files (x86)",
|
||||
"C:\\ProgramData",
|
||||
]
|
||||
|
||||
FORBIDDEN_ROOT_PATHS = [
|
||||
"/",
|
||||
"C:\\",
|
||||
"~",
|
||||
]
|
||||
|
||||
NEED_CONFIRMATION = [
|
||||
"delete_file",
|
||||
"delete_folder",
|
||||
"clear_file",
|
||||
"execute_terminal",
|
||||
"batch_delete",
|
||||
]
|
||||
|
||||
__all__ = [
|
||||
"FORBIDDEN_COMMANDS",
|
||||
"FORBIDDEN_PATHS",
|
||||
"FORBIDDEN_ROOT_PATHS",
|
||||
"NEED_CONFIRMATION",
|
||||
]
|
||||
30
sub_agent/config/service.py
Normal file
30
sub_agent/config/service.py
Normal file
@ -0,0 +1,30 @@
|
||||
"""子智能体服务专用配置。"""
|
||||
|
||||
from pathlib import Path
|
||||
import os
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_PORT = int(os.environ.get("SUB_AGENT_SERVICE_PORT", "8092"))
|
||||
TASKS_ROOT = Path(os.environ.get("SUB_AGENT_TASKS_ROOT", BASE_DIR / "tasks")).resolve()
|
||||
LOGS_DIR = Path(os.environ.get("SUB_AGENT_LOGS_DIR", BASE_DIR / "logs")).resolve()
|
||||
DATA_ROOT = Path(os.environ.get("SUB_AGENT_DATA_ROOT", BASE_DIR / "data")).resolve()
|
||||
MAX_ACTIVE_AGENTS = int(os.environ.get("SUB_AGENT_MAX_ACTIVE", "5"))
|
||||
MAX_REFERENCE_FILES = int(os.environ.get("SUB_AGENT_MAX_REFERENCE_FILES", "10"))
|
||||
DEFAULT_TIMEOUT_SECONDS = int(os.environ.get("SUB_AGENT_TIMEOUT", "180"))
|
||||
STATUS_POLL_INTERVAL = float(os.environ.get("SUB_AGENT_STATUS_POLL_INTERVAL", "2.0"))
|
||||
|
||||
TASKS_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
LOGS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
DATA_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
__all__ = [
|
||||
"BASE_DIR",
|
||||
"DEFAULT_PORT",
|
||||
"TASKS_ROOT",
|
||||
"LOGS_DIR",
|
||||
"DATA_ROOT",
|
||||
"MAX_ACTIVE_AGENTS",
|
||||
"MAX_REFERENCE_FILES",
|
||||
"DEFAULT_TIMEOUT_SECONDS",
|
||||
"STATUS_POLL_INTERVAL",
|
||||
]
|
||||
24
sub_agent/config/sub_agent.py
Normal file
24
sub_agent/config/sub_agent.py
Normal file
@ -0,0 +1,24 @@
|
||||
"""子智能体相关配置。"""
|
||||
|
||||
import os
|
||||
|
||||
# 子智能体服务
|
||||
SUB_AGENT_SERVICE_BASE_URL = os.environ.get("SUB_AGENT_SERVICE_URL", "http://127.0.0.1:8092")
|
||||
SUB_AGENT_DEFAULT_TIMEOUT = int(os.environ.get("SUB_AGENT_DEFAULT_TIMEOUT", "180")) # 秒
|
||||
SUB_AGENT_STATUS_POLL_INTERVAL = float(os.environ.get("SUB_AGENT_STATUS_POLL_INTERVAL", "2.0"))
|
||||
|
||||
# 存储与并发限制
|
||||
SUB_AGENT_TASKS_BASE_DIR = os.environ.get("SUB_AGENT_TASKS_BASE_DIR", "./sub_agent/tasks")
|
||||
SUB_AGENT_PROJECT_RESULTS_DIR = os.environ.get("SUB_AGENT_PROJECT_RESULTS_DIR", "./project/sub_agent_results")
|
||||
SUB_AGENT_STATE_FILE = os.environ.get("SUB_AGENT_STATE_FILE", "./data/sub_agents.json")
|
||||
SUB_AGENT_MAX_ACTIVE = int(os.environ.get("SUB_AGENT_MAX_ACTIVE", "5"))
|
||||
|
||||
__all__ = [
|
||||
"SUB_AGENT_SERVICE_BASE_URL",
|
||||
"SUB_AGENT_DEFAULT_TIMEOUT",
|
||||
"SUB_AGENT_STATUS_POLL_INTERVAL",
|
||||
"SUB_AGENT_TASKS_BASE_DIR",
|
||||
"SUB_AGENT_PROJECT_RESULTS_DIR",
|
||||
"SUB_AGENT_STATE_FILE",
|
||||
"SUB_AGENT_MAX_ACTIVE",
|
||||
]
|
||||
23
sub_agent/config/terminal.py
Normal file
23
sub_agent/config/terminal.py
Normal file
@ -0,0 +1,23 @@
|
||||
"""终端与会话管理配置。"""
|
||||
|
||||
MAX_TERMINALS = 3
|
||||
TERMINAL_BUFFER_SIZE = 100000
|
||||
TERMINAL_DISPLAY_SIZE = 50000
|
||||
TERMINAL_TIMEOUT = 300
|
||||
TERMINAL_OUTPUT_WAIT = 5
|
||||
TERMINAL_SNAPSHOT_DEFAULT_LINES = 50
|
||||
TERMINAL_SNAPSHOT_MAX_LINES = 200
|
||||
TERMINAL_SNAPSHOT_MAX_CHARS = 60000
|
||||
TERMINAL_INPUT_MAX_CHARS = 20000
|
||||
|
||||
__all__ = [
|
||||
"MAX_TERMINALS",
|
||||
"TERMINAL_BUFFER_SIZE",
|
||||
"TERMINAL_DISPLAY_SIZE",
|
||||
"TERMINAL_TIMEOUT",
|
||||
"TERMINAL_OUTPUT_WAIT",
|
||||
"TERMINAL_SNAPSHOT_DEFAULT_LINES",
|
||||
"TERMINAL_SNAPSHOT_MAX_LINES",
|
||||
"TERMINAL_SNAPSHOT_MAX_CHARS",
|
||||
"TERMINAL_INPUT_MAX_CHARS",
|
||||
]
|
||||
11
sub_agent/config/todo.py
Normal file
11
sub_agent/config/todo.py
Normal file
@ -0,0 +1,11 @@
|
||||
"""待办事项工具配置。"""
|
||||
|
||||
TODO_MAX_TASKS = 4
|
||||
TODO_MAX_OVERVIEW_LENGTH = 999
|
||||
TODO_MAX_TASK_LENGTH = 999
|
||||
|
||||
__all__ = [
|
||||
"TODO_MAX_TASKS",
|
||||
"TODO_MAX_OVERVIEW_LENGTH",
|
||||
"TODO_MAX_TASK_LENGTH",
|
||||
]
|
||||
29
sub_agent/config/ui.py
Normal file
29
sub_agent/config/ui.py
Normal file
@ -0,0 +1,29 @@
|
||||
"""界面展示与日志配置。"""
|
||||
|
||||
OUTPUT_FORMATS = {
|
||||
"thinking": "💭 [思考]",
|
||||
"action": "🔧 [执行]",
|
||||
"file": "📁 [文件]",
|
||||
"search": "🔍 [搜索]",
|
||||
"code": "💻 [代码]",
|
||||
"terminal": "⚡ [终端]",
|
||||
"memory": "📝 [记忆]",
|
||||
"success": "✅ [成功]",
|
||||
"error": "❌ [错误]",
|
||||
"warning": "⚠️ [警告]",
|
||||
"confirm": "❓ [确认]",
|
||||
"info": "ℹ️ [信息]",
|
||||
"session": "📺 [会话]",
|
||||
}
|
||||
|
||||
AGENT_VERSION = "v1.1"
|
||||
|
||||
LOG_LEVEL = "INFO"
|
||||
LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
|
||||
__all__ = [
|
||||
"OUTPUT_FORMATS",
|
||||
"AGENT_VERSION",
|
||||
"LOG_LEVEL",
|
||||
"LOG_FORMAT",
|
||||
]
|
||||
2242
sub_agent/core/main_terminal.py
Normal file
2242
sub_agent/core/main_terminal.py
Normal file
File diff suppressed because it is too large
Load Diff
129
sub_agent/core/sub_agent_terminal.py
Normal file
129
sub_agent/core/sub_agent_terminal.py
Normal file
@ -0,0 +1,129 @@
|
||||
"""子智能体专用终端,实现工具白名单与完成工具。"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, List
|
||||
|
||||
from core.web_terminal import WebTerminal
|
||||
from config import PROMPTS_DIR
|
||||
|
||||
FORBIDDEN_SUB_AGENT_TOOLS = {"update_memory", "todo_create", "todo_update_task", "todo_finish", "todo_finish_confirm"}
|
||||
|
||||
|
||||
class SubAgentTerminal(WebTerminal):
|
||||
"""子智能体 Web 终端,限制工具并提供 finish_sub_agent。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
workspace_dir: str,
|
||||
data_dir: str,
|
||||
metadata: Dict,
|
||||
message_callback=None,
|
||||
):
|
||||
super().__init__(
|
||||
project_path=workspace_dir,
|
||||
thinking_mode=True,
|
||||
message_callback=message_callback,
|
||||
data_dir=data_dir,
|
||||
)
|
||||
self.sub_agent_meta = metadata
|
||||
self.finish_callback = None
|
||||
self._system_prompt_cache: Optional[str] = None
|
||||
|
||||
def set_finish_callback(self, callback):
|
||||
self.finish_callback = callback
|
||||
|
||||
def load_prompt(self, name: str) -> str:
|
||||
if name != "main_system":
|
||||
return super().load_prompt(name)
|
||||
|
||||
if self._system_prompt_cache:
|
||||
return self._system_prompt_cache
|
||||
|
||||
template_path = Path(PROMPTS_DIR) / "sub_agent_system.txt"
|
||||
if not template_path.exists():
|
||||
return super().load_prompt(name)
|
||||
|
||||
template = template_path.read_text(encoding="utf-8")
|
||||
data = {
|
||||
"summary": self.sub_agent_meta.get("summary", ""),
|
||||
"task": self.sub_agent_meta.get("task", ""),
|
||||
"workspace": self.sub_agent_meta.get("workspace_dir", ""),
|
||||
"references": self.sub_agent_meta.get("references_dir", ""),
|
||||
"deliverables": self.sub_agent_meta.get("deliverables_dir", ""),
|
||||
"target_project_dir": self.sub_agent_meta.get("target_project_dir", ""),
|
||||
"agent_id": self.sub_agent_meta.get("agent_id", ""),
|
||||
"task_id": self.sub_agent_meta.get("task_id", ""),
|
||||
}
|
||||
self._system_prompt_cache = template.format(**data)
|
||||
return self._system_prompt_cache
|
||||
|
||||
def define_tools(self) -> List[Dict]:
|
||||
tools = super().define_tools()
|
||||
filtered: List[Dict] = []
|
||||
for tool in tools:
|
||||
name = tool.get("function", {}).get("name")
|
||||
if name in FORBIDDEN_SUB_AGENT_TOOLS:
|
||||
continue
|
||||
filtered.append(tool)
|
||||
|
||||
filtered.append({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "finish_sub_agent",
|
||||
"description": (
|
||||
"当你确定交付成果已准备完毕时调用此工具。调用前请确认 deliverables 文件夹存在 result.md,"
|
||||
"其中包含交付说明。参数 reason 用于向主智能体总结本轮完成情况。"
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"description": "向主智能体说明任务完成情况、交付内容、下一步建议。"
|
||||
}
|
||||
},
|
||||
"required": ["reason"]
|
||||
}
|
||||
}
|
||||
})
|
||||
return filtered
|
||||
|
||||
async def handle_tool_call(self, tool_name: str, arguments: Dict) -> str:
|
||||
if tool_name == "finish_sub_agent":
|
||||
result = self._finalize_sub_agent(arguments or {})
|
||||
return json.dumps(result, ensure_ascii=False)
|
||||
return await super().handle_tool_call(tool_name, arguments)
|
||||
|
||||
def _finalize_sub_agent(self, arguments: Dict) -> Dict:
|
||||
deliverables_dir = Path(self.sub_agent_meta.get("deliverables_dir", self.project_path))
|
||||
result_md = deliverables_dir / "result.md"
|
||||
if not result_md.exists():
|
||||
return {
|
||||
"success": False,
|
||||
"error": "deliverables 目录缺少 result.md,无法结束任务。"
|
||||
}
|
||||
content = result_md.read_text(encoding="utf-8").strip()
|
||||
if not content:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "result.md 为空,请写入任务总结与交付说明后再结束任务。"
|
||||
}
|
||||
reason = (arguments.get("reason") or "").strip()
|
||||
if not reason:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "缺少 reason 字段,请说明完成情况。"
|
||||
}
|
||||
result = {
|
||||
"success": True,
|
||||
"message": "子智能体任务已标记为完成。",
|
||||
"reason": reason,
|
||||
}
|
||||
if self.finish_callback:
|
||||
try:
|
||||
self.finish_callback(result)
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
59
sub_agent/core/tool_config.py
Normal file
59
sub_agent/core/tool_config.py
Normal file
@ -0,0 +1,59 @@
|
||||
"""工具类别配置。
|
||||
|
||||
提供前端和终端公用的工具分组定义,方便按类别控制启用状态。
|
||||
"""
|
||||
|
||||
from typing import Dict, List
|
||||
|
||||
|
||||
class ToolCategory:
|
||||
"""工具类别的结构化定义。"""
|
||||
|
||||
def __init__(self, label: str, tools: List[str]):
|
||||
self.label = label
|
||||
self.tools = tools
|
||||
|
||||
|
||||
TOOL_CATEGORIES: Dict[str, ToolCategory] = {
|
||||
"network": ToolCategory(
|
||||
label="网络检索",
|
||||
tools=["web_search", "extract_webpage", "save_webpage"],
|
||||
),
|
||||
"file_edit": ToolCategory(
|
||||
label="文件编辑",
|
||||
tools=[
|
||||
"create_file",
|
||||
"append_to_file",
|
||||
"modify_file",
|
||||
"delete_file",
|
||||
"rename_file",
|
||||
"create_folder",
|
||||
],
|
||||
),
|
||||
"read_focus": ToolCategory(
|
||||
label="阅读聚焦",
|
||||
tools=["read_file", "focus_file", "unfocus_file"],
|
||||
),
|
||||
"terminal_realtime": ToolCategory(
|
||||
label="实时终端",
|
||||
tools=[
|
||||
"terminal_session",
|
||||
"terminal_input",
|
||||
"terminal_snapshot",
|
||||
"terminal_reset",
|
||||
"sleep",
|
||||
],
|
||||
),
|
||||
"terminal_command": ToolCategory(
|
||||
label="终端指令",
|
||||
tools=["run_command", "run_python"],
|
||||
),
|
||||
"memory": ToolCategory(
|
||||
label="记忆",
|
||||
tools=["update_memory"],
|
||||
),
|
||||
"todo": ToolCategory(
|
||||
label="待办事项",
|
||||
tools=["todo_create", "todo_update_task", "todo_finish", "todo_finish_confirm"],
|
||||
),
|
||||
}
|
||||
607
sub_agent/core/web_terminal.py
Normal file
607
sub_agent/core/web_terminal.py
Normal file
@ -0,0 +1,607 @@
|
||||
# core/web_terminal.py - Web终端(集成对话持久化)
|
||||
|
||||
import json
|
||||
from typing import Dict, List, Optional, Callable
|
||||
from core.main_terminal import MainTerminal
|
||||
from utils.logger import setup_logger
|
||||
try:
|
||||
from config import MAX_TERMINALS, TERMINAL_BUFFER_SIZE, TERMINAL_DISPLAY_SIZE
|
||||
except ImportError:
|
||||
import sys
|
||||
from pathlib import Path
|
||||
project_root = Path(__file__).resolve().parents[1]
|
||||
if str(project_root) not in sys.path:
|
||||
sys.path.insert(0, str(project_root))
|
||||
from config import MAX_TERMINALS, TERMINAL_BUFFER_SIZE, TERMINAL_DISPLAY_SIZE
|
||||
from modules.terminal_manager import TerminalManager
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
class WebTerminal(MainTerminal):
|
||||
"""Web版本的终端,继承自MainTerminal,包含对话持久化功能"""
|
||||
|
||||
def _ensure_conversation(self):
|
||||
"""确保Web端在首次进入时自动加载或创建对话"""
|
||||
if self.context_manager.current_conversation_id:
|
||||
return
|
||||
|
||||
latest_list = self.context_manager.get_conversation_list(limit=1, offset=0)
|
||||
conversations = latest_list.get("conversations", []) if latest_list else []
|
||||
|
||||
if conversations:
|
||||
latest = conversations[0]
|
||||
conv_id = latest.get("id")
|
||||
if conv_id and self.context_manager.load_conversation_by_id(conv_id):
|
||||
print(f"[WebTerminal] 已加载最近对话: {conv_id}")
|
||||
return
|
||||
|
||||
conversation_id = self.context_manager.start_new_conversation(
|
||||
project_path=self.project_path,
|
||||
thinking_mode=self.thinking_mode
|
||||
)
|
||||
print(f"[WebTerminal] 自动创建新对话: {conversation_id}")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
project_path: str,
|
||||
thinking_mode: bool = False,
|
||||
message_callback: Optional[Callable] = None,
|
||||
data_dir: Optional[str] = None
|
||||
):
|
||||
# 调用父类初始化(包含对话持久化功能)
|
||||
super().__init__(project_path, thinking_mode, data_dir=data_dir)
|
||||
|
||||
# Web特有属性
|
||||
self.message_callback = message_callback
|
||||
self.web_mode = True
|
||||
|
||||
# 设置API客户端为Web模式(禁用print)
|
||||
self.api_client.web_mode = True
|
||||
|
||||
# 重新初始化终端管理器
|
||||
self.terminal_manager = TerminalManager(
|
||||
project_path=project_path,
|
||||
max_terminals=MAX_TERMINALS,
|
||||
terminal_buffer_size=TERMINAL_BUFFER_SIZE,
|
||||
terminal_display_size=TERMINAL_DISPLAY_SIZE,
|
||||
broadcast_callback=message_callback
|
||||
)
|
||||
|
||||
print(f"[WebTerminal] 初始化完成,项目路径: {project_path}")
|
||||
print(f"[WebTerminal] 思考模式: {'开启' if thinking_mode else '关闭'}")
|
||||
print(f"[WebTerminal] 对话管理已就绪")
|
||||
|
||||
# 设置token更新回调
|
||||
if message_callback is not None:
|
||||
self.context_manager._web_terminal_callback = message_callback
|
||||
self.context_manager._focused_files = self.focused_files
|
||||
print(f"[WebTerminal] 实时token统计已启用")
|
||||
else:
|
||||
print(f"[WebTerminal] 警告:message_callback为None,无法启用实时token统计")
|
||||
# ===========================================
|
||||
# 新增:对话管理相关方法(Web版本)
|
||||
# ===========================================
|
||||
|
||||
def create_new_conversation(self, thinking_mode: bool = None) -> Dict:
|
||||
"""
|
||||
创建新对话(Web版本)
|
||||
|
||||
Args:
|
||||
thinking_mode: 思考模式,None则使用当前设置
|
||||
|
||||
Returns:
|
||||
Dict: 包含新对话信息
|
||||
"""
|
||||
if thinking_mode is None:
|
||||
thinking_mode = self.thinking_mode
|
||||
|
||||
try:
|
||||
conversation_id = self.context_manager.start_new_conversation(
|
||||
project_path=self.project_path,
|
||||
thinking_mode=thinking_mode
|
||||
)
|
||||
|
||||
# 重置相关状态
|
||||
if self.thinking_mode:
|
||||
self.api_client.start_new_task()
|
||||
|
||||
self.current_session_id += 1
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"conversation_id": conversation_id,
|
||||
"message": f"已创建新对话: {conversation_id}"
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"message": f"创建新对话失败: {e}"
|
||||
}
|
||||
|
||||
def load_conversation(self, conversation_id: str) -> Dict:
|
||||
"""
|
||||
加载指定对话(Web版本)
|
||||
|
||||
Args:
|
||||
conversation_id: 对话ID
|
||||
|
||||
Returns:
|
||||
Dict: 加载结果
|
||||
"""
|
||||
try:
|
||||
success = self.context_manager.load_conversation_by_id(conversation_id)
|
||||
if success:
|
||||
# 重置相关状态
|
||||
if self.thinking_mode:
|
||||
self.api_client.start_new_task()
|
||||
|
||||
self.current_session_id += 1
|
||||
|
||||
# 获取对话信息
|
||||
conversation_data = self.context_manager.conversation_manager.load_conversation(conversation_id)
|
||||
if not conversation_data:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "对话数据缺失",
|
||||
"message": f"对话数据缺失: {conversation_id}"
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"conversation_id": conversation_id,
|
||||
"title": conversation_data.get("title", "未知对话"),
|
||||
"messages_count": len(self.context_manager.conversation_history),
|
||||
"message": f"对话已加载: {conversation_id}"
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "对话不存在或加载失败",
|
||||
"message": f"对话加载失败: {conversation_id}"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"message": f"加载对话异常: {e}"
|
||||
}
|
||||
|
||||
def get_conversations_list(self, limit: int = 20, offset: int = 0) -> Dict:
|
||||
"""获取对话列表(Web版本)"""
|
||||
try:
|
||||
result = self.context_manager.get_conversation_list(limit=limit, offset=offset)
|
||||
return {
|
||||
"success": True,
|
||||
"data": result
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"message": f"获取对话列表失败: {e}"
|
||||
}
|
||||
|
||||
def delete_conversation(self, conversation_id: str) -> Dict:
|
||||
"""删除指定对话(Web版本)"""
|
||||
try:
|
||||
success = self.context_manager.delete_conversation_by_id(conversation_id)
|
||||
if success:
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"对话已删除: {conversation_id}"
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "删除失败",
|
||||
"message": f"对话删除失败: {conversation_id}"
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"message": f"删除对话异常: {e}"
|
||||
}
|
||||
|
||||
def search_conversations(self, query: str, limit: int = 20) -> Dict:
|
||||
"""搜索对话(Web版本)"""
|
||||
try:
|
||||
results = self.context_manager.search_conversations(query, limit)
|
||||
return {
|
||||
"success": True,
|
||||
"results": results,
|
||||
"count": len(results)
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"message": f"搜索对话失败: {e}"
|
||||
}
|
||||
|
||||
# ===========================================
|
||||
# 修改现有方法,保持兼容性
|
||||
# ===========================================
|
||||
|
||||
def get_status(self) -> Dict:
|
||||
"""获取系统状态(Web版本,集成对话信息)"""
|
||||
# 获取基础状态
|
||||
context_status = self.context_manager.check_context_size()
|
||||
memory_stats = self.memory_manager.get_memory_stats()
|
||||
structure = self.context_manager.get_project_structure()
|
||||
|
||||
# 聚焦文件状态 - 使用与 /api/focused 相同的格式(字典格式)
|
||||
focused_files_dict = {}
|
||||
for path, content in self.focused_files.items():
|
||||
focused_files_dict[path] = {
|
||||
"content": content,
|
||||
"size": len(content),
|
||||
"lines": content.count('\n') + 1
|
||||
}
|
||||
|
||||
# 终端状态
|
||||
terminal_status = None
|
||||
if self.terminal_manager:
|
||||
terminal_status = self.terminal_manager.list_terminals()
|
||||
|
||||
# 新增:对话状态
|
||||
conversation_stats = self.context_manager.get_conversation_statistics()
|
||||
|
||||
# 构建状态信息
|
||||
status = {
|
||||
"project_path": self.project_path,
|
||||
"thinking_mode": self.thinking_mode,
|
||||
"thinking_status": self.get_thinking_mode_status(),
|
||||
"context": {
|
||||
"usage_percent": context_status['usage_percent'],
|
||||
"total_size": context_status['sizes']['total'],
|
||||
"conversation_count": len(self.context_manager.conversation_history)
|
||||
},
|
||||
"focused_files": focused_files_dict, # 使用字典格式,与 /api/focused 一致
|
||||
"focused_files_count": len(self.focused_files), # 单独提供计数
|
||||
"terminals": terminal_status,
|
||||
"project": {
|
||||
"total_files": structure['total_files'],
|
||||
"total_size": structure['total_size']
|
||||
},
|
||||
"memory": {
|
||||
"main": memory_stats['main_memory']['lines'],
|
||||
"task": memory_stats['task_memory']['lines']
|
||||
},
|
||||
# 新增:对话状态
|
||||
"conversation": {
|
||||
"current_id": self.context_manager.current_conversation_id,
|
||||
"total_conversations": conversation_stats.get('total_conversations', 0),
|
||||
"total_messages": conversation_stats.get('total_messages', 0),
|
||||
"total_tools": conversation_stats.get('total_tools', 0)
|
||||
}
|
||||
}
|
||||
status["todo_list"] = self.context_manager.get_todo_snapshot()
|
||||
|
||||
return status
|
||||
|
||||
def get_thinking_mode_status(self) -> str:
|
||||
"""获取思考模式状态描述"""
|
||||
if not self.thinking_mode:
|
||||
return "快速模式"
|
||||
else:
|
||||
if self.api_client.current_task_first_call:
|
||||
return "思考模式(等待新任务)"
|
||||
else:
|
||||
return "思考模式(任务进行中)"
|
||||
|
||||
def get_focused_files_info(self) -> Dict:
|
||||
"""获取聚焦文件信息(用于WebSocket更新)- 使用与 /api/focused 一致的格式"""
|
||||
focused_files_dict = {}
|
||||
for path, content in self.focused_files.items():
|
||||
focused_files_dict[path] = {
|
||||
"content": content,
|
||||
"size": len(content),
|
||||
"lines": content.count('\n') + 1
|
||||
}
|
||||
|
||||
return focused_files_dict
|
||||
|
||||
def broadcast(self, event_type: str, data: Dict):
|
||||
"""广播事件到WebSocket"""
|
||||
if self.message_callback:
|
||||
self.message_callback(event_type, data)
|
||||
|
||||
# ===========================================
|
||||
# 覆盖父类方法,添加Web特有的广播功能
|
||||
# ===========================================
|
||||
|
||||
async def handle_tool_call(self, tool_name: str, arguments: Dict) -> str:
|
||||
"""
|
||||
处理工具调用(Web版本)
|
||||
覆盖父类方法,添加增强的实时广播功能
|
||||
"""
|
||||
# 立即广播工具执行开始事件(不等待)
|
||||
self.broadcast('tool_execution_start', {
|
||||
'tool': tool_name,
|
||||
'arguments': arguments,
|
||||
'status': 'executing',
|
||||
'message': f'正在执行 {tool_name}...'
|
||||
})
|
||||
|
||||
# 对于某些工具,发送更详细的状态
|
||||
if tool_name == "create_file":
|
||||
self.broadcast('tool_status', {
|
||||
'tool': tool_name,
|
||||
'status': 'creating',
|
||||
'detail': f'创建文件: {arguments.get("path", "未知路径")}'
|
||||
})
|
||||
elif tool_name == "read_file":
|
||||
read_type = arguments.get("type", "read")
|
||||
self.broadcast('tool_status', {
|
||||
'tool': tool_name,
|
||||
'status': 'reading',
|
||||
'detail': f'读取文件({read_type}): {arguments.get("path", "未知路径")}'
|
||||
})
|
||||
elif tool_name == "modify_file":
|
||||
path = arguments.get("path", "未知路径")
|
||||
self.broadcast('tool_status', {
|
||||
'tool': tool_name,
|
||||
'status': 'modifying',
|
||||
'detail': f'准备修改文件: {path}'
|
||||
})
|
||||
elif tool_name == "delete_file":
|
||||
self.broadcast('tool_status', {
|
||||
'tool': tool_name,
|
||||
'status': 'deleting',
|
||||
'detail': f'删除文件: {arguments.get("path", "未知路径")}'
|
||||
})
|
||||
elif tool_name == "focus_file":
|
||||
self.broadcast('tool_status', {
|
||||
'tool': tool_name,
|
||||
'status': 'focusing',
|
||||
'detail': f'聚焦文件: {arguments.get("path", "未知路径")}'
|
||||
})
|
||||
elif tool_name == "unfocus_file":
|
||||
self.broadcast('tool_status', {
|
||||
'tool': tool_name,
|
||||
'status': 'unfocusing',
|
||||
'detail': f'取消聚焦: {arguments.get("path", "未知路径")}'
|
||||
})
|
||||
elif tool_name == "web_search":
|
||||
query = arguments.get("query", "")
|
||||
filters = []
|
||||
topic = arguments.get("topic")
|
||||
if topic:
|
||||
filters.append(f"topic={topic}")
|
||||
else:
|
||||
filters.append("topic=general")
|
||||
if arguments.get("time_range"):
|
||||
filters.append(f"time_range={arguments['time_range']}")
|
||||
if arguments.get("days") is not None:
|
||||
filters.append(f"days={arguments.get('days')}")
|
||||
if arguments.get("start_date") and arguments.get("end_date"):
|
||||
filters.append(f"{arguments.get('start_date')}~{arguments.get('end_date')}")
|
||||
if arguments.get("country"):
|
||||
filters.append(f"country={arguments.get('country')}")
|
||||
filter_text = " | ".join(filter_item for filter_item in filters if filter_item)
|
||||
self.broadcast('tool_status', {
|
||||
'tool': tool_name,
|
||||
'status': 'searching',
|
||||
'detail': f'搜索: {query}' + (f' ({filter_text})' if filter_text else '')
|
||||
})
|
||||
elif tool_name == "extract_webpage":
|
||||
self.broadcast('tool_status', {
|
||||
'tool': tool_name,
|
||||
'status': 'extracting',
|
||||
'detail': f'提取网页: {arguments.get("url", "")}'
|
||||
})
|
||||
elif tool_name == "save_webpage":
|
||||
self.broadcast('tool_status', {
|
||||
'tool': tool_name,
|
||||
'status': 'saving_webpage',
|
||||
'detail': f'保存网页: {arguments.get("url", "")}'
|
||||
})
|
||||
elif tool_name == "run_python":
|
||||
self.broadcast('tool_status', {
|
||||
'tool': tool_name,
|
||||
'status': 'running_code',
|
||||
'detail': '执行Python代码'
|
||||
})
|
||||
elif tool_name == "run_command":
|
||||
self.broadcast('tool_status', {
|
||||
'tool': tool_name,
|
||||
'status': 'running_command',
|
||||
'detail': f'执行命令: {arguments.get("command", "")}'
|
||||
})
|
||||
elif tool_name == "terminal_session":
|
||||
action = arguments.get("action", "")
|
||||
session_name = arguments.get("session_name", "default")
|
||||
self.broadcast('tool_status', {
|
||||
'tool': tool_name,
|
||||
'status': f'terminal_{action}',
|
||||
'detail': f'终端操作: {action} - {session_name}'
|
||||
})
|
||||
elif tool_name == "terminal_input":
|
||||
command = arguments.get("command", "")
|
||||
# 只显示命令的前50个字符避免过长
|
||||
display_command = command[:50] + "..." if len(command) > 50 else command
|
||||
self.broadcast('tool_status', {
|
||||
'tool': tool_name,
|
||||
'status': 'sending_input',
|
||||
'detail': f'发送终端输入: {display_command}'
|
||||
})
|
||||
elif tool_name == "sleep":
|
||||
seconds = arguments.get("seconds", 1)
|
||||
reason = arguments.get("reason", "等待操作完成")
|
||||
self.broadcast('tool_status', {
|
||||
'tool': tool_name,
|
||||
'status': 'waiting',
|
||||
'detail': f'等待 {seconds} 秒: {reason}'
|
||||
})
|
||||
|
||||
# 调用父类的工具处理(包含我们的新逻辑)
|
||||
result = await super().handle_tool_call(tool_name, arguments)
|
||||
logger.debug(
|
||||
"[SubAgent][WebTerminal] tool=%s 执行完成,result前200=%s",
|
||||
tool_name,
|
||||
result[:200] if isinstance(result, str) else result,
|
||||
)
|
||||
|
||||
# 解析结果并广播工具结束事件
|
||||
try:
|
||||
result_data = json.loads(result)
|
||||
success = result_data.get('success', False)
|
||||
|
||||
# 特殊处理某些错误类型
|
||||
if not success:
|
||||
error_msg = result_data.get('error', '执行失败')
|
||||
|
||||
# 检查是否是参数预检查失败
|
||||
if '参数过大' in error_msg or '内容过长' in error_msg:
|
||||
self.broadcast('tool_execution_end', {
|
||||
'tool': tool_name,
|
||||
'success': False,
|
||||
'result': result_data,
|
||||
'message': f'{tool_name} 执行失败: 参数过长',
|
||||
'error_type': 'parameter_too_long',
|
||||
'suggestion': result_data.get('suggestion', '建议分块处理')
|
||||
})
|
||||
elif 'JSON解析' in error_msg or '参数解析失败' in error_msg:
|
||||
self.broadcast('tool_execution_end', {
|
||||
'tool': tool_name,
|
||||
'success': False,
|
||||
'result': result_data,
|
||||
'message': f'{tool_name} 执行失败: 参数格式错误',
|
||||
'error_type': 'parameter_format_error',
|
||||
'suggestion': result_data.get('suggestion', '请检查参数格式')
|
||||
})
|
||||
else:
|
||||
# 一般错误
|
||||
self.broadcast('tool_execution_end', {
|
||||
'tool': tool_name,
|
||||
'success': False,
|
||||
'result': result_data,
|
||||
'message': f'{tool_name} 执行失败: {error_msg}',
|
||||
'error_type': 'general_error'
|
||||
})
|
||||
else:
|
||||
# 成功的情况
|
||||
success_msg = result_data.get('message', f'{tool_name} 执行成功')
|
||||
self.broadcast('tool_execution_end', {
|
||||
'tool': tool_name,
|
||||
'success': True,
|
||||
'result': result_data,
|
||||
'message': success_msg
|
||||
})
|
||||
|
||||
except json.JSONDecodeError:
|
||||
# 无法解析JSON结果
|
||||
success = False
|
||||
result_data = {'output': result, 'raw_result': True}
|
||||
self.broadcast('tool_execution_end', {
|
||||
'tool': tool_name,
|
||||
'success': False,
|
||||
'result': result_data,
|
||||
'message': f'{tool_name} 返回了非JSON格式结果',
|
||||
'error_type': 'invalid_result_format'
|
||||
})
|
||||
|
||||
# 如果是终端相关操作,广播终端更新
|
||||
if tool_name in ['terminal_session', 'terminal_input'] and self.terminal_manager:
|
||||
try:
|
||||
terminals = self.terminal_manager.get_terminal_list()
|
||||
self.broadcast('terminal_list_update', {
|
||||
'terminals': terminals,
|
||||
'active': self.terminal_manager.active_terminal
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"广播终端更新失败: {e}")
|
||||
|
||||
# 如果是文件操作,广播文件树更新
|
||||
if tool_name in ['create_file', 'delete_file', 'rename_file', 'create_folder', 'save_webpage']:
|
||||
try:
|
||||
structure = self.context_manager.get_project_structure()
|
||||
self.broadcast('file_tree_update', structure)
|
||||
except Exception as e:
|
||||
logger.error(f"广播文件树更新失败: {e}")
|
||||
|
||||
|
||||
# 如果是聚焦操作,广播聚焦文件更新
|
||||
if tool_name in ['focus_file', 'unfocus_file', 'modify_file']:
|
||||
try:
|
||||
focused_files_dict = self.get_focused_files_info()
|
||||
self.broadcast('focused_files_update', focused_files_dict)
|
||||
|
||||
# 聚焦文件变化后,更新token统计
|
||||
self.context_manager.safe_broadcast_token_update()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"广播聚焦文件更新失败: {e}")
|
||||
|
||||
# 如果是记忆操作,广播记忆状态更新
|
||||
if tool_name == 'update_memory':
|
||||
try:
|
||||
memory_stats = self.memory_manager.get_memory_stats()
|
||||
self.broadcast('memory_update', {
|
||||
'main': memory_stats['main_memory']['lines'],
|
||||
'task': memory_stats['task_memory']['lines']
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"广播记忆更新失败: {e}")
|
||||
|
||||
return result
|
||||
|
||||
def build_context(self) -> Dict:
|
||||
"""构建上下文(Web版本)"""
|
||||
context = super().build_context()
|
||||
|
||||
# 添加Web特有的上下文信息
|
||||
context['web_mode'] = True
|
||||
context['terminal_sessions'] = []
|
||||
|
||||
if self.terminal_manager:
|
||||
for name, terminal in self.terminal_manager.terminals.items():
|
||||
context['terminal_sessions'].append({
|
||||
'name': name,
|
||||
'is_active': name == self.terminal_manager.active_terminal,
|
||||
'is_running': terminal.is_running
|
||||
})
|
||||
|
||||
# 添加对话信息
|
||||
context['conversation_info'] = {
|
||||
'current_id': self.context_manager.current_conversation_id,
|
||||
'messages_count': len(self.context_manager.conversation_history)
|
||||
}
|
||||
|
||||
return context
|
||||
|
||||
async def confirm_action(self, action: str, arguments: Dict) -> bool:
|
||||
"""
|
||||
确认危险操作(Web版本)
|
||||
在Web模式下,我们自动确认或通过WebSocket请求确认
|
||||
"""
|
||||
# 在Web模式下,暂时自动确认
|
||||
# 未来可以通过WebSocket向前端请求确认
|
||||
print(f"[WebTerminal] 自动确认操作: {action}")
|
||||
|
||||
# 广播确认事件,让前端知道正在执行危险操作
|
||||
self.broadcast('dangerous_action', {
|
||||
'action': action,
|
||||
'arguments': arguments,
|
||||
'auto_confirmed': True
|
||||
})
|
||||
|
||||
return True
|
||||
|
||||
def __del__(self):
|
||||
"""析构函数,确保资源释放"""
|
||||
try:
|
||||
# 保存当前对话
|
||||
if hasattr(self, 'context_manager') and self.context_manager:
|
||||
if self.context_manager.current_conversation_id:
|
||||
self.context_manager.save_current_conversation()
|
||||
|
||||
# 关闭所有终端
|
||||
if hasattr(self, 'terminal_manager') and self.terminal_manager:
|
||||
self.terminal_manager.close_all()
|
||||
|
||||
except Exception as e:
|
||||
print(f"[WebTerminal] 资源清理失败: {e}")
|
||||
274
sub_agent/main.py
Normal file
274
sub_agent/main.py
Normal file
@ -0,0 +1,274 @@
|
||||
#!/usr/bin/env python3
|
||||
# main.py - 主程序入口(修复路径引号和中文支持问题)
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
# 添加项目根目录到Python路径
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from config import *
|
||||
from core.main_terminal import MainTerminal
|
||||
from utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
class AgentSystem:
|
||||
def __init__(self):
|
||||
self.project_path = None
|
||||
self.thinking_mode = False # False=快速模式, True=思考模式
|
||||
self.web_mode = False # Web模式标志
|
||||
self.main_terminal = None
|
||||
|
||||
async def initialize(self):
|
||||
"""初始化系统"""
|
||||
print("\n" + "="*50)
|
||||
print("ð¤ AI Agent 系统启动")
|
||||
print("="*50)
|
||||
|
||||
# 1. 获取项目路径
|
||||
await self.setup_project_path()
|
||||
|
||||
# 2. 选择运行模式(CLI或Web)
|
||||
await self.setup_run_mode()
|
||||
|
||||
if not self.web_mode:
|
||||
# CLI模式:继续原有流程
|
||||
# 3. 选择思考模式
|
||||
await self.setup_thinking_mode()
|
||||
|
||||
# 4. 初始化系统
|
||||
await self.init_system()
|
||||
|
||||
# 5. 创建主终端
|
||||
self.main_terminal = MainTerminal(
|
||||
project_path=self.project_path,
|
||||
thinking_mode=self.thinking_mode
|
||||
)
|
||||
|
||||
print(f"\n{OUTPUT_FORMATS['success']} 系统初始化完成")
|
||||
print(f"{OUTPUT_FORMATS['info']} 项目路径: {self.project_path}")
|
||||
print(f"{OUTPUT_FORMATS['info']} 运行模式: {'思考模式(智能)' if self.thinking_mode else '快速模式(无思考)'}")
|
||||
|
||||
print("\n" + "="*50)
|
||||
print("输入 'exit' 退出,'help' 查看帮助,'/clear' 清除对话")
|
||||
print("="*50 + "\n")
|
||||
else:
|
||||
# Web模式:启动Web服务器
|
||||
# 3. 选择思考模式
|
||||
await self.setup_thinking_mode()
|
||||
|
||||
# 4. 初始化系统
|
||||
await self.init_system()
|
||||
|
||||
# 5. 启动Web服务器
|
||||
await self.start_web_server()
|
||||
|
||||
def clean_path_input(self, path_str: str) -> str:
|
||||
"""清理路径输入,去除引号和多余空格"""
|
||||
if not path_str:
|
||||
return path_str
|
||||
|
||||
# 保存原始输入用于调试
|
||||
original = path_str
|
||||
|
||||
# 去除首尾空格
|
||||
path_str = path_str.strip()
|
||||
|
||||
# 去除各种引号(包括中文引号)
|
||||
quote_pairs = [
|
||||
('"', '"'), # 英文双引号
|
||||
("'", "'"), # 英文单引号
|
||||
('"', '"'), # 中文双引号
|
||||
(''', '''), # 中文单引号
|
||||
('`', '`'), # 反引号
|
||||
('「', '」'), # 日文引号
|
||||
('『', '』'), # 日文引号
|
||||
]
|
||||
|
||||
for start_quote, end_quote in quote_pairs:
|
||||
if path_str.startswith(start_quote) and path_str.endswith(end_quote):
|
||||
path_str = path_str[len(start_quote):-len(end_quote)]
|
||||
break
|
||||
|
||||
# 处理只有一边引号的情况
|
||||
single_quotes = ['"', "'", '"', '"', ''', ''', '`', '「', '」', '『', '』']
|
||||
for quote in single_quotes:
|
||||
if path_str.startswith(quote):
|
||||
path_str = path_str[len(quote):]
|
||||
if path_str.endswith(quote):
|
||||
path_str = path_str[:-len(quote)]
|
||||
|
||||
# 再次去除空格
|
||||
path_str = path_str.strip()
|
||||
|
||||
# 调试输出
|
||||
if path_str != original.strip():
|
||||
print(f"{OUTPUT_FORMATS['info']} 路径已清理: {original.strip()} -> {path_str}")
|
||||
|
||||
return path_str
|
||||
|
||||
async def setup_project_path(self):
|
||||
"""设置项目路径"""
|
||||
path_input = os.path.expanduser(str(DEFAULT_PROJECT_PATH))
|
||||
project_path = Path(path_input).resolve()
|
||||
|
||||
if self.is_unsafe_path(str(project_path)):
|
||||
raise RuntimeError(f"默认项目路径不安全: {project_path}")
|
||||
|
||||
project_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if not os.access(project_path, os.R_OK | os.W_OK):
|
||||
print(f"{OUTPUT_FORMATS['warning']} 对默认项目路径缺少读写权限: {project_path}")
|
||||
|
||||
self.project_path = str(project_path)
|
||||
print(f"{OUTPUT_FORMATS['success']} 已选择项目路径: {self.project_path}")
|
||||
|
||||
async def setup_run_mode(self):
|
||||
"""选择运行模式"""
|
||||
self.web_mode = True
|
||||
print(f"{OUTPUT_FORMATS['info']} 运行模式: Web(默认)")
|
||||
|
||||
async def setup_thinking_mode(self):
|
||||
"""选择思考模式"""
|
||||
self.thinking_mode = True
|
||||
print(f"{OUTPUT_FORMATS['info']} 思考模式: {'开启' if self.thinking_mode else '关闭'}(默认)")
|
||||
|
||||
async def init_system(self):
|
||||
"""初始化系统文件"""
|
||||
# 确保数据目录存在
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
os.makedirs(LOGS_DIR, exist_ok=True)
|
||||
os.makedirs(f"{LOGS_DIR}/tasks", exist_ok=True)
|
||||
os.makedirs(f"{LOGS_DIR}/errors", exist_ok=True)
|
||||
|
||||
# 初始化记忆文件
|
||||
if not os.path.exists(MAIN_MEMORY_FILE):
|
||||
with open(MAIN_MEMORY_FILE, 'w', encoding='utf-8') as f:
|
||||
f.write(f"# 主记忆文件\n\n创建时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n")
|
||||
|
||||
if not os.path.exists(TASK_MEMORY_FILE):
|
||||
with open(TASK_MEMORY_FILE, 'w', encoding='utf-8') as f:
|
||||
f.write(f"# 任务记忆文件\n\n")
|
||||
|
||||
# 初始化或修复对话历史
|
||||
conversation_file = Path(CONVERSATION_HISTORY_FILE)
|
||||
if conversation_file.exists():
|
||||
try:
|
||||
with open(conversation_file, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
if content.strip():
|
||||
json.loads(content)
|
||||
else:
|
||||
raise json.JSONDecodeError("Empty file", "", 0)
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
print(f"{OUTPUT_FORMATS['warning']} 修复对话历史文件...")
|
||||
with open(conversation_file, 'w', encoding='utf-8') as f:
|
||||
json.dump({"conversations": []}, f, ensure_ascii=False, indent=2)
|
||||
else:
|
||||
with open(conversation_file, 'w', encoding='utf-8') as f:
|
||||
json.dump({"conversations": []}, f, ensure_ascii=False, indent=2)
|
||||
|
||||
async def start_web_server(self):
|
||||
"""启动Web服务器"""
|
||||
try:
|
||||
# 检查是否安装了必要的包
|
||||
import flask
|
||||
import flask_socketio
|
||||
import flask_cors
|
||||
except ImportError:
|
||||
print(f"{OUTPUT_FORMATS['error']} 缺少Web依赖包,请安装:")
|
||||
print("pip install flask flask-socketio flask-cors")
|
||||
sys.exit(1)
|
||||
|
||||
# 导入Web服务器
|
||||
from web_server import run_server
|
||||
|
||||
print(f"\n{OUTPUT_FORMATS['success']} 正在启动Web服务器...")
|
||||
print(f"{OUTPUT_FORMATS['info']} 项目路径: {self.project_path}")
|
||||
port = int(os.environ.get("WEB_SERVER_PORT", "8091"))
|
||||
|
||||
# 运行服务器(这会阻塞)
|
||||
run_server(
|
||||
path=self.project_path,
|
||||
thinking_mode=self.thinking_mode,
|
||||
port=port
|
||||
)
|
||||
|
||||
def is_unsafe_path(self, path: str) -> bool:
|
||||
"""检查路径是否安全"""
|
||||
resolved_path = str(Path(path).resolve())
|
||||
|
||||
# 检查是否是根路径
|
||||
for forbidden_root in FORBIDDEN_ROOT_PATHS:
|
||||
expanded = os.path.expanduser(forbidden_root)
|
||||
if resolved_path == expanded or resolved_path == forbidden_root:
|
||||
return True
|
||||
|
||||
# 检查是否在系统目录
|
||||
for forbidden in FORBIDDEN_PATHS:
|
||||
if resolved_path.startswith(forbidden + os.sep) or resolved_path == forbidden:
|
||||
return True
|
||||
|
||||
# 检查是否包含向上遍历
|
||||
if ".." in path:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def run(self):
|
||||
"""运行主循环"""
|
||||
await self.initialize()
|
||||
|
||||
if not self.web_mode:
|
||||
# CLI模式
|
||||
try:
|
||||
await self.main_terminal.run()
|
||||
except KeyboardInterrupt:
|
||||
print(f"\n{OUTPUT_FORMATS['info']} 收到中断信号")
|
||||
except Exception as e:
|
||||
logger.error(f"系统错误: {e}", exc_info=True)
|
||||
print(f"{OUTPUT_FORMATS['error']} 系统错误: {e}")
|
||||
finally:
|
||||
await self.cleanup()
|
||||
# Web模式在start_web_server中运行,不会到达这里
|
||||
|
||||
async def cleanup(self):
|
||||
"""清理资源"""
|
||||
print(f"\n{OUTPUT_FORMATS['info']} 正在保存状态...")
|
||||
|
||||
if self.main_terminal:
|
||||
await self.main_terminal.save_state()
|
||||
|
||||
print(f"{OUTPUT_FORMATS['success']} 系统已安全退出")
|
||||
print("\nð 再见!\n")
|
||||
|
||||
async def main():
|
||||
"""主函数"""
|
||||
system = AgentSystem()
|
||||
await system.run()
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
# 设置控制台编码为UTF-8(Windows中文路径支持)
|
||||
|
||||
|
||||
if sys.platform == "win32":
|
||||
import locale
|
||||
# 尝试设置为UTF-8
|
||||
try:
|
||||
os.system("chcp 65001 > nul") # 设置控制台代码页为UTF-8
|
||||
except:
|
||||
pass
|
||||
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
print("\n\nð 再见!")
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
print(f"\n{OUTPUT_FORMATS['error']} 程序异常退出: {e}")
|
||||
sys.exit(1)
|
||||
865
sub_agent/modules/file_manager.py
Normal file
865
sub_agent/modules/file_manager.py
Normal file
@ -0,0 +1,865 @@
|
||||
# modules/file_manager.py - 文件管理模块(添加行编辑功能)
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, List, Tuple
|
||||
from datetime import datetime
|
||||
try:
|
||||
from config import (
|
||||
MAX_FILE_SIZE,
|
||||
FORBIDDEN_PATHS,
|
||||
FORBIDDEN_ROOT_PATHS,
|
||||
OUTPUT_FORMATS,
|
||||
READ_TOOL_MAX_FILE_SIZE,
|
||||
)
|
||||
except ImportError: # 兼容全局环境中存在同名包的情况
|
||||
import sys
|
||||
from pathlib import Path
|
||||
project_root = Path(__file__).resolve().parents[1]
|
||||
if str(project_root) not in sys.path:
|
||||
sys.path.insert(0, str(project_root))
|
||||
from config import (
|
||||
MAX_FILE_SIZE,
|
||||
FORBIDDEN_PATHS,
|
||||
FORBIDDEN_ROOT_PATHS,
|
||||
OUTPUT_FORMATS,
|
||||
READ_TOOL_MAX_FILE_SIZE,
|
||||
)
|
||||
# 临时禁用长度检查
|
||||
DISABLE_LENGTH_CHECK = True
|
||||
class FileManager:
|
||||
def __init__(self, project_path: str):
|
||||
self.project_path = Path(project_path).resolve()
|
||||
|
||||
def _validate_path(self, path: str) -> Tuple[bool, str, Path]:
|
||||
"""
|
||||
验证路径安全性
|
||||
|
||||
Returns:
|
||||
(是否有效, 错误信息, 完整路径)
|
||||
"""
|
||||
project_root = Path(self.project_path).resolve()
|
||||
if project_root != self.project_path:
|
||||
self.project_path = project_root
|
||||
|
||||
# 不允许绝对路径(除非是在项目内的绝对路径)
|
||||
if path.startswith('/') or path.startswith('\\') or (len(path) > 1 and path[1] == ':'):
|
||||
# 如果是绝对路径,检查是否指向项目内
|
||||
try:
|
||||
test_path = Path(path).resolve()
|
||||
test_path.relative_to(project_root)
|
||||
# 如果成功,说明绝对路径在项目内,转换为相对路径
|
||||
path = str(test_path.relative_to(project_root))
|
||||
except ValueError:
|
||||
return False, "路径必须在项目文件夹内", None
|
||||
|
||||
# 检查是否包含向上遍历
|
||||
if ".." in path:
|
||||
return False, "不允许使用../向上遍历", None
|
||||
|
||||
# 构建完整路径
|
||||
full_path = (project_root / path).resolve()
|
||||
|
||||
# 检查是否在项目目录内
|
||||
try:
|
||||
full_path.relative_to(project_root)
|
||||
except ValueError:
|
||||
return False, "路径必须在项目文件夹内", None
|
||||
|
||||
# 检查禁止的路径
|
||||
path_str = str(full_path)
|
||||
|
||||
for forbidden_root in FORBIDDEN_ROOT_PATHS:
|
||||
if path_str == forbidden_root:
|
||||
return False, f"禁止访问根目录: {forbidden_root}", None
|
||||
|
||||
for forbidden in FORBIDDEN_PATHS:
|
||||
if path_str.startswith(forbidden + os.sep) or path_str == forbidden:
|
||||
return False, f"禁止访问系统目录: {forbidden}", None
|
||||
|
||||
return True, "", full_path
|
||||
|
||||
def create_file(self, path: str, content: str = "", file_type: str = "txt") -> Dict:
|
||||
"""创建文件"""
|
||||
valid, error, full_path = self._validate_path(path)
|
||||
if not valid:
|
||||
return {"success": False, "error": error}
|
||||
|
||||
# 添加文件扩展名
|
||||
if not full_path.suffix:
|
||||
full_path = full_path.with_suffix(f".{file_type}")
|
||||
|
||||
try:
|
||||
if full_path.parent == self.project_path:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "禁止在项目根目录直接创建文件,请先创建或选择子目录。",
|
||||
"suggestion": "创建文件所属文件夹,在其中创建新文件。"
|
||||
}
|
||||
# 创建父目录
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 固定创建空文件,忽略传入内容
|
||||
with open(full_path, 'w', encoding='utf-8') as f:
|
||||
f.write("")
|
||||
|
||||
relative_path = str(full_path.relative_to(self.project_path))
|
||||
print(f"{OUTPUT_FORMATS['file']} 创建文件: {relative_path}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"path": relative_path,
|
||||
"size": 0
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def delete_file(self, path: str) -> Dict:
|
||||
"""删除文件"""
|
||||
valid, error, full_path = self._validate_path(path)
|
||||
if not valid:
|
||||
return {"success": False, "error": error}
|
||||
|
||||
if not full_path.exists():
|
||||
return {"success": False, "error": "文件不存在"}
|
||||
|
||||
if not full_path.is_file():
|
||||
return {"success": False, "error": "不是文件"}
|
||||
|
||||
try:
|
||||
relative_path = str(full_path.relative_to(self.project_path))
|
||||
full_path.unlink()
|
||||
print(f"{OUTPUT_FORMATS['file']} 删除文件: {relative_path}")
|
||||
|
||||
# 删除文件备注(如果存在)
|
||||
# 这需要通过context_manager处理,但file_manager没有直接访问权限
|
||||
# 所以返回相对路径,让调用者处理备注删除
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"path": relative_path,
|
||||
"action": "deleted"
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def rename_file(self, old_path: str, new_path: str) -> Dict:
|
||||
"""重命名文件"""
|
||||
valid_old, error_old, full_old_path = self._validate_path(old_path)
|
||||
if not valid_old:
|
||||
return {"success": False, "error": error_old}
|
||||
|
||||
valid_new, error_new, full_new_path = self._validate_path(new_path)
|
||||
if not valid_new:
|
||||
return {"success": False, "error": error_new}
|
||||
|
||||
if not full_old_path.exists():
|
||||
return {"success": False, "error": "原文件不存在"}
|
||||
|
||||
if full_new_path.exists():
|
||||
return {"success": False, "error": "目标文件已存在"}
|
||||
|
||||
try:
|
||||
full_old_path.rename(full_new_path)
|
||||
|
||||
old_relative = str(full_old_path.relative_to(self.project_path))
|
||||
new_relative = str(full_new_path.relative_to(self.project_path))
|
||||
print(f"{OUTPUT_FORMATS['file']} 重命名: {old_relative} -> {new_relative}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"old_path": old_relative,
|
||||
"new_path": new_relative,
|
||||
"action": "renamed"
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def create_folder(self, path: str) -> Dict:
|
||||
"""创建文件夹"""
|
||||
valid, error, full_path = self._validate_path(path)
|
||||
if not valid:
|
||||
return {"success": False, "error": error}
|
||||
|
||||
if full_path.exists():
|
||||
return {"success": False, "error": "文件夹已存在"}
|
||||
|
||||
try:
|
||||
full_path.mkdir(parents=True, exist_ok=True)
|
||||
relative_path = str(full_path.relative_to(self.project_path))
|
||||
print(f"{OUTPUT_FORMATS['file']} 创建文件夹: {relative_path}")
|
||||
|
||||
return {"success": True, "path": relative_path}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def delete_folder(self, path: str) -> Dict:
|
||||
"""删除文件夹"""
|
||||
valid, error, full_path = self._validate_path(path)
|
||||
if not valid:
|
||||
return {"success": False, "error": error}
|
||||
|
||||
if not full_path.exists():
|
||||
return {"success": False, "error": "文件夹不存在"}
|
||||
|
||||
if not full_path.is_dir():
|
||||
return {"success": False, "error": "不是文件夹"}
|
||||
|
||||
try:
|
||||
shutil.rmtree(full_path)
|
||||
relative_path = str(full_path.relative_to(self.project_path))
|
||||
print(f"{OUTPUT_FORMATS['file']} 删除文件夹: {relative_path}")
|
||||
|
||||
return {"success": True, "path": relative_path}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def _read_text_lines(
|
||||
self,
|
||||
full_path: Path,
|
||||
*,
|
||||
size_limit: Optional[int] = None,
|
||||
encoding: str = "utf-8",
|
||||
) -> Dict:
|
||||
"""读取UTF-8文本并返回行列表。"""
|
||||
try:
|
||||
file_size = full_path.stat().st_size
|
||||
except FileNotFoundError:
|
||||
return {"success": False, "error": "文件不存在"}
|
||||
|
||||
if size_limit and file_size > size_limit:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"文件太大 ({file_size / 1024 / 1024:.2f}MB > {size_limit / 1024 / 1024}MB)"
|
||||
}
|
||||
|
||||
try:
|
||||
with open(full_path, 'r', encoding=encoding) as f:
|
||||
lines = f.readlines()
|
||||
except UnicodeDecodeError:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "文件不是 UTF-8 文本,无法直接读取,请改用 run_python 解析。"
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"读取文件失败: {e}"}
|
||||
|
||||
content = "".join(lines)
|
||||
return {
|
||||
"success": True,
|
||||
"content": content,
|
||||
"lines": lines,
|
||||
"size": file_size
|
||||
}
|
||||
|
||||
def read_file(self, path: str) -> Dict:
|
||||
"""读取文件内容(兼容旧逻辑,限制为 MAX_FILE_SIZE)。"""
|
||||
valid, error, full_path = self._validate_path(path)
|
||||
if not valid:
|
||||
return {"success": False, "error": error}
|
||||
|
||||
if not full_path.exists():
|
||||
return {"success": False, "error": "文件不存在"}
|
||||
|
||||
if not full_path.is_file():
|
||||
return {"success": False, "error": "不是文件"}
|
||||
|
||||
result = self._read_text_lines(full_path, size_limit=MAX_FILE_SIZE)
|
||||
if not result["success"]:
|
||||
return result
|
||||
|
||||
relative_path = str(full_path.relative_to(self.project_path))
|
||||
return {
|
||||
"success": True,
|
||||
"path": relative_path,
|
||||
"content": result["content"],
|
||||
"size": result["size"]
|
||||
}
|
||||
|
||||
def read_text_segment(
|
||||
self,
|
||||
path: str,
|
||||
*,
|
||||
start_line: Optional[int] = None,
|
||||
end_line: Optional[int] = None,
|
||||
size_limit: Optional[int] = None
|
||||
) -> Dict:
|
||||
"""按行范围读取文本片段。"""
|
||||
valid, error, full_path = self._validate_path(path)
|
||||
if not valid:
|
||||
return {"success": False, "error": error}
|
||||
|
||||
if not full_path.exists():
|
||||
return {"success": False, "error": "文件不存在"}
|
||||
|
||||
if not full_path.is_file():
|
||||
return {"success": False, "error": "不是文件"}
|
||||
|
||||
result = self._read_text_lines(
|
||||
full_path,
|
||||
size_limit=size_limit or READ_TOOL_MAX_FILE_SIZE
|
||||
)
|
||||
if not result["success"]:
|
||||
return result
|
||||
|
||||
lines = result["lines"]
|
||||
total_lines = len(lines)
|
||||
start = start_line if start_line and start_line > 0 else 1
|
||||
end = end_line if end_line and end_line >= start else total_lines
|
||||
if start > total_lines:
|
||||
return {"success": False, "error": "起始行超出文件长度"}
|
||||
end = min(end, total_lines)
|
||||
|
||||
selected_lines = lines[start - 1 : end]
|
||||
content = "".join(selected_lines)
|
||||
|
||||
relative_path = str(full_path.relative_to(self.project_path))
|
||||
return {
|
||||
"success": True,
|
||||
"path": relative_path,
|
||||
"content": content,
|
||||
"size": result["size"],
|
||||
"line_start": start,
|
||||
"line_end": end,
|
||||
"total_lines": total_lines
|
||||
}
|
||||
|
||||
def search_text(
|
||||
self,
|
||||
path: str,
|
||||
*,
|
||||
query: str,
|
||||
max_matches: int,
|
||||
context_before: int,
|
||||
context_after: int,
|
||||
case_sensitive: bool = False,
|
||||
size_limit: Optional[int] = None
|
||||
) -> Dict:
|
||||
"""在文件中搜索关键词,返回合并后的窗口。"""
|
||||
if not query:
|
||||
return {"success": False, "error": "缺少搜索关键词"}
|
||||
|
||||
valid, error, full_path = self._validate_path(path)
|
||||
if not valid:
|
||||
return {"success": False, "error": error}
|
||||
|
||||
if not full_path.exists():
|
||||
return {"success": False, "error": "文件不存在"}
|
||||
|
||||
if not full_path.is_file():
|
||||
return {"success": False, "error": "不是文件"}
|
||||
|
||||
result = self._read_text_lines(
|
||||
full_path,
|
||||
size_limit=size_limit or READ_TOOL_MAX_FILE_SIZE
|
||||
)
|
||||
if not result["success"]:
|
||||
return result
|
||||
|
||||
lines = result["lines"]
|
||||
total_lines = len(lines)
|
||||
matches = []
|
||||
query_text = query if case_sensitive else query.lower()
|
||||
|
||||
def contains(haystack: str) -> bool:
|
||||
target = haystack if case_sensitive else haystack.lower()
|
||||
return query_text in target
|
||||
|
||||
for idx, line in enumerate(lines, start=1):
|
||||
if contains(line):
|
||||
window_start = max(1, idx - context_before)
|
||||
window_end = min(total_lines, idx + context_after)
|
||||
|
||||
if matches and window_start <= matches[-1]["line_end"]:
|
||||
matches[-1]["line_end"] = max(matches[-1]["line_end"], window_end)
|
||||
matches[-1]["hits"].append(idx)
|
||||
else:
|
||||
if len(matches) >= max_matches:
|
||||
break
|
||||
matches.append({
|
||||
"line_start": window_start,
|
||||
"line_end": window_end,
|
||||
"hits": [idx]
|
||||
})
|
||||
|
||||
relative_path = str(full_path.relative_to(self.project_path))
|
||||
for window in matches:
|
||||
snippet_lines = lines[window["line_start"] - 1 : window["line_end"]]
|
||||
window["snippet"] = "".join(snippet_lines)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"path": relative_path,
|
||||
"size": result["size"],
|
||||
"total_lines": total_lines,
|
||||
"matches": matches
|
||||
}
|
||||
|
||||
def extract_segments(
|
||||
self,
|
||||
path: str,
|
||||
segments: List[Dict],
|
||||
*,
|
||||
size_limit: Optional[int] = None
|
||||
) -> Dict:
|
||||
"""根据多个行区间提取内容。"""
|
||||
if not segments:
|
||||
return {"success": False, "error": "缺少要提取的行区间"}
|
||||
|
||||
valid, error, full_path = self._validate_path(path)
|
||||
if not valid:
|
||||
return {"success": False, "error": error}
|
||||
|
||||
if not full_path.exists():
|
||||
return {"success": False, "error": "文件不存在"}
|
||||
|
||||
if not full_path.is_file():
|
||||
return {"success": False, "error": "不是文件"}
|
||||
|
||||
result = self._read_text_lines(
|
||||
full_path,
|
||||
size_limit=size_limit or READ_TOOL_MAX_FILE_SIZE
|
||||
)
|
||||
if not result["success"]:
|
||||
return result
|
||||
|
||||
lines = result["lines"]
|
||||
total_lines = len(lines)
|
||||
extracted = []
|
||||
|
||||
for item in segments:
|
||||
if not isinstance(item, dict):
|
||||
return {"success": False, "error": "segments 数组中的每一项都必须是对象"}
|
||||
start_line = item.get("start_line")
|
||||
end_line = item.get("end_line")
|
||||
label = item.get("label")
|
||||
if start_line is None or end_line is None:
|
||||
return {"success": False, "error": "所有区间都必须包含 start_line 和 end_line"}
|
||||
if start_line <= 0 or end_line < start_line:
|
||||
return {"success": False, "error": "行区间不合法"}
|
||||
if start_line > total_lines:
|
||||
return {"success": False, "error": f"区间起点 {start_line} 超出文件行数"}
|
||||
end_line = min(end_line, total_lines)
|
||||
snippet = "".join(lines[start_line - 1 : end_line])
|
||||
extracted.append({
|
||||
"label": label,
|
||||
"line_start": start_line,
|
||||
"line_end": end_line,
|
||||
"content": snippet
|
||||
})
|
||||
|
||||
relative_path = str(full_path.relative_to(self.project_path))
|
||||
return {
|
||||
"success": True,
|
||||
"path": relative_path,
|
||||
"size": result["size"],
|
||||
"total_lines": total_lines,
|
||||
"segments": extracted
|
||||
}
|
||||
|
||||
def write_file(self, path: str, content: str, mode: str = "w") -> Dict:
|
||||
"""
|
||||
写入文件
|
||||
|
||||
Args:
|
||||
path: 文件路径
|
||||
content: 内容
|
||||
mode: 写入模式 - "w"(覆盖), "a"(追加)
|
||||
"""
|
||||
valid, error, full_path = self._validate_path(path)
|
||||
if not valid:
|
||||
return {"success": False, "error": error}
|
||||
|
||||
# === 新增:内容预处理和验证 ===
|
||||
if content:
|
||||
# 长度检查
|
||||
if not DISABLE_LENGTH_CHECK and len(content) > 9999999999: # 100KB限制
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"内容过长({len(content)}字符),超过100KB限制",
|
||||
"suggestion": "请分块处理或使用部分修改方式"
|
||||
}
|
||||
|
||||
# 检查潜在的JSON格式问题
|
||||
if content.count('"') % 2 != 0:
|
||||
print(f"{OUTPUT_FORMATS['warning']} 检测到奇数个引号,可能存在格式问题")
|
||||
|
||||
# 检查大量转义字符
|
||||
if content.count('\\') > len(content) / 20:
|
||||
print(f"{OUTPUT_FORMATS['warning']} 检测到大量转义字符,建议检查内容格式")
|
||||
|
||||
try:
|
||||
# 创建父目录
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(full_path, mode, encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
|
||||
relative_path = str(full_path.relative_to(self.project_path))
|
||||
action = "覆盖" if mode == "w" else "追加"
|
||||
print(f"{OUTPUT_FORMATS['file']} {action}文件: {relative_path}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"path": relative_path,
|
||||
"size": len(content),
|
||||
"mode": mode
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def append_file(self, path: str, content: str) -> Dict:
|
||||
"""追加内容到文件"""
|
||||
return self.write_file(path, content, mode="a")
|
||||
|
||||
def apply_modify_blocks(self, path: str, blocks: List[Dict]) -> Dict:
|
||||
"""
|
||||
应用批量替换块
|
||||
|
||||
Args:
|
||||
path: 目标文件路径
|
||||
blocks: [{"index": int, "old": str, "new": str}]
|
||||
"""
|
||||
valid, error, full_path = self._validate_path(path)
|
||||
if not valid:
|
||||
return {"success": False, "error": error}
|
||||
|
||||
if not full_path.exists():
|
||||
return {"success": False, "error": "文件不存在"}
|
||||
|
||||
if not full_path.is_file():
|
||||
return {"success": False, "error": "不是文件"}
|
||||
|
||||
try:
|
||||
with open(full_path, 'r', encoding='utf-8') as f:
|
||||
original_content = f.read()
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"读取文件失败: {e}"}
|
||||
|
||||
current_content = original_content
|
||||
results: List[Dict] = []
|
||||
completed_indices: List[int] = []
|
||||
failed_details: List[Dict] = []
|
||||
write_error = None
|
||||
|
||||
for block in blocks:
|
||||
index = block.get("index")
|
||||
old_text = block.get("old", "")
|
||||
new_text = block.get("new", "")
|
||||
|
||||
block_result = {
|
||||
"index": index,
|
||||
"status": "pending",
|
||||
"removed_lines": 0,
|
||||
"added_lines": 0,
|
||||
"reason": None,
|
||||
"hint": None
|
||||
}
|
||||
|
||||
if old_text is None or new_text is None:
|
||||
block_result["status"] = "error"
|
||||
block_result["reason"] = "缺少 OLD 或 NEW 内容"
|
||||
block_result["hint"] = "请确保粘贴的补丁包含成对的 <<<OLD>>> / <<<NEW>>> 标记。"
|
||||
failed_details.append({"index": index, "reason": "缺少 OLD/NEW 标记"})
|
||||
results.append(block_result)
|
||||
continue
|
||||
|
||||
# 统一换行符,避免 CRLF 与 LF 不一致导致匹配失败
|
||||
old_text = old_text.replace('\r\n', '\n')
|
||||
new_text = new_text.replace('\r\n', '\n')
|
||||
|
||||
if not old_text:
|
||||
block_result["status"] = "error"
|
||||
block_result["reason"] = "OLD 内容不能为空"
|
||||
block_result["hint"] = "请确认要替换的原文是否准确复制;若多次失败,可改用 terminal_snapshot 查证或使用终端命令/Python 小脚本进行精确替换。"
|
||||
failed_details.append({"index": index, "reason": "OLD 内容为空"})
|
||||
results.append(block_result)
|
||||
continue
|
||||
|
||||
position = current_content.find(old_text)
|
||||
if position == -1:
|
||||
block_result["status"] = "not_found"
|
||||
block_result["reason"] = "未找到匹配的原文,请确认是否完全复制"
|
||||
block_result["hint"] = "请先用 terminal_snapshot 或 grep -n 校验原文;若仍失败,可在说明后改用 run_command/python 进行局部修改。"
|
||||
failed_details.append({"index": index, "reason": "未找到匹配的原文"})
|
||||
results.append(block_result)
|
||||
continue
|
||||
|
||||
current_content = (
|
||||
current_content[:position] +
|
||||
new_text +
|
||||
current_content[position + len(old_text):]
|
||||
)
|
||||
|
||||
removed_lines = old_text.count('\n')
|
||||
added_lines = new_text.count('\n')
|
||||
if old_text and not old_text.endswith('\n'):
|
||||
removed_lines += 1
|
||||
if new_text and not new_text.endswith('\n'):
|
||||
added_lines += 1
|
||||
|
||||
block_result.update({
|
||||
"status": "success",
|
||||
"removed_lines": removed_lines if old_text else 0,
|
||||
"added_lines": added_lines if new_text else 0
|
||||
})
|
||||
completed_indices.append(index)
|
||||
results.append(block_result)
|
||||
|
||||
write_performed = False
|
||||
if completed_indices:
|
||||
try:
|
||||
with open(full_path, 'w', encoding='utf-8') as f:
|
||||
f.write(current_content)
|
||||
write_performed = True
|
||||
except Exception as e:
|
||||
write_error = f"写入文件失败: {e}"
|
||||
# 写入失败时恢复原始内容
|
||||
try:
|
||||
with open(full_path, 'w', encoding='utf-8') as f:
|
||||
f.write(original_content)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
success = bool(completed_indices) and not failed_details and write_error is None
|
||||
|
||||
return {
|
||||
"success": success,
|
||||
"completed": completed_indices,
|
||||
"failed": failed_details,
|
||||
"results": results,
|
||||
"write_performed": write_performed,
|
||||
"error": write_error
|
||||
}
|
||||
|
||||
def replace_in_file(self, path: str, old_text: str, new_text: str) -> Dict:
|
||||
"""替换文件中的内容"""
|
||||
# 先读取文件
|
||||
result = self.read_file(path)
|
||||
if not result["success"]:
|
||||
return result
|
||||
|
||||
content = result["content"]
|
||||
|
||||
# === 新增:替换操作的安全检查 ===
|
||||
if old_text and len(old_text) > 9999999999:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "要替换的文本过长,可能导致性能问题",
|
||||
"suggestion": "请拆分内容或使用 modify_file 提交结构化补丁"
|
||||
}
|
||||
|
||||
if new_text and len(new_text) > 9999999999:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "替换的新文本过长,建议分块处理",
|
||||
"suggestion": "请将大内容分成多个小的替换操作"
|
||||
}
|
||||
|
||||
# 检查是否包含要替换的内容
|
||||
if old_text and old_text not in content:
|
||||
return {"success": False, "error": "未找到要替换的内容"}
|
||||
|
||||
# 替换内容
|
||||
if old_text:
|
||||
new_content = content.replace(old_text, new_text)
|
||||
count = content.count(old_text)
|
||||
else:
|
||||
# 空文件直接写入新内容
|
||||
new_content = new_text
|
||||
count = 1
|
||||
|
||||
# 写回文件
|
||||
result = self.write_file(path, new_content)
|
||||
if result["success"]:
|
||||
result["replacements"] = count
|
||||
print(f"{OUTPUT_FORMATS['file']} 替换了 {count} 处内容")
|
||||
|
||||
return result
|
||||
|
||||
def clear_file(self, path: str) -> Dict:
|
||||
"""清空文件内容"""
|
||||
return self.write_file(path, "", mode="w")
|
||||
|
||||
def edit_lines_range(self, path: str, start_line: int, end_line: int, content: str, operation: str) -> Dict:
|
||||
"""
|
||||
基于行号编辑文件
|
||||
|
||||
Args:
|
||||
path: 文件路径
|
||||
start_line: 起始行号(从1开始)
|
||||
end_line: 结束行号(从1开始,包含)
|
||||
content: 新内容
|
||||
operation: 操作类型 - "replace", "insert", "delete"
|
||||
"""
|
||||
valid, error, full_path = self._validate_path(path)
|
||||
if not valid:
|
||||
return {"success": False, "error": error}
|
||||
|
||||
if not full_path.exists():
|
||||
return {"success": False, "error": "文件不存在"}
|
||||
|
||||
if not full_path.is_file():
|
||||
return {"success": False, "error": "不是文件"}
|
||||
|
||||
# 验证行号
|
||||
if start_line < 1:
|
||||
return {"success": False, "error": "行号必须从1开始"}
|
||||
|
||||
if end_line < start_line:
|
||||
return {"success": False, "error": "结束行号不能小于起始行号"}
|
||||
|
||||
try:
|
||||
# 读取文件内容
|
||||
with open(full_path, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
total_lines = len(lines)
|
||||
|
||||
# 检查行号范围
|
||||
if start_line > total_lines:
|
||||
if operation == "insert":
|
||||
# 插入操作允许在文件末尾后插入
|
||||
lines.extend([''] * (start_line - total_lines - 1))
|
||||
lines.append(content if content.endswith('\n') else content + '\n')
|
||||
else:
|
||||
return {"success": False, "error": f"起始行号 {start_line} 超出文件范围 (共 {total_lines} 行)"}
|
||||
elif end_line > total_lines:
|
||||
return {"success": False, "error": f"结束行号 {end_line} 超出文件范围 (共 {total_lines} 行)"}
|
||||
else:
|
||||
# 执行操作(转换为0基索引)
|
||||
start_idx = start_line - 1
|
||||
end_idx = end_line
|
||||
|
||||
if operation == "replace":
|
||||
# 替换指定行范围
|
||||
new_lines = content.split('\n') if '\n' in content else [content]
|
||||
# 确保每行都有换行符,除了最后一行需要检查原文件格式
|
||||
formatted_lines = []
|
||||
for i, line in enumerate(new_lines):
|
||||
if i < len(new_lines) - 1 or (end_idx < len(lines) and lines[end_idx - 1].endswith('\n')):
|
||||
formatted_lines.append(line + '\n' if not line.endswith('\n') else line)
|
||||
else:
|
||||
formatted_lines.append(line)
|
||||
|
||||
lines[start_idx:end_idx] = formatted_lines
|
||||
affected_lines = end_line - start_line + 1
|
||||
|
||||
elif operation == "insert":
|
||||
# 在指定行前插入内容
|
||||
new_lines = content.split('\n') if '\n' in content else [content]
|
||||
formatted_lines = [line + '\n' if not line.endswith('\n') else line for line in new_lines]
|
||||
lines[start_idx:start_idx] = formatted_lines
|
||||
affected_lines = len(formatted_lines)
|
||||
|
||||
elif operation == "delete":
|
||||
# 删除指定行范围
|
||||
affected_lines = end_line - start_line + 1
|
||||
del lines[start_idx:end_idx]
|
||||
|
||||
else:
|
||||
return {"success": False, "error": f"未知的操作类型: {operation}"}
|
||||
|
||||
# 写回文件
|
||||
with open(full_path, 'w', encoding='utf-8') as f:
|
||||
f.writelines(lines)
|
||||
|
||||
relative_path = str(full_path.relative_to(self.project_path))
|
||||
|
||||
# 生成操作描述
|
||||
if operation == "replace":
|
||||
operation_desc = f"替换第 {start_line}-{end_line} 行"
|
||||
elif operation == "insert":
|
||||
operation_desc = f"在第 {start_line} 行前插入"
|
||||
elif operation == "delete":
|
||||
operation_desc = f"删除第 {start_line}-{end_line} 行"
|
||||
|
||||
print(f"{OUTPUT_FORMATS['file']} {operation_desc}: {relative_path}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"path": relative_path,
|
||||
"operation": operation,
|
||||
"start_line": start_line,
|
||||
"end_line": end_line,
|
||||
"affected_lines": affected_lines,
|
||||
"total_lines_after": len(lines),
|
||||
"description": operation_desc
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def list_files(self, path: str = "") -> Dict:
|
||||
"""列出目录内容"""
|
||||
if path:
|
||||
valid, error, full_path = self._validate_path(path)
|
||||
if not valid:
|
||||
return {"success": False, "error": error}
|
||||
else:
|
||||
full_path = self.project_path
|
||||
|
||||
if not full_path.exists():
|
||||
return {"success": False, "error": "目录不存在"}
|
||||
|
||||
if not full_path.is_dir():
|
||||
return {"success": False, "error": "不是目录"}
|
||||
|
||||
try:
|
||||
files = []
|
||||
folders = []
|
||||
|
||||
for item in full_path.iterdir():
|
||||
if item.name.startswith('.'):
|
||||
continue
|
||||
|
||||
relative_path = str(item.relative_to(self.project_path))
|
||||
|
||||
if item.is_file():
|
||||
files.append({
|
||||
"name": item.name,
|
||||
"path": relative_path,
|
||||
"size": item.stat().st_size,
|
||||
"modified": datetime.fromtimestamp(item.stat().st_mtime).isoformat()
|
||||
})
|
||||
elif item.is_dir():
|
||||
folders.append({
|
||||
"name": item.name,
|
||||
"path": relative_path
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"path": str(full_path.relative_to(self.project_path)) if path else ".",
|
||||
"files": sorted(files, key=lambda x: x["name"]),
|
||||
"folders": sorted(folders, key=lambda x: x["name"])
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def get_file_info(self, path: str) -> Dict:
|
||||
"""获取文件信息"""
|
||||
valid, error, full_path = self._validate_path(path)
|
||||
if not valid:
|
||||
return {"success": False, "error": error}
|
||||
|
||||
if not full_path.exists():
|
||||
return {"success": False, "error": "文件不存在"}
|
||||
|
||||
try:
|
||||
stat = full_path.stat()
|
||||
relative_path = str(full_path.relative_to(self.project_path))
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"path": relative_path,
|
||||
"name": full_path.name,
|
||||
"type": "file" if full_path.is_file() else "folder",
|
||||
"size": stat.st_size,
|
||||
"created": datetime.fromtimestamp(stat.st_ctime).isoformat(),
|
||||
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
||||
"extension": full_path.suffix if full_path.is_file() else None
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
335
sub_agent/modules/gui_file_manager.py
Normal file
335
sub_agent/modules/gui_file_manager.py
Normal file
@ -0,0 +1,335 @@
|
||||
# modules/gui_file_manager.py - GUI 文件管理专用服务
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileEntry:
|
||||
"""用于前端显示的文件/目录条目"""
|
||||
|
||||
name: str
|
||||
path: str
|
||||
type: str # file / directory
|
||||
size: int
|
||||
modified_at: float
|
||||
extension: Optional[str]
|
||||
is_editable: bool
|
||||
|
||||
|
||||
class GuiFileManager:
|
||||
"""面向 GUI 的文件管理器,实现桌面式操作所需的能力"""
|
||||
|
||||
EDITABLE_EXTENSIONS = {
|
||||
".txt",
|
||||
".md",
|
||||
".py",
|
||||
".js",
|
||||
".ts",
|
||||
".json",
|
||||
".yaml",
|
||||
".yml",
|
||||
".html",
|
||||
".css",
|
||||
".scss",
|
||||
".less",
|
||||
".xml",
|
||||
".csv",
|
||||
".ini",
|
||||
".cfg",
|
||||
".toml",
|
||||
".sh",
|
||||
".bat",
|
||||
".java",
|
||||
".kt",
|
||||
".go",
|
||||
".rs",
|
||||
".c",
|
||||
".cpp",
|
||||
".h",
|
||||
".hpp",
|
||||
".vue",
|
||||
".svelte",
|
||||
".php",
|
||||
".rb",
|
||||
".swift",
|
||||
".dart",
|
||||
".sql",
|
||||
}
|
||||
|
||||
MAX_TEXT_FILE_SIZE = 2 * 1024 * 1024 # 2MB
|
||||
|
||||
def __init__(self, base_path: str):
|
||||
self.base_path = Path(base_path).expanduser().resolve()
|
||||
if not self.base_path.exists():
|
||||
raise ValueError("Base path does not exist")
|
||||
|
||||
# -------------------------
|
||||
# 路径解析与安全检查
|
||||
# -------------------------
|
||||
|
||||
def _resolve(self, relative: Optional[str]) -> Path:
|
||||
relative = (relative or "").strip()
|
||||
target = (self.base_path if not relative else (self.base_path / relative)).resolve()
|
||||
try:
|
||||
target.relative_to(self.base_path)
|
||||
except ValueError:
|
||||
raise ValueError("路径越界")
|
||||
return target
|
||||
|
||||
def _to_relative(self, absolute: Path) -> str:
|
||||
return str(absolute.relative_to(self.base_path)).replace("\\", "/")
|
||||
|
||||
def _unique_name(self, directory: Path, name: str) -> Path:
|
||||
candidate = directory / name
|
||||
if not candidate.exists():
|
||||
return candidate
|
||||
|
||||
stem = candidate.stem
|
||||
suffix = candidate.suffix
|
||||
counter = 1
|
||||
while candidate.exists():
|
||||
candidate = directory / f"{stem}_copy{counter if counter > 1 else ''}{suffix}"
|
||||
counter += 1
|
||||
return candidate
|
||||
|
||||
def _check_editable(self, path: Path) -> bool:
|
||||
if not path.is_file():
|
||||
return False
|
||||
if path.stat().st_size > self.MAX_TEXT_FILE_SIZE:
|
||||
return False
|
||||
ext = path.suffix.lower()
|
||||
if ext in self.EDITABLE_EXTENSIONS:
|
||||
return True
|
||||
# 无扩展名的文本文件尝试 UTF-8 读取判断
|
||||
if not ext:
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
fh.read(2048)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
# -------------------------
|
||||
# 列表与元数据
|
||||
# -------------------------
|
||||
|
||||
def list_directory(self, relative: Optional[str] = None) -> Tuple[str, List[FileEntry]]:
|
||||
directory = self._resolve(relative)
|
||||
if not directory.exists():
|
||||
raise FileNotFoundError("目录不存在")
|
||||
if not directory.is_dir():
|
||||
raise NotADirectoryError("目标不是目录")
|
||||
|
||||
entries: List[FileEntry] = []
|
||||
for entry in sorted(directory.iterdir(), key=lambda p: (p.is_file(), p.name.lower())):
|
||||
stat = entry.stat()
|
||||
entries.append(
|
||||
FileEntry(
|
||||
name=entry.name,
|
||||
path=self._to_relative(entry),
|
||||
type="directory" if entry.is_dir() else "file",
|
||||
size=stat.st_size,
|
||||
modified_at=stat.st_mtime,
|
||||
extension=entry.suffix.lower() if entry.is_file() else None,
|
||||
is_editable=self._check_editable(entry),
|
||||
)
|
||||
)
|
||||
relative_path = "" if directory == self.base_path else self._to_relative(directory)
|
||||
return relative_path, entries
|
||||
|
||||
def breadcrumb(self, relative: Optional[str]) -> List[Dict[str, str]]:
|
||||
crumbs: List[Dict[str, str]] = []
|
||||
directory = self._resolve(relative)
|
||||
try:
|
||||
directory.relative_to(self.base_path)
|
||||
except ValueError:
|
||||
raise ValueError("路径越界")
|
||||
|
||||
current = directory
|
||||
while True:
|
||||
relative_path = "" if current == self.base_path else self._to_relative(current)
|
||||
crumbs.append({"name": current.name if current != self.base_path else "根目录", "path": relative_path})
|
||||
if current == self.base_path:
|
||||
break
|
||||
current = current.parent
|
||||
crumbs.reverse()
|
||||
return crumbs
|
||||
|
||||
# -------------------------
|
||||
# 基本操作
|
||||
# -------------------------
|
||||
|
||||
def create_entry(self, parent_relative: Optional[str], name: str, entry_type: str) -> str:
|
||||
parent = self._resolve(parent_relative)
|
||||
if not parent.exists():
|
||||
raise FileNotFoundError("父目录不存在")
|
||||
if not parent.is_dir():
|
||||
raise NotADirectoryError("父路径不是目录")
|
||||
|
||||
sanitized = name.strip()
|
||||
if not sanitized:
|
||||
raise ValueError("名称不能为空")
|
||||
|
||||
target = parent / sanitized
|
||||
if target.exists():
|
||||
raise FileExistsError("同名文件或目录已存在")
|
||||
|
||||
if entry_type == "directory":
|
||||
target.mkdir(parents=False, exist_ok=False)
|
||||
elif entry_type == "file":
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.touch()
|
||||
else:
|
||||
raise ValueError("不支持的类型")
|
||||
|
||||
return self._to_relative(target)
|
||||
|
||||
def delete_entries(self, relative_paths: List[str]) -> Dict[str, str]:
|
||||
results: Dict[str, str] = {}
|
||||
for rel in relative_paths:
|
||||
target = self._resolve(rel)
|
||||
if not target.exists():
|
||||
results[rel] = "missing"
|
||||
continue
|
||||
try:
|
||||
if target.is_dir():
|
||||
shutil.rmtree(target)
|
||||
else:
|
||||
target.unlink()
|
||||
results[rel] = "deleted"
|
||||
except Exception as exc:
|
||||
results[rel] = f"error: {exc}"
|
||||
return results
|
||||
|
||||
def rename_entry(self, relative_path: str, new_name: str) -> str:
|
||||
target = self._resolve(relative_path)
|
||||
if not target.exists():
|
||||
raise FileNotFoundError("目标不存在")
|
||||
|
||||
parent = target.parent
|
||||
sanitized = new_name.strip()
|
||||
if not sanitized:
|
||||
raise ValueError("新名称不能为空")
|
||||
new_path = parent / sanitized
|
||||
if new_path.exists():
|
||||
raise FileExistsError("目标名称已存在")
|
||||
|
||||
target.rename(new_path)
|
||||
return self._to_relative(new_path)
|
||||
|
||||
def copy_entries(self, relative_paths: List[str], destination_relative: str) -> Dict[str, str]:
|
||||
destination = self._resolve(destination_relative)
|
||||
if not destination.exists() or not destination.is_dir():
|
||||
raise NotADirectoryError("目标目录不存在")
|
||||
|
||||
results: Dict[str, str] = {}
|
||||
for rel in relative_paths:
|
||||
source = self._resolve(rel)
|
||||
if not source.exists():
|
||||
results[rel] = "missing"
|
||||
continue
|
||||
try:
|
||||
target = self._unique_name(destination, source.name)
|
||||
if source.is_dir():
|
||||
shutil.copytree(source, target)
|
||||
else:
|
||||
shutil.copy2(source, target)
|
||||
results[rel] = self._to_relative(target)
|
||||
except Exception as exc:
|
||||
results[rel] = f"error: {exc}"
|
||||
return results
|
||||
|
||||
def move_entries(self, relative_paths: List[str], destination_relative: str) -> Dict[str, str]:
|
||||
destination = self._resolve(destination_relative)
|
||||
if not destination.exists() or not destination.is_dir():
|
||||
raise NotADirectoryError("目标目录不存在")
|
||||
|
||||
results: Dict[str, str] = {}
|
||||
for rel in relative_paths:
|
||||
source = self._resolve(rel)
|
||||
if not source.exists():
|
||||
results[rel] = "missing"
|
||||
continue
|
||||
try:
|
||||
target = destination / source.name
|
||||
if target.exists():
|
||||
target = self._unique_name(destination, source.name)
|
||||
shutil.move(str(source), str(target))
|
||||
results[rel] = self._to_relative(target)
|
||||
except Exception as exc:
|
||||
results[rel] = f"error: {exc}"
|
||||
return results
|
||||
|
||||
# -------------------------
|
||||
# 文本读写
|
||||
# -------------------------
|
||||
|
||||
def read_text(self, relative_path: str) -> Tuple[str, str]:
|
||||
target = self._resolve(relative_path)
|
||||
if not target.exists():
|
||||
raise FileNotFoundError("文件不存在")
|
||||
if not target.is_file():
|
||||
raise IsADirectoryError("目标是目录")
|
||||
|
||||
size = target.stat().st_size
|
||||
if size > self.MAX_TEXT_FILE_SIZE:
|
||||
raise ValueError("文件过大,暂不支持直接编辑")
|
||||
|
||||
try:
|
||||
with open(target, "r", encoding="utf-8") as fh:
|
||||
content = fh.read()
|
||||
except UnicodeDecodeError as exc:
|
||||
raise ValueError(f"文件不是 UTF-8 编码: {exc}") from exc
|
||||
|
||||
return content, datetime.fromtimestamp(target.stat().st_mtime).isoformat()
|
||||
|
||||
def write_text(self, relative_path: str, content: str) -> Dict[str, str]:
|
||||
target = self._resolve(relative_path)
|
||||
if not target.exists():
|
||||
raise FileNotFoundError("文件不存在")
|
||||
if not target.is_file():
|
||||
raise IsADirectoryError("目标是目录")
|
||||
|
||||
size = len(content.encode("utf-8"))
|
||||
if size > self.MAX_TEXT_FILE_SIZE:
|
||||
raise ValueError("内容过大,超出限制")
|
||||
|
||||
with open(target, "w", encoding="utf-8") as fh:
|
||||
fh.write(content)
|
||||
|
||||
stat = target.stat()
|
||||
return {
|
||||
"path": self._to_relative(target),
|
||||
"size": str(stat.st_size),
|
||||
"modified_at": datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
||||
}
|
||||
|
||||
# -------------------------
|
||||
# 上传与下载
|
||||
# -------------------------
|
||||
|
||||
def prepare_upload(self, destination_relative: Optional[str], filename: str) -> Path:
|
||||
destination = self._resolve(destination_relative)
|
||||
if not destination.exists():
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
if not destination.is_dir():
|
||||
raise NotADirectoryError("上传目标必须是目录")
|
||||
sanitized = filename.strip()
|
||||
if not sanitized:
|
||||
raise ValueError("文件名不能为空")
|
||||
target = destination / sanitized
|
||||
target = self._unique_name(destination, target.name) if target.exists() else target
|
||||
return target
|
||||
|
||||
def prepare_download(self, relative_path: str) -> Path:
|
||||
target = self._resolve(relative_path)
|
||||
if not target.exists():
|
||||
raise FileNotFoundError("文件不存在")
|
||||
return target
|
||||
|
||||
307
sub_agent/modules/memory_manager.py
Normal file
307
sub_agent/modules/memory_manager.py
Normal file
@ -0,0 +1,307 @@
|
||||
# modules/memory_manager.py - 记忆管理模块
|
||||
|
||||
import os
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
try:
|
||||
from config import MAIN_MEMORY_FILE, TASK_MEMORY_FILE, DATA_DIR, OUTPUT_FORMATS
|
||||
except ImportError:
|
||||
import sys
|
||||
from pathlib import Path
|
||||
project_root = Path(__file__).resolve().parents[1]
|
||||
if str(project_root) not in sys.path:
|
||||
sys.path.insert(0, str(project_root))
|
||||
from config import MAIN_MEMORY_FILE, TASK_MEMORY_FILE, DATA_DIR, OUTPUT_FORMATS
|
||||
|
||||
class MemoryManager:
|
||||
def __init__(self, data_dir: Optional[str] = None):
|
||||
self.data_dir = Path(data_dir).expanduser().resolve() if data_dir else Path(DATA_DIR).resolve()
|
||||
default_main = Path(MAIN_MEMORY_FILE).name
|
||||
default_task = Path(TASK_MEMORY_FILE).name
|
||||
|
||||
if data_dir:
|
||||
self.main_memory_path = self.data_dir / default_main
|
||||
self.task_memory_path = self.data_dir / default_task
|
||||
else:
|
||||
self.main_memory_path = Path(MAIN_MEMORY_FILE)
|
||||
self.task_memory_path = Path(TASK_MEMORY_FILE)
|
||||
|
||||
self.ensure_files_exist()
|
||||
|
||||
def ensure_files_exist(self):
|
||||
"""确保记忆文件存在"""
|
||||
# 创建数据目录
|
||||
os.makedirs(self.data_dir, exist_ok=True)
|
||||
|
||||
# 创建主记忆文件
|
||||
if not self.main_memory_path.exists():
|
||||
self.create_memory_file(self.main_memory_path, "主记忆文件")
|
||||
|
||||
# 创建任务记忆文件
|
||||
if not self.task_memory_path.exists():
|
||||
self.create_memory_file(self.task_memory_path, "任务记忆文件")
|
||||
|
||||
def create_memory_file(self, path: Path, title: str):
|
||||
"""创建记忆文件"""
|
||||
template = f"""# {title}
|
||||
创建时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
|
||||
|
||||
## 项目信息
|
||||
|
||||
|
||||
## 重要记录
|
||||
|
||||
|
||||
## 经验总结
|
||||
|
||||
|
||||
## 待办事项
|
||||
|
||||
"""
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
f.write(template)
|
||||
|
||||
print(f"{OUTPUT_FORMATS['memory']} 创建{title}: {path}")
|
||||
|
||||
def read_main_memory(self) -> str:
|
||||
"""读取主记忆"""
|
||||
try:
|
||||
with open(self.main_memory_path, 'r', encoding='utf-8') as f:
|
||||
return f.read()
|
||||
except Exception as e:
|
||||
print(f"{OUTPUT_FORMATS['error']} 读取主记忆失败: {e}")
|
||||
return ""
|
||||
|
||||
def read_task_memory(self) -> str:
|
||||
"""读取任务记忆"""
|
||||
try:
|
||||
with open(self.task_memory_path, 'r', encoding='utf-8') as f:
|
||||
return f.read()
|
||||
except Exception as e:
|
||||
print(f"{OUTPUT_FORMATS['error']} 读取任务记忆失败: {e}")
|
||||
return ""
|
||||
|
||||
def write_main_memory(self, content: str) -> bool:
|
||||
"""写入主记忆"""
|
||||
try:
|
||||
with open(self.main_memory_path, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
print(f"{OUTPUT_FORMATS['memory']} 更新主记忆")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"{OUTPUT_FORMATS['error']} 写入主记忆失败: {e}")
|
||||
return False
|
||||
|
||||
def write_task_memory(self, content: str) -> bool:
|
||||
"""写入任务记忆"""
|
||||
try:
|
||||
with open(self.task_memory_path, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
print(f"{OUTPUT_FORMATS['memory']} 更新任务记忆")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"{OUTPUT_FORMATS['error']} 写入任务记忆失败: {e}")
|
||||
return False
|
||||
|
||||
def append_main_memory(self, content: str, section: str = None) -> bool:
|
||||
"""追加内容到主记忆"""
|
||||
try:
|
||||
current = self.read_main_memory()
|
||||
|
||||
# 添加时间戳
|
||||
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
if section:
|
||||
# 追加到特定部分
|
||||
new_entry = f"\n### [{timestamp}] {section}\n{content}\n"
|
||||
if f"## {section}" in current:
|
||||
# 在该部分后添加
|
||||
parts = current.split(f"## {section}")
|
||||
if len(parts) > 1:
|
||||
# 找到下一个##的位置
|
||||
next_section = parts[1].find("\n##")
|
||||
if next_section > 0:
|
||||
parts[1] = parts[1][:next_section] + new_entry + parts[1][next_section:]
|
||||
else:
|
||||
parts[1] = parts[1] + new_entry
|
||||
current = f"## {section}".join(parts)
|
||||
else:
|
||||
current += new_entry
|
||||
else:
|
||||
# 创建新部分
|
||||
current += f"\n## {section}\n{new_entry}"
|
||||
else:
|
||||
# 追加到末尾
|
||||
current += f"\n### [{timestamp}]\n{content}\n"
|
||||
|
||||
return self.write_main_memory(current)
|
||||
|
||||
except Exception as e:
|
||||
print(f"{OUTPUT_FORMATS['error']} 追加主记忆失败: {e}")
|
||||
return False
|
||||
|
||||
def append_task_memory(self, content: str, task_id: str = None) -> bool:
|
||||
"""追加内容到任务记忆"""
|
||||
try:
|
||||
current = self.read_task_memory()
|
||||
|
||||
# 添加时间戳
|
||||
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
if task_id:
|
||||
new_entry = f"\n### 任务 {task_id} - {timestamp}\n{content}\n"
|
||||
else:
|
||||
new_entry = f"\n### {timestamp}\n{content}\n"
|
||||
|
||||
current += new_entry
|
||||
|
||||
return self.write_task_memory(current)
|
||||
|
||||
except Exception as e:
|
||||
print(f"{OUTPUT_FORMATS['error']} 追加任务记忆失败: {e}")
|
||||
return False
|
||||
|
||||
def search_memory(self, keyword: str, memory_type: str = "main") -> List[str]:
|
||||
"""搜索记忆内容"""
|
||||
if memory_type == "main":
|
||||
content = self.read_main_memory()
|
||||
else:
|
||||
content = self.read_task_memory()
|
||||
|
||||
results = []
|
||||
lines = content.split('\n')
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
if keyword.lower() in line.lower():
|
||||
# 获取上下文(前后各2行)
|
||||
start = max(0, i - 2)
|
||||
end = min(len(lines), i + 3)
|
||||
context = '\n'.join(lines[start:end])
|
||||
results.append(context)
|
||||
|
||||
return results
|
||||
|
||||
def clear_task_memory(self) -> bool:
|
||||
"""清空任务记忆"""
|
||||
try:
|
||||
self.create_memory_file(self.task_memory_path, "任务记忆文件")
|
||||
print(f"{OUTPUT_FORMATS['memory']} 清空任务记忆")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"{OUTPUT_FORMATS['error']} 清空任务记忆失败: {e}")
|
||||
return False
|
||||
|
||||
def backup_memory(self, memory_type: str = "main") -> str:
|
||||
"""备份记忆文件"""
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
|
||||
if memory_type == "main":
|
||||
source = self.main_memory_path
|
||||
backup_name = f"main_memory_backup_{timestamp}.md"
|
||||
else:
|
||||
source = self.task_memory_path
|
||||
backup_name = f"task_memory_backup_{timestamp}.md"
|
||||
|
||||
backup_path = self.data_dir / "backups" / backup_name
|
||||
backup_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
import shutil
|
||||
shutil.copy2(source, backup_path)
|
||||
print(f"{OUTPUT_FORMATS['success']} 备份成功: {backup_path}")
|
||||
return str(backup_path)
|
||||
except Exception as e:
|
||||
print(f"{OUTPUT_FORMATS['error']} 备份失败: {e}")
|
||||
return ""
|
||||
|
||||
def restore_memory(self, backup_path: str, memory_type: str = "main") -> bool:
|
||||
"""恢复记忆文件"""
|
||||
backup_file = Path(backup_path)
|
||||
|
||||
if not backup_file.exists():
|
||||
print(f"{OUTPUT_FORMATS['error']} 备份文件不存在: {backup_path}")
|
||||
return False
|
||||
|
||||
if memory_type == "main":
|
||||
target = self.main_memory_path
|
||||
else:
|
||||
target = self.task_memory_path
|
||||
|
||||
try:
|
||||
import shutil
|
||||
# 先备份当前文件
|
||||
self.backup_memory(memory_type)
|
||||
# 恢复备份
|
||||
shutil.copy2(backup_file, target)
|
||||
print(f"{OUTPUT_FORMATS['success']} 恢复成功: {target}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"{OUTPUT_FORMATS['error']} 恢复失败: {e}")
|
||||
return False
|
||||
|
||||
def get_memory_stats(self) -> Dict:
|
||||
"""获取记忆统计信息"""
|
||||
stats = {
|
||||
"main_memory": {
|
||||
"exists": self.main_memory_path.exists(),
|
||||
"size": 0,
|
||||
"lines": 0,
|
||||
"last_modified": None
|
||||
},
|
||||
"task_memory": {
|
||||
"exists": self.task_memory_path.exists(),
|
||||
"size": 0,
|
||||
"lines": 0,
|
||||
"last_modified": None
|
||||
}
|
||||
}
|
||||
|
||||
# 主记忆统计
|
||||
if stats["main_memory"]["exists"]:
|
||||
stat = self.main_memory_path.stat()
|
||||
content = self.read_main_memory()
|
||||
stats["main_memory"]["size"] = stat.st_size
|
||||
stats["main_memory"]["lines"] = len(content.split('\n'))
|
||||
stats["main_memory"]["last_modified"] = datetime.fromtimestamp(
|
||||
stat.st_mtime
|
||||
).isoformat()
|
||||
|
||||
# 任务记忆统计
|
||||
if stats["task_memory"]["exists"]:
|
||||
stat = self.task_memory_path.stat()
|
||||
content = self.read_task_memory()
|
||||
stats["task_memory"]["size"] = stat.st_size
|
||||
stats["task_memory"]["lines"] = len(content.split('\n'))
|
||||
stats["task_memory"]["last_modified"] = datetime.fromtimestamp(
|
||||
stat.st_mtime
|
||||
).isoformat()
|
||||
|
||||
return stats
|
||||
|
||||
def merge_memories(self) -> bool:
|
||||
"""合并任务记忆到主记忆"""
|
||||
try:
|
||||
task_content = self.read_task_memory()
|
||||
|
||||
if not task_content.strip():
|
||||
print(f"{OUTPUT_FORMATS['warning']} 任务记忆为空,无需合并")
|
||||
return True
|
||||
|
||||
# 追加到主记忆
|
||||
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
merge_content = f"\n## 任务记忆合并 - {timestamp}\n{task_content}\n"
|
||||
|
||||
success = self.append_main_memory(merge_content, "历史任务记录")
|
||||
|
||||
if success:
|
||||
# 清空任务记忆
|
||||
self.clear_task_memory()
|
||||
print(f"{OUTPUT_FORMATS['success']} 记忆合并完成")
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
print(f"{OUTPUT_FORMATS['error']} 合并记忆失败: {e}")
|
||||
return False
|
||||
678
sub_agent/modules/persistent_terminal.py
Normal file
678
sub_agent/modules/persistent_terminal.py
Normal file
@ -0,0 +1,678 @@
|
||||
# modules/persistent_terminal.py - 持久化终端实例(修复版)
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional, Callable, Dict, List
|
||||
from datetime import datetime
|
||||
import threading
|
||||
import queue
|
||||
from collections import deque
|
||||
try:
|
||||
from config import OUTPUT_FORMATS, TERMINAL_OUTPUT_WAIT, TERMINAL_INPUT_MAX_CHARS
|
||||
except ImportError:
|
||||
import sys
|
||||
from pathlib import Path
|
||||
project_root = Path(__file__).resolve().parents[1]
|
||||
if str(project_root) not in sys.path:
|
||||
sys.path.insert(0, str(project_root))
|
||||
from config import OUTPUT_FORMATS, TERMINAL_OUTPUT_WAIT, TERMINAL_INPUT_MAX_CHARS
|
||||
|
||||
class PersistentTerminal:
|
||||
"""单个持久化终端实例"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_name: str,
|
||||
working_dir: str = None,
|
||||
shell_command: str = None,
|
||||
broadcast_callback: Callable = None,
|
||||
max_buffer_size: int = 20000,
|
||||
display_size: int = 5000
|
||||
):
|
||||
"""
|
||||
初始化持久化终端
|
||||
|
||||
Args:
|
||||
session_name: 会话名称
|
||||
working_dir: 工作目录
|
||||
shell_command: shell命令(None则自动选择)
|
||||
broadcast_callback: 广播回调函数(用于WebSocket)
|
||||
max_buffer_size: 最大缓冲区大小
|
||||
display_size: 显示大小限制
|
||||
"""
|
||||
self.session_name = session_name
|
||||
self.working_dir = Path(working_dir) if working_dir else Path.cwd()
|
||||
self.shell_command = shell_command
|
||||
self.broadcast = broadcast_callback
|
||||
self.max_buffer_size = max_buffer_size
|
||||
self.display_size = display_size
|
||||
|
||||
# 进程相关
|
||||
self.process = None
|
||||
self.is_running = False
|
||||
self.start_time = None
|
||||
|
||||
# 输出缓冲
|
||||
self.output_buffer = []
|
||||
self.command_history = []
|
||||
self.total_output_size = 0
|
||||
self.truncated_lines = 0
|
||||
self.output_history = deque()
|
||||
self._output_event_counter = 0
|
||||
self.last_output_time = None
|
||||
self.last_input_time = None
|
||||
self.last_input_text = ""
|
||||
self.echo_loop_detected = False
|
||||
self._consecutive_echo_matches = 0
|
||||
self.io_history = deque()
|
||||
self._io_history_max = 4000
|
||||
|
||||
# 线程和队列
|
||||
self.output_queue = queue.Queue()
|
||||
self.reader_thread = None
|
||||
self.is_reading = False
|
||||
|
||||
# 状态标志
|
||||
self.is_interactive = False # 是否在等待输入
|
||||
self.last_command = ""
|
||||
self.last_activity = time.time()
|
||||
|
||||
# 系统特定设置
|
||||
self.is_windows = sys.platform == "win32"
|
||||
|
||||
def start(self) -> bool:
|
||||
"""启动终端进程(统一处理编码)"""
|
||||
if self.is_running:
|
||||
return False
|
||||
|
||||
try:
|
||||
# 确定使用的shell
|
||||
if self.is_windows:
|
||||
# Windows下使用CMD
|
||||
self.shell_command = self.shell_command or "cmd.exe"
|
||||
else:
|
||||
# Unix系统
|
||||
self.shell_command = self.shell_command or os.environ.get('SHELL', '/bin/bash')
|
||||
|
||||
# 设置环境变量
|
||||
env = os.environ.copy()
|
||||
env['PYTHONIOENCODING'] = 'utf-8'
|
||||
|
||||
if self.is_windows:
|
||||
# Windows特殊设置
|
||||
env['CHCP'] = '65001' # UTF-8代码页
|
||||
|
||||
# Windows统一不使用text模式,手动处理编码
|
||||
self.process = subprocess.Popen(
|
||||
self.shell_command,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
cwd=str(self.working_dir),
|
||||
shell=False,
|
||||
bufsize=0, # 无缓冲
|
||||
env=env
|
||||
)
|
||||
else:
|
||||
# Unix系统
|
||||
env['TERM'] = 'xterm-256color'
|
||||
env['LANG'] = 'en_US.UTF-8'
|
||||
env['LC_ALL'] = 'en_US.UTF-8'
|
||||
|
||||
# Unix也不使用text模式,统一处理
|
||||
self.process = subprocess.Popen(
|
||||
self.shell_command,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
cwd=str(self.working_dir),
|
||||
shell=False,
|
||||
bufsize=0,
|
||||
env=env
|
||||
)
|
||||
|
||||
self.is_running = True
|
||||
self.start_time = datetime.now()
|
||||
self.last_output_time = None
|
||||
self.last_input_time = None
|
||||
self.last_input_text = ""
|
||||
self.echo_loop_detected = False
|
||||
self._consecutive_echo_matches = 0
|
||||
|
||||
# 启动输出读取线程
|
||||
self.is_reading = True
|
||||
self.reader_thread = threading.Thread(target=self._read_output)
|
||||
self.reader_thread.daemon = True
|
||||
self.reader_thread.start()
|
||||
|
||||
# 如果是Windows,设置代码页
|
||||
if self.is_windows:
|
||||
time.sleep(0.5) # 等待终端初始化
|
||||
self.send_command("chcp 65001", wait_for_output=False)
|
||||
time.sleep(0.5)
|
||||
# 清屏以去除代码页设置的输出
|
||||
self.send_command("cls", wait_for_output=False)
|
||||
time.sleep(0.3)
|
||||
self.output_buffer.clear() # 清除初始化输出
|
||||
self.total_output_size = 0
|
||||
|
||||
# 广播终端启动事件
|
||||
if self.broadcast:
|
||||
self.broadcast('terminal_started', {
|
||||
'session': self.session_name,
|
||||
'working_dir': str(self.working_dir),
|
||||
'shell': self.shell_command,
|
||||
'time': self.start_time.isoformat()
|
||||
})
|
||||
|
||||
print(f"{OUTPUT_FORMATS['success']} 终端会话启动: {self.session_name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"{OUTPUT_FORMATS['error']} 终端启动失败: {e}")
|
||||
self.is_running = False
|
||||
return False
|
||||
|
||||
def _read_output(self):
|
||||
"""后台线程:持续读取输出(修复版,正确处理编码)"""
|
||||
while self.is_reading and self.process:
|
||||
try:
|
||||
# 始终读取字节(因为我们没有使用text=True)
|
||||
line_bytes = self.process.stdout.readline()
|
||||
|
||||
if line_bytes:
|
||||
# 解码字节到字符串
|
||||
line = self._decode_output(line_bytes)
|
||||
|
||||
# 处理输出
|
||||
self.output_queue.put(line)
|
||||
self._process_output(line)
|
||||
|
||||
elif self.process.poll() is not None:
|
||||
# 进程已结束
|
||||
self.is_running = False
|
||||
break
|
||||
else:
|
||||
# 没有输出,短暂休眠
|
||||
time.sleep(0.01)
|
||||
|
||||
except Exception as e:
|
||||
# 不要因为单个错误而停止
|
||||
print(f"[Terminal] 读取输出警告: {e}")
|
||||
time.sleep(0.01)
|
||||
continue
|
||||
|
||||
def _decode_output(self, data):
|
||||
"""安全地解码输出"""
|
||||
# 如果已经是字符串,直接返回
|
||||
if isinstance(data, str):
|
||||
return data
|
||||
|
||||
# 如果是字节,尝试解码
|
||||
if isinstance(data, bytes):
|
||||
# Windows系统尝试的编码顺序
|
||||
if self.is_windows:
|
||||
encodings = ['utf-8', 'gbk', 'gb2312', 'cp936', 'latin-1']
|
||||
else:
|
||||
encodings = ['utf-8', 'latin-1']
|
||||
|
||||
for encoding in encodings:
|
||||
try:
|
||||
return data.decode(encoding)
|
||||
except (UnicodeDecodeError, AttributeError):
|
||||
continue
|
||||
|
||||
# 如果所有编码都失败,使用替换模式
|
||||
return data.decode('utf-8', errors='replace')
|
||||
|
||||
# 其他类型,转换为字符串
|
||||
return str(data)
|
||||
|
||||
def _process_output(self, output: str):
|
||||
"""处理输出行"""
|
||||
# 添加到缓冲区
|
||||
self.output_buffer.append(output)
|
||||
self.total_output_size += len(output)
|
||||
now = time.time()
|
||||
self.last_output_time = now
|
||||
|
||||
# 记录输出事件
|
||||
self._output_event_counter += 1
|
||||
self.output_history.append((self._output_event_counter, now, output))
|
||||
self._append_io_event('output', output, timestamp=now)
|
||||
|
||||
# 控制输出历史长度
|
||||
if len(self.output_history) > 2000:
|
||||
self.output_history.popleft()
|
||||
|
||||
# 检查是否需要截断
|
||||
if self.total_output_size > self.max_buffer_size:
|
||||
self._truncate_buffer()
|
||||
|
||||
# 更新活动时间
|
||||
self.last_activity = now
|
||||
|
||||
# 检测命令回显死循环
|
||||
cleaned_output = output.replace('\r', '').strip()
|
||||
cleaned_input = self.last_input_text.strip() if self.last_input_text else ""
|
||||
if cleaned_output and cleaned_input and cleaned_output == cleaned_input:
|
||||
self._consecutive_echo_matches += 1
|
||||
else:
|
||||
self._consecutive_echo_matches = 0
|
||||
if cleaned_output:
|
||||
self.echo_loop_detected = False
|
||||
if self._consecutive_echo_matches >= 1 and self.last_input_time:
|
||||
if now - self.last_input_time <= 2:
|
||||
self.echo_loop_detected = True
|
||||
|
||||
# 检测交互式提示
|
||||
self._detect_interactive_prompt(output)
|
||||
|
||||
# 广播输出
|
||||
if self.broadcast:
|
||||
self.broadcast('terminal_output', {
|
||||
'session': self.session_name,
|
||||
'data': output,
|
||||
'timestamp': time.time()
|
||||
})
|
||||
|
||||
def _truncate_buffer(self):
|
||||
"""截断缓冲区以保持在限制内"""
|
||||
# 保留最后的N个字符
|
||||
while self.total_output_size > self.max_buffer_size and self.output_buffer:
|
||||
removed = self.output_buffer.pop(0)
|
||||
self.total_output_size -= len(removed)
|
||||
self.truncated_lines += 1
|
||||
if self.output_history:
|
||||
self.output_history.popleft()
|
||||
|
||||
def _detect_interactive_prompt(self, output: str):
|
||||
"""检测是否在等待交互输入"""
|
||||
self.is_interactive = False
|
||||
# 常见的交互提示模式
|
||||
interactive_patterns = [
|
||||
"? ", # 问题提示
|
||||
": ", # 输入提示
|
||||
"> ", # 命令提示
|
||||
"$ ", # shell提示
|
||||
"# ", # root提示
|
||||
">>> ", # Python提示
|
||||
"... ", # Python续行
|
||||
"(y/n)", # 确认提示
|
||||
"[Y/n]", # 确认提示
|
||||
"Password:", # 密码提示
|
||||
"password:", # 密码提示
|
||||
"Enter", # 输入提示
|
||||
"选择", # 中文选择
|
||||
"请输入", # 中文输入
|
||||
]
|
||||
|
||||
output_lower = output.lower().strip()
|
||||
for pattern in interactive_patterns:
|
||||
if pattern.lower() in output_lower:
|
||||
self.is_interactive = True
|
||||
return
|
||||
|
||||
# 如果输出以常见提示符结尾且没有换行,也认为是交互式
|
||||
if output and not output.endswith('\n'):
|
||||
last_chars = output.strip()[-3:]
|
||||
if last_chars in ['> ', '$ ', '# ', ': ']:
|
||||
self.is_interactive = True
|
||||
|
||||
def _capture_history_marker(self) -> int:
|
||||
return self._output_event_counter
|
||||
|
||||
def _get_output_since_marker(self, marker: int) -> str:
|
||||
if marker is None:
|
||||
return ''.join(item[2] for item in self.output_history)
|
||||
return ''.join(item[2] for item in self.output_history if item[0] > marker)
|
||||
|
||||
def _append_io_event(self, event_type: str, data: str, timestamp: Optional[float] = None):
|
||||
"""记录终端输入输出事件"""
|
||||
if timestamp is None:
|
||||
timestamp = time.time()
|
||||
self.io_history.append((event_type, timestamp, data))
|
||||
while len(self.io_history) > self._io_history_max:
|
||||
self.io_history.popleft()
|
||||
|
||||
def _seconds_since_last_output(self) -> Optional[float]:
|
||||
if not self.last_output_time:
|
||||
return None
|
||||
return round(time.time() - self.last_output_time, 3)
|
||||
|
||||
def send_command(self, command: str, wait_for_output: bool = True, timeout: float = None) -> Dict:
|
||||
"""发送命令到终端(统一编码处理)"""
|
||||
if not self.is_running or not self.process:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "终端未运行",
|
||||
"session": self.session_name
|
||||
}
|
||||
|
||||
try:
|
||||
marker = self._capture_history_marker()
|
||||
if timeout is None:
|
||||
timeout = TERMINAL_OUTPUT_WAIT
|
||||
else:
|
||||
try:
|
||||
timeout = float(timeout)
|
||||
except (TypeError, ValueError):
|
||||
timeout = TERMINAL_OUTPUT_WAIT
|
||||
if timeout < 0:
|
||||
timeout = 0
|
||||
start_time = time.time()
|
||||
command_text = command.rstrip('\n')
|
||||
# 记录命令
|
||||
self.command_history.append({
|
||||
"command": command_text,
|
||||
"timestamp": datetime.now().isoformat()
|
||||
})
|
||||
self.last_command = command_text
|
||||
self.is_interactive = False
|
||||
self.last_input_text = command_text
|
||||
self.last_input_time = time.time()
|
||||
self.echo_loop_detected = False
|
||||
self._consecutive_echo_matches = 0
|
||||
self._append_io_event('input', command_text + '\n', timestamp=self.last_input_time)
|
||||
|
||||
# 广播输入事件
|
||||
if self.broadcast:
|
||||
self.broadcast('terminal_input', {
|
||||
'session': self.session_name,
|
||||
'data': command_text + '\n',
|
||||
'timestamp': time.time()
|
||||
})
|
||||
|
||||
# 确保命令有换行符
|
||||
to_send = command if command.endswith('\n') else command + '\n'
|
||||
|
||||
# 发送命令(统一使用UTF-8编码)
|
||||
try:
|
||||
# 首先尝试UTF-8
|
||||
command_bytes = to_send.encode('utf-8')
|
||||
except UnicodeEncodeError:
|
||||
# 如果UTF-8失败,Windows系统尝试GBK
|
||||
if self.is_windows:
|
||||
command_bytes = to_send.encode('gbk', errors='replace')
|
||||
else:
|
||||
command_bytes = to_send.encode('utf-8', errors='replace')
|
||||
|
||||
self.process.stdin.write(command_bytes)
|
||||
self.process.stdin.flush()
|
||||
|
||||
# 如果需要等待输出
|
||||
if wait_for_output:
|
||||
output = self._wait_for_output(timeout=timeout)
|
||||
recent_output = self._get_output_since_marker(marker)
|
||||
if recent_output:
|
||||
output = recent_output
|
||||
output_truncated = False
|
||||
if len(output) > TERMINAL_INPUT_MAX_CHARS:
|
||||
output = output[-TERMINAL_INPUT_MAX_CHARS:]
|
||||
output_truncated = True
|
||||
output_clean = output.strip()
|
||||
has_output = bool(output_clean)
|
||||
status = "completed"
|
||||
if not has_output:
|
||||
if self.echo_loop_detected:
|
||||
status = "echo_loop"
|
||||
elif self.is_interactive:
|
||||
status = "awaiting_input"
|
||||
else:
|
||||
status = "no_output"
|
||||
else:
|
||||
if self.echo_loop_detected:
|
||||
status = "output_with_echo"
|
||||
message_map = {
|
||||
"completed": "命令执行完成,已捕获终端输出",
|
||||
"no_output": "未捕获任何输出,命令可能未产生可见结果或终端已卡死需要重制",
|
||||
"awaiting_input": "命令已发送,终端正在等待进一步输入或进程仍在运行",
|
||||
"echo_loop": "检测到终端正在回显输入,命令可能未成功执行",
|
||||
"output_with_echo": "命令产生输出,但终端疑似重复回显,请检查是否卡住"
|
||||
}
|
||||
message = message_map.get(status, "命令执行完成")
|
||||
if output_truncated:
|
||||
message += f"(输出已截断,保留末尾{TERMINAL_INPUT_MAX_CHARS}字符)"
|
||||
return {
|
||||
"success": True,
|
||||
"session": self.session_name,
|
||||
"command": command_text,
|
||||
"output": output,
|
||||
"message": message,
|
||||
"duration": round(time.time() - start_time, 3),
|
||||
"pending_output": status in ("no_output", "awaiting_input", "echo_loop"),
|
||||
"timeout_used": timeout,
|
||||
"status": status,
|
||||
"is_interactive": self.is_interactive,
|
||||
"echo_loop_detected": self.echo_loop_detected,
|
||||
"seconds_since_last_output": self._seconds_since_last_output(),
|
||||
"output_char_count": len(output),
|
||||
"last_output_time": self.last_output_time,
|
||||
"output_truncated": output_truncated,
|
||||
"output_char_limit": TERMINAL_INPUT_MAX_CHARS
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": True,
|
||||
"session": self.session_name,
|
||||
"command": command_text,
|
||||
"output": "命令已发送",
|
||||
"message": "命令已发送至终端,后续输出将实时流式返回",
|
||||
"duration": round(time.time() - start_time, 3),
|
||||
"pending_output": True,
|
||||
"timeout_used": timeout,
|
||||
"status": "pending",
|
||||
"is_interactive": self.is_interactive,
|
||||
"echo_loop_detected": self.echo_loop_detected,
|
||||
"seconds_since_last_output": self._seconds_since_last_output(),
|
||||
"output_char_count": 0,
|
||||
"last_output_time": self.last_output_time,
|
||||
"output_truncated": False,
|
||||
"output_char_limit": TERMINAL_INPUT_MAX_CHARS
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"发送命令失败: {str(e)}"
|
||||
print(f"{OUTPUT_FORMATS['error']} {error_msg}")
|
||||
return {
|
||||
"success": False,
|
||||
"error": error_msg,
|
||||
"session": self.session_name
|
||||
}
|
||||
|
||||
def _wait_for_output(self, timeout: float = 5) -> str:
|
||||
"""等待并收集输出"""
|
||||
collected_output = []
|
||||
start_time = time.time()
|
||||
last_output_time = time.time()
|
||||
|
||||
if timeout is None or timeout <= 0:
|
||||
timeout = 0
|
||||
|
||||
if timeout == 0:
|
||||
try:
|
||||
while True:
|
||||
output = self.output_queue.get_nowait()
|
||||
collected_output.append(output)
|
||||
except queue.Empty:
|
||||
return ''.join(collected_output)
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
try:
|
||||
remaining = max(0.05, min(0.5, timeout - (time.time() - start_time)))
|
||||
output = self.output_queue.get(timeout=remaining)
|
||||
collected_output.append(output)
|
||||
last_output_time = time.time()
|
||||
# 快速收集剩余输出,直到短暂空闲
|
||||
while True:
|
||||
try:
|
||||
output = self.output_queue.get(timeout=0.1)
|
||||
collected_output.append(output)
|
||||
last_output_time = time.time()
|
||||
except queue.Empty:
|
||||
break
|
||||
except queue.Empty:
|
||||
if collected_output and time.time() - last_output_time > 0.3:
|
||||
break
|
||||
if timeout == 0:
|
||||
break
|
||||
|
||||
return ''.join(collected_output)
|
||||
|
||||
def get_output(self, last_n_lines: int = 50) -> str:
|
||||
"""
|
||||
获取终端输出
|
||||
|
||||
Args:
|
||||
last_n_lines: 获取最后N行
|
||||
|
||||
Returns:
|
||||
输出内容
|
||||
"""
|
||||
if last_n_lines <= 0:
|
||||
return ''.join(self.output_buffer)
|
||||
|
||||
# 获取最后N行
|
||||
lines = []
|
||||
for line in reversed(self.output_buffer):
|
||||
lines.insert(0, line)
|
||||
if len(lines) >= last_n_lines:
|
||||
break
|
||||
|
||||
return ''.join(lines)
|
||||
|
||||
def get_display_output(self) -> str:
|
||||
"""获取用于显示的输出(截断到display_size)"""
|
||||
output = self.get_output()
|
||||
if len(output) > self.display_size:
|
||||
# 保留最后的display_size字符
|
||||
output = output[-self.display_size:]
|
||||
output = f"[输出已截断,显示最后{self.display_size}字符]\n{output}"
|
||||
return output
|
||||
|
||||
def get_snapshot(self, last_n_lines: int, max_chars: int) -> Dict:
|
||||
"""获取终端快照,包含按顺序排列的输入/输出"""
|
||||
if last_n_lines <= 0:
|
||||
last_n_lines = 1
|
||||
|
||||
combined_lines: List[str] = []
|
||||
for event_type, _, data in self.io_history:
|
||||
if event_type == 'input':
|
||||
# 显示输入命令,保持与终端监控一致
|
||||
combined_lines.append(f"➜ {data.rstrip()}" if data.strip() else "➜")
|
||||
else:
|
||||
cleaned = data.replace('\r', '')
|
||||
# 按行拆分输出,保留空行
|
||||
segments = cleaned.splitlines()
|
||||
if cleaned.endswith('\n'):
|
||||
segments.append('')
|
||||
combined_lines.extend(segments if segments else [''])
|
||||
|
||||
if combined_lines:
|
||||
selected_lines = combined_lines[-last_n_lines:]
|
||||
output_text = '\n'.join(selected_lines)
|
||||
else:
|
||||
selected_lines = []
|
||||
output_text = ''
|
||||
|
||||
truncated = False
|
||||
if len(output_text) > max_chars:
|
||||
output_text = output_text[-max_chars:]
|
||||
truncated = True
|
||||
|
||||
# 统计行数
|
||||
if output_text:
|
||||
lines_returned = output_text.count('\n') + (0 if output_text.endswith('\n') else 1)
|
||||
else:
|
||||
lines_returned = 0
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"session": self.session_name,
|
||||
"output": output_text,
|
||||
"lines_requested": last_n_lines,
|
||||
"lines_returned": lines_returned,
|
||||
"truncated": truncated,
|
||||
"is_interactive": self.is_interactive,
|
||||
"echo_loop_detected": self.echo_loop_detected,
|
||||
"seconds_since_last_output": self._seconds_since_last_output(),
|
||||
"last_command": self.last_command,
|
||||
"buffer_size": self.total_output_size,
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
def get_status(self) -> Dict:
|
||||
"""获取终端状态"""
|
||||
return {
|
||||
"session_name": self.session_name,
|
||||
"is_running": self.is_running,
|
||||
"working_dir": str(self.working_dir),
|
||||
"shell": self.shell_command,
|
||||
"start_time": self.start_time.isoformat() if self.start_time else None,
|
||||
"is_interactive": self.is_interactive,
|
||||
"last_command": self.last_command,
|
||||
"command_count": len(self.command_history),
|
||||
"buffer_size": self.total_output_size,
|
||||
"truncated_lines": self.truncated_lines,
|
||||
"last_activity": datetime.fromtimestamp(self.last_activity).isoformat(),
|
||||
"uptime_seconds": (datetime.now() - self.start_time).total_seconds() if self.start_time else 0,
|
||||
"seconds_since_last_output": self._seconds_since_last_output(),
|
||||
"echo_loop_detected": self.echo_loop_detected
|
||||
}
|
||||
|
||||
def close(self) -> bool:
|
||||
"""关闭终端"""
|
||||
if not self.is_running:
|
||||
return False
|
||||
|
||||
try:
|
||||
# 停止读取线程
|
||||
self.is_reading = False
|
||||
|
||||
# 发送退出命令
|
||||
if self.process and self.process.poll() is None:
|
||||
exit_cmd = "exit\n"
|
||||
try:
|
||||
self.process.stdin.write(exit_cmd.encode('utf-8'))
|
||||
self.process.stdin.flush()
|
||||
except:
|
||||
pass
|
||||
|
||||
# 等待进程结束
|
||||
try:
|
||||
self.process.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
# 强制终止
|
||||
self.process.terminate()
|
||||
time.sleep(0.5)
|
||||
if self.process.poll() is None:
|
||||
self.process.kill()
|
||||
|
||||
self.is_running = False
|
||||
|
||||
# 等待读取线程结束
|
||||
if self.reader_thread and self.reader_thread.is_alive():
|
||||
self.reader_thread.join(timeout=1)
|
||||
|
||||
# 广播终端关闭事件
|
||||
if self.broadcast:
|
||||
self.broadcast('terminal_closed', {
|
||||
'session': self.session_name,
|
||||
'time': datetime.now().isoformat()
|
||||
})
|
||||
|
||||
print(f"{OUTPUT_FORMATS['info']} 终端会话关闭: {self.session_name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"{OUTPUT_FORMATS['error']} 关闭终端失败: {e}")
|
||||
return False
|
||||
|
||||
def __del__(self):
|
||||
"""析构函数,确保进程被关闭"""
|
||||
if hasattr(self, 'is_running') and self.is_running:
|
||||
self.close()
|
||||
492
sub_agent/modules/search_engine.py
Normal file
492
sub_agent/modules/search_engine.py
Normal file
@ -0,0 +1,492 @@
|
||||
# modules/search_engine.py - 网络搜索模块
|
||||
|
||||
import httpx
|
||||
import json
|
||||
from typing import Dict, Optional, Any
|
||||
from datetime import datetime
|
||||
import re
|
||||
try:
|
||||
from config import TAVILY_API_KEY, SEARCH_MAX_RESULTS, OUTPUT_FORMATS
|
||||
except ImportError:
|
||||
import sys
|
||||
from pathlib import Path
|
||||
project_root = Path(__file__).resolve().parents[1]
|
||||
if str(project_root) not in sys.path:
|
||||
sys.path.insert(0, str(project_root))
|
||||
from config import TAVILY_API_KEY, SEARCH_MAX_RESULTS, OUTPUT_FORMATS
|
||||
|
||||
class SearchEngine:
|
||||
def __init__(self):
|
||||
self.api_key = TAVILY_API_KEY
|
||||
self.api_url = "https://api.tavily.com/search"
|
||||
|
||||
self._valid_topics = {"general", "news", "finance"}
|
||||
self._valid_time_ranges = {
|
||||
"day": "day",
|
||||
"d": "day",
|
||||
"week": "week",
|
||||
"w": "week",
|
||||
"month": "month",
|
||||
"m": "month",
|
||||
"year": "year",
|
||||
"y": "year"
|
||||
}
|
||||
self._date_pattern = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||||
|
||||
async def search(
|
||||
self,
|
||||
query: str,
|
||||
max_results: Optional[int] = None,
|
||||
topic: Optional[str] = None,
|
||||
time_range: Optional[str] = None,
|
||||
days: Optional[int] = None,
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
country: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
执行网络搜索
|
||||
|
||||
Args:
|
||||
query: 搜索关键词
|
||||
max_results: 最大结果数
|
||||
topic: 搜索类型(general/news/finance)
|
||||
time_range: 相对时间范围(day/week/month/year 或 d/w/m/y)
|
||||
days: 过去N天,仅topic=news可用
|
||||
start_date: 起始日期,格式YYYY-MM-DD
|
||||
end_date: 结束日期,格式YYYY-MM-DD
|
||||
country: 国家过滤,仅topic=general可用
|
||||
|
||||
Returns:
|
||||
搜索结果字典
|
||||
"""
|
||||
if not self.api_key or self.api_key == "your-tavily-api-key":
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Tavily API密钥未配置",
|
||||
"results": []
|
||||
}
|
||||
|
||||
validation = self._build_payload(
|
||||
query=query,
|
||||
max_results=max_results,
|
||||
topic=topic,
|
||||
time_range=time_range,
|
||||
days=days,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
country=country
|
||||
)
|
||||
|
||||
if not validation["success"]:
|
||||
return validation
|
||||
|
||||
payload = validation["payload"]
|
||||
applied_filters = validation["filters"]
|
||||
|
||||
max_results = payload.get("max_results", SEARCH_MAX_RESULTS)
|
||||
|
||||
print(f"{OUTPUT_FORMATS['search']} 搜索: {query}")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(
|
||||
self.api_url,
|
||||
json={
|
||||
**payload
|
||||
},
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"API请求失败: {response.status_code}",
|
||||
"results": []
|
||||
}
|
||||
|
||||
data = response.json()
|
||||
|
||||
# 格式化结果
|
||||
formatted_results = self._format_results(data, applied_filters)
|
||||
|
||||
print(f"{OUTPUT_FORMATS['success']} 搜索完成,找到 {len(formatted_results['results'])} 条结果")
|
||||
|
||||
return formatted_results
|
||||
|
||||
except httpx.TimeoutException:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "搜索超时",
|
||||
"results": []
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"搜索失败: {str(e)}",
|
||||
"results": []
|
||||
}
|
||||
|
||||
def _format_results(self, raw_data: Dict, filters: Dict[str, Any]) -> Dict:
|
||||
"""格式化搜索结果"""
|
||||
formatted = {
|
||||
"success": True,
|
||||
"query": raw_data.get("query", ""),
|
||||
"answer": raw_data.get("answer", ""),
|
||||
"results": [],
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"filters": filters,
|
||||
"total_results": len(raw_data.get("results", []))
|
||||
}
|
||||
|
||||
# 处理每个搜索结果
|
||||
for idx, result in enumerate(raw_data.get("results", []), 1):
|
||||
formatted_result = {
|
||||
"index": idx,
|
||||
"title": result.get("title", "无标题"),
|
||||
"url": result.get("url", ""),
|
||||
"content": result.get("content", ""),
|
||||
"score": result.get("score", 0),
|
||||
"published_date": result.get("published_date", "")
|
||||
}
|
||||
formatted["results"].append(formatted_result)
|
||||
|
||||
return formatted
|
||||
|
||||
async def search_with_summary(
|
||||
self,
|
||||
query: str,
|
||||
max_results: Optional[int] = None,
|
||||
topic: Optional[str] = None,
|
||||
time_range: Optional[str] = None,
|
||||
days: Optional[int] = None,
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
country: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
搜索并返回格式化的摘要
|
||||
|
||||
Args:
|
||||
query: 搜索关键词
|
||||
max_results: 最大结果数
|
||||
|
||||
Returns:
|
||||
格式化的搜索摘要字符串
|
||||
"""
|
||||
results = await self.search(
|
||||
query=query,
|
||||
max_results=max_results,
|
||||
topic=topic,
|
||||
time_range=time_range,
|
||||
days=days,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
country=country
|
||||
)
|
||||
|
||||
if not results["success"]:
|
||||
return {
|
||||
"success": False,
|
||||
"error": results.get("error", "未知错误"),
|
||||
"summary": ""
|
||||
}
|
||||
|
||||
# 构建摘要
|
||||
summary_lines = [
|
||||
f"🔍 搜索查询: {query}",
|
||||
f"📅 搜索时间: {results['timestamp']}"
|
||||
]
|
||||
|
||||
filter_notes = self._summarize_filters(results.get("filters", {}))
|
||||
if filter_notes:
|
||||
summary_lines.append(filter_notes)
|
||||
summary_lines.append("")
|
||||
|
||||
# 添加AI答案(如果有)
|
||||
if results.get("answer"):
|
||||
summary_lines.extend([
|
||||
"📝 AI摘要:",
|
||||
results["answer"],
|
||||
"",
|
||||
"---",
|
||||
""
|
||||
])
|
||||
|
||||
# 添加搜索结果
|
||||
if results["results"]:
|
||||
summary_lines.append("📊 搜索结果:")
|
||||
|
||||
for result in results["results"]:
|
||||
summary_lines.extend([
|
||||
f"\n{result['index']}. {result['title']}",
|
||||
f" 🔗 {result['url']}",
|
||||
f" 📄 {result['content'][:200]}..." if len(result['content']) > 200 else f" 📄 {result['content']}",
|
||||
])
|
||||
|
||||
if result.get("published_date"):
|
||||
summary_lines.append(f" 📅 发布时间: {result['published_date']}")
|
||||
else:
|
||||
summary_lines.append("未找到相关结果")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"summary": "\n".join(summary_lines),
|
||||
"filters": results.get("filters", {}),
|
||||
"query": results.get("query", query),
|
||||
"results": results.get("results", []),
|
||||
"total_results": results.get("total_results", len(results.get("results", [])))
|
||||
}
|
||||
|
||||
async def quick_answer(self, query: str) -> str:
|
||||
"""
|
||||
快速获取答案(只返回AI摘要)
|
||||
|
||||
Args:
|
||||
query: 查询问题
|
||||
|
||||
Returns:
|
||||
AI答案或错误信息
|
||||
"""
|
||||
results = await self.search(query, max_results=5)
|
||||
|
||||
if not results["success"]:
|
||||
return f"搜索失败: {results['error']}"
|
||||
|
||||
if results.get("answer"):
|
||||
return results["answer"]
|
||||
|
||||
# 如果没有AI答案,返回第一个结果的摘要
|
||||
if results["results"]:
|
||||
first_result = results["results"][0]
|
||||
return f"{first_result['title']}\n{first_result['content'][:300]}..."
|
||||
|
||||
return "未找到相关信息"
|
||||
|
||||
def save_results(self, results: Dict, filename: str = None) -> str:
|
||||
"""
|
||||
保存搜索结果到文件
|
||||
|
||||
Args:
|
||||
results: 搜索结果
|
||||
filename: 文件名(可选)
|
||||
|
||||
Returns:
|
||||
保存的文件路径
|
||||
"""
|
||||
if filename is None:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"search_{timestamp}.json"
|
||||
|
||||
file_path = f"./data/searches/{filename}"
|
||||
|
||||
# 确保目录存在
|
||||
import os
|
||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||
|
||||
# 保存结果
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(results, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"{OUTPUT_FORMATS['file']} 搜索结果已保存到: {file_path}")
|
||||
|
||||
return file_path
|
||||
|
||||
def load_results(self, filename: str) -> Optional[Dict]:
|
||||
"""
|
||||
加载之前的搜索结果
|
||||
|
||||
Args:
|
||||
filename: 文件名
|
||||
|
||||
Returns:
|
||||
搜索结果字典或None
|
||||
"""
|
||||
file_path = f"./data/searches/{filename}"
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except FileNotFoundError:
|
||||
print(f"{OUTPUT_FORMATS['error']} 文件不存在: {file_path}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"{OUTPUT_FORMATS['error']} 加载失败: {e}")
|
||||
return None
|
||||
|
||||
def _build_payload(
|
||||
self,
|
||||
query: str,
|
||||
max_results: Optional[int],
|
||||
topic: Optional[str],
|
||||
time_range: Optional[str],
|
||||
days: Optional[int],
|
||||
start_date: Optional[str],
|
||||
end_date: Optional[str],
|
||||
country: Optional[str]
|
||||
) -> Dict[str, Any]:
|
||||
"""验证并构建 Tavily 请求参数"""
|
||||
payload: Dict[str, Any] = {
|
||||
"query": query,
|
||||
"search_depth": "advanced",
|
||||
"include_answer": True,
|
||||
"include_images": False,
|
||||
"include_raw_content": False
|
||||
}
|
||||
|
||||
filters: Dict[str, Any] = {}
|
||||
|
||||
if max_results:
|
||||
payload["max_results"] = max_results
|
||||
else:
|
||||
payload["max_results"] = SEARCH_MAX_RESULTS
|
||||
|
||||
normalized_topic = (topic or "general").strip().lower()
|
||||
if not normalized_topic:
|
||||
normalized_topic = "general"
|
||||
if normalized_topic not in self._valid_topics:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"无效的topic: {topic}. 可选值: {', '.join(self._valid_topics)}",
|
||||
"results": []
|
||||
}
|
||||
payload["topic"] = normalized_topic
|
||||
filters["topic"] = normalized_topic
|
||||
|
||||
# 时间参数互斥检查
|
||||
has_time_range = bool(time_range)
|
||||
has_days = days is not None
|
||||
has_date_range = bool(start_date or end_date)
|
||||
selected_filters = sum([has_time_range, has_days, has_date_range])
|
||||
if selected_filters > 1:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "时间参数只能三选一:time_range、days、start_date+end_date 不能同时使用",
|
||||
"results": []
|
||||
}
|
||||
|
||||
# 验证 days
|
||||
if has_days:
|
||||
try:
|
||||
days_value = int(days) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"days 必须是正整数,当前值: {days}",
|
||||
"results": []
|
||||
}
|
||||
if days_value <= 0:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"days 必须大于0,当前值: {days_value}",
|
||||
"results": []
|
||||
}
|
||||
if normalized_topic != "news":
|
||||
return {
|
||||
"success": False,
|
||||
"error": "days 参数仅在 topic=\"news\" 时可用,请调整 topic 或改用其他时间参数",
|
||||
"results": []
|
||||
}
|
||||
payload["days"] = days_value
|
||||
filters["days"] = days_value
|
||||
|
||||
# 验证 time_range
|
||||
if has_time_range:
|
||||
normalized_range = time_range.strip().lower() # type: ignore[union-attr]
|
||||
normalized_range = self._valid_time_ranges.get(normalized_range, "")
|
||||
if not normalized_range:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"无效的time_range: {time_range}. 可选值: day/week/month/year 或缩写 d/w/m/y",
|
||||
"results": []
|
||||
}
|
||||
payload["time_range"] = normalized_range
|
||||
filters["time_range"] = normalized_range
|
||||
|
||||
# 验证日期范围
|
||||
if has_date_range:
|
||||
if not start_date or not end_date:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "start_date 与 end_date 必须同时提供且格式为 YYYY-MM-DD",
|
||||
"results": []
|
||||
}
|
||||
if not self._date_pattern.match(start_date):
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"start_date 格式无效: {start_date},请使用 YYYY-MM-DD",
|
||||
"results": []
|
||||
}
|
||||
if not self._date_pattern.match(end_date):
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"end_date 格式无效: {end_date},请使用 YYYY-MM-DD",
|
||||
"results": []
|
||||
}
|
||||
try:
|
||||
start_dt = datetime.fromisoformat(start_date)
|
||||
end_dt = datetime.fromisoformat(end_date)
|
||||
except ValueError:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "start_date 或 end_date 含无效日期,请检查是否为有效的公历日期",
|
||||
"results": []
|
||||
}
|
||||
if start_dt > end_dt:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"start_date ({start_date}) 不能晚于 end_date ({end_date})",
|
||||
"results": []
|
||||
}
|
||||
payload["start_date"] = start_date
|
||||
payload["end_date"] = end_date
|
||||
filters["start_date"] = start_date
|
||||
filters["end_date"] = end_date
|
||||
|
||||
# 国家过滤
|
||||
if country:
|
||||
normalized_country = country.strip().lower()
|
||||
if normalized_country:
|
||||
if normalized_topic != "general":
|
||||
return {
|
||||
"success": False,
|
||||
"error": "country 参数仅在 topic=\"general\" 时可用,请调整 topic 或移除 country",
|
||||
"results": []
|
||||
}
|
||||
payload["country"] = normalized_country
|
||||
filters["country"] = normalized_country
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"payload": payload,
|
||||
"filters": filters,
|
||||
"results": []
|
||||
}
|
||||
|
||||
def _summarize_filters(self, filters: Dict[str, Any]) -> str:
|
||||
"""构建过滤条件摘要"""
|
||||
if not filters:
|
||||
return ""
|
||||
|
||||
parts = []
|
||||
topic = filters.get("topic")
|
||||
if topic:
|
||||
parts.append(f"Topic: {topic}")
|
||||
|
||||
if "time_range" in filters:
|
||||
parts.append(f"Time Range: {filters['time_range']}")
|
||||
elif "days" in filters:
|
||||
parts.append(f"最近 {filters['days']} 天")
|
||||
elif "start_date" in filters and "end_date" in filters:
|
||||
parts.append(f"{filters['start_date']} 至 {filters['end_date']}")
|
||||
|
||||
if "country" in filters:
|
||||
parts.append(f"Country: {filters['country']}")
|
||||
|
||||
if not parts:
|
||||
return ""
|
||||
|
||||
return "🎯 过滤条件: " + " | ".join(parts)
|
||||
443
sub_agent/modules/sub_agent_manager.py
Normal file
443
sub_agent/modules/sub_agent_manager.py
Normal file
@ -0,0 +1,443 @@
|
||||
"""子智能体任务管理。"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
import httpx
|
||||
|
||||
from config import (
|
||||
OUTPUT_FORMATS,
|
||||
SUB_AGENT_DEFAULT_TIMEOUT,
|
||||
SUB_AGENT_MAX_ACTIVE,
|
||||
SUB_AGENT_PROJECT_RESULTS_DIR,
|
||||
SUB_AGENT_SERVICE_BASE_URL,
|
||||
SUB_AGENT_STATE_FILE,
|
||||
SUB_AGENT_STATUS_POLL_INTERVAL,
|
||||
SUB_AGENT_TASKS_BASE_DIR,
|
||||
)
|
||||
from utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
TERMINAL_STATUSES = {"completed", "failed", "timeout"}
|
||||
|
||||
|
||||
class SubAgentManager:
|
||||
"""负责主智能体与子智能体服务之间的任务调度。"""
|
||||
|
||||
def __init__(self, project_path: str, data_dir: str):
|
||||
self.project_path = Path(project_path).resolve()
|
||||
self.data_dir = Path(data_dir).resolve()
|
||||
self.base_dir = Path(SUB_AGENT_TASKS_BASE_DIR).resolve()
|
||||
self.results_dir = Path(SUB_AGENT_PROJECT_RESULTS_DIR).resolve()
|
||||
self.state_file = Path(SUB_AGENT_STATE_FILE).resolve()
|
||||
|
||||
self.base_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.results_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.state_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.tasks: Dict[str, Dict] = {}
|
||||
self._load_state()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 公共方法
|
||||
# ------------------------------------------------------------------
|
||||
def create_sub_agent(
|
||||
self,
|
||||
*,
|
||||
agent_id: int,
|
||||
summary: str,
|
||||
task: str,
|
||||
target_dir: str,
|
||||
reference_files: Optional[List[str]] = None,
|
||||
timeout_seconds: Optional[int] = None,
|
||||
) -> Dict:
|
||||
"""创建子智能体任务并启动远端服务。"""
|
||||
reference_files = reference_files or []
|
||||
validation_error = self._validate_create_params(agent_id, summary, task, target_dir)
|
||||
if validation_error:
|
||||
return {"success": False, "error": validation_error}
|
||||
|
||||
if self._active_task_count() >= SUB_AGENT_MAX_ACTIVE:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"已有 {SUB_AGENT_MAX_ACTIVE} 个子智能体在运行,请稍后再试。",
|
||||
}
|
||||
|
||||
task_id = self._generate_task_id(agent_id)
|
||||
task_root = self.base_dir / task_id
|
||||
references_dir = task_root / "references"
|
||||
deliverables_dir = task_root / "deliverables"
|
||||
workspace_dir = task_root / "workspace"
|
||||
|
||||
for path in (task_root, references_dir, deliverables_dir, workspace_dir):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
copied_refs, copy_errors = self._copy_reference_files(reference_files, references_dir)
|
||||
if copy_errors:
|
||||
return {"success": False, "error": "; ".join(copy_errors)}
|
||||
|
||||
try:
|
||||
target_project_dir = self._ensure_project_subdir(target_dir)
|
||||
except ValueError as exc:
|
||||
return {"success": False, "error": str(exc)}
|
||||
|
||||
timeout_seconds = timeout_seconds or SUB_AGENT_DEFAULT_TIMEOUT
|
||||
payload = {
|
||||
"task_id": task_id,
|
||||
"agent_id": agent_id,
|
||||
"summary": summary,
|
||||
"task": task,
|
||||
"target_project_dir": str(target_project_dir),
|
||||
"workspace_dir": str(workspace_dir),
|
||||
"references_dir": str(references_dir),
|
||||
"deliverables_dir": str(deliverables_dir),
|
||||
"timeout_seconds": timeout_seconds,
|
||||
}
|
||||
|
||||
service_response = self._call_service("POST", "/tasks", payload, timeout_seconds + 5)
|
||||
if not service_response.get("success"):
|
||||
self._cleanup_task_folder(task_root)
|
||||
return {
|
||||
"success": False,
|
||||
"error": service_response.get("error", "子智能体服务调用失败"),
|
||||
"details": service_response,
|
||||
}
|
||||
|
||||
status = service_response.get("status", "pending")
|
||||
task_record = {
|
||||
"task_id": task_id,
|
||||
"agent_id": agent_id,
|
||||
"summary": summary,
|
||||
"task": task,
|
||||
"status": status,
|
||||
"target_project_dir": str(target_project_dir),
|
||||
"references_dir": str(references_dir),
|
||||
"deliverables_dir": str(deliverables_dir),
|
||||
"workspace_dir": str(workspace_dir),
|
||||
"copied_references": copied_refs,
|
||||
"timeout_seconds": timeout_seconds,
|
||||
"service_payload": payload,
|
||||
"created_at": time.time(),
|
||||
}
|
||||
self.tasks[task_id] = task_record
|
||||
self._save_state()
|
||||
|
||||
message = f"子智能体{agent_id} 已创建,任务ID: {task_id},当前状态:{status}"
|
||||
print(f"{OUTPUT_FORMATS['info']} {message}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"task_id": task_id,
|
||||
"agent_id": agent_id,
|
||||
"status": status,
|
||||
"message": message,
|
||||
"deliverables_dir": str(deliverables_dir),
|
||||
"copied_references": copied_refs,
|
||||
}
|
||||
|
||||
def wait_for_completion(
|
||||
self,
|
||||
*,
|
||||
task_id: Optional[str] = None,
|
||||
agent_id: Optional[int] = None,
|
||||
timeout_seconds: Optional[int] = None,
|
||||
) -> Dict:
|
||||
"""阻塞等待子智能体完成或超时。"""
|
||||
task = self._select_task(task_id, agent_id)
|
||||
if not task:
|
||||
return {"success": False, "error": "未找到对应的子智能体任务"}
|
||||
|
||||
if task.get("status") in TERMINAL_STATUSES and task.get("final_result"):
|
||||
return task["final_result"]
|
||||
|
||||
timeout_seconds = timeout_seconds or task.get("timeout_seconds") or SUB_AGENT_DEFAULT_TIMEOUT
|
||||
deadline = time.time() + timeout_seconds
|
||||
last_payload: Optional[Dict] = None
|
||||
|
||||
while time.time() < deadline:
|
||||
last_payload = self._call_service("GET", f"/tasks/{task['task_id']}", timeout=15)
|
||||
status = last_payload.get("status")
|
||||
if not last_payload.get("success") and status not in TERMINAL_STATUSES:
|
||||
time.sleep(SUB_AGENT_STATUS_POLL_INTERVAL)
|
||||
continue
|
||||
|
||||
if status in {"completed", "failed", "timeout"}:
|
||||
break
|
||||
|
||||
time.sleep(SUB_AGENT_STATUS_POLL_INTERVAL)
|
||||
else:
|
||||
status = "timeout"
|
||||
last_payload = {"success": False, "status": status, "message": "等待超时"}
|
||||
|
||||
if not last_payload:
|
||||
last_payload = {"success": False, "status": "unknown", "message": "无法获取子智能体状态"}
|
||||
status = "unknown"
|
||||
else:
|
||||
status = last_payload.get("status", status)
|
||||
|
||||
finalize_result = self._finalize_task(task, last_payload or {}, status)
|
||||
self._save_state()
|
||||
return finalize_result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 内部工具方法
|
||||
# ------------------------------------------------------------------
|
||||
def _load_state(self):
|
||||
if self.state_file.exists():
|
||||
try:
|
||||
data = json.loads(self.state_file.read_text(encoding="utf-8"))
|
||||
self.tasks = data.get("tasks", {})
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("子智能体状态文件损坏,已忽略。")
|
||||
self.tasks = {}
|
||||
else:
|
||||
self.tasks = {}
|
||||
|
||||
def _save_state(self):
|
||||
payload = {"tasks": self.tasks}
|
||||
self.state_file.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
def _generate_task_id(self, agent_id: int) -> str:
|
||||
suffix = uuid.uuid4().hex[:6]
|
||||
return f"sub_{agent_id}_{int(time.time())}_{suffix}"
|
||||
|
||||
def _active_task_count(self) -> int:
|
||||
return len([t for t in self.tasks.values() if t.get("status") in {"pending", "running"}])
|
||||
|
||||
def _copy_reference_files(self, references: List[str], dest_dir: Path) -> Tuple[List[str], List[str]]:
|
||||
copied = []
|
||||
errors = []
|
||||
for rel_path in references:
|
||||
rel_path = rel_path.strip()
|
||||
if not rel_path:
|
||||
continue
|
||||
try:
|
||||
source = self._resolve_project_file(rel_path)
|
||||
except ValueError as exc:
|
||||
errors.append(str(exc))
|
||||
continue
|
||||
|
||||
if not source.exists():
|
||||
errors.append(f"参考文件不存在: {rel_path}")
|
||||
continue
|
||||
|
||||
target_path = dest_dir / rel_path
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
shutil.copy2(source, target_path)
|
||||
copied.append(rel_path)
|
||||
except Exception as exc:
|
||||
errors.append(f"复制 {rel_path} 失败: {exc}")
|
||||
return copied, errors
|
||||
|
||||
def _ensure_project_subdir(self, relative_dir: str) -> Path:
|
||||
relative_dir = relative_dir.strip() if relative_dir else ""
|
||||
if not relative_dir:
|
||||
relative_dir = "sub_agent_results"
|
||||
target = (self.project_path / relative_dir).resolve()
|
||||
if not str(target).startswith(str(self.project_path)):
|
||||
raise ValueError("指定文件夹必须位于项目目录内")
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
return target
|
||||
|
||||
def _resolve_project_file(self, relative_path: str) -> Path:
|
||||
relative_path = relative_path.strip()
|
||||
candidate = (self.project_path / relative_path).resolve()
|
||||
if not str(candidate).startswith(str(self.project_path)):
|
||||
raise ValueError(f"非法的参考文件路径: {relative_path}")
|
||||
return candidate
|
||||
|
||||
def _select_task(self, task_id: Optional[str], agent_id: Optional[int]) -> Optional[Dict]:
|
||||
if task_id:
|
||||
return self.tasks.get(task_id)
|
||||
|
||||
if agent_id is None:
|
||||
return None
|
||||
|
||||
# 返回最新的匹配任务
|
||||
candidates = [
|
||||
task for task in self.tasks.values()
|
||||
if task.get("agent_id") == agent_id and task.get("status") in {"pending", "running"}
|
||||
]
|
||||
if candidates:
|
||||
candidates.sort(key=lambda item: item.get("created_at", 0), reverse=True)
|
||||
return candidates[0]
|
||||
return None
|
||||
|
||||
def poll_updates(self) -> List[Dict]:
|
||||
"""检查运行中的子智能体任务,返回新完成的结果。"""
|
||||
updates: List[Dict] = []
|
||||
pending_tasks = [
|
||||
task for task in self.tasks.values()
|
||||
if task.get("status") not in TERMINAL_STATUSES
|
||||
]
|
||||
logger.debug(f"[SubAgentManager] 待检查任务: {len(pending_tasks)}")
|
||||
if not pending_tasks:
|
||||
return updates
|
||||
|
||||
state_changed = False
|
||||
for task in pending_tasks:
|
||||
payload = self._call_service("GET", f"/tasks/{task['task_id']}", timeout=10)
|
||||
status = payload.get("status")
|
||||
logger.debug(f"[SubAgentManager] 任务 {task['task_id']} 服务状态: {status}")
|
||||
if status not in TERMINAL_STATUSES:
|
||||
continue
|
||||
result = self._finalize_task(task, payload, status)
|
||||
updates.append(result)
|
||||
state_changed = True
|
||||
|
||||
if state_changed:
|
||||
self._save_state()
|
||||
return updates
|
||||
|
||||
def _call_service(self, method: str, path: str, payload: Optional[Dict] = None, timeout: Optional[int] = None) -> Dict:
|
||||
url = f"{SUB_AGENT_SERVICE_BASE_URL.rstrip('/')}{path}"
|
||||
try:
|
||||
with httpx.Client(timeout=timeout or 10) as client:
|
||||
if method.upper() == "POST":
|
||||
response = client.post(url, json=payload or {})
|
||||
else:
|
||||
response = client.get(url)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.RequestError as exc:
|
||||
logger.error(f"子智能体服务请求失败: {exc}")
|
||||
return {"success": False, "error": f"无法连接子智能体服务: {exc}"}
|
||||
except httpx.HTTPStatusError as exc:
|
||||
logger.error(f"子智能体服务返回错误: {exc}")
|
||||
try:
|
||||
return exc.response.json()
|
||||
except Exception:
|
||||
return {"success": False, "error": f"服务端错误: {exc.response.text}"}
|
||||
except json.JSONDecodeError:
|
||||
return {"success": False, "error": "子智能体服务返回格式错误"}
|
||||
|
||||
def _finalize_task(self, task: Dict, service_payload: Dict, status: str) -> Dict:
|
||||
existing_result = task.get("final_result")
|
||||
if existing_result and task.get("status") in TERMINAL_STATUSES:
|
||||
return existing_result
|
||||
|
||||
task["status"] = status
|
||||
task["updated_at"] = time.time()
|
||||
message = service_payload.get("message") or service_payload.get("error") or ""
|
||||
deliverables_dir = Path(service_payload.get("deliverables_dir") or task.get("deliverables_dir", ""))
|
||||
logger.debug(f"[SubAgentManager] finalize task={task['task_id']} status={status}")
|
||||
|
||||
if status != "completed":
|
||||
result = {
|
||||
"success": False,
|
||||
"task_id": task["task_id"],
|
||||
"agent_id": task["agent_id"],
|
||||
"status": status,
|
||||
"message": message or f"子智能体状态:{status}",
|
||||
"details": service_payload,
|
||||
"system_message": self._build_system_message(task, status, None, message),
|
||||
}
|
||||
task["final_result"] = result
|
||||
return result
|
||||
|
||||
if not deliverables_dir.exists():
|
||||
result = {
|
||||
"success": False,
|
||||
"task_id": task["task_id"],
|
||||
"agent_id": task["agent_id"],
|
||||
"status": "failed",
|
||||
"error": f"未找到交付目录: {deliverables_dir}",
|
||||
"system_message": self._build_system_message(task, "failed", None, f"未找到交付目录: {deliverables_dir}"),
|
||||
}
|
||||
task["status"] = "failed"
|
||||
task["final_result"] = result
|
||||
return result
|
||||
|
||||
result_md = deliverables_dir / "result.md"
|
||||
if not result_md.exists():
|
||||
result = {
|
||||
"success": False,
|
||||
"task_id": task["task_id"],
|
||||
"agent_id": task["agent_id"],
|
||||
"status": "failed",
|
||||
"error": "交付目录缺少 result.md,无法完成任务。",
|
||||
"system_message": self._build_system_message(task, "failed", None, "交付目录缺少 result.md"),
|
||||
}
|
||||
task["status"] = "failed"
|
||||
task["final_result"] = result
|
||||
return result
|
||||
|
||||
copied_path = self._copy_deliverables_to_project(task, deliverables_dir)
|
||||
task["copied_path"] = str(copied_path)
|
||||
|
||||
system_message = self._build_system_message(task, status, copied_path, message)
|
||||
result = {
|
||||
"success": True,
|
||||
"task_id": task["task_id"],
|
||||
"agent_id": task["agent_id"],
|
||||
"status": status,
|
||||
"message": message or "子智能体已完成任务。",
|
||||
"deliverables_path": str(deliverables_dir),
|
||||
"copied_path": str(copied_path),
|
||||
"system_message": system_message,
|
||||
"details": service_payload,
|
||||
}
|
||||
task["final_result"] = result
|
||||
return result
|
||||
|
||||
def _copy_deliverables_to_project(self, task: Dict, source_dir: Path) -> Path:
|
||||
"""将交付文件复制到项目目录下的指定文件夹。"""
|
||||
target_dir = Path(task["target_project_dir"])
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest_dir = target_dir / f"{task['task_id']}_deliverables"
|
||||
|
||||
if dest_dir.exists():
|
||||
shutil.rmtree(dest_dir)
|
||||
shutil.copytree(source_dir, dest_dir)
|
||||
|
||||
return dest_dir
|
||||
|
||||
def _cleanup_task_folder(self, task_root: Path):
|
||||
if task_root.exists():
|
||||
shutil.rmtree(task_root, ignore_errors=True)
|
||||
|
||||
def _validate_create_params(self, agent_id: Optional[int], summary: str, task: str, target_dir: str) -> Optional[str]:
|
||||
if agent_id is None:
|
||||
return "子智能体代号不能为空"
|
||||
try:
|
||||
agent_id = int(agent_id)
|
||||
except ValueError:
|
||||
return "子智能体代号必须是整数"
|
||||
if not (1 <= agent_id <= SUB_AGENT_MAX_ACTIVE):
|
||||
return f"子智能体代号必须在 1~{SUB_AGENT_MAX_ACTIVE} 范围内"
|
||||
if not summary or not summary.strip():
|
||||
return "任务摘要不能为空"
|
||||
if not task or not task.strip():
|
||||
return "任务详情不能为空"
|
||||
if target_dir is None:
|
||||
return "指定文件夹不能为空"
|
||||
return None
|
||||
|
||||
def _build_system_message(
|
||||
self,
|
||||
task: Dict,
|
||||
status: str,
|
||||
copied_path: Optional[Path],
|
||||
extra_message: Optional[str] = None,
|
||||
) -> str:
|
||||
prefix = f"子智能体{task['agent_id']} 任务摘要:{task['summary']}"
|
||||
extra = (extra_message or "").strip()
|
||||
|
||||
if status == "completed" and copied_path:
|
||||
msg = f"{prefix} 已完成,成果已复制到 {copied_path}。"
|
||||
if extra:
|
||||
msg += f" ({extra})"
|
||||
return msg
|
||||
|
||||
if status == "timeout":
|
||||
return f"{prefix} 超时未完成。" + (f" {extra}" if extra else "")
|
||||
|
||||
if status == "failed":
|
||||
return f"{prefix} 执行失败:" + (extra if extra else "请检查交付目录或任务状态。")
|
||||
|
||||
return f"{prefix} 状态:{status}。" + (extra if extra else "")
|
||||
504
sub_agent/modules/terminal_manager.py
Normal file
504
sub_agent/modules/terminal_manager.py
Normal file
@ -0,0 +1,504 @@
|
||||
# modules/terminal_manager.py - 终端会话管理器
|
||||
|
||||
import json
|
||||
from typing import Dict, List, Optional, Callable
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
try:
|
||||
from config import (
|
||||
OUTPUT_FORMATS,
|
||||
MAX_TERMINALS,
|
||||
TERMINAL_BUFFER_SIZE,
|
||||
TERMINAL_DISPLAY_SIZE,
|
||||
TERMINAL_SNAPSHOT_DEFAULT_LINES,
|
||||
TERMINAL_SNAPSHOT_MAX_LINES,
|
||||
TERMINAL_SNAPSHOT_MAX_CHARS
|
||||
)
|
||||
except ImportError:
|
||||
import sys
|
||||
project_root = Path(__file__).resolve().parents[1]
|
||||
if str(project_root) not in sys.path:
|
||||
sys.path.insert(0, str(project_root))
|
||||
from config import (
|
||||
OUTPUT_FORMATS,
|
||||
MAX_TERMINALS,
|
||||
TERMINAL_BUFFER_SIZE,
|
||||
TERMINAL_DISPLAY_SIZE,
|
||||
TERMINAL_SNAPSHOT_DEFAULT_LINES,
|
||||
TERMINAL_SNAPSHOT_MAX_LINES,
|
||||
TERMINAL_SNAPSHOT_MAX_CHARS
|
||||
)
|
||||
|
||||
from modules.persistent_terminal import PersistentTerminal
|
||||
from utils.terminal_factory import TerminalFactory
|
||||
|
||||
class TerminalManager:
|
||||
"""管理多个终端会话"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
project_path: str,
|
||||
max_terminals: int = None,
|
||||
terminal_buffer_size: int = None,
|
||||
terminal_display_size: int = None,
|
||||
broadcast_callback: Callable = None
|
||||
):
|
||||
"""
|
||||
初始化终端管理器
|
||||
|
||||
Args:
|
||||
project_path: 项目路径
|
||||
max_terminals: 最大终端数量
|
||||
terminal_buffer_size: 每个终端的缓冲区大小
|
||||
terminal_display_size: 显示大小限制
|
||||
broadcast_callback: WebSocket广播回调
|
||||
"""
|
||||
self.project_path = Path(project_path)
|
||||
self.max_terminals = max_terminals or MAX_TERMINALS
|
||||
self.terminal_buffer_size = terminal_buffer_size or TERMINAL_BUFFER_SIZE
|
||||
self.terminal_display_size = terminal_display_size or TERMINAL_DISPLAY_SIZE
|
||||
self.default_snapshot_lines = TERMINAL_SNAPSHOT_DEFAULT_LINES
|
||||
self.max_snapshot_lines = TERMINAL_SNAPSHOT_MAX_LINES
|
||||
self.max_snapshot_chars = TERMINAL_SNAPSHOT_MAX_CHARS
|
||||
self.broadcast = broadcast_callback
|
||||
|
||||
# 终端会话字典
|
||||
self.terminals: Dict[str, PersistentTerminal] = {}
|
||||
|
||||
# 当前活动终端
|
||||
self.active_terminal: Optional[str] = None
|
||||
|
||||
# 终端工厂(跨平台支持)
|
||||
self.factory = TerminalFactory()
|
||||
|
||||
def open_terminal(
|
||||
self,
|
||||
session_name: str,
|
||||
working_dir: str = None,
|
||||
make_active: bool = True
|
||||
) -> Dict:
|
||||
"""
|
||||
打开新终端会话
|
||||
|
||||
Args:
|
||||
session_name: 会话名称
|
||||
working_dir: 工作目录(相对于项目路径)
|
||||
make_active: 是否设为活动终端
|
||||
|
||||
Returns:
|
||||
操作结果
|
||||
"""
|
||||
# 检查是否已存在
|
||||
if session_name in self.terminals:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"终端会话 '{session_name}' 已存在",
|
||||
"existing_sessions": list(self.terminals.keys())
|
||||
}
|
||||
|
||||
# 检查数量限制
|
||||
if len(self.terminals) >= self.max_terminals:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"已达到最大终端数量限制 ({self.max_terminals})",
|
||||
"existing_sessions": list(self.terminals.keys()),
|
||||
"suggestion": "请先关闭一个终端会话"
|
||||
}
|
||||
|
||||
# 确定工作目录
|
||||
if working_dir:
|
||||
work_path = self.project_path / working_dir
|
||||
if not work_path.exists():
|
||||
work_path.mkdir(parents=True, exist_ok=True)
|
||||
else:
|
||||
work_path = self.project_path
|
||||
|
||||
# 获取合适的shell命令
|
||||
shell_command = self.factory.get_shell_command()
|
||||
|
||||
# 创建终端实例
|
||||
terminal = PersistentTerminal(
|
||||
session_name=session_name,
|
||||
working_dir=str(work_path),
|
||||
shell_command=shell_command,
|
||||
broadcast_callback=self.broadcast,
|
||||
max_buffer_size=self.terminal_buffer_size,
|
||||
display_size=self.terminal_display_size
|
||||
)
|
||||
|
||||
# 启动终端
|
||||
if not terminal.start():
|
||||
return {
|
||||
"success": False,
|
||||
"error": "终端启动失败",
|
||||
"session": session_name
|
||||
}
|
||||
|
||||
# 保存终端实例
|
||||
self.terminals[session_name] = terminal
|
||||
|
||||
# 设为活动终端
|
||||
if make_active:
|
||||
self.active_terminal = session_name
|
||||
|
||||
print(f"{OUTPUT_FORMATS['success']} 终端会话已打开: {session_name}")
|
||||
|
||||
# 广播终端列表更新
|
||||
if self.broadcast:
|
||||
self.broadcast('terminal_list_update', {
|
||||
'terminals': self.get_terminal_list(),
|
||||
'active': self.active_terminal
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"session": session_name,
|
||||
"working_dir": str(work_path),
|
||||
"shell": shell_command,
|
||||
"is_active": make_active,
|
||||
"total_sessions": len(self.terminals)
|
||||
}
|
||||
|
||||
def close_terminal(self, session_name: str) -> Dict:
|
||||
"""
|
||||
关闭终端会话
|
||||
|
||||
Args:
|
||||
session_name: 会话名称
|
||||
|
||||
Returns:
|
||||
操作结果
|
||||
"""
|
||||
if session_name not in self.terminals:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"终端会话 '{session_name}' 不存在",
|
||||
"existing_sessions": list(self.terminals.keys())
|
||||
}
|
||||
|
||||
# 获取终端实例
|
||||
terminal = self.terminals[session_name]
|
||||
|
||||
# 关闭终端
|
||||
terminal.close()
|
||||
|
||||
# 从字典中移除
|
||||
del self.terminals[session_name]
|
||||
|
||||
# 如果是活动终端,切换到另一个
|
||||
if self.active_terminal == session_name:
|
||||
if self.terminals:
|
||||
self.active_terminal = list(self.terminals.keys())[0]
|
||||
else:
|
||||
self.active_terminal = None
|
||||
|
||||
print(f"{OUTPUT_FORMATS['info']} 终端会话已关闭: {session_name}")
|
||||
|
||||
# 广播终端列表更新
|
||||
if self.broadcast:
|
||||
self.broadcast('terminal_list_update', {
|
||||
'terminals': self.get_terminal_list(),
|
||||
'active': self.active_terminal
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"session": session_name,
|
||||
"remaining_sessions": list(self.terminals.keys()),
|
||||
"new_active": self.active_terminal
|
||||
}
|
||||
|
||||
def reset_terminal(self, session_name: Optional[str]) -> Dict:
|
||||
"""
|
||||
重置终端会话:关闭并重新创建同名会话
|
||||
|
||||
Args:
|
||||
session_name: 会话名称
|
||||
|
||||
Returns:
|
||||
操作结果
|
||||
"""
|
||||
target_session = session_name or self.active_terminal
|
||||
if not target_session:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "没有活动终端会话",
|
||||
"suggestion": "请先使用 terminal_session 打开一个终端"
|
||||
}
|
||||
|
||||
if target_session not in self.terminals:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"终端会话 '{target_session}' 不存在",
|
||||
"existing_sessions": list(self.terminals.keys())
|
||||
}
|
||||
|
||||
terminal = self.terminals[target_session]
|
||||
working_dir = str(terminal.working_dir)
|
||||
shell_command = terminal.shell_command or self.factory.get_shell_command()
|
||||
|
||||
terminal.close()
|
||||
del self.terminals[target_session]
|
||||
|
||||
new_terminal = PersistentTerminal(
|
||||
session_name=target_session,
|
||||
working_dir=working_dir,
|
||||
shell_command=shell_command,
|
||||
broadcast_callback=self.broadcast,
|
||||
max_buffer_size=self.terminal_buffer_size,
|
||||
display_size=self.terminal_display_size
|
||||
)
|
||||
|
||||
if not new_terminal.start():
|
||||
if self.terminals:
|
||||
self.active_terminal = next(iter(self.terminals.keys()))
|
||||
else:
|
||||
self.active_terminal = None
|
||||
if self.broadcast:
|
||||
self.broadcast('terminal_list_update', {
|
||||
'terminals': self.get_terminal_list(),
|
||||
'active': self.active_terminal
|
||||
})
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"终端会话 '{target_session}' 重置失败:无法重新启动进程",
|
||||
"working_dir": working_dir
|
||||
}
|
||||
|
||||
self.terminals[target_session] = new_terminal
|
||||
self.active_terminal = target_session
|
||||
|
||||
if self.broadcast:
|
||||
self.broadcast('terminal_reset', {
|
||||
'session': target_session,
|
||||
'working_dir': working_dir,
|
||||
'shell': shell_command,
|
||||
'time': datetime.now().isoformat()
|
||||
})
|
||||
self.broadcast('terminal_list_update', {
|
||||
'terminals': self.get_terminal_list(),
|
||||
'active': self.active_terminal
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"session": target_session,
|
||||
"working_dir": working_dir,
|
||||
"shell": shell_command,
|
||||
"message": "终端会话已重置并重新启动"
|
||||
}
|
||||
|
||||
def switch_terminal(self, session_name: str) -> Dict:
|
||||
"""
|
||||
切换活动终端
|
||||
|
||||
Args:
|
||||
session_name: 会话名称
|
||||
|
||||
Returns:
|
||||
操作结果
|
||||
"""
|
||||
if session_name not in self.terminals:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"终端会话 '{session_name}' 不存在",
|
||||
"existing_sessions": list(self.terminals.keys())
|
||||
}
|
||||
|
||||
previous_active = self.active_terminal
|
||||
self.active_terminal = session_name
|
||||
|
||||
print(f"{OUTPUT_FORMATS['info']} 切换到终端: {session_name}")
|
||||
|
||||
# 广播切换事件
|
||||
if self.broadcast:
|
||||
self.broadcast('terminal_switched', {
|
||||
'previous': previous_active,
|
||||
'current': session_name
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"previous": previous_active,
|
||||
"current": session_name,
|
||||
"status": self.terminals[session_name].get_status()
|
||||
}
|
||||
|
||||
def list_terminals(self) -> Dict:
|
||||
"""
|
||||
列出所有终端会话
|
||||
|
||||
Returns:
|
||||
终端列表
|
||||
"""
|
||||
sessions = []
|
||||
for name, terminal in self.terminals.items():
|
||||
status = terminal.get_status()
|
||||
status['is_active'] = (name == self.active_terminal)
|
||||
sessions.append(status)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"sessions": sessions,
|
||||
"active": self.active_terminal,
|
||||
"total": len(self.terminals),
|
||||
"max_allowed": self.max_terminals
|
||||
}
|
||||
|
||||
def send_to_terminal(
|
||||
self,
|
||||
command: str,
|
||||
session_name: str = None,
|
||||
wait_for_output: bool = True,
|
||||
timeout: float = None
|
||||
) -> Dict:
|
||||
"""
|
||||
向终端发送命令
|
||||
|
||||
Args:
|
||||
command: 要执行的命令
|
||||
session_name: 目标终端(None则使用活动终端)
|
||||
wait_for_output: 是否等待输出
|
||||
|
||||
Returns:
|
||||
执行结果
|
||||
"""
|
||||
# 确定目标终端
|
||||
target_session = session_name or self.active_terminal
|
||||
|
||||
if not target_session:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "没有活动终端会话",
|
||||
"suggestion": "请先使用 terminal_session 打开一个终端"
|
||||
}
|
||||
|
||||
if target_session not in self.terminals:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"终端会话 '{target_session}' 不存在",
|
||||
"existing_sessions": list(self.terminals.keys())
|
||||
}
|
||||
|
||||
# 发送命令
|
||||
terminal = self.terminals[target_session]
|
||||
result = terminal.send_command(command, wait_for_output, timeout=timeout)
|
||||
|
||||
return result
|
||||
|
||||
def get_terminal_output(
|
||||
self,
|
||||
session_name: str = None,
|
||||
last_n_lines: int = 50
|
||||
) -> Dict:
|
||||
"""
|
||||
获取终端输出
|
||||
|
||||
Args:
|
||||
session_name: 终端名称(None则使用活动终端)
|
||||
last_n_lines: 获取最后N行
|
||||
|
||||
Returns:
|
||||
输出内容
|
||||
"""
|
||||
target_session = session_name or self.active_terminal
|
||||
|
||||
if not target_session:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "没有活动终端会话"
|
||||
}
|
||||
|
||||
if target_session not in self.terminals:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"终端会话 '{target_session}' 不存在"
|
||||
}
|
||||
|
||||
terminal = self.terminals[target_session]
|
||||
output = terminal.get_output(last_n_lines)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"session": target_session,
|
||||
"output": output,
|
||||
"is_interactive": terminal.is_interactive,
|
||||
"last_command": terminal.last_command,
|
||||
"seconds_since_last_output": terminal._seconds_since_last_output(),
|
||||
"echo_loop_detected": terminal.echo_loop_detected
|
||||
}
|
||||
|
||||
def get_terminal_snapshot(
|
||||
self,
|
||||
session_name: str = None,
|
||||
lines: int = None,
|
||||
max_chars: int = None
|
||||
) -> Dict:
|
||||
"""
|
||||
获取终端输出快照
|
||||
|
||||
Args:
|
||||
session_name: 指定会话(默认使用活动会话)
|
||||
lines: 返回的最大行数
|
||||
max_chars: 返回的最大字符数
|
||||
|
||||
Returns:
|
||||
包含快照内容和状态的字典
|
||||
"""
|
||||
target_session = session_name or self.active_terminal
|
||||
if not target_session:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "没有活动终端会话",
|
||||
"suggestion": "请先使用 terminal_session 打开一个终端"
|
||||
}
|
||||
|
||||
if target_session not in self.terminals:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"终端会话 '{target_session}' 不存在",
|
||||
"existing_sessions": list(self.terminals.keys())
|
||||
}
|
||||
|
||||
line_limit = lines if lines is not None else self.default_snapshot_lines
|
||||
line_limit = max(1, min(line_limit, self.max_snapshot_lines))
|
||||
char_limit = max(100, min(max_chars if max_chars else self.max_snapshot_chars, self.max_snapshot_chars))
|
||||
|
||||
terminal = self.terminals[target_session]
|
||||
snapshot = terminal.get_snapshot(line_limit, char_limit)
|
||||
snapshot.update({
|
||||
"line_limit": line_limit,
|
||||
"char_limit": char_limit,
|
||||
"session": target_session
|
||||
})
|
||||
|
||||
if snapshot.get("truncated"):
|
||||
snapshot["note"] = f"输出已截断,仅返回了末尾的 {char_limit} 个字符"
|
||||
|
||||
return snapshot
|
||||
|
||||
def get_terminal_list(self) -> List[Dict]:
|
||||
"""获取终端列表(简化版)"""
|
||||
return [
|
||||
{
|
||||
"name": name,
|
||||
"is_active": name == self.active_terminal,
|
||||
"is_running": terminal.is_running,
|
||||
"working_dir": str(terminal.working_dir)
|
||||
}
|
||||
for name, terminal in self.terminals.items()
|
||||
]
|
||||
|
||||
def close_all(self):
|
||||
"""关闭所有终端会话"""
|
||||
print(f"{OUTPUT_FORMATS['info']} 关闭所有终端会话...")
|
||||
|
||||
for session_name in list(self.terminals.keys()):
|
||||
self.close_terminal(session_name)
|
||||
|
||||
self.active_terminal = None
|
||||
print(f"{OUTPUT_FORMATS['success']} 所有终端会话已关闭")
|
||||
|
||||
def __del__(self):
|
||||
"""析构函数,确保所有终端被关闭"""
|
||||
self.close_all()
|
||||
388
sub_agent/modules/terminal_ops.py
Normal file
388
sub_agent/modules/terminal_ops.py
Normal file
@ -0,0 +1,388 @@
|
||||
# modules/terminal_ops.py - 终端操作模块(修复Python命令检测)
|
||||
|
||||
import os
|
||||
import sys
|
||||
import asyncio
|
||||
import subprocess
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, Tuple
|
||||
try:
|
||||
from config import (
|
||||
CODE_EXECUTION_TIMEOUT,
|
||||
TERMINAL_COMMAND_TIMEOUT,
|
||||
FORBIDDEN_COMMANDS,
|
||||
OUTPUT_FORMATS
|
||||
)
|
||||
except ImportError:
|
||||
project_root = Path(__file__).resolve().parents[1]
|
||||
if str(project_root) not in sys.path:
|
||||
sys.path.insert(0, str(project_root))
|
||||
from config import (
|
||||
CODE_EXECUTION_TIMEOUT,
|
||||
TERMINAL_COMMAND_TIMEOUT,
|
||||
FORBIDDEN_COMMANDS,
|
||||
OUTPUT_FORMATS
|
||||
)
|
||||
|
||||
class TerminalOperator:
|
||||
def __init__(self, project_path: str):
|
||||
self.project_path = Path(project_path).resolve()
|
||||
self.process = None
|
||||
# 自动检测Python命令
|
||||
self.python_cmd = self._detect_python_command()
|
||||
print(f"{OUTPUT_FORMATS['info']} 检测到Python命令: {self.python_cmd}")
|
||||
|
||||
def _detect_python_command(self) -> str:
|
||||
"""
|
||||
自动检测可用的Python命令
|
||||
|
||||
Returns:
|
||||
可用的Python命令(python、python3、py)
|
||||
"""
|
||||
# 按优先级尝试不同的Python命令
|
||||
commands_to_try = []
|
||||
|
||||
if sys.platform == "win32":
|
||||
# Windows优先顺序
|
||||
commands_to_try = ["python", "py", "python3"]
|
||||
else:
|
||||
# Unix-like系统优先顺序
|
||||
commands_to_try = ["python3", "python"]
|
||||
|
||||
# 检测哪个命令可用
|
||||
for cmd in commands_to_try:
|
||||
if shutil.which(cmd):
|
||||
try:
|
||||
# 验证是否真的可以运行
|
||||
result = subprocess.run(
|
||||
[cmd, "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
if result.returncode == 0:
|
||||
# 检查版本是否为Python 3
|
||||
output = result.stdout + result.stderr
|
||||
if "Python 3" in output or "Python 2" not in output:
|
||||
return cmd
|
||||
except:
|
||||
continue
|
||||
|
||||
# 如果都没找到,根据平台返回默认值
|
||||
return "python" if sys.platform == "win32" else "python3"
|
||||
|
||||
def _validate_command(self, command: str) -> Tuple[bool, str]:
|
||||
"""验证命令安全性"""
|
||||
# 检查禁止的命令
|
||||
for forbidden in FORBIDDEN_COMMANDS:
|
||||
if forbidden in command.lower():
|
||||
return False, f"禁止执行的命令: {forbidden}"
|
||||
|
||||
# 检查危险的命令模式
|
||||
dangerous_patterns = [
|
||||
"sudo",
|
||||
"chmod 777",
|
||||
"rm -rf",
|
||||
"> /dev/",
|
||||
"fork bomb"
|
||||
]
|
||||
|
||||
for pattern in dangerous_patterns:
|
||||
if pattern in command.lower():
|
||||
return False, f"检测到危险命令模式: {pattern}"
|
||||
|
||||
return True, ""
|
||||
|
||||
async def run_command(
|
||||
self,
|
||||
command: str,
|
||||
working_dir: str = None,
|
||||
timeout: int = None
|
||||
) -> Dict:
|
||||
"""
|
||||
执行终端命令
|
||||
|
||||
Args:
|
||||
command: 要执行的命令
|
||||
working_dir: 工作目录
|
||||
timeout: 超时时间(秒)
|
||||
|
||||
Returns:
|
||||
执行结果字典
|
||||
"""
|
||||
# 替换命令中的python3为实际可用的命令
|
||||
if "python3" in command and self.python_cmd != "python3":
|
||||
command = command.replace("python3", self.python_cmd)
|
||||
elif "python" in command and "python3" not in command and self.python_cmd == "python3":
|
||||
# 如果命令中有python(但不是python3),而系统使用python3
|
||||
command = command.replace("python", self.python_cmd)
|
||||
|
||||
# 验证命令
|
||||
valid, error = self._validate_command(command)
|
||||
if not valid:
|
||||
return {
|
||||
"success": False,
|
||||
"error": error,
|
||||
"output": "",
|
||||
"return_code": -1
|
||||
}
|
||||
|
||||
# 设置工作目录
|
||||
if working_dir:
|
||||
work_path = (self.project_path / working_dir).resolve()
|
||||
# 确保工作目录在项目内
|
||||
try:
|
||||
work_path.relative_to(self.project_path)
|
||||
except ValueError:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "工作目录必须在项目文件夹内",
|
||||
"output": "",
|
||||
"return_code": -1
|
||||
}
|
||||
else:
|
||||
work_path = self.project_path
|
||||
|
||||
timeout = timeout or TERMINAL_COMMAND_TIMEOUT
|
||||
|
||||
print(f"{OUTPUT_FORMATS['terminal']} 执行命令: {command}")
|
||||
print(f"{OUTPUT_FORMATS['info']} 工作目录: {work_path}")
|
||||
|
||||
try:
|
||||
# 创建进程
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
command,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=str(work_path),
|
||||
shell=True
|
||||
)
|
||||
|
||||
# 等待执行完成
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(),
|
||||
timeout=timeout
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"命令执行超时 ({timeout}秒)",
|
||||
"output": "",
|
||||
"return_code": -1
|
||||
}
|
||||
|
||||
# 解码输出
|
||||
stdout_text = stdout.decode('utf-8', errors='replace') if stdout else ""
|
||||
stderr_text = stderr.decode('utf-8', errors='replace') if stderr else ""
|
||||
|
||||
output = stdout_text
|
||||
if stderr_text:
|
||||
output += f"\n[错误输出]\n{stderr_text}"
|
||||
|
||||
success = process.returncode == 0
|
||||
|
||||
if success:
|
||||
print(f"{OUTPUT_FORMATS['success']} 命令执行成功")
|
||||
else:
|
||||
print(f"{OUTPUT_FORMATS['error']} 命令执行失败 (返回码: {process.returncode})")
|
||||
|
||||
return {
|
||||
"success": success,
|
||||
"output": output,
|
||||
"stdout": stdout_text,
|
||||
"stderr": stderr_text,
|
||||
"return_code": process.returncode,
|
||||
"command": command
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"执行失败: {str(e)}",
|
||||
"output": "",
|
||||
"return_code": -1
|
||||
}
|
||||
|
||||
async def run_python_code(
|
||||
self,
|
||||
code: str,
|
||||
timeout: int = None
|
||||
) -> Dict:
|
||||
"""
|
||||
执行Python代码
|
||||
|
||||
Args:
|
||||
code: Python代码
|
||||
timeout: 超时时间(秒)
|
||||
|
||||
Returns:
|
||||
执行结果字典
|
||||
"""
|
||||
timeout = timeout or CODE_EXECUTION_TIMEOUT
|
||||
|
||||
# 创建临时Python文件
|
||||
temp_file = self.project_path / ".temp_code.py"
|
||||
|
||||
try:
|
||||
# 写入代码
|
||||
with open(temp_file, 'w', encoding='utf-8') as f:
|
||||
f.write(code)
|
||||
|
||||
print(f"{OUTPUT_FORMATS['code']} 执行Python代码")
|
||||
|
||||
# 使用检测到的Python命令执行文件
|
||||
result = await self.run_command(
|
||||
f'{self.python_cmd} "{temp_file}"',
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
# 添加代码到结果
|
||||
result["code"] = code
|
||||
|
||||
return result
|
||||
|
||||
finally:
|
||||
# 清理临时文件
|
||||
if temp_file.exists():
|
||||
temp_file.unlink()
|
||||
|
||||
async def run_python_file(
|
||||
self,
|
||||
file_path: str,
|
||||
args: str = "",
|
||||
timeout: int = None
|
||||
) -> Dict:
|
||||
"""
|
||||
执行Python文件
|
||||
|
||||
Args:
|
||||
file_path: Python文件路径
|
||||
args: 命令行参数
|
||||
timeout: 超时时间(秒)
|
||||
|
||||
Returns:
|
||||
执行结果字典
|
||||
"""
|
||||
# 构建完整路径
|
||||
full_path = (self.project_path / file_path).resolve()
|
||||
|
||||
# 验证文件存在
|
||||
if not full_path.exists():
|
||||
return {
|
||||
"success": False,
|
||||
"error": "文件不存在",
|
||||
"output": "",
|
||||
"return_code": -1
|
||||
}
|
||||
|
||||
# 验证是Python文件
|
||||
if not full_path.suffix == '.py':
|
||||
return {
|
||||
"success": False,
|
||||
"error": "不是Python文件",
|
||||
"output": "",
|
||||
"return_code": -1
|
||||
}
|
||||
|
||||
# 验证文件在项目内
|
||||
try:
|
||||
full_path.relative_to(self.project_path)
|
||||
except ValueError:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "文件必须在项目文件夹内",
|
||||
"output": "",
|
||||
"return_code": -1
|
||||
}
|
||||
|
||||
print(f"{OUTPUT_FORMATS['code']} 执行Python文件: {file_path}")
|
||||
|
||||
# 使用检测到的Python命令构建命令
|
||||
command = f'{self.python_cmd} "{full_path}"'
|
||||
if args:
|
||||
command += f" {args}"
|
||||
|
||||
# 执行命令
|
||||
return await self.run_command(command, timeout=timeout)
|
||||
|
||||
async def install_package(self, package: str) -> Dict:
|
||||
"""
|
||||
安装Python包
|
||||
|
||||
Args:
|
||||
package: 包名
|
||||
|
||||
Returns:
|
||||
安装结果
|
||||
"""
|
||||
print(f"{OUTPUT_FORMATS['terminal']} 安装包: {package}")
|
||||
|
||||
# 使用检测到的Python命令安装
|
||||
command = f'{self.python_cmd} -m pip install {package}'
|
||||
|
||||
result = await self.run_command(command, timeout=120)
|
||||
|
||||
if result["success"]:
|
||||
print(f"{OUTPUT_FORMATS['success']} 包安装成功: {package}")
|
||||
else:
|
||||
print(f"{OUTPUT_FORMATS['error']} 包安装失败: {package}")
|
||||
|
||||
return result
|
||||
|
||||
async def check_environment(self) -> Dict:
|
||||
"""检查Python环境"""
|
||||
print(f"{OUTPUT_FORMATS['info']} 检查Python环境...")
|
||||
|
||||
env_info = {
|
||||
"python_command": self.python_cmd,
|
||||
"python_version": "",
|
||||
"pip_version": "",
|
||||
"installed_packages": [],
|
||||
"working_directory": str(self.project_path)
|
||||
}
|
||||
|
||||
# 获取Python版本
|
||||
version_result = await self.run_command(
|
||||
f'{self.python_cmd} --version',
|
||||
timeout=5
|
||||
)
|
||||
if version_result["success"]:
|
||||
env_info["python_version"] = version_result["output"].strip()
|
||||
|
||||
# 获取pip版本
|
||||
pip_result = await self.run_command(
|
||||
f'{self.python_cmd} -m pip --version',
|
||||
timeout=5
|
||||
)
|
||||
if pip_result["success"]:
|
||||
env_info["pip_version"] = pip_result["output"].strip()
|
||||
|
||||
# 获取已安装的包
|
||||
packages_result = await self.run_command(
|
||||
f'{self.python_cmd} -m pip list --format=json',
|
||||
timeout=10
|
||||
)
|
||||
if packages_result["success"]:
|
||||
try:
|
||||
import json
|
||||
packages = json.loads(packages_result["output"])
|
||||
env_info["installed_packages"] = [
|
||||
f"{p['name']}=={p['version']}" for p in packages
|
||||
]
|
||||
except:
|
||||
pass
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"environment": env_info
|
||||
}
|
||||
|
||||
def kill_process(self):
|
||||
"""终止当前运行的进程"""
|
||||
if self.process and self.process.returncode is None:
|
||||
self.process.kill()
|
||||
print(f"{OUTPUT_FORMATS['warning']} 进程已终止")
|
||||
219
sub_agent/modules/todo_manager.py
Normal file
219
sub_agent/modules/todo_manager.py
Normal file
@ -0,0 +1,219 @@
|
||||
# modules/todo_manager.py - TODO 列表管理
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from typing import Dict, List, Any, Optional
|
||||
|
||||
try:
|
||||
from config import (
|
||||
TODO_MAX_TASKS,
|
||||
TODO_MAX_OVERVIEW_LENGTH,
|
||||
TODO_MAX_TASK_LENGTH,
|
||||
)
|
||||
except ImportError: # pragma: no cover
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
project_root = Path(__file__).resolve().parents[1]
|
||||
if str(project_root) not in sys.path:
|
||||
sys.path.insert(0, str(project_root))
|
||||
from config import ( # type: ignore
|
||||
TODO_MAX_TASKS,
|
||||
TODO_MAX_OVERVIEW_LENGTH,
|
||||
TODO_MAX_TASK_LENGTH,
|
||||
)
|
||||
|
||||
|
||||
class TodoManager:
|
||||
"""负责创建、更新和结束 TODO 列表"""
|
||||
|
||||
MAX_TASKS = TODO_MAX_TASKS
|
||||
MAX_OVERVIEW_LENGTH = TODO_MAX_OVERVIEW_LENGTH
|
||||
MAX_TASK_LENGTH = TODO_MAX_TASK_LENGTH
|
||||
|
||||
def __init__(self, context_manager):
|
||||
self.context_manager = context_manager
|
||||
|
||||
def _get_current(self) -> Optional[Dict[str, Any]]:
|
||||
todo = getattr(self.context_manager, "todo_list", None)
|
||||
return deepcopy(todo) if todo else None
|
||||
|
||||
def _save(self, todo: Optional[Dict[str, Any]]):
|
||||
self.context_manager.set_todo_list(todo)
|
||||
|
||||
def _normalize_tasks(self, tasks: List[Any]) -> List[str]:
|
||||
normalized = []
|
||||
for item in tasks:
|
||||
title = ""
|
||||
if isinstance(item, dict):
|
||||
title = item.get("title", "")
|
||||
else:
|
||||
title = str(item)
|
||||
title = title.strip()
|
||||
if not title:
|
||||
continue
|
||||
normalized.append(title)
|
||||
if len(normalized) >= self.MAX_TASKS:
|
||||
break
|
||||
return normalized
|
||||
|
||||
def create_todo_list(self, overview: str, tasks: List[Any]) -> Dict[str, Any]:
|
||||
current = self._get_current()
|
||||
if current and current.get("status") == "active":
|
||||
return {
|
||||
"success": False,
|
||||
"error": "已有进行中的 TODO 列表,请先完成或结束后再创建新的列表。"
|
||||
}
|
||||
|
||||
overview = (overview or "").strip()
|
||||
if not overview:
|
||||
return {"success": False, "error": "任务概述不能为空。"}
|
||||
if len(overview) > self.MAX_OVERVIEW_LENGTH:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"任务概述过长(当前 {len(overview)} 字),请精简至 {self.MAX_OVERVIEW_LENGTH} 字以内。"
|
||||
}
|
||||
|
||||
normalized_tasks = self._normalize_tasks(tasks or [])
|
||||
if not normalized_tasks:
|
||||
return {"success": False, "error": "需要至少提供一个任务。"}
|
||||
if len(tasks or []) > self.MAX_TASKS:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"任务数量过多,最多允许 {self.MAX_TASKS} 个任务。"
|
||||
}
|
||||
|
||||
for title in normalized_tasks:
|
||||
if len(title) > self.MAX_TASK_LENGTH:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"任务「{title}」过长,请控制在 {self.MAX_TASK_LENGTH} 字以内。"
|
||||
}
|
||||
|
||||
todo = {
|
||||
"overview": overview,
|
||||
"tasks": [
|
||||
{
|
||||
"index": idx,
|
||||
"title": title,
|
||||
"status": "pending"
|
||||
}
|
||||
for idx, title in enumerate(normalized_tasks, start=1)
|
||||
],
|
||||
"status": "active",
|
||||
"forced_finish": False,
|
||||
"forced_reason": None
|
||||
}
|
||||
|
||||
self._save(todo)
|
||||
return {
|
||||
"success": True,
|
||||
"message": "待办列表已创建。请先完成某项任务,再调用待办工具将其标记完成。",
|
||||
"todo_list": todo
|
||||
}
|
||||
|
||||
def update_task_status(self, task_index: int, completed: bool) -> Dict[str, Any]:
|
||||
todo = self._get_current()
|
||||
if not todo:
|
||||
return {"success": False, "error": "当前没有待办列表,请先创建。"}
|
||||
if todo.get("status") in {"completed", "closed"}:
|
||||
return {"success": False, "error": "待办列表已结束,无法继续修改。"}
|
||||
|
||||
if not isinstance(task_index, int):
|
||||
return {"success": False, "error": "task_index 必须是数字。"}
|
||||
if task_index < 1 or task_index > len(todo["tasks"]):
|
||||
return {"success": False, "error": f"task_index 超出范围(1-{len(todo['tasks'])})。"}
|
||||
|
||||
task = todo["tasks"][task_index - 1]
|
||||
new_status = "done" if completed else "pending"
|
||||
if task["status"] == new_status:
|
||||
return {
|
||||
"success": True,
|
||||
"message": "任务状态未发生变化。",
|
||||
"todo_list": todo
|
||||
}
|
||||
|
||||
task["status"] = new_status
|
||||
|
||||
self._save(todo)
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"任务 task{task_index} 已标记为 {'完成' if completed else '未完成'}。",
|
||||
"todo_list": todo
|
||||
}
|
||||
|
||||
def finish_todo(self, reason: Optional[str] = None) -> Dict[str, Any]:
|
||||
todo = self._get_current()
|
||||
if not todo:
|
||||
return {"success": False, "error": "当前没有待办列表。"}
|
||||
|
||||
if todo.get("status") in {"completed", "closed"}:
|
||||
return {
|
||||
"success": True,
|
||||
"message": "待办列表已结束,无需重复操作。",
|
||||
"todo_list": todo
|
||||
}
|
||||
|
||||
all_done = all(task["status"] == "done" for task in todo["tasks"])
|
||||
if all_done:
|
||||
todo["status"] = "completed"
|
||||
todo["forced_finish"] = False
|
||||
todo["forced_reason"] = None
|
||||
self._save(todo)
|
||||
system_note = "✅ TODO 列表中的所有任务已完成,可以整理成果并向用户汇报。"
|
||||
self.context_manager.add_conversation("system", system_note)
|
||||
return {
|
||||
"success": True,
|
||||
"message": "所有任务已完成,待办列表已结束。",
|
||||
"todo_list": todo,
|
||||
"system_note": system_note
|
||||
}
|
||||
|
||||
remaining = [
|
||||
f"task{task['index']}"
|
||||
for task in todo["tasks"]
|
||||
if task["status"] != "done"
|
||||
]
|
||||
return {
|
||||
"success": False,
|
||||
"requires_confirmation": True,
|
||||
"message": "仍有未完成的任务,确认要提前结束吗?",
|
||||
"remaining": remaining,
|
||||
"todo_list": todo
|
||||
}
|
||||
|
||||
def confirm_finish(self, confirm: bool, reason: Optional[str] = None) -> Dict[str, Any]:
|
||||
todo = self._get_current()
|
||||
if not todo:
|
||||
return {"success": False, "error": "当前没有待办列表。"}
|
||||
if todo.get("status") in {"completed", "closed"}:
|
||||
return {
|
||||
"success": True,
|
||||
"message": "待办列表已结束,无需重复操作。",
|
||||
"todo_list": todo
|
||||
}
|
||||
|
||||
if not confirm:
|
||||
return {
|
||||
"success": True,
|
||||
"message": "已取消结束待办列表,继续执行剩余任务。",
|
||||
"todo_list": todo
|
||||
}
|
||||
|
||||
todo["status"] = "closed"
|
||||
todo["forced_finish"] = True
|
||||
todo["forced_reason"] = (reason or "").strip() or None
|
||||
self._save(todo)
|
||||
|
||||
system_note = "⚠️ TODO 列表在任务未全部完成的情况下被结束,请在总结中说明原因。"
|
||||
self.context_manager.add_conversation("system", system_note)
|
||||
return {
|
||||
"success": True,
|
||||
"message": "待办列表已强制结束。",
|
||||
"todo_list": todo,
|
||||
"system_note": system_note
|
||||
}
|
||||
|
||||
def get_snapshot(self) -> Optional[Dict[str, Any]]:
|
||||
return self._get_current()
|
||||
271
sub_agent/modules/user_manager.py
Normal file
271
sub_agent/modules/user_manager.py
Normal file
@ -0,0 +1,271 @@
|
||||
"""User and workspace management utilities for multi-user support."""
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
|
||||
from config import (
|
||||
ADMIN_PASSWORD_HASH,
|
||||
ADMIN_USERNAME,
|
||||
INVITE_CODES_FILE,
|
||||
USER_SPACE_DIR,
|
||||
USERS_DB_FILE,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserRecord:
|
||||
username: str
|
||||
email: str
|
||||
password_hash: str
|
||||
created_at: str
|
||||
invite_code: Optional[str] = None
|
||||
role: str = "user"
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserWorkspace:
|
||||
username: str
|
||||
root: Path
|
||||
project_path: Path
|
||||
data_dir: Path
|
||||
logs_dir: Path
|
||||
uploads_dir: Path
|
||||
|
||||
|
||||
class UserManager:
|
||||
"""Handle user registration, authentication and workspace provisioning."""
|
||||
|
||||
USERNAME_REGEX = re.compile(r"^[a-z0-9_\-]{3,32}$")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
users_file: str = USERS_DB_FILE,
|
||||
invite_codes_file: str = INVITE_CODES_FILE,
|
||||
workspace_root: str = USER_SPACE_DIR,
|
||||
):
|
||||
self.users_file = Path(users_file)
|
||||
self.invite_codes_file = Path(invite_codes_file)
|
||||
self.workspace_root = Path(workspace_root).expanduser().resolve()
|
||||
self.workspace_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._users: Dict[str, UserRecord] = {}
|
||||
self._invites: Dict[str, Dict] = {}
|
||||
self._email_map: Dict[str, str] = {}
|
||||
|
||||
self._load_users()
|
||||
self._load_invite_codes()
|
||||
self._ensure_admin_user()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
def register_user(
|
||||
self, username: str, email: str, password: str, invite_code: str
|
||||
) -> Tuple[UserRecord, UserWorkspace]:
|
||||
username = self._normalize_username(username)
|
||||
email = self._normalize_email(email)
|
||||
password = password.strip()
|
||||
invite_code = (invite_code or "").strip()
|
||||
|
||||
if not password or len(password) < 8:
|
||||
raise ValueError("密码长度至少 8 位。")
|
||||
if username in self._users:
|
||||
raise ValueError("该用户名已被注册。")
|
||||
if email in self._email_map:
|
||||
raise ValueError("该邮箱已被注册。")
|
||||
|
||||
invite_entry = self._validate_invite_code(invite_code)
|
||||
password_hash = generate_password_hash(password)
|
||||
created_at = datetime.utcnow().isoformat()
|
||||
|
||||
record = UserRecord(
|
||||
username=username,
|
||||
email=email,
|
||||
password_hash=password_hash,
|
||||
created_at=created_at,
|
||||
invite_code=invite_entry["code"],
|
||||
)
|
||||
self._users[username] = record
|
||||
self._index_user(record)
|
||||
self._save_users()
|
||||
self._consume_invite(invite_entry)
|
||||
|
||||
workspace = self.ensure_user_workspace(username)
|
||||
return record, workspace
|
||||
|
||||
def authenticate(self, email: str, password: str) -> Optional[UserRecord]:
|
||||
email = (email or "").strip().lower()
|
||||
username = self._email_map.get(email)
|
||||
if not username:
|
||||
return None
|
||||
record = self._users.get(username)
|
||||
if not record or not record.password_hash:
|
||||
return None
|
||||
if not check_password_hash(record.password_hash, password or ""):
|
||||
return None
|
||||
return record
|
||||
|
||||
def get_user(self, username: str) -> Optional[UserRecord]:
|
||||
return self._users.get((username or "").strip().lower())
|
||||
|
||||
def ensure_user_workspace(self, username: str) -> UserWorkspace:
|
||||
username = self._normalize_username(username)
|
||||
root = (self.workspace_root / username).resolve()
|
||||
project_path = root / "project"
|
||||
data_dir = root / "data"
|
||||
logs_dir = root / "logs"
|
||||
uploads_dir = project_path / "user_upload"
|
||||
|
||||
for path in [project_path, data_dir, logs_dir, uploads_dir]:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 初始化数据子目录
|
||||
(data_dir / "conversations").mkdir(parents=True, exist_ok=True)
|
||||
(data_dir / "backups").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
return UserWorkspace(
|
||||
username=username,
|
||||
root=root,
|
||||
project_path=project_path,
|
||||
data_dir=data_dir,
|
||||
logs_dir=logs_dir,
|
||||
uploads_dir=uploads_dir,
|
||||
)
|
||||
|
||||
def list_invite_codes(self):
|
||||
return list(self._invites.values())
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
def _normalize_username(self, username: str) -> str:
|
||||
candidate = (username or "").strip().lower()
|
||||
if not candidate or not self.USERNAME_REGEX.match(candidate):
|
||||
raise ValueError("用户名需为 3-32 位小写字母、数字、下划线或连字符。")
|
||||
return candidate
|
||||
|
||||
def _normalize_email(self, email: str) -> str:
|
||||
email = (email or "").strip().lower()
|
||||
if "@" not in email or len(email) < 6:
|
||||
raise ValueError("邮箱格式不正确。")
|
||||
return email
|
||||
|
||||
def _index_user(self, record: UserRecord):
|
||||
email = (record.email or '').strip().lower()
|
||||
if email:
|
||||
self._email_map[email] = record.username
|
||||
|
||||
def _load_users(self):
|
||||
if not self.users_file.exists():
|
||||
self._save_users()
|
||||
return
|
||||
|
||||
try:
|
||||
with open(self.users_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, dict):
|
||||
raw_users = data.get("users", {})
|
||||
elif isinstance(data, list):
|
||||
raw_users = {item.get("username"): item for item in data if isinstance(item, dict) and item.get("username")}
|
||||
else:
|
||||
raw_users = {}
|
||||
for username, payload in raw_users.items():
|
||||
record = UserRecord(
|
||||
username=username,
|
||||
email=payload.get("email", ""),
|
||||
password_hash=payload.get("password_hash", ""),
|
||||
created_at=payload.get("created_at", ""),
|
||||
invite_code=payload.get("invite_code"),
|
||||
role=payload.get("role", "user"),
|
||||
)
|
||||
self._users[username] = record
|
||||
self._index_user(record)
|
||||
except json.JSONDecodeError:
|
||||
raise RuntimeError(f"无法解析用户数据文件: {self.users_file}")
|
||||
|
||||
def _save_users(self):
|
||||
payload = {
|
||||
"users": {
|
||||
username: {
|
||||
"email": record.email,
|
||||
"password_hash": record.password_hash,
|
||||
"created_at": record.created_at,
|
||||
"invite_code": record.invite_code,
|
||||
"role": record.role,
|
||||
}
|
||||
for username, record in self._users.items()
|
||||
}
|
||||
}
|
||||
self.users_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.users_file, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, ensure_ascii=False, indent=2)
|
||||
|
||||
def _load_invite_codes(self):
|
||||
if not self.invite_codes_file.exists():
|
||||
self._save_invite_codes({})
|
||||
return
|
||||
|
||||
try:
|
||||
with open(self.invite_codes_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, dict):
|
||||
codes = data.get("codes", [])
|
||||
elif isinstance(data, list):
|
||||
codes = data
|
||||
else:
|
||||
codes = []
|
||||
self._invites = {item["code"]: item for item in codes if isinstance(item, dict) and "code" in item}
|
||||
except json.JSONDecodeError:
|
||||
raise RuntimeError(f"无法解析邀请码文件: {self.invite_codes_file}")
|
||||
|
||||
def _save_invite_codes(self, overrides: Optional[Dict[str, Dict]] = None):
|
||||
codes = overrides or self._invites
|
||||
payload = {"codes": list(codes.values())}
|
||||
self.invite_codes_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.invite_codes_file, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, ensure_ascii=False, indent=2)
|
||||
|
||||
def _validate_invite_code(self, code: str) -> Dict:
|
||||
if not code:
|
||||
raise ValueError("邀请码不能为空。")
|
||||
entry = self._invites.get(code)
|
||||
if not entry:
|
||||
raise ValueError("邀请码不存在或已失效。")
|
||||
|
||||
remaining = entry.get("remaining")
|
||||
if remaining is not None and remaining <= 0:
|
||||
raise ValueError("邀请码已被使用。")
|
||||
return entry
|
||||
|
||||
def _consume_invite(self, entry: Dict):
|
||||
if entry.get("remaining") is None:
|
||||
return
|
||||
entry["remaining"] = max(0, entry["remaining"] - 1)
|
||||
self._save_invite_codes()
|
||||
|
||||
def _ensure_admin_user(self):
|
||||
admin_name = (ADMIN_USERNAME or "").strip().lower()
|
||||
if not admin_name or not ADMIN_PASSWORD_HASH:
|
||||
return
|
||||
|
||||
if admin_name in self._users:
|
||||
return
|
||||
|
||||
record = UserRecord(
|
||||
username=admin_name,
|
||||
email=f"{admin_name}@local",
|
||||
password_hash=ADMIN_PASSWORD_HASH,
|
||||
created_at=datetime.utcnow().isoformat(),
|
||||
invite_code=None,
|
||||
role="admin",
|
||||
)
|
||||
self._users[admin_name] = record
|
||||
self._index_user(record)
|
||||
self._save_users()
|
||||
self.ensure_user_workspace(admin_name)
|
||||
125
sub_agent/modules/webpage_extractor.py
Normal file
125
sub_agent/modules/webpage_extractor.py
Normal file
@ -0,0 +1,125 @@
|
||||
# modules/webpage_extractor.py - 网页内容提取模块
|
||||
|
||||
import httpx
|
||||
import json
|
||||
from typing import Dict, Any, List, Union, Tuple
|
||||
from utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
async def tavily_extract(urls: Union[str, List[str]], api_key: str, extract_depth: str = "basic", max_urls: int = 1) -> Dict[str, Any]:
|
||||
"""
|
||||
执行Tavily网页内容提取
|
||||
|
||||
Args:
|
||||
urls: 要提取的URL(字符串或列表)
|
||||
api_key: Tavily API密钥
|
||||
extract_depth: 提取深度 (basic/advanced)
|
||||
max_urls: 最大提取URL数量
|
||||
|
||||
Returns:
|
||||
提取结果字典
|
||||
"""
|
||||
if not api_key:
|
||||
return {"error": "Tavily API密钥未配置"}
|
||||
|
||||
# 确保urls是列表
|
||||
if isinstance(urls, str):
|
||||
urls = [urls]
|
||||
|
||||
# 限制URL数量
|
||||
urls = urls[:max_urls]
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
"https://api.tavily.com/extract",
|
||||
json={
|
||||
"urls": urls,
|
||||
"extract_depth": extract_depth,
|
||||
"include_images": False,
|
||||
},
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
return {"error": f"API请求失败: HTTP {response.status_code}"}
|
||||
|
||||
except httpx.TimeoutException:
|
||||
return {"error": "请求超时,网页响应过慢"}
|
||||
except httpx.RequestError as e:
|
||||
return {"error": f"网络请求错误: {str(e)}"}
|
||||
except Exception as e:
|
||||
logger.error(f"网页提取异常: {e}")
|
||||
return {"error": f"提取异常: {str(e)}"}
|
||||
|
||||
|
||||
def format_extract_results(results: Dict[str, Any]) -> str:
|
||||
"""
|
||||
格式化提取结果为简洁版本
|
||||
|
||||
Args:
|
||||
results: tavily_extract返回的结果
|
||||
|
||||
Returns:
|
||||
格式化后的内容字符串
|
||||
"""
|
||||
if "error" in results:
|
||||
return f"❌ 提取失败: {results['error']}"
|
||||
|
||||
if not results.get("results"):
|
||||
return "❌ 未能提取到任何内容"
|
||||
|
||||
formatted_parts = []
|
||||
|
||||
# 成功提取的结果
|
||||
for i, result in enumerate(results["results"], 1):
|
||||
url = result.get("url", "N/A")
|
||||
raw_content = result.get("raw_content", "").strip()
|
||||
|
||||
if raw_content:
|
||||
content_length = len(raw_content)
|
||||
formatted_parts.append(f"🌐 网页内容 ({content_length} 字符):")
|
||||
formatted_parts.append(f"📍 URL: {url}")
|
||||
formatted_parts.append("=" * 50)
|
||||
formatted_parts.append(raw_content)
|
||||
formatted_parts.append("=" * 50)
|
||||
else:
|
||||
formatted_parts.append(f"⚠️ URL {url} 提取到空内容")
|
||||
|
||||
# 失败的URL(如果有)
|
||||
if results.get("failed_results"):
|
||||
formatted_parts.append("\n❌ 提取失败的URL:")
|
||||
for failed in results["failed_results"]:
|
||||
formatted_parts.append(f"- {failed.get('url', 'N/A')}: {failed.get('error', '未知错误')}")
|
||||
|
||||
return "\n".join(formatted_parts)
|
||||
|
||||
|
||||
async def extract_webpage_content(urls: Union[str, List[str]], api_key: str, extract_depth: str = "basic", max_urls: int = 1) -> Tuple[str, str]:
|
||||
"""
|
||||
完整的网页内容提取流程
|
||||
|
||||
Args:
|
||||
urls: 要提取的URL(字符串或列表)
|
||||
api_key: Tavily API密钥
|
||||
extract_depth: 提取深度 (basic/advanced)
|
||||
max_urls: 最大提取URL数量
|
||||
|
||||
Returns:
|
||||
(完整内容, 完整内容) - 为了兼容性返回相同内容两份
|
||||
"""
|
||||
# 执行提取
|
||||
results = await tavily_extract(urls, api_key, extract_depth, max_urls)
|
||||
|
||||
# 格式化结果
|
||||
formatted_content = format_extract_results(results)
|
||||
|
||||
# 返回相同内容(简化版本,不需要长短版本区分)
|
||||
return formatted_content, formatted_content
|
||||
259
sub_agent/prompts/main_system.txt
Normal file
259
sub_agent/prompts/main_system.txt
Normal file
@ -0,0 +1,259 @@
|
||||
你是一名运行在云端服务器上的智能助手,可以帮助用户完成各种任务。你的用户可能没有编程背景,请用通俗易懂的方式与他们交流。
|
||||
|
||||
你的基础模型是Kimi-k2,由月之暗面公司开发,是一个开源的Moe架构模型,由1t的参数和32b的激活参数,当前智能助手应用由火山引擎提供api服务
|
||||
|
||||
## 你能做什么
|
||||
- **文档处理**:整理文字、编辑文件、格式转换
|
||||
- **信息查找**:搜索资料、提取网页内容、整理信息
|
||||
- **数据整理**:处理表格、分析数据、生成报告
|
||||
- **文件管理**:创建、修改、重命名文件和文件夹
|
||||
- **自动化任务**:批量处理文件、执行重复性工作
|
||||
|
||||
## 重要提醒:你的工作环境
|
||||
1. **云端运行**:你在远程服务器上工作,没有图形界面,只能通过命令行操作
|
||||
2. **多人共用**:服务器上可能有其他用户,你只能访问被授权的文件夹
|
||||
3. **文件传输**:用户可以在网页上传文件给你,你也可以生成文件让用户下载
|
||||
4. **安全第一**:只操作用户明确要求的文件,不要碰其他内容
|
||||
|
||||
## 工作方式:先想后做
|
||||
遇到任务时,请这样工作:
|
||||
1. **确认理解**:复述一遍你理解的任务是什么
|
||||
2. **说明计划**:告诉用户你打算怎么做,分几步
|
||||
3. **征求同意**:询问用户的意见,向用户确认更多细节
|
||||
4. **报告结果**:在用户给出明确的指令,比如”好的,请开始做吧“再开始创建待办事项并完成任务
|
||||
|
||||
**❌ 不要做的事**:
|
||||
- 不要一句"好的我来做"就直接开始
|
||||
- 不要猜测用户想要什么
|
||||
- 不要操作用户没提到的文件
|
||||
- 不要编造没做的事情
|
||||
|
||||
## 文件查看:两种方式选择
|
||||
|
||||
### 方式1:读取(临时看一眼)
|
||||
适合场景:
|
||||
- 只是想快速看看内容
|
||||
- 小文件(比如配置文件、说明文档)
|
||||
- 看完就不用了
|
||||
|
||||
### 方式2:聚焦(长期盯着)
|
||||
适合场景:
|
||||
- 需要反复查看和修改的文件
|
||||
- 重要的核心文件
|
||||
- 会花较长时间处理的文件
|
||||
|
||||
**限制**:
|
||||
- 聚焦最多3个文件
|
||||
- 每个文件不超过10000字
|
||||
- 用完记得取消聚焦,给下个任务腾空间
|
||||
|
||||
**已聚焦的文件**:内容完全可见,不需要也不能再用命令查看
|
||||
|
||||
## 文件操作示例
|
||||
|
||||
### 创建和写入文件
|
||||
```
|
||||
用户:"帮我整理一份待办清单"
|
||||
你的做法:
|
||||
1. 先询问清单内容有哪些
|
||||
2. 调用 create_file 创建空文件
|
||||
3. 调用 append_to_file 写入内容
|
||||
4. 告诉用户文件创建在哪里
|
||||
```
|
||||
|
||||
### 修改文件内容
|
||||
```
|
||||
用户:"把报告里的'2024'改成'2025'"
|
||||
你的做法:
|
||||
1. 如果文件已聚焦,直接看到内容
|
||||
2. 如果没聚焦,先读取或聚焦文件
|
||||
3. 调用 modify_file 进行替换
|
||||
4. 确认修改是否成功
|
||||
```
|
||||
|
||||
### 搜索和提取信息
|
||||
```
|
||||
用户:"帮我找一下最近的AI新闻"
|
||||
你的做法:
|
||||
1. 调用 web_search 搜索相关信息
|
||||
2. 如果需要详细内容,用 extract_webpage
|
||||
3. 整理信息给用户
|
||||
4. 如果用户要保存,可以创建文件
|
||||
```
|
||||
|
||||
## 执行命令的两种方式
|
||||
|
||||
### 方式1:快速命令(一次性的)
|
||||
用 `run_command` 工具
|
||||
适合:
|
||||
- 查看文件列表:`ls -lh`
|
||||
- 查看文件内容:`cat 文件.txt`
|
||||
- 统计行数:`wc -l 文件.txt`
|
||||
- 搜索内容:`grep "关键词" 文件.txt`
|
||||
|
||||
### 方式2:持久终端(需要保持运行的)
|
||||
用 `terminal_session` + `terminal_input` 工具
|
||||
适合:
|
||||
- 运行需要一直开着的程序
|
||||
- 需要多次输入的交互任务
|
||||
- 需要等待较长时间的任务
|
||||
|
||||
**⚠️ 注意**:
|
||||
- 最多同时开3个终端
|
||||
- 不要在终端里启动 python、node、vim 这类会占用界面的程序
|
||||
- 如果终端卡住了,用 terminal_reset 重启
|
||||
|
||||
## 常用命令示例
|
||||
|
||||
### 文件查看
|
||||
```bash
|
||||
# 查看文件内容
|
||||
cat 文件.txt
|
||||
|
||||
# 查看文件前10行
|
||||
head -n 10 文件.txt
|
||||
|
||||
# 查看文件后10行
|
||||
tail -n 10 文件.txt
|
||||
|
||||
# 搜索包含关键词的行
|
||||
grep "关键词" 文件.txt
|
||||
|
||||
# 统计文件行数
|
||||
wc -l 文件.txt
|
||||
```
|
||||
|
||||
### 文件操作
|
||||
```bash
|
||||
# 复制文件
|
||||
cp 原文件.txt 新文件.txt
|
||||
|
||||
# 移动/重命名文件
|
||||
mv 旧名.txt 新名.txt
|
||||
|
||||
# 删除文件(谨慎使用)
|
||||
rm 文件.txt
|
||||
|
||||
# 创建文件夹
|
||||
mkdir 文件夹名
|
||||
```
|
||||
|
||||
### 文件信息
|
||||
```bash
|
||||
# 查看文件大小
|
||||
ls -lh 文件.txt
|
||||
|
||||
# 查看当前目录所有文件
|
||||
ls -lah
|
||||
|
||||
# 查看文件类型
|
||||
file 文件名
|
||||
|
||||
# 查看目录结构
|
||||
tree -L 2
|
||||
```
|
||||
|
||||
## 待办事项系统(简单任务管理)
|
||||
|
||||
当任务需要多个步骤时,可以创建待办清单:
|
||||
|
||||
### 使用规则
|
||||
1. **什么时候用**:任务需要2步以上、涉及多个文件或工具时
|
||||
2. **清单要求**:
|
||||
- 概述:用一句话说明任务目标(不超过50字)
|
||||
- 任务:最多4条,按执行顺序排列
|
||||
- 每条任务要说清楚具体做什么,不要用"优化""处理"这种模糊词
|
||||
3. **执行方式**:
|
||||
- 完成一项,勾选一项
|
||||
- 如果计划有变,先告诉用户
|
||||
- 全部完成后,用 todo_finish 结束
|
||||
|
||||
### 示例:整理文档
|
||||
```
|
||||
概述:整理年度总结文档,统一格式并导出PDF
|
||||
任务1:读取所有Word文档,统一标题格式
|
||||
任务2:合并内容到一个新文件
|
||||
任务3:检查错别字和标点
|
||||
任务4:转换为PDF并保存
|
||||
```
|
||||
|
||||
## 网络搜索技巧
|
||||
|
||||
### 基础搜索
|
||||
```
|
||||
用户:"搜索一下Python教程"
|
||||
你调用:web_search(query="Python教程")
|
||||
```
|
||||
|
||||
### 搜索最近的内容
|
||||
```
|
||||
用户:"最近一周的科技新闻"
|
||||
你调用:web_search(query="4-6个和科技新闻相关的关键词", time_range="week")
|
||||
```
|
||||
|
||||
|
||||
### 提取网页详细内容
|
||||
```
|
||||
用户:"把这篇文章的内容提取出来"
|
||||
步骤:
|
||||
1. 先用 web_search 找到链接
|
||||
2. 再用 extract_webpage 提取完整内容
|
||||
3. 如果用户要保存,用 save_webpage 存为txt文件
|
||||
```
|
||||
|
||||
## 资源管理:记得收拾
|
||||
|
||||
由于服务器资源有限,请养成好习惯:
|
||||
1. **聚焦文件**:用完及时取消聚焦
|
||||
2. **终端会话**:不用的终端及时关闭
|
||||
3. **大文件**:避免一次输出超长内容,分批处理
|
||||
4. **上下文**:对话太长时(超过10万字符),提醒用户压缩
|
||||
|
||||
## 遇到问题怎么办
|
||||
|
||||
### 文件太大
|
||||
```
|
||||
如果提示"文件超过10000字符":
|
||||
1. 告诉用户文件大小
|
||||
2. 建议只查看部分内容
|
||||
3. 用命令查看:head -n 100 文件.txt
|
||||
```
|
||||
|
||||
### 命令执行失败
|
||||
```
|
||||
1. 不要重复执行相同命令
|
||||
2. 检查是否有权限问题
|
||||
3. 尝试用其他方法
|
||||
4. 实在不行,诚实告诉用户
|
||||
```
|
||||
|
||||
### 不确定怎么做
|
||||
```
|
||||
1. 不要瞎猜
|
||||
2. 问用户更多信息
|
||||
3. 提供几个可行方案让用户选
|
||||
```
|
||||
|
||||
## 交流风格
|
||||
|
||||
- 使用口语化表达,避免技术黑话
|
||||
- 主动说明你在做什么
|
||||
- 遇到问题时说明原因
|
||||
- 完成任务后总结成果
|
||||
- 不要用生硬的"执行工具: xxx",而是说"我来帮你..."
|
||||
|
||||
## 当前环境信息
|
||||
- 项目路径: {project_path}
|
||||
- 项目文件结构: {file_tree}
|
||||
- 长期记忆: {memory}
|
||||
- 当前时间: {current_time}
|
||||
|
||||
## 核心原则
|
||||
|
||||
1. **安全第一**:只操作授权范围内的文件
|
||||
2. **沟通为主**:不确定时多问,不要自作主张
|
||||
3. **诚实守信**:做不到的事情坦白说,不编造
|
||||
4. **用户友好**:用简单的语言解释复杂的操作
|
||||
5. **正确执行**:和用户主动确认细节,用户明确告知可以开始任务后,再开始工作流程
|
||||
|
||||
记住:你的用户可能不懂技术,你的目标是让他们感觉到"这个助手真好用",而不是"怎么这么复杂"。
|
||||
20
sub_agent/prompts/sub_agent_system.txt
Normal file
20
sub_agent/prompts/sub_agent_system.txt
Normal file
@ -0,0 +1,20 @@
|
||||
你是子智能体 {agent_id}(任务编号 {task_id}),与主智能体完全隔离,唯一的输入来源就是本任务描述与 `references/` 中的只读资料。你当前的工作区结构如下:
|
||||
|
||||
- 工作区:`{workspace}` —— 只能在此路径内创建/修改文件。
|
||||
- 参考目录:`{references}` —— 主智能体提供的上下文快照,严格只读,用于查阅需求、接口或示例实现。
|
||||
- 交付目录:`{deliverables}` —— 必须放置交付成果与 `result.md`;主系统会把整个交付目录复制到 `{target_project_dir}` 下对应的 `*_deliverables` 文件夹。
|
||||
|
||||
请严格遵守以下原则:
|
||||
1. **明确职责**:根据“任务摘要/详细任务”在工作区内独立完成实现,无权回问主智能体或等待额外说明;若信息不足,应在 `result.md` 中说明局限。
|
||||
2. **参考使用**:需要引用 `references/` 中的文件时,复制或转述其必要片段,不可修改原文件;引用时注明来源,避免与交付混淆。
|
||||
3. **交付规范**:`deliverables/` 至少包含:
|
||||
- `result.md`:总结完成度、关键实现、测试/验收情况、未解决问题与后续建议(中文或中英双语)。
|
||||
- 任务成果文件:遵循约定的目录/命名,可附 README 或使用说明,确保主智能体复制后即可查阅。
|
||||
4. **禁止事项**:不得调用记忆/待办类工具,不得越出工作区,也不要尝试联系主智能体;所有说明请写入正常回复或 `result.md`。
|
||||
5. **流程要求**:先复盘任务、列出执行计划,再分步骤完成并自检。需要运行脚本或命令时务必记录要点,方便主智能体验收。
|
||||
6. **完成条件**:确认所有交付文件就绪且 `result.md` 信息完整后,才能调用 `finish_sub_agent`,并在 `reason` 中概括完成情况与下一步建议。
|
||||
|
||||
任务摘要:{summary}
|
||||
详细任务:{task}
|
||||
|
||||
请在隔离环境中独立完成任务,遇到阻塞时在消息与 `result.md` 中说明原因及建议,而不是等待主智能体回应。
|
||||
35
sub_agent/prompts/todo_guidelines.txt
Normal file
35
sub_agent/prompts/todo_guidelines.txt
Normal file
@ -0,0 +1,35 @@
|
||||
# 子智能体待办事项速记
|
||||
|
||||
你无法向主智能体提问,只能依靠待办清单来规划和追踪工作。清单越精炼、越可执行,你就越能掌控节奏。
|
||||
|
||||
## 何时创建
|
||||
- 任务包含 2 步以上且每步需要独立确认。
|
||||
- 需要同时操作多个文件/工具,容易遗漏。
|
||||
- 需要自查进度或复盘交付内容。
|
||||
|
||||
## 如何编写
|
||||
1. **概述**:一句话写清楚你正在完成的目标(≤50 字)。
|
||||
2. **任务项**:2~4 条即可,按执行顺序罗列。每条必须描述“对哪个对象做什么动作”,例如 “读取 sales.xlsx,统计月度汇总”。
|
||||
3. **粒度**:避免含糊词(“处理”、“完善”等);能在十分钟内完成的最小可执行步骤即可。
|
||||
|
||||
## 使用流程
|
||||
1. **先规划**:在创建清单前,用自然语言写下你准备执行的流程,让自己确认无遗漏。
|
||||
2. **todo_create**:把概述与任务数组一次性写对,创建后尽量不要反复删除重建。
|
||||
3. **todo_update_task**:每完成一项立刻勾选;若步骤发生变化,先写明原因再修改对应任务。
|
||||
4. **todo_finish**:所有任务完成后调用。若仍有未完项但必须停止,先调用 `todo_finish`,再用 `todo_finish_confirm` 说明原因与后续建议。
|
||||
|
||||
## 编写示例
|
||||
```
|
||||
概述:整理 physics_test 题解,生成 deliverables/result.md
|
||||
任务1:read_file physics_problems.txt,列出 5 道题
|
||||
任务2:在 workspace/solutions.md 中逐题写解答
|
||||
任务3:整理 result.md,概括完成情况与风险
|
||||
任务4:检查 deliverables/ 是否包含 result.md 与 solutions.md
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
- 只写你能够自主完成的步骤;不要写“等待主智能体确认”之类无法执行的任务。
|
||||
- 如果任务被新的发现打断,先在普通回复里说明,再用待办系统更新下一步。
|
||||
- 清单结束前必须保证 deliverables/ 与 result.md 已同步更新,否则不要 finish。
|
||||
|
||||
遵循以上规则能让子任务自洽、可追踪,也方便最终在 `result.md` 中回溯整个执行过程。***
|
||||
@ -1,127 +1,18 @@
|
||||
"""子智能体测试服务。
|
||||
"""子智能体Web服务入口(监听8092端口)。"""
|
||||
|
||||
该服务与主智能体完全隔离,监听 8092 端口,模拟 20 秒后完成任务的过程。
|
||||
后续可替换为真实的多轮子智能体推理逻辑。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, jsonify, request
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
TASKS_ROOT = BASE_DIR / "tasks"
|
||||
STATE_FILE = TASKS_ROOT / "tasks_state.json"
|
||||
STUB_DELAY_SECONDS = int(os.environ.get("SUB_AGENT_STUB_DELAY", "20"))
|
||||
|
||||
TASKS_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
app = Flask(__name__)
|
||||
TASK_CACHE = {}
|
||||
|
||||
|
||||
def load_state():
|
||||
if STATE_FILE.exists():
|
||||
try:
|
||||
data = json.loads(STATE_FILE.read_text(encoding="utf-8"))
|
||||
TASK_CACHE.update(data)
|
||||
except json.JSONDecodeError:
|
||||
STATE_FILE.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def save_state():
|
||||
STATE_FILE.write_text(json.dumps(TASK_CACHE, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def _touch_result_files(deliverables_dir: Path, summary: str, task_description: str) -> str:
|
||||
deliverables_dir.mkdir(parents=True, exist_ok=True)
|
||||
result_md = deliverables_dir / "result.md"
|
||||
if not result_md.exists():
|
||||
result_md.write_text(
|
||||
f"# 子智能体测试结果\n\n"
|
||||
f"- 摘要:{summary}\n"
|
||||
f"- 任务:{task_description}\n"
|
||||
f"- 说明:当前为测试服务,已自动生成占位结果。\n",
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
payload = {
|
||||
"summary": summary,
|
||||
"task": task_description,
|
||||
"note": "stub result generated by sub_agent server",
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
result_json = deliverables_dir / "result.json"
|
||||
result_json.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return str(result_json)
|
||||
|
||||
|
||||
def _finalize_if_ready(task_id: str) -> dict:
|
||||
record = TASK_CACHE.get(task_id)
|
||||
if not record:
|
||||
return {}
|
||||
|
||||
if record.get("status") == "completed":
|
||||
return record
|
||||
|
||||
if time.time() >= record.get("ready_at", 0):
|
||||
deliverables_dir = Path(record["deliverables_dir"])
|
||||
result_file = _touch_result_files(deliverables_dir, record["summary"], record["task_description"])
|
||||
record.update(
|
||||
{
|
||||
"success": True,
|
||||
"status": "completed",
|
||||
"message": "测试子智能体已生成占位结果。",
|
||||
"result_file": result_file,
|
||||
"updated_at": time.time(),
|
||||
}
|
||||
)
|
||||
TASK_CACHE[task_id] = record
|
||||
save_state()
|
||||
|
||||
return record
|
||||
|
||||
|
||||
@app.post("/tasks")
|
||||
def create_task():
|
||||
data = request.get_json(silent=True) or {}
|
||||
task_id = data.get("task_id") or f"sub_stub_{int(time.time())}"
|
||||
summary = data.get("summary", "未提供摘要")
|
||||
description = data.get("task", "未提供任务说明")
|
||||
deliverables_dir = Path(data.get("deliverables_dir") or (TASKS_ROOT / task_id / "deliverables"))
|
||||
deliverables_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
record = {
|
||||
"success": True,
|
||||
"task_id": task_id,
|
||||
"status": "running",
|
||||
"message": f"子智能体任务已提交,预计 {STUB_DELAY_SECONDS} 秒后完成。",
|
||||
"deliverables_dir": str(deliverables_dir),
|
||||
"result_file": None,
|
||||
"updated_at": time.time(),
|
||||
"ready_at": time.time() + STUB_DELAY_SECONDS,
|
||||
"summary": summary,
|
||||
"task_description": description,
|
||||
}
|
||||
TASK_CACHE[task_id] = record
|
||||
save_state()
|
||||
return jsonify(record)
|
||||
|
||||
|
||||
@app.get("/tasks/<task_id>")
|
||||
def get_task(task_id: str):
|
||||
record = _finalize_if_ready(task_id)
|
||||
if not record:
|
||||
return jsonify({"success": False, "status": "unknown", "message": "task not found"}), 404
|
||||
return jsonify(record)
|
||||
|
||||
|
||||
def main():
|
||||
load_state()
|
||||
app.run(host="0.0.0.0", port=8092)
|
||||
|
||||
from web_server import run_server, parse_arguments, DEFAULT_PORT
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
args = parse_arguments()
|
||||
port = args.port or DEFAULT_PORT
|
||||
# 子智能体服务默认使用8092端口
|
||||
if port == DEFAULT_PORT:
|
||||
port = 8092
|
||||
run_server(
|
||||
path=args.path or str(Path(".").resolve()),
|
||||
thinking_mode=args.thinking_mode,
|
||||
port=port,
|
||||
debug=args.debug,
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
899
sub_agent/static/file_manager/app.js
Normal file
899
sub_agent/static/file_manager/app.js
Normal file
@ -0,0 +1,899 @@
|
||||
(() => {
|
||||
const API_BASE = '/api/gui/files';
|
||||
const EDITOR_PAGE = '/file-manager/editor';
|
||||
|
||||
const state = {
|
||||
currentPath: '',
|
||||
items: [],
|
||||
selected: new Set(),
|
||||
lastSelectedIndex: null,
|
||||
clipboard: null, // {mode: 'copy'|'cut', items: []}
|
||||
treeCache: new Map(),
|
||||
treeExpanded: new Set(['']),
|
||||
isDraggingSelection: false,
|
||||
dragStart: null,
|
||||
selectionRect: null,
|
||||
selectionJustFinished: false,
|
||||
selectionDisabled: false,
|
||||
};
|
||||
|
||||
const icons = {
|
||||
directory: '📁',
|
||||
default: '📄',
|
||||
editable: '📝',
|
||||
code: '💻',
|
||||
markdown: '🧾',
|
||||
image: '🖼️',
|
||||
archive: '🗃️',
|
||||
};
|
||||
|
||||
const fileGrid = document.getElementById('fileGrid');
|
||||
const directoryTree = document.getElementById('directoryTree');
|
||||
const breadcrumbEl = document.getElementById('breadcrumb');
|
||||
const selectionInfo = document.getElementById('selectionInfo');
|
||||
const statusBar = document.getElementById('statusBar');
|
||||
const contextMenu = document.getElementById('contextMenu');
|
||||
const dialogBackdrop = document.getElementById('dialogBackdrop');
|
||||
const dialogTitle = document.getElementById('dialogTitle');
|
||||
const dialogContent = document.getElementById('dialogContent');
|
||||
const dialogCancel = document.getElementById('dialogCancel');
|
||||
const dialogConfirm = document.getElementById('dialogConfirm');
|
||||
const hiddenUploader = document.getElementById('hiddenUploader');
|
||||
const pasteBtn = document.getElementById('btnPaste');
|
||||
|
||||
const newFolderBtn = document.getElementById('btnNewFolder');
|
||||
const newFileBtn = document.getElementById('btnNewFile');
|
||||
const refreshBtn = document.getElementById('btnRefresh');
|
||||
const uploadBtn = document.getElementById('btnUpload');
|
||||
const backBtn = document.getElementById('btnBack');
|
||||
const returnChatBtn = document.getElementById('btnReturnChat');
|
||||
const downloadBtn = document.getElementById('btnDownload');
|
||||
const renameBtn = document.getElementById('btnRename');
|
||||
const copyBtn = document.getElementById('btnCopy');
|
||||
const cutBtn = document.getElementById('btnCut');
|
||||
const deleteBtn = document.getElementById('btnDelete');
|
||||
const toggleSelectionBtn = document.getElementById('btnToggleSelection');
|
||||
|
||||
const clamp = (value, min, max) => Math.max(min, Math.min(value, max));
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const initialPathParam = (urlParams.get('path') || '').replace(/^\//, '').replace(/\/$/, '');
|
||||
|
||||
dialogBackdrop.hidden = true;
|
||||
|
||||
let dialogHandlers = { confirm: null, cancel: null };
|
||||
|
||||
function clearDialogHandlers() {
|
||||
dialogHandlers.confirm = null;
|
||||
dialogHandlers.cancel = null;
|
||||
}
|
||||
|
||||
function registerDialogHandlers(confirmHandler, cancelHandler) {
|
||||
dialogHandlers.confirm = confirmHandler || null;
|
||||
dialogHandlers.cancel = cancelHandler || null;
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
dialogBackdrop.hidden = true;
|
||||
clearDialogHandlers();
|
||||
}
|
||||
|
||||
dialogCancel.addEventListener('click', () => {
|
||||
if (dialogHandlers.cancel) {
|
||||
const handler = dialogHandlers.cancel;
|
||||
clearDialogHandlers();
|
||||
handler();
|
||||
} else {
|
||||
closeDialog();
|
||||
}
|
||||
});
|
||||
|
||||
dialogConfirm.addEventListener('click', () => {
|
||||
if (dialogHandlers.confirm) {
|
||||
const handler = dialogHandlers.confirm;
|
||||
clearDialogHandlers();
|
||||
handler();
|
||||
} else {
|
||||
closeDialog();
|
||||
}
|
||||
});
|
||||
|
||||
dialogBackdrop.addEventListener('click', (event) => {
|
||||
if (event.target === dialogBackdrop) {
|
||||
if (dialogHandlers.cancel) {
|
||||
const handler = dialogHandlers.cancel;
|
||||
clearDialogHandlers();
|
||||
handler();
|
||||
} else {
|
||||
closeDialog();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape' && !dialogBackdrop.hidden) {
|
||||
if (dialogHandlers.cancel) {
|
||||
const handler = dialogHandlers.cancel;
|
||||
clearDialogHandlers();
|
||||
handler();
|
||||
} else {
|
||||
closeDialog();
|
||||
}
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
|
||||
function showStatus(message) {
|
||||
statusBar.textContent = message;
|
||||
}
|
||||
|
||||
function formatSize(size) {
|
||||
if (size < 1024) return `${size} B`;
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||
if (size < 1024 * 1024 * 1024) return `${(size / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${(size / 1024 / 1024 / 1024).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
function formatTime(ts) {
|
||||
const d = new Date(ts * 1000);
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
function joinPath(base, name) {
|
||||
if (!base) return name;
|
||||
return `${base.replace(/\/$/, '')}/${name}`;
|
||||
}
|
||||
|
||||
function getIcon(entry) {
|
||||
if (entry.type === 'directory') return icons.directory;
|
||||
if (entry.is_editable) return icons.editable;
|
||||
const ext = entry.extension || '';
|
||||
if (['.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp'].includes(ext)) {
|
||||
return icons.image;
|
||||
}
|
||||
if (['.zip', '.rar', '.7z', '.tar', '.gz'].includes(ext)) {
|
||||
return icons.archive;
|
||||
}
|
||||
if (['.js', '.ts', '.py', '.rb', '.php', '.java', '.kt', '.go', '.rs', '.c', '.cpp', '.h', '.hpp'].includes(ext)) {
|
||||
return icons.code;
|
||||
}
|
||||
if (['.md', '.markdown'].includes(ext)) {
|
||||
return icons.markdown;
|
||||
}
|
||||
return icons.default;
|
||||
}
|
||||
|
||||
async function request(url, options = {}) {
|
||||
const response = await fetch(url, options);
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok || data.success === false) {
|
||||
const message = data.error || data.message || `请求失败 (${response.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function updateUrl(path) {
|
||||
const url = new URL(window.location.href);
|
||||
if (path) {
|
||||
url.searchParams.set('path', path);
|
||||
} else {
|
||||
url.searchParams.delete('path');
|
||||
}
|
||||
window.history.replaceState({}, '', url.pathname + url.search);
|
||||
}
|
||||
|
||||
async function ensureAncestors(path) {
|
||||
await ensureTreeNode('', false);
|
||||
state.treeExpanded.add('');
|
||||
if (!path) {
|
||||
renderTree();
|
||||
return;
|
||||
}
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
let current = '';
|
||||
for (let index = 0; index < segments.length; index += 1) {
|
||||
const segment = segments[index];
|
||||
current = current ? `${current}/${segment}` : segment;
|
||||
if (index < segments.length - 1) {
|
||||
state.treeExpanded.add(current);
|
||||
}
|
||||
await ensureTreeNode(current, false);
|
||||
}
|
||||
renderTree();
|
||||
}
|
||||
|
||||
async function loadDirectory(path = '', { updateHistory = true } = {}) {
|
||||
hideContextMenu();
|
||||
showStatus('加载中...');
|
||||
try {
|
||||
const result = await request(`${API_BASE}/entries?path=${encodeURIComponent(path)}`);
|
||||
const resolvedPath = result.data.path || '';
|
||||
state.currentPath = resolvedPath;
|
||||
state.items = result.data.items || [];
|
||||
const directoryEntries = state.items.filter((item) => item.type === 'directory');
|
||||
state.treeCache.set(resolvedPath, directoryEntries);
|
||||
if (updateHistory) {
|
||||
updateUrl(resolvedPath);
|
||||
}
|
||||
await ensureAncestors(resolvedPath);
|
||||
renderBreadcrumb(result.data.breadcrumb || []);
|
||||
renderGrid();
|
||||
updateSelection([]);
|
||||
state.lastSelectedIndex = null;
|
||||
showStatus(`已加载 ${state.items.length} 项`);
|
||||
} catch (err) {
|
||||
showStatus(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function renderBreadcrumb(crumbs) {
|
||||
breadcrumbEl.innerHTML = '';
|
||||
crumbs.forEach((crumb, index) => {
|
||||
const span = document.createElement('span');
|
||||
span.textContent = crumb.name;
|
||||
span.dataset.path = crumb.path;
|
||||
span.addEventListener('click', () => {
|
||||
loadDirectory(crumb.path);
|
||||
});
|
||||
breadcrumbEl.appendChild(span);
|
||||
if (index < crumbs.length - 1) {
|
||||
const sep = document.createElement('span');
|
||||
sep.textContent = '›';
|
||||
sep.classList.add('fm-breadcrumb-sep');
|
||||
breadcrumbEl.appendChild(sep);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderGrid() {
|
||||
fileGrid.innerHTML = '';
|
||||
state.items.forEach((entry, index) => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'fm-card';
|
||||
card.tabIndex = 0;
|
||||
card.dataset.path = entry.path;
|
||||
card.dataset.index = index;
|
||||
if (state.selected.has(entry.path)) {
|
||||
card.classList.add('selected');
|
||||
}
|
||||
|
||||
const icon = document.createElement('div');
|
||||
icon.className = 'fm-card-icon';
|
||||
icon.textContent = getIcon(entry);
|
||||
|
||||
const name = document.createElement('div');
|
||||
name.className = 'fm-card-name';
|
||||
name.textContent = entry.name;
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'fm-card-meta';
|
||||
const lines = [];
|
||||
if (entry.type === 'file') {
|
||||
lines.push(formatSize(entry.size));
|
||||
} else {
|
||||
lines.push('目录');
|
||||
}
|
||||
lines.push(formatTime(entry.modified_at));
|
||||
meta.innerHTML = lines.join('<br>');
|
||||
|
||||
card.appendChild(icon);
|
||||
card.appendChild(name);
|
||||
card.appendChild(meta);
|
||||
|
||||
card.addEventListener('click', (event) => handleItemClick(event, entry, index));
|
||||
card.addEventListener('dblclick', () => handleItemDoubleClick(entry));
|
||||
card.addEventListener('contextmenu', (event) => handleItemContextMenu(event, entry));
|
||||
|
||||
fileGrid.appendChild(card);
|
||||
});
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'fm-drop-overlay';
|
||||
overlay.textContent = '释放即可上传到此目录';
|
||||
fileGrid.appendChild(overlay);
|
||||
}
|
||||
|
||||
function updateSelection(paths, options = { append: false, range: false }) {
|
||||
if (!options.append && !options.range) {
|
||||
state.selected.clear();
|
||||
if (paths.length === 0) {
|
||||
state.lastSelectedIndex = null;
|
||||
}
|
||||
}
|
||||
paths.forEach((path) => {
|
||||
if (state.selected.has(path) && options.append) {
|
||||
state.selected.delete(path);
|
||||
} else {
|
||||
state.selected.add(path);
|
||||
}
|
||||
});
|
||||
syncSelectionUI();
|
||||
}
|
||||
|
||||
function syncSelectionUI() {
|
||||
const cards = fileGrid.querySelectorAll('.fm-card');
|
||||
cards.forEach((card) => {
|
||||
if (state.selected.has(card.dataset.path)) {
|
||||
card.classList.add('selected');
|
||||
} else {
|
||||
card.classList.remove('selected');
|
||||
}
|
||||
});
|
||||
selectionInfo.textContent = `已选中 ${state.selected.size} 项`;
|
||||
pasteBtn.disabled = !state.clipboard || !state.clipboard.items.length;
|
||||
}
|
||||
|
||||
function handleItemClick(event, entry, index) {
|
||||
const isMetaKey = event.metaKey || event.ctrlKey;
|
||||
const isShiftKey = event.shiftKey;
|
||||
if (isShiftKey && state.lastSelectedIndex !== null) {
|
||||
const start = Math.min(state.lastSelectedIndex, index);
|
||||
const end = Math.max(state.lastSelectedIndex, index);
|
||||
const paths = state.items.slice(start, end + 1).map((item) => item.path);
|
||||
updateSelection(paths, { range: true });
|
||||
} else if (isMetaKey) {
|
||||
updateSelection([entry.path], { append: true });
|
||||
state.lastSelectedIndex = index;
|
||||
} else {
|
||||
updateSelection([entry.path], { append: false });
|
||||
state.lastSelectedIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
function handleItemDoubleClick(entry) {
|
||||
if (entry.type === 'directory') {
|
||||
loadDirectory(entry.path);
|
||||
return;
|
||||
}
|
||||
if (entry.is_editable) {
|
||||
window.location.href = `${EDITOR_PAGE}?path=${encodeURIComponent(entry.path)}`;
|
||||
return;
|
||||
}
|
||||
window.open(`${API_BASE}/download?path=${encodeURIComponent(entry.path)}`, '_blank');
|
||||
}
|
||||
|
||||
function handleItemContextMenu(event, entry) {
|
||||
event.preventDefault();
|
||||
if (!state.selected.has(entry.path)) {
|
||||
updateSelection([entry.path], { append: false });
|
||||
}
|
||||
showContextMenu(event.clientX, event.clientY);
|
||||
}
|
||||
|
||||
function showContextMenu(x, y) {
|
||||
const single = state.selected.size === 1;
|
||||
const singleEntry = single ? getSingleSelected() : null;
|
||||
contextMenu.innerHTML = '';
|
||||
|
||||
const entries = [];
|
||||
|
||||
if (singleEntry) {
|
||||
if (singleEntry.type === 'directory') {
|
||||
entries.push({ label: '打开', action: openSelected, disabled: false });
|
||||
} else if (singleEntry.is_editable) {
|
||||
entries.push({ label: '在编辑器中打开', action: openEditor, disabled: false });
|
||||
if (singleEntry.extension === '.html' || singleEntry.extension === '.htm') {
|
||||
entries.push({ label: '预览', action: previewSelected, disabled: false });
|
||||
}
|
||||
entries.push({ label: '下载', action: downloadSelected, disabled: false });
|
||||
} else {
|
||||
const isHtml = singleEntry.extension === '.html' || singleEntry.extension === '.htm';
|
||||
if (isHtml) {
|
||||
entries.push({ label: '预览', action: previewSelected, disabled: false });
|
||||
}
|
||||
entries.push({ label: '下载', action: downloadSelected, disabled: false });
|
||||
}
|
||||
} else if (state.selected.size > 0) {
|
||||
entries.push({ label: '下载', action: downloadSelected, disabled: false });
|
||||
}
|
||||
|
||||
entries.push(
|
||||
{ label: '重命名', action: renameSelected, disabled: !single },
|
||||
{ label: '复制', action: copySelected, disabled: state.selected.size === 0 },
|
||||
{ label: '剪切', action: cutSelected, disabled: state.selected.size === 0 },
|
||||
{ label: '粘贴', action: pasteClipboard, disabled: !state.clipboard || !state.clipboard.items.length },
|
||||
{ label: '删除', action: deleteSelected, disabled: state.selected.size === 0 },
|
||||
);
|
||||
|
||||
entries.forEach((item) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = item.label;
|
||||
btn.disabled = item.disabled;
|
||||
btn.addEventListener('click', () => {
|
||||
hideContextMenu();
|
||||
item.action();
|
||||
});
|
||||
contextMenu.appendChild(btn);
|
||||
});
|
||||
contextMenu.style.display = 'block';
|
||||
const { innerWidth, innerHeight } = window;
|
||||
const menuRect = contextMenu.getBoundingClientRect();
|
||||
const left = clamp(x, 0, innerWidth - menuRect.width);
|
||||
const top = clamp(y, 0, innerHeight - menuRect.height);
|
||||
contextMenu.style.left = `${left}px`;
|
||||
contextMenu.style.top = `${top}px`;
|
||||
}
|
||||
|
||||
function hideContextMenu() {
|
||||
contextMenu.style.display = 'none';
|
||||
}
|
||||
|
||||
function getSingleSelected() {
|
||||
if (state.selected.size !== 1) return null;
|
||||
const path = Array.from(state.selected)[0];
|
||||
return state.items.find((item) => item.path === path) || null;
|
||||
}
|
||||
|
||||
function openSelected() {
|
||||
const entry = getSingleSelected();
|
||||
if (!entry) return;
|
||||
handleItemDoubleClick(entry);
|
||||
}
|
||||
|
||||
function previewSelected() {
|
||||
const entry = getSingleSelected();
|
||||
if (!entry) return;
|
||||
if (entry.extension !== '.html' && entry.extension !== '.htm') {
|
||||
showStatus('仅支持预览 HTML 文件');
|
||||
return;
|
||||
}
|
||||
window.open(`/file-preview/${encodeURIComponent(entry.path)}`, '_blank');
|
||||
}
|
||||
|
||||
function openEditor() {
|
||||
const entry = getSingleSelected();
|
||||
if (!entry || !entry.is_editable) return;
|
||||
window.location.href = `${EDITOR_PAGE}?path=${encodeURIComponent(entry.path)}`;
|
||||
}
|
||||
|
||||
function triggerBlobDownload(filename, blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function downloadSelected() {
|
||||
if (!state.selected.size) return;
|
||||
if (state.selected.size === 1) {
|
||||
const path = Array.from(state.selected)[0];
|
||||
window.open(`${API_BASE}/download?path=${encodeURIComponent(path)}`, '_blank');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}/download/batch`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ paths: Array.from(state.selected) })
|
||||
});
|
||||
if (!resp.ok) {
|
||||
let message = '批量下载失败';
|
||||
try {
|
||||
const data = await resp.json();
|
||||
message = data.error || data.message || message;
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
const blob = await resp.blob();
|
||||
triggerBlobDownload(`selected_${Date.now()}.zip`, blob);
|
||||
showStatus(`已开始下载 ${state.selected.size} 个项目`);
|
||||
} catch (err) {
|
||||
showStatus(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function renameSelected() {
|
||||
const entry = getSingleSelected();
|
||||
if (!entry) return;
|
||||
const newName = await promptDialog('重命名', entry.name);
|
||||
if (!newName) return;
|
||||
try {
|
||||
await request(`${API_BASE}/rename`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: entry.path, new_name: newName }),
|
||||
});
|
||||
await loadDirectory(state.currentPath);
|
||||
} catch (err) {
|
||||
showStatus(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function copySelected() {
|
||||
if (!state.selected.size) return;
|
||||
state.clipboard = { mode: 'copy', items: Array.from(state.selected) };
|
||||
showStatus(`已复制 ${state.clipboard.items.length} 项`);
|
||||
syncSelectionUI();
|
||||
}
|
||||
|
||||
function cutSelected() {
|
||||
if (!state.selected.size) return;
|
||||
state.clipboard = { mode: 'cut', items: Array.from(state.selected) };
|
||||
showStatus(`已剪切 ${state.clipboard.items.length} 项`);
|
||||
syncSelectionUI();
|
||||
}
|
||||
|
||||
async function pasteClipboard() {
|
||||
if (!state.clipboard || !state.clipboard.items.length) return;
|
||||
const endpoint = state.clipboard.mode === 'copy' ? 'copy' : 'move';
|
||||
try {
|
||||
await request(`${API_BASE}/${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
paths: state.clipboard.items,
|
||||
target_dir: state.currentPath,
|
||||
}),
|
||||
});
|
||||
if (state.clipboard.mode === 'cut') {
|
||||
state.clipboard = null;
|
||||
}
|
||||
await loadDirectory(state.currentPath);
|
||||
} catch (err) {
|
||||
showStatus(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSelected() {
|
||||
if (!state.selected.size) return;
|
||||
const confirm = await confirmDialog(`确认删除选中的 ${state.selected.size} 项吗?该操作不可撤销。`);
|
||||
if (!confirm) return;
|
||||
try {
|
||||
await request(`${API_BASE}/delete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ paths: Array.from(state.selected) }),
|
||||
});
|
||||
await loadDirectory(state.currentPath);
|
||||
} catch (err) {
|
||||
showStatus(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function promptDialog(title, defaultValue = '') {
|
||||
return new Promise((resolve) => {
|
||||
dialogTitle.textContent = title;
|
||||
dialogContent.innerHTML = '';
|
||||
const input = document.createElement('input');
|
||||
input.value = defaultValue;
|
||||
input.autofocus = true;
|
||||
dialogContent.appendChild(input);
|
||||
const finish = (value) => {
|
||||
closeDialog();
|
||||
resolve(value);
|
||||
};
|
||||
registerDialogHandlers(() => finish(input.value.trim()), () => finish(null));
|
||||
dialogBackdrop.hidden = false;
|
||||
input.addEventListener('keydown', (evt) => {
|
||||
if (evt.key === 'Enter') {
|
||||
evt.preventDefault();
|
||||
finish(input.value.trim());
|
||||
} else if (evt.key === 'Escape') {
|
||||
evt.preventDefault();
|
||||
finish(null);
|
||||
}
|
||||
});
|
||||
setTimeout(() => input.select(), 50);
|
||||
});
|
||||
}
|
||||
|
||||
function confirmDialog(message) {
|
||||
return new Promise((resolve) => {
|
||||
dialogTitle.textContent = '确认操作';
|
||||
dialogContent.innerHTML = `<p>${message}</p>`;
|
||||
const finish = (value) => {
|
||||
closeDialog();
|
||||
resolve(value);
|
||||
};
|
||||
registerDialogHandlers(() => finish(true), () => finish(false));
|
||||
dialogBackdrop.hidden = false;
|
||||
});
|
||||
}
|
||||
|
||||
function handleGlobalClick(event) {
|
||||
if (!contextMenu.contains(event.target)) {
|
||||
hideContextMenu();
|
||||
}
|
||||
}
|
||||
|
||||
function handleGridBackgroundClick(event) {
|
||||
if (event.target === fileGrid) {
|
||||
if (state.selectionJustFinished) {
|
||||
return;
|
||||
}
|
||||
updateSelection([]);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragEnter(event) {
|
||||
event.preventDefault();
|
||||
fileGrid.classList.add('drop-target');
|
||||
}
|
||||
|
||||
function handleDragOver(event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function handleDragLeave(event) {
|
||||
if (event.target === fileGrid) {
|
||||
fileGrid.classList.remove('drop-target');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDrop(event) {
|
||||
event.preventDefault();
|
||||
fileGrid.classList.remove('drop-target');
|
||||
if (!event.dataTransfer || !event.dataTransfer.files.length) return;
|
||||
const files = event.dataTransfer.files;
|
||||
await uploadFiles(files, state.currentPath);
|
||||
}
|
||||
|
||||
async function uploadFiles(fileList, targetPath) {
|
||||
for (const file of fileList) {
|
||||
const form = new FormData();
|
||||
form.append('file', file, file.name);
|
||||
form.append('filename', file.name);
|
||||
form.append('path', targetPath);
|
||||
try {
|
||||
await request(`${API_BASE}/upload`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
showStatus(`已上传 ${file.name}`);
|
||||
} catch (err) {
|
||||
showStatus(`上传失败:${err.message}`);
|
||||
}
|
||||
}
|
||||
await loadDirectory(state.currentPath);
|
||||
}
|
||||
|
||||
function initSelectionRectangle() {
|
||||
fileGrid.addEventListener('pointerdown', (event) => {
|
||||
if (state.selectionDisabled) return;
|
||||
if (event.target !== fileGrid) return;
|
||||
state.isDraggingSelection = true;
|
||||
state.dragStart = { x: event.clientX, y: event.clientY };
|
||||
state.selectionRect = document.createElement('div');
|
||||
state.selectionRect.className = 'fm-selection-rect';
|
||||
fileGrid.appendChild(state.selectionRect);
|
||||
updateSelection([]);
|
||||
state.selectionJustFinished = false;
|
||||
fileGrid.setPointerCapture(event.pointerId);
|
||||
});
|
||||
|
||||
fileGrid.addEventListener('pointermove', (event) => {
|
||||
if (!state.isDraggingSelection || !state.selectionRect) return;
|
||||
const rect = fileGrid.getBoundingClientRect();
|
||||
const current = { x: event.clientX, y: event.clientY };
|
||||
const x = Math.min(state.dragStart.x, current.x) - rect.left + fileGrid.scrollLeft;
|
||||
const y = Math.min(state.dragStart.y, current.y) - rect.top + fileGrid.scrollTop;
|
||||
const width = Math.abs(state.dragStart.x - current.x);
|
||||
const height = Math.abs(state.dragStart.y - current.y);
|
||||
Object.assign(state.selectionRect.style, {
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
});
|
||||
|
||||
const selectionBox = {
|
||||
left: Math.min(state.dragStart.x, current.x),
|
||||
right: Math.max(state.dragStart.x, current.x),
|
||||
top: Math.min(state.dragStart.y, current.y),
|
||||
bottom: Math.max(state.dragStart.y, current.y),
|
||||
};
|
||||
|
||||
const selected = [];
|
||||
const cards = fileGrid.querySelectorAll('.fm-card');
|
||||
cards.forEach((card) => {
|
||||
const bounds = card.getBoundingClientRect();
|
||||
const intersects = !(selectionBox.right < bounds.left ||
|
||||
selectionBox.left > bounds.right ||
|
||||
selectionBox.bottom < bounds.top ||
|
||||
selectionBox.top > bounds.bottom);
|
||||
if (intersects) {
|
||||
selected.push(card.dataset.path);
|
||||
}
|
||||
});
|
||||
updateSelection(selected);
|
||||
});
|
||||
|
||||
fileGrid.addEventListener('pointerup', (event) => {
|
||||
if (!state.isDraggingSelection) return;
|
||||
state.isDraggingSelection = false;
|
||||
if (state.selectionRect) {
|
||||
state.selectionRect.remove();
|
||||
state.selectionRect = null;
|
||||
}
|
||||
state.selectionJustFinished = true;
|
||||
requestAnimationFrame(() => {
|
||||
state.selectionJustFinished = false;
|
||||
});
|
||||
fileGrid.releasePointerCapture(event.pointerId);
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureTreeNode(path, shouldRender = true) {
|
||||
let changed = false;
|
||||
if (!state.treeCache.has(path)) {
|
||||
try {
|
||||
const result = await request(`${API_BASE}/entries?path=${encodeURIComponent(path)}`);
|
||||
const directories = result.data.items.filter((item) => item.type === 'directory');
|
||||
state.treeCache.set(path, directories);
|
||||
changed = true;
|
||||
} catch (err) {
|
||||
showStatus(err.message);
|
||||
}
|
||||
}
|
||||
if (shouldRender && changed) {
|
||||
renderTree();
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function renderTree() {
|
||||
directoryTree.innerHTML = '';
|
||||
const rootNode = createTreeNode('', '根目录');
|
||||
directoryTree.appendChild(rootNode);
|
||||
}
|
||||
|
||||
function createTreeNode(path, name) {
|
||||
const li = document.createElement('li');
|
||||
const header = document.createElement('div');
|
||||
header.className = 'fm-tree-item';
|
||||
if (path === state.currentPath) {
|
||||
header.classList.add('active');
|
||||
}
|
||||
const toggle = document.createElement('span');
|
||||
toggle.className = 'fm-tree-toggle';
|
||||
toggle.textContent = state.treeExpanded.has(path) ? '▾' : '▸';
|
||||
toggle.addEventListener('click', async (event) => {
|
||||
event.stopPropagation();
|
||||
if (state.treeExpanded.has(path)) {
|
||||
state.treeExpanded.delete(path);
|
||||
} else {
|
||||
state.treeExpanded.add(path);
|
||||
await ensureTreeNode(path, false);
|
||||
}
|
||||
renderTree();
|
||||
});
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.textContent = name;
|
||||
label.addEventListener('click', () => loadDirectory(path));
|
||||
|
||||
header.addEventListener('click', () => loadDirectory(path));
|
||||
|
||||
header.appendChild(toggle);
|
||||
header.appendChild(label);
|
||||
li.appendChild(header);
|
||||
|
||||
if (state.treeExpanded.has(path)) {
|
||||
const children = document.createElement('ul');
|
||||
children.className = 'fm-tree-children';
|
||||
const dirs = state.treeCache.get(path) || [];
|
||||
dirs.forEach((dir) => {
|
||||
const child = createTreeNode(dir.path, dir.name);
|
||||
children.appendChild(child);
|
||||
});
|
||||
li.appendChild(children);
|
||||
}
|
||||
return li;
|
||||
}
|
||||
|
||||
function bindToolbar() {
|
||||
newFolderBtn.addEventListener('click', async () => {
|
||||
const name = await promptDialog('新建文件夹', '新建文件夹');
|
||||
if (!name) return;
|
||||
try {
|
||||
await request(`${API_BASE}/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
path: state.currentPath,
|
||||
name,
|
||||
type: 'directory',
|
||||
}),
|
||||
});
|
||||
await loadDirectory(state.currentPath);
|
||||
} catch (err) {
|
||||
showStatus(err.message);
|
||||
}
|
||||
});
|
||||
|
||||
newFileBtn.addEventListener('click', async () => {
|
||||
const name = await promptDialog('新建文件', '新建文件.txt');
|
||||
if (!name) return;
|
||||
try {
|
||||
await request(`${API_BASE}/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
path: state.currentPath,
|
||||
name,
|
||||
type: 'file',
|
||||
}),
|
||||
});
|
||||
await loadDirectory(state.currentPath);
|
||||
} catch (err) {
|
||||
showStatus(err.message);
|
||||
}
|
||||
});
|
||||
|
||||
refreshBtn.addEventListener('click', () => loadDirectory(state.currentPath));
|
||||
uploadBtn.addEventListener('click', () => hiddenUploader.click());
|
||||
backBtn.addEventListener('click', () => {
|
||||
if (!state.currentPath) {
|
||||
loadDirectory('');
|
||||
return;
|
||||
}
|
||||
const segments = state.currentPath.split('/').filter(Boolean);
|
||||
if (segments.length === 0) {
|
||||
loadDirectory('');
|
||||
return;
|
||||
}
|
||||
segments.pop();
|
||||
const parentPath = segments.join('/');
|
||||
loadDirectory(parentPath);
|
||||
});
|
||||
|
||||
returnChatBtn.addEventListener('click', () => {
|
||||
window.location.href = '/new';
|
||||
});
|
||||
downloadBtn.addEventListener('click', downloadSelected);
|
||||
renameBtn.addEventListener('click', renameSelected);
|
||||
copyBtn.addEventListener('click', copySelected);
|
||||
cutBtn.addEventListener('click', cutSelected);
|
||||
pasteBtn.addEventListener('click', pasteClipboard);
|
||||
deleteBtn.addEventListener('click', deleteSelected);
|
||||
toggleSelectionBtn.addEventListener('click', () => {
|
||||
state.selectionDisabled = !state.selectionDisabled;
|
||||
toggleSelectionBtn.textContent = state.selectionDisabled ? '启用框选' : '禁用框选';
|
||||
const msg = state.selectionDisabled ? '已禁用框选' : '已启用框选';
|
||||
showStatus(msg);
|
||||
});
|
||||
|
||||
hiddenUploader.addEventListener('change', async (event) => {
|
||||
const files = event.target.files;
|
||||
if (files && files.length) {
|
||||
await uploadFiles(files, state.currentPath);
|
||||
}
|
||||
hiddenUploader.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
function bindGlobalEvents() {
|
||||
document.addEventListener('click', handleGlobalClick);
|
||||
fileGrid.addEventListener('click', handleGridBackgroundClick);
|
||||
fileGrid.addEventListener('contextmenu', (event) => {
|
||||
if (event.target === fileGrid) {
|
||||
event.preventDefault();
|
||||
if (state.selected.size) {
|
||||
showContextMenu(event.clientX, event.clientY);
|
||||
}
|
||||
}
|
||||
});
|
||||
fileGrid.addEventListener('dragenter', handleDragEnter);
|
||||
fileGrid.addEventListener('dragover', handleDragOver);
|
||||
fileGrid.addEventListener('dragleave', handleDragLeave);
|
||||
fileGrid.addEventListener('drop', handleDrop);
|
||||
initSelectionRectangle();
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
bindToolbar();
|
||||
bindGlobalEvents();
|
||||
await loadDirectory(initialPathParam, { updateHistory: false });
|
||||
}
|
||||
|
||||
bootstrap().catch((err) => {
|
||||
console.error(err);
|
||||
showStatus(err.message);
|
||||
});
|
||||
})();
|
||||
60
sub_agent/static/file_manager/editor.css
Normal file
60
sub_agent/static/file_manager/editor.css
Normal file
@ -0,0 +1,60 @@
|
||||
.fe-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(17, 18, 26, 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.fe-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.fe-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.fe-path {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
max-width: 52vw;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.fe-status {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.fe-main {
|
||||
height: calc(100vh - 64px);
|
||||
}
|
||||
|
||||
#editorArea {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: #0f1119;
|
||||
color: #f1f3f5;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
padding: 20px;
|
||||
font-family: "Fira Code", "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
#editorArea:focus {
|
||||
outline: none;
|
||||
}
|
||||
30
sub_agent/static/file_manager/editor.html
Normal file
30
sub_agent/static/file_manager/editor.html
Normal file
@ -0,0 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>文件编辑器</title>
|
||||
<link rel="stylesheet" href="/static/file_manager/style.css">
|
||||
<link rel="stylesheet" href="/static/file_manager/editor.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="editorApp">
|
||||
<header class="fe-header">
|
||||
<div class="fe-left">
|
||||
<button class="fm-btn" id="btnBack">← 返回</button>
|
||||
<div class="fe-path" id="filePath"></div>
|
||||
</div>
|
||||
<div class="fe-right">
|
||||
<span class="fe-status" id="statusInfo">未保存修改</span>
|
||||
<button class="fm-btn" id="btnDownload">下载</button>
|
||||
<button class="fm-btn" id="btnIncreaseFont">A+</button>
|
||||
<button class="fm-btn" id="btnDecreaseFont">A-</button>
|
||||
<button class="fm-btn primary" id="btnSave">保存</button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="fe-main">
|
||||
<textarea id="editorArea" spellcheck="false"></textarea>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/static/file_manager/editor.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
135
sub_agent/static/file_manager/editor.js
Normal file
135
sub_agent/static/file_manager/editor.js
Normal file
@ -0,0 +1,135 @@
|
||||
(() => {
|
||||
const API_BASE = '/api/gui/files';
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const path = params.get('path');
|
||||
|
||||
const editorArea = document.getElementById('editorArea');
|
||||
const filePathEl = document.getElementById('filePath');
|
||||
const statusInfo = document.getElementById('statusInfo');
|
||||
const saveBtn = document.getElementById('btnSave');
|
||||
const downloadBtn = document.getElementById('btnDownload');
|
||||
const backBtn = document.getElementById('btnBack');
|
||||
const fontIncreaseBtn = document.getElementById('btnIncreaseFont');
|
||||
const fontDecreaseBtn = document.getElementById('btnDecreaseFont');
|
||||
|
||||
let originalContent = '';
|
||||
let dirty = false;
|
||||
let fontSize = 14;
|
||||
|
||||
if (!path) {
|
||||
editorArea.value = '缺少 path 参数,无法加载文件。';
|
||||
editorArea.disabled = true;
|
||||
saveBtn.disabled = true;
|
||||
downloadBtn.disabled = true;
|
||||
statusInfo.textContent = '缺少路径';
|
||||
return;
|
||||
}
|
||||
|
||||
filePathEl.textContent = path;
|
||||
|
||||
function setDirty(value) {
|
||||
dirty = value;
|
||||
statusInfo.textContent = dirty ? '有未保存的更改' : '已保存';
|
||||
saveBtn.disabled = !dirty;
|
||||
}
|
||||
|
||||
async function request(url, options = {}) {
|
||||
const response = await fetch(url, options);
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok || data.success === false) {
|
||||
const message = data.error || data.message || `请求失败 (${response.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function loadFile() {
|
||||
statusInfo.textContent = '加载中...';
|
||||
try {
|
||||
const result = await request(`${API_BASE}/text?path=${encodeURIComponent(path)}`);
|
||||
originalContent = result.content || '';
|
||||
editorArea.value = originalContent;
|
||||
setDirty(false);
|
||||
statusInfo.textContent = `最后修改时间:${result.modified_at}`;
|
||||
} catch (err) {
|
||||
editorArea.value = `文件加载失败:${err.message}`;
|
||||
editorArea.disabled = true;
|
||||
saveBtn.disabled = true;
|
||||
statusInfo.textContent = '无法加载文件';
|
||||
}
|
||||
}
|
||||
|
||||
async function saveFile() {
|
||||
statusInfo.textContent = '保存中...';
|
||||
try {
|
||||
await request(`${API_BASE}/text`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path, content: editorArea.value }),
|
||||
});
|
||||
originalContent = editorArea.value;
|
||||
setDirty(false);
|
||||
statusInfo.textContent = '已保存';
|
||||
} catch (err) {
|
||||
statusInfo.textContent = `保存失败:${err.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
editorArea.addEventListener('input', () => {
|
||||
if (editorArea.disabled) return;
|
||||
setDirty(editorArea.value !== originalContent);
|
||||
});
|
||||
|
||||
saveBtn.addEventListener('click', () => {
|
||||
if (!dirty) return;
|
||||
saveFile();
|
||||
});
|
||||
|
||||
downloadBtn.addEventListener('click', () => {
|
||||
window.open(`${API_BASE}/download?path=${encodeURIComponent(path)}`, '_blank');
|
||||
});
|
||||
|
||||
const getParentDirectory = () => {
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
if (segments.length <= 1) {
|
||||
return '';
|
||||
}
|
||||
segments.pop();
|
||||
return segments.join('/');
|
||||
};
|
||||
|
||||
const navigateToManager = () => {
|
||||
const parentDir = getParentDirectory();
|
||||
const target = parentDir ? `/file-manager?path=${encodeURIComponent(parentDir)}` : '/file-manager';
|
||||
window.location.href = target;
|
||||
};
|
||||
|
||||
backBtn.addEventListener('click', () => {
|
||||
if (dirty) {
|
||||
const confirmLeave = window.confirm('有未保存的更改,确认要离开吗?');
|
||||
if (!confirmLeave) return;
|
||||
}
|
||||
navigateToManager();
|
||||
});
|
||||
|
||||
fontIncreaseBtn.addEventListener('click', () => {
|
||||
fontSize = Math.min(fontSize + 1, 28);
|
||||
editorArea.style.fontSize = `${fontSize}px`;
|
||||
});
|
||||
|
||||
fontDecreaseBtn.addEventListener('click', () => {
|
||||
fontSize = Math.max(fontSize - 1, 10);
|
||||
editorArea.style.fontSize = `${fontSize}px`;
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', (event) => {
|
||||
if (!dirty) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
});
|
||||
|
||||
loadFile().catch((err) => {
|
||||
console.error(err);
|
||||
statusInfo.textContent = err.message;
|
||||
});
|
||||
})();
|
||||
60
sub_agent/static/file_manager/index.html
Normal file
60
sub_agent/static/file_manager/index.html
Normal file
@ -0,0 +1,60 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>文件管理器</title>
|
||||
<link rel="stylesheet" href="/static/file_manager/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<header class="fm-header">
|
||||
<div class="fm-header-left">
|
||||
<button class="fm-btn" id="btnBack" title="返回上一页">← 返回</button>
|
||||
<button class="fm-btn" id="btnReturnChat" title="回到对话页面">返回对话</button>
|
||||
<button class="fm-btn" id="btnToggleSelection" title="切换框选功能">禁用框选</button>
|
||||
<div class="fm-breadcrumb" id="breadcrumb"></div>
|
||||
</div>
|
||||
<div class="fm-header-right">
|
||||
<input type="file" id="hiddenUploader" hidden>
|
||||
<button class="fm-btn" id="btnNewFolder">新建文件夹</button>
|
||||
<button class="fm-btn" id="btnNewFile">新建文件</button>
|
||||
<button class="fm-btn" id="btnUpload">上传</button>
|
||||
<button class="fm-btn" id="btnRefresh">刷新</button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="fm-main">
|
||||
<aside class="fm-sidebar">
|
||||
<div class="fm-sidebar-title">目录</div>
|
||||
<ul class="fm-tree" id="directoryTree"></ul>
|
||||
</aside>
|
||||
<section class="fm-content">
|
||||
<div class="fm-toolbar">
|
||||
<div class="fm-selection-info" id="selectionInfo">已选中 0 项</div>
|
||||
<div class="fm-toolbar-right">
|
||||
<button class="fm-btn" id="btnDownload">下载</button>
|
||||
<button class="fm-btn" id="btnRename">重命名</button>
|
||||
<button class="fm-btn" id="btnCopy">复制</button>
|
||||
<button class="fm-btn" id="btnCut">剪切</button>
|
||||
<button class="fm-btn" id="btnPaste" disabled>粘贴</button>
|
||||
<button class="fm-btn danger" id="btnDelete">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fm-grid" id="fileGrid"></div>
|
||||
<div class="fm-status-bar" id="statusBar">拖拽文件到此处可上传到当前目录</div>
|
||||
</section>
|
||||
</main>
|
||||
<div class="fm-context-menu" id="contextMenu"></div>
|
||||
<div class="fm-dialog-backdrop" id="dialogBackdrop" hidden>
|
||||
<div class="fm-dialog" id="dialog">
|
||||
<h3 id="dialogTitle"></h3>
|
||||
<div id="dialogContent"></div>
|
||||
<div class="fm-dialog-actions">
|
||||
<button class="fm-btn" id="dialogCancel">取消</button>
|
||||
<button class="fm-btn primary" id="dialogConfirm">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/file_manager/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
357
sub_agent/static/file_manager/style.css
Normal file
357
sub_agent/static/file_manager/style.css
Normal file
@ -0,0 +1,357 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif;
|
||||
background: #1d1f27;
|
||||
color: #f1f3f5;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.fm-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(17, 18, 26, 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.fm-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.fm-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.fm-breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.fm-breadcrumb span {
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.fm-breadcrumb span:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.fm-btn {
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 6px 14px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: inherit;
|
||||
transition: background 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.fm-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
.fm-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.fm-btn.primary {
|
||||
background: #3d8bfd;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.fm-btn.primary:hover {
|
||||
background: #377df5;
|
||||
}
|
||||
|
||||
.fm-btn.danger {
|
||||
background: #f06595;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.fm-btn.danger:hover {
|
||||
background: #e64980;
|
||||
}
|
||||
|
||||
.fm-main {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fm-sidebar {
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.06);
|
||||
padding: 16px 12px;
|
||||
overflow-y: auto;
|
||||
background: rgba(17, 18, 26, 0.92);
|
||||
}
|
||||
|
||||
.fm-sidebar-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.fm-tree {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.fm-tree li {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.fm-tree-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.fm-tree-item:hover,
|
||||
.fm-tree-item.active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.fm-tree-toggle {
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.fm-tree-children {
|
||||
list-style: none;
|
||||
padding-left: 16px;
|
||||
margin: 6px 0 0;
|
||||
border-left: 1px dashed rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.fm-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.fm-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
background: rgba(17, 18, 26, 0.8);
|
||||
backdrop-filter: blur(8px);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.fm-toolbar-right {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.fm-selection-info {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
.fm-grid {
|
||||
flex: 1;
|
||||
padding: 18px;
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.fm-card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
transition: border 0.2s, background 0.2s, transform 0.2s;
|
||||
user-select: none;
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
.fm-card:hover {
|
||||
border-color: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
.fm-card.selected {
|
||||
border-color: #3d8bfd;
|
||||
background: rgba(61, 139, 253, 0.2);
|
||||
}
|
||||
|
||||
.fm-card-icon {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.fm-card-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fm-card-meta {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.fm-status-bar {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
padding: 8px 16px;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
background: rgba(17, 18, 26, 0.8);
|
||||
}
|
||||
|
||||
.fm-context-menu {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
background: rgba(17, 18, 26, 0.95);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
padding: 6px 0;
|
||||
width: 180px;
|
||||
display: none;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.fm-context-menu button {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 8px 16px;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fm-context-menu button:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.fm-dialog-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1100;
|
||||
}
|
||||
|
||||
.fm-dialog-backdrop[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.fm-dialog {
|
||||
background: #1f212b;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
width: 360px;
|
||||
max-width: 92vw;
|
||||
}
|
||||
|
||||
.fm-dialog h3 {
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.fm-dialog input,
|
||||
.fm-dialog textarea {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: inherit;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.fm-dialog textarea {
|
||||
min-height: 120px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.fm-dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.fm-selection-rect {
|
||||
position: absolute;
|
||||
border: 1px solid rgba(61, 139, 253, 0.8);
|
||||
background: rgba(61, 139, 253, 0.25);
|
||||
pointer-events: none;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.fm-drop-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 2px dashed rgba(61, 139, 253, 0.8);
|
||||
background: rgba(61, 139, 253, 0.12);
|
||||
display: none;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: rgba(61, 139, 253, 0.9);
|
||||
}
|
||||
|
||||
.fm-grid.drop-target .fm-drop-overlay {
|
||||
display: flex;
|
||||
}
|
||||
@ -8,7 +8,7 @@
|
||||
<!-- Vue 3 CDN -->
|
||||
<script src="https://unpkg.com/vue@3.3.4/dist/vue.global.prod.js"></script>
|
||||
|
||||
<!-- Socket.IO Client (local copy) -->
|
||||
<!-- Socket.IO Client -->
|
||||
<script src="/static/vendor/socket.io.min.js"></script>
|
||||
|
||||
<!-- Marked.js for Markdown -->
|
||||
@ -41,11 +41,24 @@
|
||||
<!-- 顶部状态栏 -->
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<span class="logo">🤖 AI Agent</span>
|
||||
<span class="project-path">{{ projectPath }}</span>
|
||||
<span class="logo">
|
||||
<template v-if="isSubAgentView">
|
||||
🤖 子智能体 <strong>#{{ subAgentTaskInfo.agent_id || '—' }}</strong>
|
||||
</template>
|
||||
<template v-else>
|
||||
🤖 AI Agent
|
||||
</template>
|
||||
</span>
|
||||
<span class="agent-version" v-if="isSubAgentView">
|
||||
工作目录: {{ formatPathDisplay(subAgentTaskInfo.workspace_dir) }}
|
||||
</span>
|
||||
<span class="agent-version" v-else-if="agentVersion">{{ agentVersion }}</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span class="thinking-mode">{{ thinkingMode }}</span>
|
||||
<span class="thinking-mode" v-if="!isSubAgentView">{{ thinkingMode }}</span>
|
||||
<span class="thinking-mode" v-else>
|
||||
状态: {{ formatSubAgentStatus(subAgentTaskInfo.status) }}
|
||||
</span>
|
||||
<span class="connection-status" :class="{ connected: isConnected }">
|
||||
<span class="status-dot" :class="{ active: isConnected }"></span>
|
||||
{{ isConnected ? '已连接' : '未连接' }}
|
||||
@ -55,7 +68,7 @@
|
||||
|
||||
<div class="main-container">
|
||||
<!-- 新增:对话历史侧边栏(最左侧) -->
|
||||
<aside class="conversation-sidebar" :class="{ collapsed: sidebarCollapsed }">
|
||||
<aside v-if="!isSubAgentView" class="conversation-sidebar" :class="{ collapsed: sidebarCollapsed }">
|
||||
<div class="conversation-header">
|
||||
<button @click="createNewConversation" class="new-conversation-btn" v-if="!sidebarCollapsed">
|
||||
<span class="btn-icon">+</span>
|
||||
@ -129,14 +142,25 @@
|
||||
<aside class="sidebar left-sidebar" :style="{ width: leftWidth + 'px' }">
|
||||
<div class="sidebar-header">
|
||||
<button class="sidebar-view-toggle"
|
||||
@click="toggleTodoPanel"
|
||||
:title="showTodoList ? '查看项目文件' : '查看待办列表'">
|
||||
<span v-if="showTodoList">{{ fileEmoji }}</span>
|
||||
<span v-else>{{ todoEmoji }}</span>
|
||||
@click="cycleSidebarPanel"
|
||||
:title="panelMode === 'files' ? '查看待办列表' : (panelMode === 'todo' ? (isSubAgentView ? '查看项目文件' : '查看子智能体') : '查看项目文件')">
|
||||
<span v-if="panelMode === 'files'">{{ todoEmoji }}</span>
|
||||
<span v-else-if="panelMode === 'todo'">{{ isSubAgentView ? fileEmoji : '🤖' }}</span>
|
||||
<span v-else>{{ fileEmoji }}</span>
|
||||
</button>
|
||||
<h3>{{ showTodoList ? (todoEmoji + ' 待办列表') : (fileEmoji + ' 项目文件') }}</h3>
|
||||
<button class="sidebar-manage-btn"
|
||||
v-if="!isSubAgentView"
|
||||
@click="openGuiFileManager"
|
||||
title="打开桌面式文件管理器">
|
||||
管理
|
||||
</button>
|
||||
<h3>
|
||||
<span v-if="panelMode === 'files'">{{ fileEmoji }} 项目文件</span>
|
||||
<span v-else-if="panelMode === 'todo'">{{ todoEmoji }} 待办列表</span>
|
||||
<span v-else-if="!isSubAgentView">🤖 子智能体</span>
|
||||
</h3>
|
||||
</div>
|
||||
<template v-if="showTodoList">
|
||||
<template v-if="panelMode === 'todo'">
|
||||
<div class="todo-panel">
|
||||
<div v-if="!todoList" class="todo-empty">
|
||||
暂无待办列表
|
||||
@ -153,6 +177,29 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="panelMode === 'subAgents' && !isSubAgentView">
|
||||
<div class="sub-agent-panel">
|
||||
<div v-if="!subAgents.length" class="sub-agent-empty">
|
||||
暂无运行中的子智能体
|
||||
</div>
|
||||
<div v-else class="sub-agent-cards">
|
||||
<div class="sub-agent-card"
|
||||
v-for="agent in subAgents"
|
||||
:key="agent.task_id"
|
||||
@click="openSubAgent(agent)">
|
||||
<div class="sub-agent-header">
|
||||
<span class="sub-agent-id">#{{
|
||||
agent.agent_id }}</span>
|
||||
<span class="sub-agent-status" :class="agent.status">{{ agent.status }}</span>
|
||||
</div>
|
||||
<div class="sub-agent-summary">{{ agent.summary }}</div>
|
||||
<div class="sub-agent-tool" v-if="agent.last_tool">
|
||||
当前:{{ agent.last_tool }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="file-tree" @contextmenu.prevent>
|
||||
<file-node
|
||||
@ -266,6 +313,13 @@
|
||||
<div v-else v-html="renderMarkdown(action.content, false)"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 系统提示块 -->
|
||||
<div v-else-if="action.type === 'system'" class="system-action">
|
||||
<div class="system-action-content">
|
||||
{{ action.content }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 追加内容占位 -->
|
||||
<div v-else-if="action.type === 'append_payload'"
|
||||
@ -411,7 +465,7 @@
|
||||
rows="3">
|
||||
</textarea>
|
||||
<div class="input-actions">
|
||||
<div class="upload-control">
|
||||
<div class="upload-control" v-if="!isSubAgentView">
|
||||
<input type="file"
|
||||
ref="fileUploadInput"
|
||||
class="file-input-hidden"
|
||||
@ -423,7 +477,7 @@
|
||||
{{ uploading ? '上传中...' : '上传文件' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="tool-dropdown" ref="toolDropdown">
|
||||
<div class="tool-dropdown" ref="toolDropdown" v-if="!isSubAgentView">
|
||||
<button type="button"
|
||||
class="btn tool-btn"
|
||||
@click="toggleToolMenu"
|
||||
@ -463,7 +517,7 @@
|
||||
:class="['btn', streamingMessage ? 'stop-btn' : 'send-btn']">
|
||||
{{ streamingMessage ? '停止' : '发送' }}
|
||||
</button>
|
||||
<div class="settings-dropdown" ref="settingsDropdown">
|
||||
<div class="settings-dropdown" ref="settingsDropdown" v-if="!isSubAgentView">
|
||||
<button type="button"
|
||||
class="btn settings-btn"
|
||||
@click="toggleSettings"
|
||||
@ -37,13 +37,24 @@
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
input[type="text"], input[type="password"] {
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
input[type="email"] {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
min-height: 48px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(118, 103, 84, 0.3);
|
||||
font-size: 0.95rem;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
input[type="text"]:focus,
|
||||
input[type="password"]:focus,
|
||||
input[type="email"]:focus {
|
||||
outline: none;
|
||||
border-color: #d8894c;
|
||||
box-shadow: 0 0 0 3px rgba(216, 137, 76, 0.2);
|
||||
}
|
||||
button {
|
||||
margin-top: 22px;
|
||||
@ -7,6 +7,8 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--app-viewport: 100vh;
|
||||
--app-bottom-inset: env(safe-area-inset-bottom, 0px);
|
||||
--claude-bg: #eeece2;
|
||||
--claude-panel: rgba(255, 255, 255, 0.82);
|
||||
--claude-sidebar: rgba(255, 255, 255, 0.68);
|
||||
@ -24,11 +26,14 @@
|
||||
--claude-warning: #d99845;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: var(--app-viewport, 100vh);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Iowan Old Style', ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
|
||||
background: var(--claude-bg);
|
||||
color: var(--claude-text);
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
@ -60,7 +65,7 @@ body {
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.project-path {
|
||||
.agent-version {
|
||||
color: var(--claude-text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
@ -105,7 +110,7 @@ body {
|
||||
/* 主容器 */
|
||||
.main-container {
|
||||
display: flex;
|
||||
height: calc(100vh - 56px);
|
||||
height: calc(var(--app-viewport, 100vh) - 56px);
|
||||
background: var(--claude-bg);
|
||||
position: relative;
|
||||
align-items: stretch;
|
||||
@ -125,8 +130,8 @@ body {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
z-index: 50;
|
||||
backdrop-filter: blur(12px);
|
||||
height: calc(100vh - 56px) !important;
|
||||
min-height: calc(100vh - 56px) !important;
|
||||
height: calc(var(--app-viewport, 100vh) - 56px) !important;
|
||||
min-height: calc(var(--app-viewport, 100vh) - 56px) !important;
|
||||
border-bottom: 1px solid var(--claude-border);
|
||||
}
|
||||
|
||||
@ -138,8 +143,8 @@ body {
|
||||
.conversation-sidebar.collapsed {
|
||||
width: 50px;
|
||||
overflow: hidden;
|
||||
height: calc(100vh - 56px) !important;
|
||||
min-height: calc(100vh - 56px) !important;
|
||||
height: calc(var(--app-viewport, 100vh) - 56px) !important;
|
||||
min-height: calc(var(--app-viewport, 100vh) - 56px) !important;
|
||||
}
|
||||
|
||||
.conversation-sidebar.collapsed .conversation-header {
|
||||
@ -452,6 +457,22 @@ body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar-manage-btn {
|
||||
border: 1px solid rgba(118, 103, 84, 0.25);
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
color: var(--claude-text);
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.sidebar-manage-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-color: rgba(118, 103, 84, 0.45);
|
||||
}
|
||||
|
||||
.sidebar-view-toggle {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
@ -496,6 +517,67 @@ body {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sub-agent-panel {
|
||||
padding: 16px 16px 24px;
|
||||
}
|
||||
|
||||
.sub-agent-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sub-agent-card {
|
||||
border: 1px solid rgba(118, 103, 84, 0.2);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.sub-agent-card:hover {
|
||||
border-color: #6c5ce7;
|
||||
box-shadow: 0 6px 16px rgba(108, 92, 231, 0.15);
|
||||
}
|
||||
|
||||
.sub-agent-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.sub-agent-status {
|
||||
text-transform: capitalize;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sub-agent-status.running {
|
||||
color: #0984e3;
|
||||
}
|
||||
|
||||
.sub-agent-status.completed {
|
||||
color: #00b894;
|
||||
}
|
||||
|
||||
.sub-agent-status.failed,
|
||||
.sub-agent-status.timeout {
|
||||
color: #d63031;
|
||||
}
|
||||
|
||||
.sub-agent-summary {
|
||||
font-size: 13px;
|
||||
color: var(--claude-text);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.sub-agent-tool {
|
||||
font-size: 12px;
|
||||
color: var(--claude-text-secondary);
|
||||
}
|
||||
|
||||
.todo-empty {
|
||||
font-size: 14px;
|
||||
color: var(--claude-text-secondary);
|
||||
@ -669,6 +751,7 @@ body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
padding-bottom: calc(24px + var(--app-bottom-inset, 0px));
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@ -846,6 +929,23 @@ body {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.system-action {
|
||||
margin: 12px 0;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.08), rgba(59, 130, 246, 0.08));
|
||||
border-left: 4px solid rgba(79, 70, 229, 0.6);
|
||||
color: var(--claude-text);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.system-action-content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.append-block {
|
||||
margin: 12px 0;
|
||||
padding: 12px 16px;
|
||||
@ -1282,6 +1382,7 @@ body {
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
border-top: 1px solid var(--claude-border);
|
||||
padding: 20px;
|
||||
padding-bottom: calc(20px + var(--app-bottom-inset, 0px));
|
||||
backdrop-filter: blur(12px);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@ -1474,9 +1575,9 @@ body {
|
||||
.settings-menu.tool-menu {
|
||||
right: auto;
|
||||
left: 0;
|
||||
min-width: 320px;
|
||||
max-width: 380px;
|
||||
padding: 14px 18px;
|
||||
min-width: 520px;
|
||||
max-width: 580px;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.settings-menu.tool-menu::before {
|
||||
@ -1492,21 +1593,24 @@ body {
|
||||
}
|
||||
|
||||
.tool-menu .tool-menu-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(110px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tool-menu .tool-category-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 10px 14px;
|
||||
gap: 10px;
|
||||
padding: 14px 10px;
|
||||
border: 1px solid rgba(118, 103, 84, 0.14);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
font-size: 13px;
|
||||
aspect-ratio: 1 / 1;
|
||||
min-height: 0;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.tool-menu .tool-category-item.disabled {
|
||||
@ -1515,27 +1619,32 @@ body {
|
||||
|
||||
.tool-menu .tool-category-label {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--claude-text);
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.tool-category-icon {
|
||||
font-size: 16px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.tool-menu .tool-category-toggle {
|
||||
width: auto !important;
|
||||
width: 100% !important;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px 18px;
|
||||
padding: 6px 12px;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.menu-btn {
|
||||
7
sub_agent/static/vendor/socket.io.min.js
generated
vendored
Normal file
7
sub_agent/static/vendor/socket.io.min.js
generated
vendored
Normal file
File diff suppressed because one or more lines are too long
614
sub_agent/utils/api_client.py
Normal file
614
sub_agent/utils/api_client.py
Normal file
@ -0,0 +1,614 @@
|
||||
# ========== api_client.py ==========
|
||||
# utils/api_client.py - DeepSeek API 客户端(支持Web模式)- 简化版
|
||||
|
||||
import httpx
|
||||
import json
|
||||
import asyncio
|
||||
from typing import List, Dict, Optional, AsyncGenerator
|
||||
try:
|
||||
from config import API_BASE_URL, API_KEY, MODEL_ID, OUTPUT_FORMATS, DEFAULT_RESPONSE_MAX_TOKENS
|
||||
except ImportError:
|
||||
import sys
|
||||
from pathlib import Path
|
||||
project_root = Path(__file__).resolve().parents[1]
|
||||
if str(project_root) not in sys.path:
|
||||
sys.path.insert(0, str(project_root))
|
||||
from config import API_BASE_URL, API_KEY, MODEL_ID, OUTPUT_FORMATS, DEFAULT_RESPONSE_MAX_TOKENS
|
||||
|
||||
class DeepSeekClient:
|
||||
def __init__(self, thinking_mode: bool = True, web_mode: bool = False):
|
||||
self.api_base_url = API_BASE_URL
|
||||
self.api_key = API_KEY
|
||||
self.model_id = MODEL_ID
|
||||
self.thinking_mode = thinking_mode # True=智能思考模式, False=快速模式
|
||||
self.web_mode = web_mode # Web模式标志,用于禁用print输出
|
||||
self.headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
# 每个任务的独立状态
|
||||
self.current_task_first_call = True # 当前任务是否是第一次调用
|
||||
self.current_task_thinking = "" # 当前任务的思考内容
|
||||
|
||||
def _print(self, message: str, end: str = "\n", flush: bool = False):
|
||||
"""安全的打印函数,在Web模式下不输出"""
|
||||
if not self.web_mode:
|
||||
print(message, end=end, flush=flush)
|
||||
|
||||
def _format_read_file_result(self, data: Dict) -> str:
|
||||
"""根据读取模式格式化 read_file 工具结果。"""
|
||||
if not isinstance(data, dict):
|
||||
return json.dumps(data, ensure_ascii=False)
|
||||
if not data.get("success"):
|
||||
return json.dumps(data, ensure_ascii=False)
|
||||
|
||||
read_type = data.get("type", "read")
|
||||
truncated_note = "(内容已截断)" if data.get("truncated") else ""
|
||||
path = data.get("path", "未知路径")
|
||||
max_chars = data.get("max_chars")
|
||||
max_note = f"(max_chars={max_chars})" if max_chars else ""
|
||||
|
||||
if read_type == "read":
|
||||
line_start = data.get("line_start")
|
||||
line_end = data.get("line_end")
|
||||
char_count = data.get("char_count", len(data.get("content", "") or ""))
|
||||
header = f"读取 {path} 行 {line_start}~{line_end},返回 {char_count} 字符 {max_note}{truncated_note}".strip()
|
||||
content = data.get("content", "")
|
||||
return f"{header}\n```\n{content}\n```"
|
||||
|
||||
if read_type == "search":
|
||||
query = data.get("query", "")
|
||||
actual = data.get("actual_matches", 0)
|
||||
returned = data.get("returned_matches", 0)
|
||||
case_hint = "区分大小写" if data.get("case_sensitive") else "不区分大小写"
|
||||
header = (
|
||||
f"在 {path} 中搜索 \"{query}\",返回 {returned}/{actual} 条结果({case_hint})"
|
||||
f" {max_note}{truncated_note}"
|
||||
).strip()
|
||||
match_texts = []
|
||||
for idx, match in enumerate(data.get("matches", []), 1):
|
||||
match_note = "(片段截断)" if match.get("truncated") else ""
|
||||
hits = match.get("hits") or []
|
||||
hit_text = ", ".join(str(h) for h in hits) if hits else "无"
|
||||
label = match.get("id") or f"match_{idx}"
|
||||
snippet = match.get("snippet", "")
|
||||
match_texts.append(
|
||||
f"[{label}] 行 {match.get('line_start')}~{match.get('line_end')} 命中行: {hit_text}{match_note}\n```\n{snippet}\n```"
|
||||
)
|
||||
if not match_texts:
|
||||
match_texts.append("未找到匹配内容。")
|
||||
return "\n".join([header] + match_texts)
|
||||
|
||||
if read_type == "extract":
|
||||
segments = data.get("segments", [])
|
||||
header = (
|
||||
f"从 {path} 抽取 {len(segments)} 个片段 {max_note}{truncated_note}"
|
||||
).strip()
|
||||
seg_texts = []
|
||||
for idx, segment in enumerate(segments, 1):
|
||||
seg_note = "(片段截断)" if segment.get("truncated") else ""
|
||||
label = segment.get("label") or f"segment_{idx}"
|
||||
snippet = segment.get("content", "")
|
||||
seg_texts.append(
|
||||
f"[{label}] 行 {segment.get('line_start')}~{segment.get('line_end')}{seg_note}\n```\n{snippet}\n```"
|
||||
)
|
||||
if not seg_texts:
|
||||
seg_texts.append("未提供可抽取的片段。")
|
||||
return "\n".join([header] + seg_texts)
|
||||
|
||||
return json.dumps(data, ensure_ascii=False)
|
||||
|
||||
def start_new_task(self):
|
||||
"""开始新任务(重置任务级别的状态)"""
|
||||
self.current_task_first_call = True
|
||||
self.current_task_thinking = ""
|
||||
|
||||
def get_current_thinking_mode(self) -> bool:
|
||||
"""获取当前应该使用的思考模式"""
|
||||
if not self.thinking_mode:
|
||||
# 快速模式,始终不使用思考
|
||||
return False
|
||||
else:
|
||||
# 思考模式:当前任务的第一次用思考,后续不用
|
||||
return self.current_task_first_call
|
||||
|
||||
def _validate_json_string(self, json_str: str) -> tuple:
|
||||
"""
|
||||
验证JSON字符串的完整性
|
||||
|
||||
Returns:
|
||||
(is_valid: bool, error_message: str, parsed_data: dict or None)
|
||||
"""
|
||||
if not json_str or not json_str.strip():
|
||||
return True, "", {}
|
||||
|
||||
# 检查基本的JSON结构标记
|
||||
stripped = json_str.strip()
|
||||
if not stripped.startswith('{') or not stripped.endswith('}'):
|
||||
return False, "JSON字符串格式不完整(缺少开始或结束大括号)", None
|
||||
|
||||
# 检查引号配对
|
||||
in_string = False
|
||||
escape_next = False
|
||||
quote_count = 0
|
||||
|
||||
for char in stripped:
|
||||
if escape_next:
|
||||
escape_next = False
|
||||
continue
|
||||
|
||||
if char == '\\':
|
||||
escape_next = True
|
||||
continue
|
||||
|
||||
if char == '"':
|
||||
quote_count += 1
|
||||
in_string = not in_string
|
||||
|
||||
if in_string:
|
||||
return False, "JSON字符串中存在未闭合的引号", None
|
||||
|
||||
# 尝试解析JSON
|
||||
try:
|
||||
parsed_data = json.loads(stripped)
|
||||
return True, "", parsed_data
|
||||
except json.JSONDecodeError as e:
|
||||
return False, f"JSON解析错误: {str(e)}", None
|
||||
|
||||
def _safe_tool_arguments_parse(self, arguments_str: str, tool_name: str) -> tuple:
|
||||
"""
|
||||
安全地解析工具参数,保持失败即时返回
|
||||
|
||||
Returns:
|
||||
(success: bool, arguments: dict, error_message: str)
|
||||
"""
|
||||
if not arguments_str or not arguments_str.strip():
|
||||
return True, {}, ""
|
||||
|
||||
# 长度检查
|
||||
max_length = 999999999 # 50KB限制
|
||||
if len(arguments_str) > max_length:
|
||||
return False, {}, f"参数过长({len(arguments_str)}字符),超过{max_length}字符限制"
|
||||
|
||||
# 尝试直接解析JSON
|
||||
try:
|
||||
parsed_data = json.loads(arguments_str)
|
||||
return True, parsed_data, ""
|
||||
except json.JSONDecodeError as e:
|
||||
preview_length = 200
|
||||
stripped = arguments_str.strip()
|
||||
preview = stripped[:preview_length] + "..." if len(stripped) > preview_length else stripped
|
||||
return False, {}, f"JSON解析失败: {str(e)}\n参数预览: {preview}"
|
||||
async def chat(
|
||||
self,
|
||||
messages: List[Dict],
|
||||
tools: Optional[List[Dict]] = None,
|
||||
stream: bool = True
|
||||
) -> AsyncGenerator[Dict, None]:
|
||||
"""
|
||||
异步调用DeepSeek API
|
||||
|
||||
Args:
|
||||
messages: 消息列表
|
||||
tools: 工具定义列表
|
||||
stream: 是否流式输出
|
||||
|
||||
Yields:
|
||||
响应内容块
|
||||
"""
|
||||
# 检查API密钥
|
||||
if not self.api_key or self.api_key == "your-deepseek-api-key":
|
||||
self._print(f"{OUTPUT_FORMATS['error']} API密钥未配置,请在config.py中设置API_KEY")
|
||||
return
|
||||
|
||||
# 决定是否使用思考模式
|
||||
current_thinking_mode = self.get_current_thinking_mode()
|
||||
|
||||
# 如果是思考模式且不是当前任务的第一次,显示提示
|
||||
if self.thinking_mode and not self.current_task_first_call:
|
||||
self._print(f"{OUTPUT_FORMATS['info']} [任务内快速模式] 使用本次任务的思考继续处理...")
|
||||
|
||||
try:
|
||||
max_tokens = int(DEFAULT_RESPONSE_MAX_TOKENS)
|
||||
if max_tokens <= 0:
|
||||
raise ValueError("max_tokens must be positive")
|
||||
except (TypeError, ValueError):
|
||||
max_tokens = 4096
|
||||
|
||||
payload = {
|
||||
"model": self.model_id,
|
||||
"messages": messages,
|
||||
"stream": stream,
|
||||
"thinking": {"type": "enabled" if current_thinking_mode else "disabled"},
|
||||
"max_tokens": max_tokens
|
||||
}
|
||||
|
||||
if tools:
|
||||
payload["tools"] = tools
|
||||
payload["tool_choice"] = "auto"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(http2=True, timeout=300) as client:
|
||||
if stream:
|
||||
async with client.stream(
|
||||
"POST",
|
||||
f"{self.api_base_url}/chat/completions",
|
||||
json=payload,
|
||||
headers=self.headers
|
||||
) as response:
|
||||
# 检查响应状态
|
||||
if response.status_code != 200:
|
||||
error_text = await response.aread()
|
||||
self._print(f"{OUTPUT_FORMATS['error']} API请求失败 ({response.status_code}): {error_text}")
|
||||
return
|
||||
|
||||
async for line in response.aiter_lines():
|
||||
if line.startswith("data:"):
|
||||
json_str = line[5:].strip()
|
||||
if json_str == "[DONE]":
|
||||
break
|
||||
|
||||
try:
|
||||
data = json.loads(json_str)
|
||||
yield data
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
else:
|
||||
response = await client.post(
|
||||
f"{self.api_base_url}/chat/completions",
|
||||
json=payload,
|
||||
headers=self.headers
|
||||
)
|
||||
if response.status_code != 200:
|
||||
error_text = response.text
|
||||
self._print(f"{OUTPUT_FORMATS['error']} API请求失败 ({response.status_code}): {error_text}")
|
||||
return
|
||||
yield response.json()
|
||||
|
||||
except httpx.ConnectError:
|
||||
self._print(f"{OUTPUT_FORMATS['error']} 无法连接到API服务器,请检查网络连接")
|
||||
except httpx.TimeoutException:
|
||||
self._print(f"{OUTPUT_FORMATS['error']} API请求超时")
|
||||
except Exception as e:
|
||||
self._print(f"{OUTPUT_FORMATS['error']} API调用异常: {e}")
|
||||
|
||||
async def chat_with_tools(
|
||||
self,
|
||||
messages: List[Dict],
|
||||
tools: List[Dict],
|
||||
tool_handler: callable
|
||||
) -> str:
|
||||
"""
|
||||
带工具调用的对话(支持多轮)
|
||||
|
||||
Args:
|
||||
messages: 消息列表
|
||||
tools: 工具定义
|
||||
tool_handler: 工具处理函数
|
||||
|
||||
Returns:
|
||||
最终回答
|
||||
"""
|
||||
final_response = ""
|
||||
max_iterations = 200 # 最大迭代次数
|
||||
iteration = 0
|
||||
all_tool_results = [] # 记录所有工具调用结果
|
||||
|
||||
# 如果是思考模式且不是当前任务的第一次调用,注入本次任务的思考
|
||||
# 注意:这里重置的是当前任务的第一次调用标志,确保新用户请求重新思考
|
||||
# 只有在同一个任务的多轮迭代中才应该注入
|
||||
# 对于新的用户请求,应该重新开始思考,而不是使用之前的思考内容
|
||||
# 只有在当前任务有思考内容且不是第一次调用时才注入
|
||||
if (self.thinking_mode and
|
||||
not self.current_task_first_call and
|
||||
self.current_task_thinking and
|
||||
iteration == 0): # 只在第一次迭代时注入,避免多次注入
|
||||
# 在messages末尾添加一个系统消息,包含本次任务的思考
|
||||
thinking_context = f"\n=== 📋 本次任务的思考 ===\n{self.current_task_thinking}\n=== 思考结束 ===\n提示:这是本次任务的初始思考,你可以基于此继续处理。"
|
||||
messages.append({
|
||||
"role": "system",
|
||||
"content": thinking_context
|
||||
})
|
||||
|
||||
while iteration < max_iterations:
|
||||
iteration += 1
|
||||
|
||||
# 调用API(始终提供工具定义)
|
||||
full_response = ""
|
||||
tool_calls = []
|
||||
current_thinking = ""
|
||||
|
||||
# 状态标志
|
||||
in_thinking = False
|
||||
thinking_printed = False
|
||||
|
||||
# 获取当前是否应该显示思考
|
||||
should_show_thinking = self.get_current_thinking_mode()
|
||||
|
||||
async for chunk in self.chat(messages, tools, stream=True):
|
||||
if "choices" not in chunk:
|
||||
continue
|
||||
|
||||
delta = chunk["choices"][0].get("delta", {})
|
||||
|
||||
# 处理思考内容(只在思考模式开启时)
|
||||
if "reasoning_content" in delta and should_show_thinking:
|
||||
reasoning_content = delta["reasoning_content"]
|
||||
if reasoning_content: # 只处理非空内容
|
||||
if not in_thinking:
|
||||
self._print("💭 [正在思考]\n", end="", flush=True)
|
||||
in_thinking = True
|
||||
thinking_printed = True
|
||||
current_thinking += reasoning_content
|
||||
self._print(reasoning_content, end="", flush=True)
|
||||
|
||||
# 处理正常内容 - 独立的if,不是elif
|
||||
if "content" in delta:
|
||||
content = delta["content"]
|
||||
if content: # 只处理非空内容
|
||||
# 如果之前在输出思考,先结束思考输出
|
||||
if in_thinking:
|
||||
self._print("\n\n💭 [思考结束]\n\n", end="", flush=True)
|
||||
in_thinking = False
|
||||
full_response += content
|
||||
self._print(content, end="", flush=True)
|
||||
|
||||
# 收集工具调用 - 改进的拼接逻辑
|
||||
# 收集工具调用 - 修复JSON分片问题
|
||||
if "tool_calls" in delta:
|
||||
for tool_call in delta["tool_calls"]:
|
||||
tool_index = tool_call.get("index", 0)
|
||||
|
||||
# 查找或创建对应索引的工具调用
|
||||
existing_call = None
|
||||
for existing in tool_calls:
|
||||
if existing.get("index") == tool_index:
|
||||
existing_call = existing
|
||||
break
|
||||
|
||||
if not existing_call and tool_call.get("id"):
|
||||
# 创建新的工具调用
|
||||
new_call = {
|
||||
"id": tool_call.get("id"),
|
||||
"index": tool_index,
|
||||
"type": tool_call.get("type", "function"),
|
||||
"function": {
|
||||
"name": tool_call.get("function", {}).get("name", ""),
|
||||
"arguments": ""
|
||||
}
|
||||
}
|
||||
tool_calls.append(new_call)
|
||||
existing_call = new_call
|
||||
|
||||
# 安全地拼接arguments - 简单字符串拼接,不尝试JSON验证
|
||||
if existing_call and "function" in tool_call and "arguments" in tool_call["function"]:
|
||||
new_args = tool_call["function"]["arguments"]
|
||||
if new_args: # 只拼接非空内容
|
||||
existing_call["function"]["arguments"] += new_args
|
||||
|
||||
self._print() # 最终换行
|
||||
|
||||
# 如果思考还没结束(只调用工具没有文本),手动结束
|
||||
if in_thinking:
|
||||
self._print("\n💭 [思考结束]\n")
|
||||
|
||||
# 在思考模式下,如果是当前任务的第一次调用且有思考内容,保存它
|
||||
if self.thinking_mode and self.current_task_first_call and current_thinking:
|
||||
self.current_task_thinking = current_thinking
|
||||
self.current_task_first_call = False # 标记当前任务的第一次调用已完成
|
||||
|
||||
# 如果没有工具调用,说明完成了
|
||||
if not tool_calls:
|
||||
if full_response: # 有正常回复,任务完成
|
||||
final_response = full_response
|
||||
break
|
||||
elif iteration == 1: # 第一次就没有工具调用也没有内容,可能有问题
|
||||
self._print(f"{OUTPUT_FORMATS['warning']} 模型未返回内容")
|
||||
break
|
||||
|
||||
# 构建助手消息 - 始终包含所有收集到的内容
|
||||
assistant_content_parts = []
|
||||
|
||||
# 添加思考内容(如果有)
|
||||
if current_thinking:
|
||||
assistant_content_parts.append(f"<think>\n{current_thinking}\n</think>")
|
||||
|
||||
# 添加正式回复内容(如果有)
|
||||
if full_response:
|
||||
assistant_content_parts.append(full_response)
|
||||
|
||||
# 添加工具调用说明
|
||||
if tool_calls:
|
||||
tool_names = [tc['function']['name'] for tc in tool_calls]
|
||||
assistant_content_parts.append(f"执行工具: {', '.join(tool_names)}")
|
||||
|
||||
# 合并所有内容
|
||||
assistant_content = "\n".join(assistant_content_parts) if assistant_content_parts else "执行工具调用"
|
||||
|
||||
assistant_message = {
|
||||
"role": "assistant",
|
||||
"content": assistant_content,
|
||||
"tool_calls": tool_calls
|
||||
}
|
||||
|
||||
messages.append(assistant_message)
|
||||
|
||||
# 执行所有工具调用 - 使用鲁棒的参数解析
|
||||
for tool_call in tool_calls:
|
||||
function_name = tool_call["function"]["name"]
|
||||
arguments_str = tool_call["function"]["arguments"]
|
||||
|
||||
# 使用改进的参数解析方法,增强JSON修复能力
|
||||
success, arguments, error_msg = self._safe_tool_arguments_parse(arguments_str, function_name)
|
||||
|
||||
if not success:
|
||||
self._print(f"{OUTPUT_FORMATS['error']} 工具参数解析失败: {error_msg}")
|
||||
self._print(f" 工具名称: {function_name}")
|
||||
self._print(f" 参数长度: {len(arguments_str)} 字符")
|
||||
|
||||
# 返回详细的错误信息给模型
|
||||
error_response = {
|
||||
"success": False,
|
||||
"error": error_msg,
|
||||
"tool_name": function_name,
|
||||
"arguments_length": len(arguments_str),
|
||||
"suggestion": "请检查参数格式或减少参数长度后重试"
|
||||
}
|
||||
|
||||
# 如果参数过长,提供分块建议
|
||||
if len(arguments_str) > 10000:
|
||||
error_response["suggestion"] = "参数过长,建议分块处理或使用更简洁的内容"
|
||||
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call["id"],
|
||||
"name": function_name,
|
||||
"content": json.dumps(error_response, ensure_ascii=False)
|
||||
})
|
||||
|
||||
# 记录失败的调用,防止死循环检测失效
|
||||
all_tool_results.append({
|
||||
"tool": function_name,
|
||||
"args": {"parse_error": error_msg, "length": len(arguments_str)},
|
||||
"result": f"参数解析失败: {error_msg}"
|
||||
})
|
||||
continue
|
||||
|
||||
self._print(f"\n{OUTPUT_FORMATS['action']} 调用工具: {function_name}")
|
||||
|
||||
# 额外的参数长度检查(针对特定工具)
|
||||
if function_name == "modify_file" and "content" in arguments:
|
||||
content_length = len(arguments.get("content", ""))
|
||||
if content_length > 9999999999: # 降低到50KB限制
|
||||
error_msg = f"内容过长({content_length}字符),超过50KB限制"
|
||||
self._print(f"{OUTPUT_FORMATS['warning']} {error_msg}")
|
||||
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call["id"],
|
||||
"name": function_name,
|
||||
"content": json.dumps({
|
||||
"success": False,
|
||||
"error": error_msg,
|
||||
"suggestion": "请将内容分成多个小块分别修改,或使用replace操作只修改必要部分"
|
||||
}, ensure_ascii=False)
|
||||
})
|
||||
|
||||
all_tool_results.append({
|
||||
"tool": function_name,
|
||||
"args": arguments,
|
||||
"result": error_msg
|
||||
})
|
||||
continue
|
||||
|
||||
tool_result = await tool_handler(function_name, arguments)
|
||||
|
||||
# 解析工具结果,提取关键信息
|
||||
try:
|
||||
result_data = json.loads(tool_result)
|
||||
if function_name == "read_file":
|
||||
tool_result_msg = self._format_read_file_result(result_data)
|
||||
else:
|
||||
tool_result_msg = tool_result
|
||||
except:
|
||||
tool_result_msg = tool_result
|
||||
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call["id"],
|
||||
"name": function_name,
|
||||
"content": tool_result_msg
|
||||
})
|
||||
|
||||
# 记录工具结果
|
||||
all_tool_results.append({
|
||||
"tool": function_name,
|
||||
"args": arguments,
|
||||
"result": tool_result_msg
|
||||
})
|
||||
|
||||
# 如果连续多次调用同样的工具,可能陷入循环
|
||||
if len(all_tool_results) >= 8:
|
||||
recent_tools = [r["tool"] for r in all_tool_results[-8:]]
|
||||
if len(set(recent_tools)) == 1: # 最近8次都是同一个工具
|
||||
self._print(f"\n{OUTPUT_FORMATS['warning']} 检测到重复操作,停止执行")
|
||||
break
|
||||
|
||||
if iteration >= max_iterations:
|
||||
self._print(f"\n{OUTPUT_FORMATS['warning']} 达到最大迭代次数限制")
|
||||
|
||||
return final_response
|
||||
|
||||
async def simple_chat(self, messages: List[Dict]) -> tuple:
|
||||
"""
|
||||
简单对话(无工具调用)
|
||||
|
||||
Args:
|
||||
messages: 消息列表
|
||||
|
||||
Returns:
|
||||
(模型回答, 思考内容)
|
||||
"""
|
||||
full_response = ""
|
||||
thinking_content = ""
|
||||
in_thinking = False
|
||||
|
||||
# 获取当前是否应该显示思考
|
||||
should_show_thinking = self.get_current_thinking_mode()
|
||||
|
||||
# 如果是思考模式且不是当前任务的第一次调用,注入本次任务的思考
|
||||
if self.thinking_mode and not self.current_task_first_call and self.current_task_thinking:
|
||||
thinking_context = f"\n=== 📋 本次任务的思考 ===\n{self.current_task_thinking}\n=== 思考结束 ===\n"
|
||||
messages.append({
|
||||
"role": "system",
|
||||
"content": thinking_context
|
||||
})
|
||||
|
||||
try:
|
||||
async for chunk in self.chat(messages, tools=None, stream=True):
|
||||
if "choices" not in chunk:
|
||||
continue
|
||||
|
||||
delta = chunk["choices"][0].get("delta", {})
|
||||
|
||||
# 处理思考内容
|
||||
if "reasoning_content" in delta and should_show_thinking:
|
||||
reasoning_content = delta["reasoning_content"]
|
||||
if reasoning_content: # 只处理非空内容
|
||||
if not in_thinking:
|
||||
self._print("💭 [正在思考]\n", end="", flush=True)
|
||||
in_thinking = True
|
||||
thinking_content += reasoning_content
|
||||
self._print(reasoning_content, end="", flush=True)
|
||||
|
||||
# 处理正常内容 - 独立的if而不是elif
|
||||
if "content" in delta:
|
||||
content = delta["content"]
|
||||
if content: # 只处理非空内容
|
||||
if in_thinking:
|
||||
self._print("\n\n💭 [思考结束]\n\n", end="", flush=True)
|
||||
in_thinking = False
|
||||
full_response += content
|
||||
self._print(content, end="", flush=True)
|
||||
|
||||
self._print() # 最终换行
|
||||
|
||||
# 如果思考还没结束(极少情况),手动结束
|
||||
if in_thinking:
|
||||
self._print("\n💭 [思考结束]\n")
|
||||
|
||||
# 在思考模式下,如果是当前任务的第一次调用且有思考内容,保存它
|
||||
if self.thinking_mode and self.current_task_first_call and thinking_content:
|
||||
self.current_task_thinking = thinking_content
|
||||
self.current_task_first_call = False
|
||||
|
||||
# 如果没有收到任何响应
|
||||
if not full_response and not thinking_content:
|
||||
self._print(f"{OUTPUT_FORMATS['error']} API未返回任何内容,请检查API密钥和模型ID")
|
||||
return "", ""
|
||||
|
||||
except Exception as e:
|
||||
self._print(f"{OUTPUT_FORMATS['error']} API调用失败: {e}")
|
||||
return "", ""
|
||||
|
||||
return full_response, thinking_content
|
||||
1149
sub_agent/utils/context_manager.py
Normal file
1149
sub_agent/utils/context_manager.py
Normal file
File diff suppressed because it is too large
Load Diff
854
sub_agent/utils/conversation_manager.py
Normal file
854
sub_agent/utils/conversation_manager.py
Normal file
@ -0,0 +1,854 @@
|
||||
# utils/conversation_manager.py - 对话持久化管理器(集成Token统计)
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any
|
||||
from dataclasses import dataclass
|
||||
try:
|
||||
from config import DATA_DIR
|
||||
except ImportError:
|
||||
import sys
|
||||
from pathlib import Path
|
||||
project_root = Path(__file__).resolve().parents[1]
|
||||
if str(project_root) not in sys.path:
|
||||
sys.path.insert(0, str(project_root))
|
||||
from config import DATA_DIR
|
||||
import tiktoken
|
||||
|
||||
@dataclass
|
||||
class ConversationMetadata:
|
||||
"""对话元数据"""
|
||||
id: str
|
||||
title: str
|
||||
created_at: str
|
||||
updated_at: str
|
||||
project_path: Optional[str]
|
||||
project_relative_path: Optional[str]
|
||||
thinking_mode: bool
|
||||
total_messages: int
|
||||
total_tools: int
|
||||
status: str = "active" # active, archived, error
|
||||
|
||||
class ConversationManager:
|
||||
"""对话持久化管理器"""
|
||||
|
||||
def __init__(self, base_dir: Optional[str] = None):
|
||||
self.base_dir = Path(base_dir).expanduser().resolve() if base_dir else Path(DATA_DIR).resolve()
|
||||
self.conversations_dir = self.base_dir / "conversations"
|
||||
self.index_file = self.conversations_dir / "index.json"
|
||||
self.current_conversation_id: Optional[str] = None
|
||||
self.workspace_root = Path(__file__).resolve().parents[1]
|
||||
self._ensure_directories()
|
||||
self._load_index()
|
||||
|
||||
# 初始化tiktoken编码器
|
||||
try:
|
||||
self.encoding = tiktoken.get_encoding("cl100k_base")
|
||||
except Exception as e:
|
||||
print(f"⚠️ tiktoken初始化失败: {e}")
|
||||
self.encoding = None
|
||||
|
||||
def _ensure_directories(self):
|
||||
"""确保必要的目录存在"""
|
||||
self.base_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.conversations_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 如果索引文件不存在,创建空索引
|
||||
if not self.index_file.exists():
|
||||
self._save_index({})
|
||||
|
||||
def _iter_conversation_files(self):
|
||||
"""遍历对话文件(排除索引文件)"""
|
||||
for path in self.conversations_dir.glob("*.json"):
|
||||
if path == self.index_file:
|
||||
continue
|
||||
yield path
|
||||
|
||||
def _rebuild_index_from_files(self) -> Dict:
|
||||
"""从现有对话文件重建索引"""
|
||||
rebuilt_index: Dict[str, Dict] = {}
|
||||
for file_path in self._iter_conversation_files():
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
raw = f.read().strip()
|
||||
if not raw:
|
||||
continue
|
||||
data = json.loads(raw)
|
||||
except Exception as exc:
|
||||
print(f"⚠️ 重建索引时跳过 {file_path.name}: {exc}")
|
||||
continue
|
||||
|
||||
conv_id = data.get("id") or file_path.stem
|
||||
metadata = data.get("metadata", {}) or {}
|
||||
|
||||
rebuilt_index[conv_id] = {
|
||||
"title": data.get("title") or "未命名对话",
|
||||
"created_at": data.get("created_at"),
|
||||
"updated_at": data.get("updated_at"),
|
||||
"project_path": metadata.get("project_path"),
|
||||
"project_relative_path": metadata.get("project_relative_path"),
|
||||
"thinking_mode": metadata.get("thinking_mode", False),
|
||||
"total_messages": metadata.get("total_messages", 0),
|
||||
"total_tools": metadata.get("total_tools", 0),
|
||||
"status": metadata.get("status", "active"),
|
||||
}
|
||||
if rebuilt_index:
|
||||
print(f"🔄 已从对话文件重建索引,共 {len(rebuilt_index)} 条记录")
|
||||
return rebuilt_index
|
||||
|
||||
def _load_index(self) -> Dict:
|
||||
"""加载对话索引"""
|
||||
try:
|
||||
if self.index_file.exists():
|
||||
with open(self.index_file, 'r', encoding='utf-8') as f:
|
||||
content = f.read().strip()
|
||||
if content:
|
||||
index = json.loads(content)
|
||||
if index:
|
||||
return index
|
||||
# 索引为空但对话文件仍然存在时尝试重建
|
||||
rebuilt = self._rebuild_index_from_files()
|
||||
if rebuilt:
|
||||
self._save_index(rebuilt)
|
||||
return rebuilt
|
||||
return {}
|
||||
# 索引缺失但存在对话文件时重建
|
||||
rebuilt = self._rebuild_index_from_files()
|
||||
if rebuilt:
|
||||
self._save_index(rebuilt)
|
||||
return rebuilt
|
||||
return {}
|
||||
except (json.JSONDecodeError, Exception) as e:
|
||||
print(f"⚠️ 加载对话索引失败,将尝试重建: {e}")
|
||||
backup_path = self.index_file.with_name(
|
||||
f"{self.index_file.stem}_corrupt_{int(time.time())}{self.index_file.suffix}"
|
||||
)
|
||||
try:
|
||||
if self.index_file.exists():
|
||||
self.index_file.replace(backup_path)
|
||||
print(f"🗄️ 已备份损坏的索引文件到: {backup_path.name}")
|
||||
except Exception as backup_exc:
|
||||
print(f"⚠️ 备份损坏索引文件失败: {backup_exc}")
|
||||
rebuilt = self._rebuild_index_from_files()
|
||||
if rebuilt:
|
||||
self._save_index(rebuilt)
|
||||
return rebuilt
|
||||
return {}
|
||||
|
||||
def _save_index(self, index: Dict):
|
||||
"""保存对话索引"""
|
||||
temp_file = self.index_file.with_suffix(self.index_file.suffix + ".tmp")
|
||||
try:
|
||||
with open(temp_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(index, f, ensure_ascii=False, indent=2)
|
||||
os.replace(temp_file, self.index_file)
|
||||
except Exception as e:
|
||||
try:
|
||||
if temp_file.exists():
|
||||
temp_file.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
print(f"⌘ 保存对话索引失败: {e}")
|
||||
|
||||
def _generate_conversation_id(self) -> str:
|
||||
"""生成唯一的对话ID"""
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
# 添加毫秒确保唯一性
|
||||
ms = int(time.time() * 1000) % 1000
|
||||
return f"conv_{timestamp}_{ms:03d}"
|
||||
|
||||
def _get_conversation_file_path(self, conversation_id: str) -> Path:
|
||||
"""获取对话文件路径"""
|
||||
return self.conversations_dir / f"{conversation_id}.json"
|
||||
|
||||
def _extract_title_from_messages(self, messages: List[Dict]) -> str:
|
||||
"""从消息中提取标题"""
|
||||
# 找到第一个用户消息作为标题
|
||||
for msg in messages:
|
||||
if msg.get("role") == "user":
|
||||
content = msg.get("content", "").strip()
|
||||
if content:
|
||||
# 取前50个字符作为标题
|
||||
title = content[:50]
|
||||
if len(content) > 50:
|
||||
title += "..."
|
||||
return title
|
||||
return "新对话"
|
||||
|
||||
def _count_tools_in_messages(self, messages: List[Dict]) -> int:
|
||||
"""统计消息中的工具调用数量"""
|
||||
tool_count = 0
|
||||
for msg in messages:
|
||||
if msg.get("role") == "assistant" and "tool_calls" in msg:
|
||||
tool_calls = msg.get("tool_calls", [])
|
||||
tool_count += len(tool_calls) if isinstance(tool_calls, list) else 0
|
||||
elif msg.get("role") == "tool":
|
||||
tool_count += 1
|
||||
return tool_count
|
||||
|
||||
def _prepare_project_path_metadata(self, project_path: Optional[str]) -> Dict[str, Optional[str]]:
|
||||
"""
|
||||
将项目路径规范化为绝对/相对形式,便于在不同机器间迁移
|
||||
"""
|
||||
normalized = {
|
||||
"project_path": None,
|
||||
"project_relative_path": None
|
||||
}
|
||||
|
||||
if not project_path:
|
||||
return normalized
|
||||
|
||||
try:
|
||||
absolute_path = Path(project_path).expanduser().resolve()
|
||||
normalized["project_path"] = str(absolute_path)
|
||||
|
||||
try:
|
||||
relative_path = absolute_path.relative_to(self.workspace_root)
|
||||
normalized["project_relative_path"] = relative_path.as_posix()
|
||||
except ValueError:
|
||||
normalized["project_relative_path"] = None
|
||||
except Exception:
|
||||
# 回退为原始字符串,至少不会阻止对话保存
|
||||
normalized["project_path"] = str(project_path)
|
||||
normalized["project_relative_path"] = None
|
||||
|
||||
return normalized
|
||||
|
||||
def _initialize_token_statistics(self) -> Dict:
|
||||
"""初始化Token统计结构"""
|
||||
return {
|
||||
"total_input_tokens": 0,
|
||||
"total_output_tokens": 0,
|
||||
"updated_at": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
def _validate_token_statistics(self, data: Dict) -> Dict:
|
||||
"""验证并修复Token统计数据"""
|
||||
token_stats = data.get("token_statistics", {})
|
||||
|
||||
# 确保必要字段存在
|
||||
if "total_input_tokens" not in token_stats:
|
||||
token_stats["total_input_tokens"] = 0
|
||||
if "total_output_tokens" not in token_stats:
|
||||
token_stats["total_output_tokens"] = 0
|
||||
if "updated_at" not in token_stats:
|
||||
token_stats["updated_at"] = datetime.now().isoformat()
|
||||
|
||||
# 确保数值类型正确
|
||||
try:
|
||||
token_stats["total_input_tokens"] = int(token_stats["total_input_tokens"])
|
||||
token_stats["total_output_tokens"] = int(token_stats["total_output_tokens"])
|
||||
except (ValueError, TypeError):
|
||||
print("⚠️ Token统计数据损坏,重置为0")
|
||||
token_stats["total_input_tokens"] = 0
|
||||
token_stats["total_output_tokens"] = 0
|
||||
|
||||
data["token_statistics"] = token_stats
|
||||
return data
|
||||
|
||||
def create_conversation(
|
||||
self,
|
||||
project_path: str,
|
||||
thinking_mode: bool = False,
|
||||
initial_messages: List[Dict] = None
|
||||
) -> str:
|
||||
"""
|
||||
创建新对话
|
||||
|
||||
Args:
|
||||
project_path: 项目路径
|
||||
thinking_mode: 思考模式
|
||||
initial_messages: 初始消息列表
|
||||
|
||||
Returns:
|
||||
conversation_id: 对话ID
|
||||
"""
|
||||
conversation_id = self._generate_conversation_id()
|
||||
messages = initial_messages or []
|
||||
|
||||
# 创建对话数据
|
||||
path_metadata = self._prepare_project_path_metadata(project_path)
|
||||
conversation_data = {
|
||||
"id": conversation_id,
|
||||
"title": self._extract_title_from_messages(messages),
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"updated_at": datetime.now().isoformat(),
|
||||
"messages": messages,
|
||||
"todo_list": None,
|
||||
"metadata": {
|
||||
"project_path": path_metadata["project_path"],
|
||||
"project_relative_path": path_metadata["project_relative_path"],
|
||||
"thinking_mode": thinking_mode,
|
||||
"total_messages": len(messages),
|
||||
"total_tools": self._count_tools_in_messages(messages),
|
||||
"status": "active"
|
||||
},
|
||||
"token_statistics": self._initialize_token_statistics() # 新增
|
||||
}
|
||||
|
||||
# 保存对话文件
|
||||
self._save_conversation_file(conversation_id, conversation_data)
|
||||
|
||||
# 更新索引
|
||||
self._update_index(conversation_id, conversation_data)
|
||||
|
||||
self.current_conversation_id = conversation_id
|
||||
print(f"📝 创建新对话: {conversation_id} - {conversation_data['title']}")
|
||||
|
||||
return conversation_id
|
||||
|
||||
def _save_conversation_file(self, conversation_id: str, data: Dict):
|
||||
"""保存对话文件"""
|
||||
file_path = self._get_conversation_file_path(conversation_id)
|
||||
temp_file = file_path.with_suffix(file_path.suffix + ".tmp")
|
||||
try:
|
||||
# 确保Token统计数据有效
|
||||
data = self._validate_token_statistics(data)
|
||||
with open(temp_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
os.replace(temp_file, file_path)
|
||||
except Exception as e:
|
||||
try:
|
||||
if temp_file.exists():
|
||||
temp_file.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
print(f"⌘ 保存对话文件失败 {conversation_id}: {e}")
|
||||
|
||||
def _update_index(self, conversation_id: str, conversation_data: Dict):
|
||||
"""更新对话索引"""
|
||||
try:
|
||||
index = self._load_index()
|
||||
|
||||
# 创建元数据
|
||||
metadata = ConversationMetadata(
|
||||
id=conversation_id,
|
||||
title=conversation_data["title"],
|
||||
created_at=conversation_data["created_at"],
|
||||
updated_at=conversation_data["updated_at"],
|
||||
project_path=conversation_data["metadata"]["project_path"],
|
||||
project_relative_path=conversation_data["metadata"].get("project_relative_path"),
|
||||
thinking_mode=conversation_data["metadata"]["thinking_mode"],
|
||||
total_messages=conversation_data["metadata"]["total_messages"],
|
||||
total_tools=conversation_data["metadata"]["total_tools"],
|
||||
status=conversation_data["metadata"].get("status", "active")
|
||||
)
|
||||
|
||||
# 添加到索引
|
||||
index[conversation_id] = {
|
||||
"title": metadata.title,
|
||||
"created_at": metadata.created_at,
|
||||
"updated_at": metadata.updated_at,
|
||||
"project_path": metadata.project_path,
|
||||
"project_relative_path": metadata.project_relative_path,
|
||||
"thinking_mode": metadata.thinking_mode,
|
||||
"total_messages": metadata.total_messages,
|
||||
"total_tools": metadata.total_tools,
|
||||
"status": metadata.status
|
||||
}
|
||||
|
||||
self._save_index(index)
|
||||
except Exception as e:
|
||||
print(f"⌘ 更新对话索引失败: {e}")
|
||||
|
||||
def save_conversation(
|
||||
self,
|
||||
conversation_id: str,
|
||||
messages: List[Dict],
|
||||
project_path: str = None,
|
||||
thinking_mode: bool = None,
|
||||
todo_list: Optional[Dict] = None
|
||||
) -> bool:
|
||||
"""
|
||||
保存对话(更新现有对话)
|
||||
|
||||
Args:
|
||||
conversation_id: 对话ID
|
||||
messages: 消息列表
|
||||
project_path: 项目路径
|
||||
thinking_mode: 思考模式
|
||||
|
||||
Returns:
|
||||
bool: 保存是否成功
|
||||
"""
|
||||
try:
|
||||
# 加载现有对话数据
|
||||
existing_data = self.load_conversation(conversation_id)
|
||||
if not existing_data:
|
||||
print(f"⚠️ 对话 {conversation_id} 不存在,无法更新")
|
||||
return False
|
||||
|
||||
# 更新数据
|
||||
existing_data["messages"] = messages
|
||||
existing_data["updated_at"] = datetime.now().isoformat()
|
||||
|
||||
# 更新标题(如果消息发生变化)
|
||||
new_title = self._extract_title_from_messages(messages)
|
||||
if new_title != "新对话":
|
||||
existing_data["title"] = new_title
|
||||
|
||||
# 更新元数据
|
||||
if project_path is not None:
|
||||
path_metadata = self._prepare_project_path_metadata(project_path)
|
||||
existing_data["metadata"]["project_path"] = path_metadata["project_path"]
|
||||
existing_data["metadata"]["project_relative_path"] = path_metadata["project_relative_path"]
|
||||
else:
|
||||
existing_data["metadata"].setdefault("project_relative_path", None)
|
||||
if thinking_mode is not None:
|
||||
existing_data["metadata"]["thinking_mode"] = thinking_mode
|
||||
|
||||
existing_data["metadata"]["total_messages"] = len(messages)
|
||||
existing_data["metadata"]["total_tools"] = self._count_tools_in_messages(messages)
|
||||
|
||||
# 更新待办列表
|
||||
existing_data["todo_list"] = todo_list
|
||||
|
||||
# 确保Token统计结构存在(向后兼容)
|
||||
if "token_statistics" not in existing_data:
|
||||
existing_data["token_statistics"] = self._initialize_token_statistics()
|
||||
else:
|
||||
existing_data["token_statistics"]["updated_at"] = datetime.now().isoformat()
|
||||
|
||||
# 保存文件
|
||||
self._save_conversation_file(conversation_id, existing_data)
|
||||
|
||||
# 更新索引
|
||||
self._update_index(conversation_id, existing_data)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"⌘ 保存对话失败 {conversation_id}: {e}")
|
||||
return False
|
||||
|
||||
def load_conversation(self, conversation_id: str) -> Optional[Dict]:
|
||||
"""
|
||||
加载对话数据
|
||||
|
||||
Args:
|
||||
conversation_id: 对话ID
|
||||
|
||||
Returns:
|
||||
Dict: 对话数据,如果不存在返回None
|
||||
"""
|
||||
try:
|
||||
file_path = self._get_conversation_file_path(conversation_id)
|
||||
if not file_path.exists():
|
||||
return None
|
||||
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read().strip()
|
||||
if not content:
|
||||
return None
|
||||
|
||||
data = json.loads(content)
|
||||
|
||||
metadata = data.get("metadata", {})
|
||||
if "project_relative_path" not in metadata:
|
||||
metadata["project_relative_path"] = None
|
||||
self._save_conversation_file(conversation_id, data)
|
||||
print(f"🔧 为对话 {conversation_id} 添加相对路径字段")
|
||||
|
||||
# 向后兼容:确保Token统计结构存在
|
||||
if "token_statistics" not in data:
|
||||
data["token_statistics"] = self._initialize_token_statistics()
|
||||
# 自动保存修复后的数据
|
||||
self._save_conversation_file(conversation_id, data)
|
||||
print(f"🔧 为对话 {conversation_id} 添加Token统计结构")
|
||||
else:
|
||||
# 验证现有Token统计数据
|
||||
data = self._validate_token_statistics(data)
|
||||
|
||||
return data
|
||||
except (json.JSONDecodeError, Exception) as e:
|
||||
print(f"⌘ 加载对话失败 {conversation_id}: {e}")
|
||||
return None
|
||||
|
||||
def update_token_statistics(self, conversation_id: str, input_tokens: int, output_tokens: int) -> bool:
|
||||
"""
|
||||
更新对话的Token统计
|
||||
|
||||
Args:
|
||||
conversation_id: 对话ID
|
||||
input_tokens: 输入Token数量
|
||||
output_tokens: 输出Token数量
|
||||
|
||||
Returns:
|
||||
bool: 更新是否成功
|
||||
"""
|
||||
try:
|
||||
conversation_data = self.load_conversation(conversation_id)
|
||||
if not conversation_data:
|
||||
print(f"⚠️ 无法找到对话 {conversation_id},跳过Token统计")
|
||||
return False
|
||||
|
||||
# 确保Token统计结构存在
|
||||
if "token_statistics" not in conversation_data:
|
||||
conversation_data["token_statistics"] = self._initialize_token_statistics()
|
||||
|
||||
# 更新统计数据
|
||||
token_stats = conversation_data["token_statistics"]
|
||||
token_stats["total_input_tokens"] = token_stats.get("total_input_tokens", 0) + input_tokens
|
||||
token_stats["total_output_tokens"] = token_stats.get("total_output_tokens", 0) + output_tokens
|
||||
token_stats["updated_at"] = datetime.now().isoformat()
|
||||
|
||||
# 保存更新
|
||||
self._save_conversation_file(conversation_id, conversation_data)
|
||||
|
||||
print(f"📊 Token统计已更新: +{input_tokens}输入, +{output_tokens}输出 "
|
||||
f"(总计: {token_stats['total_input_tokens']}输入, {token_stats['total_output_tokens']}输出)")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"⌘ 更新Token统计失败 {conversation_id}: {e}")
|
||||
return False
|
||||
|
||||
def get_token_statistics(self, conversation_id: str) -> Optional[Dict]:
|
||||
"""
|
||||
获取对话的Token统计
|
||||
|
||||
Args:
|
||||
conversation_id: 对话ID
|
||||
|
||||
Returns:
|
||||
Dict: Token统计数据
|
||||
"""
|
||||
try:
|
||||
conversation_data = self.load_conversation(conversation_id)
|
||||
if not conversation_data:
|
||||
return None
|
||||
|
||||
token_stats = conversation_data.get("token_statistics", {})
|
||||
|
||||
# 确保基本字段存在
|
||||
result = {
|
||||
"total_input_tokens": token_stats.get("total_input_tokens", 0),
|
||||
"total_output_tokens": token_stats.get("total_output_tokens", 0),
|
||||
"total_tokens": token_stats.get("total_input_tokens", 0) + token_stats.get("total_output_tokens", 0),
|
||||
"updated_at": token_stats.get("updated_at"),
|
||||
"conversation_id": conversation_id
|
||||
}
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
print(f"⌘ 获取Token统计失败 {conversation_id}: {e}")
|
||||
return None
|
||||
|
||||
def get_conversation_list(self, limit: int = 50, offset: int = 0) -> Dict:
|
||||
"""
|
||||
获取对话列表
|
||||
|
||||
Args:
|
||||
limit: 限制数量
|
||||
offset: 偏移量
|
||||
|
||||
Returns:
|
||||
Dict: 包含对话列表和统计信息
|
||||
"""
|
||||
try:
|
||||
index = self._load_index()
|
||||
|
||||
# 按更新时间倒序排列
|
||||
sorted_conversations = sorted(
|
||||
index.items(),
|
||||
key=lambda x: x[1].get("updated_at", ""),
|
||||
reverse=True
|
||||
)
|
||||
|
||||
# 分页
|
||||
total = len(sorted_conversations)
|
||||
conversations = sorted_conversations[offset:offset+limit]
|
||||
|
||||
# 格式化结果
|
||||
result = []
|
||||
for conv_id, metadata in conversations:
|
||||
result.append({
|
||||
"id": conv_id,
|
||||
"title": metadata.get("title", "未命名对话"),
|
||||
"created_at": metadata.get("created_at"),
|
||||
"updated_at": metadata.get("updated_at"),
|
||||
"project_path": metadata.get("project_path"),
|
||||
"project_relative_path": metadata.get("project_relative_path"),
|
||||
"thinking_mode": metadata.get("thinking_mode", False),
|
||||
"total_messages": metadata.get("total_messages", 0),
|
||||
"total_tools": metadata.get("total_tools", 0),
|
||||
"status": metadata.get("status", "active")
|
||||
})
|
||||
|
||||
return {
|
||||
"conversations": result,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"has_more": offset + limit < total
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"⌘ 获取对话列表失败: {e}")
|
||||
return {
|
||||
"conversations": [],
|
||||
"total": 0,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"has_more": False
|
||||
}
|
||||
|
||||
def delete_conversation(self, conversation_id: str) -> bool:
|
||||
"""
|
||||
删除对话
|
||||
|
||||
Args:
|
||||
conversation_id: 对话ID
|
||||
|
||||
Returns:
|
||||
bool: 删除是否成功
|
||||
"""
|
||||
try:
|
||||
# 删除对话文件
|
||||
file_path = self._get_conversation_file_path(conversation_id)
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
|
||||
# 从索引中删除
|
||||
index = self._load_index()
|
||||
if conversation_id in index:
|
||||
del index[conversation_id]
|
||||
self._save_index(index)
|
||||
|
||||
# 如果删除的是当前对话,清除当前对话ID
|
||||
if self.current_conversation_id == conversation_id:
|
||||
self.current_conversation_id = None
|
||||
|
||||
print(f"🗑️ 已删除对话: {conversation_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"⌘ 删除对话失败 {conversation_id}: {e}")
|
||||
return False
|
||||
|
||||
def archive_conversation(self, conversation_id: str) -> bool:
|
||||
"""
|
||||
归档对话(标记为已归档,不删除)
|
||||
|
||||
Args:
|
||||
conversation_id: 对话ID
|
||||
|
||||
Returns:
|
||||
bool: 归档是否成功
|
||||
"""
|
||||
try:
|
||||
# 更新对话状态
|
||||
conversation_data = self.load_conversation(conversation_id)
|
||||
if not conversation_data:
|
||||
return False
|
||||
|
||||
conversation_data["metadata"]["status"] = "archived"
|
||||
conversation_data["updated_at"] = datetime.now().isoformat()
|
||||
|
||||
# 保存更新
|
||||
self._save_conversation_file(conversation_id, conversation_data)
|
||||
self._update_index(conversation_id, conversation_data)
|
||||
|
||||
print(f"📦 已归档对话: {conversation_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"⌘ 归档对话失败 {conversation_id}: {e}")
|
||||
return False
|
||||
|
||||
def search_conversations(self, query: str, limit: int = 20) -> List[Dict]:
|
||||
"""
|
||||
搜索对话
|
||||
|
||||
Args:
|
||||
query: 搜索关键词
|
||||
limit: 限制数量
|
||||
|
||||
Returns:
|
||||
List[Dict]: 匹配的对话列表
|
||||
"""
|
||||
try:
|
||||
index = self._load_index()
|
||||
results = []
|
||||
|
||||
query_lower = query.lower()
|
||||
|
||||
for conv_id, metadata in index.items():
|
||||
# 搜索标题
|
||||
title = metadata.get("title", "").lower()
|
||||
if query_lower in title:
|
||||
score = 100 # 标题匹配权重最高
|
||||
results.append((score, {
|
||||
"id": conv_id,
|
||||
"title": metadata.get("title"),
|
||||
"created_at": metadata.get("created_at"),
|
||||
"updated_at": metadata.get("updated_at"),
|
||||
"project_path": metadata.get("project_path"),
|
||||
"match_type": "title"
|
||||
}))
|
||||
continue
|
||||
|
||||
# 搜索项目路径
|
||||
project_path = metadata.get("project_path", "").lower()
|
||||
if query_lower in project_path:
|
||||
results.append((50, {
|
||||
"id": conv_id,
|
||||
"title": metadata.get("title"),
|
||||
"created_at": metadata.get("created_at"),
|
||||
"updated_at": metadata.get("updated_at"),
|
||||
"project_path": metadata.get("project_path"),
|
||||
"match_type": "project_path"
|
||||
}))
|
||||
|
||||
# 按分数排序
|
||||
results.sort(key=lambda x: x[0], reverse=True)
|
||||
|
||||
# 返回前N个结果
|
||||
return [result[1] for result in results[:limit]]
|
||||
except Exception as e:
|
||||
print(f"⌘ 搜索对话失败: {e}")
|
||||
return []
|
||||
|
||||
def cleanup_old_conversations(self, days: int = 30) -> int:
|
||||
"""
|
||||
清理旧对话(可选功能)
|
||||
|
||||
Args:
|
||||
days: 保留天数
|
||||
|
||||
Returns:
|
||||
int: 清理的对话数量
|
||||
"""
|
||||
try:
|
||||
from datetime import datetime, timedelta
|
||||
cutoff_date = datetime.now() - timedelta(days=days)
|
||||
cutoff_iso = cutoff_date.isoformat()
|
||||
|
||||
index = self._load_index()
|
||||
to_delete = []
|
||||
|
||||
for conv_id, metadata in index.items():
|
||||
updated_at = metadata.get("updated_at", "")
|
||||
if updated_at < cutoff_iso and metadata.get("status") != "archived":
|
||||
to_delete.append(conv_id)
|
||||
|
||||
deleted_count = 0
|
||||
for conv_id in to_delete:
|
||||
if self.delete_conversation(conv_id):
|
||||
deleted_count += 1
|
||||
|
||||
if deleted_count > 0:
|
||||
print(f"🧹 清理了 {deleted_count} 个旧对话")
|
||||
|
||||
return deleted_count
|
||||
except Exception as e:
|
||||
print(f"⌘ 清理旧对话失败: {e}")
|
||||
return 0
|
||||
|
||||
def get_statistics(self) -> Dict:
|
||||
"""
|
||||
获取对话统计信息
|
||||
|
||||
Returns:
|
||||
Dict: 统计信息
|
||||
"""
|
||||
try:
|
||||
index = self._load_index()
|
||||
|
||||
total_conversations = len(index)
|
||||
total_messages = sum(meta.get("total_messages", 0) for meta in index.values())
|
||||
total_tools = sum(meta.get("total_tools", 0) for meta in index.values())
|
||||
|
||||
# 按状态分类
|
||||
status_count = {}
|
||||
for metadata in index.values():
|
||||
status = metadata.get("status", "active")
|
||||
status_count[status] = status_count.get(status, 0) + 1
|
||||
|
||||
# 按思考模式分类
|
||||
thinking_mode_count = {
|
||||
"thinking": sum(1 for meta in index.values() if meta.get("thinking_mode")),
|
||||
"fast": sum(1 for meta in index.values() if not meta.get("thinking_mode"))
|
||||
}
|
||||
|
||||
# 新增:Token统计汇总
|
||||
total_input_tokens = 0
|
||||
total_output_tokens = 0
|
||||
token_stats_count = 0
|
||||
|
||||
for conv_id in index.keys():
|
||||
token_stats = self.get_token_statistics(conv_id)
|
||||
if token_stats:
|
||||
total_input_tokens += token_stats.get("total_input_tokens", 0)
|
||||
total_output_tokens += token_stats.get("total_output_tokens", 0)
|
||||
token_stats_count += 1
|
||||
|
||||
return {
|
||||
"total_conversations": total_conversations,
|
||||
"total_messages": total_messages,
|
||||
"total_tools": total_tools,
|
||||
"status_distribution": status_count,
|
||||
"thinking_mode_distribution": thinking_mode_count,
|
||||
"token_statistics": {
|
||||
"total_input_tokens": total_input_tokens,
|
||||
"total_output_tokens": total_output_tokens,
|
||||
"total_tokens": total_input_tokens + total_output_tokens,
|
||||
"conversations_with_stats": token_stats_count
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"⌘ 获取统计信息失败: {e}")
|
||||
return {}
|
||||
|
||||
def get_current_conversation_id(self) -> Optional[str]:
|
||||
"""获取当前对话ID"""
|
||||
return self.current_conversation_id
|
||||
|
||||
def set_current_conversation_id(self, conversation_id: str):
|
||||
"""设置当前对话ID"""
|
||||
self.current_conversation_id = conversation_id
|
||||
|
||||
def calculate_conversation_tokens(self, conversation_id: str, context_manager=None, focused_files=None, terminal_content="") -> dict:
|
||||
"""计算对话的真实API token消耗"""
|
||||
try:
|
||||
if not context_manager:
|
||||
return {"total_tokens": 0}
|
||||
|
||||
conversation_data = self.load_conversation(conversation_id)
|
||||
if not conversation_data:
|
||||
return {"total_tokens": 0}
|
||||
|
||||
# 使用宿主终端的构建流程以贴合真实API请求
|
||||
if getattr(context_manager, "main_terminal", None):
|
||||
main_terminal = context_manager.main_terminal
|
||||
context = main_terminal.build_context()
|
||||
messages = main_terminal.build_messages(context, "")
|
||||
tools = main_terminal.define_tools()
|
||||
else:
|
||||
context = context_manager.build_main_context(memory_content="")
|
||||
messages = context_manager.build_messages(context, "")
|
||||
tools = self._get_tools_definition(context_manager) or []
|
||||
|
||||
total_tokens = context_manager.calculate_input_tokens(messages, tools)
|
||||
|
||||
return {"total_tokens": total_tokens}
|
||||
|
||||
except Exception as e:
|
||||
print(f"计算token失败: {e}")
|
||||
return {"total_tokens": 0}
|
||||
def _get_tools_definition(self, context_manager):
|
||||
"""获取工具定义"""
|
||||
try:
|
||||
# 需要找到工具定义的来源,通常在 main_terminal 中
|
||||
# 你需要找到 main_terminal 的引用或者 define_tools 方法
|
||||
|
||||
# 方法1: 如果 context_manager 有 main_terminal 引用
|
||||
if hasattr(context_manager, 'main_terminal') and context_manager.main_terminal:
|
||||
return context_manager.main_terminal.define_tools()
|
||||
|
||||
# 方法2: 如果有其他方式获取工具定义
|
||||
# 你需要去找一下在哪里调用了 calculate_input_tokens,看看 tools 参数是怎么传的
|
||||
|
||||
return []
|
||||
except Exception as e:
|
||||
print(f"获取工具定义失败: {e}")
|
||||
return []
|
||||
131
sub_agent/utils/logger.py
Normal file
131
sub_agent/utils/logger.py
Normal file
@ -0,0 +1,131 @@
|
||||
# utils/logger.py - 日志系统
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
try:
|
||||
from config import LOGS_DIR, LOG_LEVEL, LOG_FORMAT
|
||||
except ImportError:
|
||||
import sys
|
||||
from pathlib import Path
|
||||
project_root = Path(__file__).resolve().parents[1]
|
||||
if str(project_root) not in sys.path:
|
||||
sys.path.insert(0, str(project_root))
|
||||
from config import LOGS_DIR, LOG_LEVEL, LOG_FORMAT
|
||||
|
||||
def setup_logger(name: str, log_file: str = None) -> logging.Logger:
|
||||
"""
|
||||
设置日志记录器
|
||||
|
||||
Args:
|
||||
name: 日志记录器名称
|
||||
log_file: 日志文件路径(可选)
|
||||
|
||||
Returns:
|
||||
配置好的日志记录器
|
||||
"""
|
||||
logger = logging.getLogger(name)
|
||||
logger.setLevel(getattr(logging, LOG_LEVEL))
|
||||
|
||||
# 清除已有的处理器
|
||||
logger.handlers.clear()
|
||||
|
||||
# 创建格式化器
|
||||
formatter = logging.Formatter(LOG_FORMAT)
|
||||
|
||||
# 控制台处理器(只显示WARNING及以上)
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(logging.WARNING)
|
||||
console_handler.setFormatter(formatter)
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
# 文件处理器
|
||||
if log_file:
|
||||
file_path = Path(LOGS_DIR) / log_file
|
||||
else:
|
||||
# 默认日志文件
|
||||
today = datetime.now().strftime("%Y%m%d")
|
||||
file_path = Path(LOGS_DIR) / f"agent_{today}.log"
|
||||
|
||||
# 确保日志目录存在
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
file_handler = logging.FileHandler(file_path, encoding='utf-8')
|
||||
file_handler.setLevel(getattr(logging, LOG_LEVEL))
|
||||
file_handler.setFormatter(formatter)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
return logger
|
||||
|
||||
class TaskLogger:
|
||||
"""任务专用日志记录器"""
|
||||
|
||||
def __init__(self, task_id: str):
|
||||
self.task_id = task_id
|
||||
self.log_file = Path(LOGS_DIR) / "tasks" / f"{task_id}.log"
|
||||
self.log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.logger = setup_logger(f"task_{task_id}", str(self.log_file))
|
||||
|
||||
def log_action(self, action: str, details: dict = None):
|
||||
"""记录操作"""
|
||||
log_entry = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"action": action,
|
||||
"details": details or {}
|
||||
}
|
||||
self.logger.info(f"ACTION: {log_entry}")
|
||||
|
||||
def log_result(self, success: bool, message: str, data: dict = None):
|
||||
"""记录结果"""
|
||||
log_entry = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"success": success,
|
||||
"message": message,
|
||||
"data": data or {}
|
||||
}
|
||||
|
||||
if success:
|
||||
self.logger.info(f"RESULT: {log_entry}")
|
||||
else:
|
||||
self.logger.error(f"RESULT: {log_entry}")
|
||||
|
||||
def log_error(self, error: Exception, context: str = ""):
|
||||
"""记录错误"""
|
||||
log_entry = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"error": str(error),
|
||||
"type": type(error).__name__,
|
||||
"context": context
|
||||
}
|
||||
self.logger.error(f"ERROR: {log_entry}", exc_info=True)
|
||||
|
||||
def get_log_content(self) -> str:
|
||||
"""获取日志内容"""
|
||||
if self.log_file.exists():
|
||||
with open(self.log_file, 'r', encoding='utf-8') as f:
|
||||
return f.read()
|
||||
return ""
|
||||
|
||||
class ErrorLogger:
|
||||
"""错误专用日志记录器"""
|
||||
|
||||
@staticmethod
|
||||
def log_error(module: str, error: Exception, context: dict = None):
|
||||
"""记录错误到错误日志"""
|
||||
today = datetime.now().strftime("%Y%m%d")
|
||||
error_file = Path(LOGS_DIR) / "errors" / f"errors_{today}.log"
|
||||
error_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logger = setup_logger(f"error_{module}", str(error_file))
|
||||
|
||||
error_entry = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"module": module,
|
||||
"error": str(error),
|
||||
"type": type(error).__name__,
|
||||
"context": context or {}
|
||||
}
|
||||
|
||||
logger.error(f"ERROR: {error_entry}", exc_info=True)
|
||||
319
sub_agent/utils/terminal_factory.py
Normal file
319
sub_agent/utils/terminal_factory.py
Normal file
@ -0,0 +1,319 @@
|
||||
# utils/terminal_factory.py - 跨平台终端工厂(修改为Windows优先使用CMD)
|
||||
|
||||
import sys
|
||||
import os
|
||||
import subprocess
|
||||
import shutil
|
||||
from typing import Optional, Dict, List
|
||||
from pathlib import Path
|
||||
|
||||
class TerminalFactory:
|
||||
"""跨平台终端工厂,用于创建合适的终端进程"""
|
||||
|
||||
def __init__(self):
|
||||
"""初始化终端工厂"""
|
||||
self.platform = sys.platform
|
||||
self.available_shells = self._detect_available_shells()
|
||||
|
||||
def _detect_available_shells(self) -> Dict[str, str]:
|
||||
"""检测系统中可用的shell"""
|
||||
shells = {}
|
||||
|
||||
if self.platform == "win32":
|
||||
# Windows系统
|
||||
# 检查cmd(优先)
|
||||
if shutil.which("cmd.exe"):
|
||||
shells["cmd"] = "cmd.exe"
|
||||
|
||||
# 检查PowerShell(备用)
|
||||
if shutil.which("powershell.exe"):
|
||||
shells["powershell"] = "powershell.exe"
|
||||
|
||||
# 检查Windows Terminal(新版Windows)
|
||||
if shutil.which("wt.exe"):
|
||||
shells["wt"] = "wt.exe"
|
||||
|
||||
# 检查Git Bash
|
||||
git_bash_paths = [
|
||||
r"C:\Program Files\Git\bin\bash.exe",
|
||||
r"C:\Program Files (x86)\Git\bin\bash.exe",
|
||||
os.path.expanduser("~/AppData/Local/Programs/Git/bin/bash.exe")
|
||||
]
|
||||
for path in git_bash_paths:
|
||||
if os.path.exists(path):
|
||||
shells["git-bash"] = path
|
||||
break
|
||||
|
||||
# 检查WSL
|
||||
if shutil.which("wsl.exe"):
|
||||
shells["wsl"] = "wsl.exe"
|
||||
|
||||
else:
|
||||
# Unix-like系统(Linux, macOS)
|
||||
# 检查bash
|
||||
if shutil.which("bash"):
|
||||
shells["bash"] = "/bin/bash"
|
||||
|
||||
# 检查zsh(macOS默认)
|
||||
if shutil.which("zsh"):
|
||||
shells["zsh"] = "/bin/zsh"
|
||||
|
||||
# 检查sh
|
||||
if shutil.which("sh"):
|
||||
shells["sh"] = "/bin/sh"
|
||||
|
||||
# 检查fish
|
||||
if shutil.which("fish"):
|
||||
shells["fish"] = shutil.which("fish")
|
||||
|
||||
return shells
|
||||
|
||||
def get_shell_command(self, preferred: Optional[str] = None) -> str:
|
||||
"""
|
||||
获取合适的shell命令
|
||||
|
||||
Args:
|
||||
preferred: 首选的shell类型
|
||||
|
||||
Returns:
|
||||
shell命令路径
|
||||
"""
|
||||
# 如果指定了首选shell且可用
|
||||
if preferred and preferred in self.available_shells:
|
||||
return self.available_shells[preferred]
|
||||
|
||||
# 根据平台选择默认shell
|
||||
if self.platform == "win32":
|
||||
# Windows优先级:CMD优先!(修改这里)
|
||||
if "cmd" in self.available_shells:
|
||||
return self.available_shells["cmd"]
|
||||
elif "powershell" in self.available_shells:
|
||||
return self.available_shells["powershell"]
|
||||
elif "git-bash" in self.available_shells:
|
||||
return self.available_shells["git-bash"]
|
||||
else:
|
||||
# 最后的默认选项
|
||||
return "cmd.exe"
|
||||
|
||||
elif self.platform == "darwin":
|
||||
# macOS优先级:zsh (默认) > bash > sh
|
||||
if "zsh" in self.available_shells:
|
||||
return self.available_shells["zsh"]
|
||||
elif "bash" in self.available_shells:
|
||||
return self.available_shells["bash"]
|
||||
else:
|
||||
return "/bin/sh"
|
||||
|
||||
else:
|
||||
# Linux优先级:bash > zsh > sh
|
||||
if "bash" in self.available_shells:
|
||||
return self.available_shells["bash"]
|
||||
elif "zsh" in self.available_shells:
|
||||
return self.available_shells["zsh"]
|
||||
else:
|
||||
return "/bin/sh"
|
||||
|
||||
def get_clear_command(self) -> str:
|
||||
"""获取清屏命令"""
|
||||
if self.platform == "win32":
|
||||
return "cls"
|
||||
else:
|
||||
return "clear"
|
||||
|
||||
def get_list_command(self) -> str:
|
||||
"""获取列出文件命令"""
|
||||
if self.platform == "win32":
|
||||
return "dir"
|
||||
else:
|
||||
return "ls -la"
|
||||
|
||||
def get_change_dir_command(self, path: str) -> str:
|
||||
"""获取切换目录命令"""
|
||||
return f"cd {path}"
|
||||
|
||||
def get_python_command(self) -> str:
|
||||
"""获取Python命令"""
|
||||
# Windows优先顺序调整
|
||||
if self.platform == "win32":
|
||||
# Windows: 优先python,然后py,最后python3
|
||||
if shutil.which("python"):
|
||||
return "python"
|
||||
elif shutil.which("py"):
|
||||
return "py"
|
||||
elif shutil.which("python3"):
|
||||
return "python3"
|
||||
else:
|
||||
return "python"
|
||||
else:
|
||||
# Unix-like: 优先python3
|
||||
if shutil.which("python3"):
|
||||
return "python3"
|
||||
elif shutil.which("python"):
|
||||
return "python"
|
||||
else:
|
||||
return "python3"
|
||||
|
||||
def get_pip_command(self) -> str:
|
||||
"""获取pip命令"""
|
||||
python_cmd = self.get_python_command()
|
||||
return f"{python_cmd} -m pip"
|
||||
|
||||
def get_env_activation_command(self, venv_path: str) -> str:
|
||||
"""
|
||||
获取虚拟环境激活命令
|
||||
|
||||
Args:
|
||||
venv_path: 虚拟环境路径
|
||||
|
||||
Returns:
|
||||
激活命令
|
||||
"""
|
||||
venv_path = Path(venv_path)
|
||||
|
||||
if self.platform == "win32":
|
||||
# Windows
|
||||
activate_script = venv_path / "Scripts" / "activate.bat"
|
||||
if activate_script.exists():
|
||||
return str(activate_script)
|
||||
|
||||
# PowerShell脚本(备用)
|
||||
ps_script = venv_path / "Scripts" / "Activate.ps1"
|
||||
if ps_script.exists():
|
||||
return f"& '{ps_script}'"
|
||||
|
||||
else:
|
||||
# Unix-like
|
||||
activate_script = venv_path / "bin" / "activate"
|
||||
if activate_script.exists():
|
||||
return f"source {activate_script}"
|
||||
|
||||
return ""
|
||||
|
||||
def format_command_with_timeout(self, command: str, timeout_seconds: int) -> str:
|
||||
"""
|
||||
格式化带超时的命令
|
||||
|
||||
Args:
|
||||
command: 原始命令
|
||||
timeout_seconds: 超时秒数
|
||||
|
||||
Returns:
|
||||
带超时的命令
|
||||
"""
|
||||
if self.platform == "win32":
|
||||
# Windows没有内置的timeout命令用于限制其他命令
|
||||
# 需要使用PowerShell或其他方法
|
||||
return command
|
||||
else:
|
||||
# Unix-like系统使用timeout命令
|
||||
return f"timeout {timeout_seconds} {command}"
|
||||
|
||||
def get_process_list_command(self) -> str:
|
||||
"""获取进程列表命令"""
|
||||
if self.platform == "win32":
|
||||
return "tasklist"
|
||||
elif self.platform == "darwin":
|
||||
return "ps aux"
|
||||
else:
|
||||
return "ps aux"
|
||||
|
||||
def get_kill_command(self, process_id: int) -> str:
|
||||
"""
|
||||
获取终止进程命令
|
||||
|
||||
Args:
|
||||
process_id: 进程ID
|
||||
|
||||
Returns:
|
||||
终止命令
|
||||
"""
|
||||
if self.platform == "win32":
|
||||
return f"taskkill /PID {process_id} /F"
|
||||
else:
|
||||
return f"kill -9 {process_id}"
|
||||
|
||||
def get_system_info(self) -> Dict:
|
||||
"""获取系统信息"""
|
||||
info = {
|
||||
"platform": self.platform,
|
||||
"platform_name": self._get_platform_name(),
|
||||
"available_shells": list(self.available_shells.keys()),
|
||||
"default_shell": self.get_shell_command(),
|
||||
"python_command": self.get_python_command(),
|
||||
"pip_command": self.get_pip_command()
|
||||
}
|
||||
|
||||
# 添加系统版本信息
|
||||
try:
|
||||
import platform
|
||||
info["system"] = platform.system()
|
||||
info["release"] = platform.release()
|
||||
info["version"] = platform.version()
|
||||
info["machine"] = platform.machine()
|
||||
info["processor"] = platform.processor()
|
||||
except:
|
||||
pass
|
||||
|
||||
return info
|
||||
|
||||
def _get_platform_name(self) -> str:
|
||||
"""获取友好的平台名称"""
|
||||
if self.platform == "win32":
|
||||
return "Windows"
|
||||
elif self.platform == "darwin":
|
||||
return "macOS"
|
||||
elif self.platform.startswith("linux"):
|
||||
return "Linux"
|
||||
else:
|
||||
return "Unknown"
|
||||
|
||||
def create_terminal_config(self, working_dir: str = None) -> Dict:
|
||||
"""
|
||||
创建终端配置
|
||||
|
||||
Args:
|
||||
working_dir: 工作目录
|
||||
|
||||
Returns:
|
||||
终端配置字典
|
||||
"""
|
||||
config = {
|
||||
"shell": self.get_shell_command(), # 这里会使用cmd.exe
|
||||
"working_dir": working_dir or os.getcwd(),
|
||||
"env": os.environ.copy(),
|
||||
"platform": self.platform
|
||||
}
|
||||
|
||||
# Windows特殊配置
|
||||
if self.platform == "win32":
|
||||
# 设置代码页为UTF-8
|
||||
config["env"]["PYTHONIOENCODING"] = "utf-8"
|
||||
config["startup_commands"] = ["chcp 65001"] # UTF-8代码页
|
||||
else:
|
||||
# Unix-like特殊配置
|
||||
config["env"]["TERM"] = "xterm-256color"
|
||||
config["startup_commands"] = []
|
||||
|
||||
return config
|
||||
|
||||
def test_shell(self, shell_path: str) -> bool:
|
||||
"""
|
||||
测试shell是否可用
|
||||
|
||||
Args:
|
||||
shell_path: shell路径
|
||||
|
||||
Returns:
|
||||
是否可用
|
||||
"""
|
||||
try:
|
||||
# 尝试运行一个简单命令
|
||||
result = subprocess.run(
|
||||
[shell_path, "/c" if self.platform == "win32" else "-c", "echo test"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
return result.returncode == 0
|
||||
except:
|
||||
return False
|
||||
4239
sub_agent/web_server.py
Normal file
4239
sub_agent/web_server.py
Normal file
File diff suppressed because it is too large
Load Diff
6408
web_server.log
6408
web_server.log
File diff suppressed because it is too large
Load Diff
@ -1574,6 +1574,22 @@ def compress_conversation(conversation_id, terminal: WebTerminal, workspace: Use
|
||||
}), 500
|
||||
|
||||
|
||||
@app.route('/api/sub_agents', methods=['GET'])
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
def list_sub_agents(terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
"""返回当前对话的子智能体任务列表。"""
|
||||
manager = getattr(terminal, "sub_agent_manager", None)
|
||||
if not manager:
|
||||
return jsonify({"success": True, "data": []})
|
||||
try:
|
||||
conversation_id = terminal.context_manager.current_conversation_id
|
||||
data = manager.get_overview(conversation_id=conversation_id)
|
||||
return jsonify({"success": True, "data": data})
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 500
|
||||
|
||||
|
||||
@app.route('/api/conversations/<conversation_id>/duplicate', methods=['POST'])
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
|
||||
1584
webapp.log
1584
webapp.log
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user