Compare commits

...

2 Commits

Author SHA1 Message Date
01138d0881 fix: clean sub agent tooling 2025-11-15 15:51:17 +08:00
ba47147425 chore: snapshot sub agent baseline 2025-11-15 11:21:31 +08:00
78 changed files with 18928 additions and 16481 deletions

View File

@ -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":

View File

@ -1,3 +0,0 @@
{
"active_sessions": []
}

View File

@ -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"
}
}

View File

@ -1,8 +0,0 @@
{
"codes": [
{
"code": "invite2025",
"remaining": 7
}
]
}

View File

@ -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

View File

@ -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

View File

@ -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` 在仍有未完成任务时要求提供剩余事项及后续建议。***

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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 ClientCDN优先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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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);

View 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
View 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
View File

@ -0,0 +1,9 @@
"""认证与后台账户配置。"""
ADMIN_USERNAME = "jojo"
ADMIN_PASSWORD_HASH = "pbkdf2:sha256:600000$FSNAVncPXW6CBtfj$b7f093f4256de9d1a16d588565d4b1e108a9c66b2901884dd118c515258d78c7"
__all__ = [
"ADMIN_USERNAME",
"ADMIN_PASSWORD_HASH",
]

View 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",
]

View 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",
]

View 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
View 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",
]

View 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",
]

View 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",
]

View 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",
]

View 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
View 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
View 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",
]

File diff suppressed because it is too large Load Diff

View 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

View 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"],
),
}

View 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
View 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-8Windows中文路径支持
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)

View 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)}

View 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

View 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

View 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()

View 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)

View 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 "")

View 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()

View 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命令pythonpython3py
"""
# 按优先级尝试不同的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']} 进程已终止")

View 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()

View 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)

View 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

View 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. **正确执行**:和用户主动确认细节,用户明确告知可以开始任务后,再开始工作流程
记住:你的用户可能不懂技术,你的目标是让他们感觉到"这个助手真好用",而不是"怎么这么复杂"。

View 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` 中说明原因及建议,而不是等待主智能体回应。

View 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
任务1read_file physics_problems.txt列出 5 道题
任务2在 workspace/solutions.md 中逐题写解答
任务3整理 result.md概括完成情况与风险
任务4检查 deliverables/ 是否包含 result.md 与 solutions.md
```
## 注意事项
- 只写你能够自主完成的步骤;不要写“等待主智能体确认”之类无法执行的任务。
- 如果任务被新的发现打断,先在普通回复里说明,再用待办系统更新下一步。
- 清单结束前必须保证 deliverables/ 与 result.md 已同步更新,否则不要 finish。
遵循以上规则能让子任务自洽、可追踪,也方便最终在 `result.md` 中回溯整个执行过程。***

View File

@ -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,
)

View 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);
});
})();

View 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;
}

View 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>

View 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;
});
})();

View 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>

View 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;
}

View File

@ -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"

View File

@ -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;

View File

@ -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

File diff suppressed because one or more lines are too long

View 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

File diff suppressed because it is too large Load Diff

View 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
View 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)

View 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"
# 检查zshmacOS默认
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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -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

File diff suppressed because it is too large Load Diff