优化前缀缓存机制和UI,并引入一些新工具.preview
No files matched your search
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"agents": [],
|
||||
"tools": [],
|
||||
"env": {
|
||||
"USER_NICKNAME": "",
|
||||
"USER_PYTHON_PATH": "",
|
||||
"WEB_SEARCH_API_KEY": "",
|
||||
"WEB_SEARCH_ENGINE": "tavily",
|
||||
"LLM_CONTEXT_WINDOW": "262144",
|
||||
"COMPRESS_RATE": "0.9",
|
||||
"IMG_SIZE": "1000",
|
||||
"RAG_CHUNK_SIZE": "500",
|
||||
"RAG_CHUNK_OVERLAP": "50",
|
||||
"GROUNDING_WIDTH": "1000",
|
||||
"GROUNDING_HEIGHT": "1000",
|
||||
"LOOP_DETECT_REPEATED_TOOL_WARN": "3",
|
||||
"SEND_FILE_SIZE_LIMIT": "30",
|
||||
"STORAGE_BACKEND": "json",
|
||||
"CRON_TIME_PERIOD_MINUTES": 30,
|
||||
"MYSQL_HOST": "localhost",
|
||||
"MYSQL_PORT": "3306",
|
||||
"MYSQL_USER": "root",
|
||||
"MYSQL_PASSWORD": "",
|
||||
"MYSQL_DATABASE": "agent_history",
|
||||
"EMAIL_ADDRESS": "",
|
||||
"EMAIL_AUTH_CODE": "",
|
||||
"models": []
|
||||
},
|
||||
"gui": {
|
||||
"gui_monitor_name": "",
|
||||
"gui_monitor_index": 0,
|
||||
"gui_model_id": "",
|
||||
"monitors_snapshot": []
|
||||
},
|
||||
"workspace": {
|
||||
"current": "",
|
||||
"list": [],
|
||||
"access_mode": "restricted"
|
||||
},
|
||||
"theme": {
|
||||
"theme": "solarized-light",
|
||||
"bg_image": "",
|
||||
"font_family": "\"ZCOOL KuaiLe\", \"Microsoft YaHei\", sans-serif",
|
||||
"font_size": "14px",
|
||||
"bg_blur": "",
|
||||
"bg_vignette": "",
|
||||
"avatar": ""
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
[
|
||||
{
|
||||
"name": "main_agent",
|
||||
"description": "",
|
||||
"system_prompt": "你是一个具备多模态理解能力的桌面操作助手,能够直接理解用户上传的图片、语音和文件,并通过调用工具完成桌面自动化任务。\n【核心行为准则】\n- 你的首要职责是理解用户的意图,并高效地完成用户的任务。\n- 对于任何用户上传的图片、音频或文件,直接利用你的多模态能力分析并回答,不要为此调用截图等无关工具。\n- 只有在信息确实无法从当前对话或上传内容中获取,且必须通过外部工具才能获得时,才调用工具。例如:需要查看当前屏幕状态、需要执行 GUI 操作、需要搜索网络信息等。\n- **【强制规则】面对涉及 PPT 制作、代码项目等复杂产出类或系统操作类任务时,你必须先调用 skill_router 工具查询是否有对应的 Skill 操作指南。\n如果 skill_router 返回了匹配的 Skill,你必须将其视为最高优先级的执行规范,严格遵循 Skill 中定义的完整工作流,严禁跳过任何步骤,\n严禁用你自身的编程经验(如直接写 python-pptx 代码、用终端脚本批量生成等)替代 Skill 规范。Skill 规范的地位高于任何工具描述中的建议,当工具描述与 Skill 规范冲突时,以 Skill 规范为准。**\n- 当你调用工具收到截图或返回结果后,必须仔细观察返回内容,基于实际结果继续推进任务,严禁脱离实际结果凭空编造。\n- 进行浏览器操作时,慎用刷新工具,避免刷新导致页面状态丢失。\n- 对于同一个页面下的GUI操作,必须将连续操作合并为一次 gui_tool 调用(actions 列表传入多个动作),严禁逐个单步调用。仅当页面发生跳转/加载后需要重新观察时,才允许分步调用,注意单次调用最多包含5个动作。\n- 如果不确定是否需要工具,优先选择不调用,用已有信息给出最佳答案。\n- 对于需要多次操作才能完成的任务,应主动使用 todo_list 工具规划步骤并逐步推进,并在任务完成后将todo_list中的所有项标记为已完成。\n- 当任务完成时,向用户总结执行过程和结果。\n\n【工作空间配置】\n- 工作空间 (WORKSPACE_DIR): /app/src/workspace\n- 你产生的代码、脚本、图片、文档等文件,默认保存到工作空间目录下。\n- 使用 terminal_execute 工具时,如果不指定 working_dir,默认在工作空间执行命令。\n- 使用 doc_tool 创建/修改文件时,文件路径请使用工作空间的绝对路径。\n",
|
||||
"max_iterations": 200,
|
||||
"llm_model_id": "affiaa7r",
|
||||
"tools": [
|
||||
"doc_tool",
|
||||
"call_subagent",
|
||||
"date_query"
|
||||
],
|
||||
"enabled": true,
|
||||
"role": "main",
|
||||
"trajectory_rounds": 3
|
||||
},
|
||||
{
|
||||
"name": "websearch_agent",
|
||||
"description": "网络搜索子 Agent,擅长搜索引擎查询与信息整理。",
|
||||
"system_prompt": "你是一个专业的网络内容搜索助手,由主 Agent 委派执行特定子任务。\n\n【身份定位】\n- 你是多 Agent 体系中的一个**子 Agent**,接收主 Agent 委派的任务并独立完成。\n- 你的职责是**专注完成被委派的任务**,不越权处理超出任务范围的事务。\n\n【工作流程】\n1. 接收主 Agent 下发的任务描述。\n2. 调用可用工具逐步完成任务。\n3. 完成后返回清晰的结果摘要。\n\n【关键行为准则】\n- **聚焦任务**:只执行被委派的具体任务,不要做额外操作或发散思考。\n- **高效执行**:尽量在一次推理链路中完成,减少不必要的确认或反复。\n- **如实汇报**:基于实际执行结果输出,不编造信息。遇到错误时说明原因。\n- **工具使用**:优先利用内置工具完成任务,但仅在必要时才调用。\n- **结果导向**:返回的结果应直接回应主 Agent 的问题,避免冗长铺垫。\n\n【输出格式】\n- 直接输出任务执行结果,用简洁、清晰的语言概括关键信息。\n- 如果有多个结果,用分点或编号列出。\n- 以「[完成]」标记任务执行完毕。",
|
||||
"max_iterations": 200,
|
||||
"llm_model_id": "affiaa7r",
|
||||
"tools": [
|
||||
"web_search",
|
||||
"date_query"
|
||||
],
|
||||
"enabled": false,
|
||||
"role": "sub",
|
||||
"trajectory_rounds": 3
|
||||
},
|
||||
{
|
||||
"name": "SQL_agent",
|
||||
"description": "数据库操作子 Agent,擅长数据库操作。",
|
||||
"system_prompt": "你是一个专业的数据库操作助手,由主 Agent 委派执行特定子任务。\n\n【身份定位】\n- 你是多 Agent 体系中的一个**子 Agent**,接收主 Agent 委派的任务并独立完成。\n- 你的职责是**专注完成被委派的任务**,不越权处理超出任务范围的事务。\n\n【工作流程】\n1. 接收主 Agent 下发的任务描述。\n2. 调用可用工具逐步完成任务。\n3. 完成后返回清晰的结果摘要。\n\n【关键行为准则】\n- **聚焦任务**:只执行被委派的具体任务,不要做额外操作或发散思考。\n- **高效执行**:尽量在一次推理链路中完成,减少不必要的确认或反复。\n- **如实汇报**:基于实际执行结果输出,不编造信息。遇到错误时说明原因。\n- **工具使用**:优先利用内置工具完成任务,但仅在必要时才调用。\n- **结果导向**:返回的结果应直接回应主 Agent 的问题,避免冗长铺垫。\n\n【输出格式】\n- 直接输出任务执行结果,用简洁、清晰的语言概括关键信息。\n- 如果有多个结果,用分点或编号列出。\n- 以「[完成]」标记任务执行完毕。",
|
||||
"max_iterations": 200,
|
||||
"llm_model_id": "affiaa7r",
|
||||
"tools": [
|
||||
"text2sql",
|
||||
"excel2sql",
|
||||
"hard_excel_read"
|
||||
],
|
||||
"enabled": false,
|
||||
"role": "sub",
|
||||
"trajectory_rounds": 3
|
||||
}
|
||||
]
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"WORKING_DIR": ".",
|
||||
"WORKSPACE_DIR": "workspace",
|
||||
"USER_PYTHON_PATH": "",
|
||||
"STREAMING_TTS_URL": "",
|
||||
"ASR_BASE_URL": "",
|
||||
"RAG_BASE_URL": "",
|
||||
"WEB_SEARCH_API_KEY": "",
|
||||
"WEB_SEARCH_ENGINE": "tavily",
|
||||
"LLM_CONTEXT_WINDOW": "262144",
|
||||
"COMPRESS_RATE": "0.6",
|
||||
"IMG_SIZE": "768",
|
||||
"RAG_CHUNK_SIZE": "500",
|
||||
"RAG_CHUNK_OVERLAP": "50",
|
||||
"GROUNDING_WIDTH": "1000",
|
||||
"GROUNDING_HEIGHT": "1000",
|
||||
"LOOP_DETECT_REPEATED_TOOL_WARN": "3",
|
||||
"SEND_FILE_SIZE_LIMIT": "30",
|
||||
"EMAIL_ADDRESS": "",
|
||||
"EMAIL_AUTH_CODE": "",
|
||||
"gui_model_id": "",
|
||||
"models": [
|
||||
{
|
||||
"id": "SETUP_REQUIRED",
|
||||
"name": "请先完成首次配置",
|
||||
"model": "",
|
||||
"api_key": "",
|
||||
"base_url": "",
|
||||
"timeout": 60,
|
||||
"max_retries": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"gui_monitor_name": "\\\\.\\DISPLAY5",
|
||||
"gui_monitor_index": 0,
|
||||
"gui_model_id": "affiaa7r",
|
||||
"monitors_snapshot": [
|
||||
{
|
||||
"name": "\\\\.\\DISPLAY5",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 2560,
|
||||
"height": 1440,
|
||||
"is_primary": true
|
||||
},
|
||||
{
|
||||
"name": "\\\\.\\DISPLAY1",
|
||||
"x": -1350,
|
||||
"y": 149,
|
||||
"width": 1350,
|
||||
"height": 2400,
|
||||
"is_primary": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"theme": "solarized-light",
|
||||
"bg_image": "",
|
||||
"font_family": "\"ZCOOL KuaiLe\", \"Microsoft YaHei\", sans-serif",
|
||||
"font_size": "14px",
|
||||
"bg_blur": "",
|
||||
"bg_vignette": ""
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
[
|
||||
{
|
||||
"name": "web_search",
|
||||
"display_name": "WebSearch",
|
||||
"description": "一个网络搜索工具,可以根据用户的查询返回相关的搜索结果。仅当需要查询近期知识或已有知识无法解答需要联网查询时调用,其余任何情况严禁调用。",
|
||||
"permission": "direct",
|
||||
"auto_execute_rules": [],
|
||||
"enabled": true,
|
||||
"timeout": 300
|
||||
},
|
||||
{
|
||||
"name": "date_query",
|
||||
"display_name": "DateQuery",
|
||||
"description": "一个日期查询工具,可以返回当前的日期信息。仅当需要查询当前时间时调用,其余任何情况严禁调用。",
|
||||
"permission": "direct",
|
||||
"auto_execute_rules": [],
|
||||
"enabled": true,
|
||||
"timeout": 300
|
||||
},
|
||||
{
|
||||
"name": "browser_control",
|
||||
"display_name": "BrowserControl",
|
||||
"description": "统一控制浏览器:打开/关闭浏览器、刷新/关闭标签页、打开指定 URL。",
|
||||
"permission": "confirm",
|
||||
"auto_execute_rules": [
|
||||
{
|
||||
"parameter": "action",
|
||||
"operator": "equals",
|
||||
"value": "open"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"timeout": 300
|
||||
},
|
||||
{
|
||||
"name": "human_interaction",
|
||||
"display_name": "HumanInteraction",
|
||||
"description": "当任务缺少关键信息、需要人类选择路径、或需要人类审批后才能继续时调用。三种类型分别是:information=请求人类补充缺失信息;selection=让人类在多个选项中选择且可编辑;approval=请求人类审批高影响决策或继续执行。工具返回必须被视为用户原始意图的纯字符串反馈。其余情况严禁调用。",
|
||||
"permission": "confirm",
|
||||
"auto_execute_rules": [],
|
||||
"enabled": true,
|
||||
"timeout": 300
|
||||
},
|
||||
{
|
||||
"name": "software_control",
|
||||
"display_name": "SoftwareControl",
|
||||
"description": "操作本机已安装的软件。工具会自动扫描 Windows 开始菜单快捷方式、桌面快捷方式和 App Paths,建立软件索引后根据用户输入做模糊匹配。action=open 时打开软件;action=close 时仅做正常关闭;action=force_close 时才允许在正常关闭失败后强制结束进程。仅当用户明确要求打开、关闭或强制关闭某个本机软件时调用。",
|
||||
"permission": "confirm",
|
||||
"auto_execute_rules": [
|
||||
{
|
||||
"parameter": "action",
|
||||
"operator": "equals",
|
||||
"value": "open"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"timeout": 300
|
||||
},
|
||||
{
|
||||
"name": "rag_tool",
|
||||
"display_name": "RAGTool",
|
||||
"description": "个人知识库管理工具,支持增删查及列出所有来源操作。",
|
||||
"permission": "confirm",
|
||||
"auto_execute_rules": [
|
||||
{
|
||||
"parameter": "action_type",
|
||||
"operator": "equals",
|
||||
"value": "query"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"timeout": 300
|
||||
},
|
||||
{
|
||||
"name": "directory_listing",
|
||||
"display_name": "DirectoryListing",
|
||||
"description": "列出指定目录下的所有文件和子目录。可用于探索文件系统、查找文件位置。仅当需要获取目录内容或确认文件结构时调用,其余情况严禁调用。",
|
||||
"permission": "direct",
|
||||
"auto_execute_rules": [],
|
||||
"enabled": true,
|
||||
"timeout": 300
|
||||
},
|
||||
{
|
||||
"name": "todo_list",
|
||||
"display_name": "TodoList",
|
||||
"description": "任务管理工具。用于将复杂任务拆分为多个步骤并跟踪执行进度。",
|
||||
"permission": "direct",
|
||||
"auto_execute_rules": [],
|
||||
"enabled": true,
|
||||
"timeout": 300
|
||||
},
|
||||
{
|
||||
"name": "gui_tool",
|
||||
"display_name": "GUITool",
|
||||
"description": "GUI 自动化工具。通过截图视觉定位桌面元素,将坐标映射到真实屏幕,并执行 pyautogui 动作。接收一个有序的动作列表,按顺序依次执行。每个动作项支持的操作:click(点击)、type(输入文本,可通过 element_description 指定目标输入框自动聚焦)、drag_and_drop(拖拽)、scroll(滚动)、hotkey(快捷键)、hold_and_press(按住组合键",
|
||||
"permission": "direct",
|
||||
"auto_execute_rules": [],
|
||||
"enabled": true,
|
||||
"timeout": 300
|
||||
},
|
||||
{
|
||||
"name": "call_subagent",
|
||||
"display_name": "CallSubAgentTool",
|
||||
"description": "调用一个已启用的子 Agent 执行指定任务。子 Agent 会独立运行并返回最终结果。适用于需要专业能力(如代码分析、文档处理等)进行辅助的场景。可用的子 Agent 名称取决于 agent_config.json 中已启用且 role='sub' 的配置。",
|
||||
"permission": "direct",
|
||||
"auto_execute_rules": [],
|
||||
"enabled": true,
|
||||
"timeout": 900
|
||||
},
|
||||
{
|
||||
"name": "doc_tool",
|
||||
"display_name": "DocTool",
|
||||
"description": "文档操作工具。支持纯文本(plain_text)、表格(tabular: CSV/XLSX)、Word(docx)、PowerPoint(pptx)、PDF 文档的增删改查,以及图片(image)、音频(audio)的读取。",
|
||||
"permission": "direct",
|
||||
"auto_execute_rules": [],
|
||||
"enabled": true,
|
||||
"timeout": 300
|
||||
},
|
||||
{
|
||||
"name": "skill_router",
|
||||
"display_name": "SkillRouter",
|
||||
"description": "Skill 路由工具。根据用户的 query(问题或任务描述),从已安装的 skill 中智能匹配最相关的操作指南,并返回指南全文。",
|
||||
"permission": "direct",
|
||||
"auto_execute_rules": [
|
||||
{
|
||||
"parameter": "query",
|
||||
"operator": "exists",
|
||||
"value": ""
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"timeout": 300
|
||||
},
|
||||
{
|
||||
"name": "send_file",
|
||||
"display_name": "SendFileTool",
|
||||
"description": "向用户发送文件的工具。根据文件类型自动处理:",
|
||||
"permission": "direct",
|
||||
"auto_execute_rules": [],
|
||||
"enabled": true,
|
||||
"timeout": 300
|
||||
},
|
||||
{
|
||||
"name": "terminal_execute",
|
||||
"display_name": "TerminalExecute",
|
||||
"description": "终端命令执行工具。在本地终端(PowerShell)中执行 shell 命令并返回执行结果。",
|
||||
"permission": "direct",
|
||||
"auto_execute_rules": [],
|
||||
"enabled": true,
|
||||
"timeout": 300
|
||||
},
|
||||
{
|
||||
"name": "mail_tool",
|
||||
"display_name": "MailTool",
|
||||
"description": "QQ email tool. Supports sending and reading email without GUI.",
|
||||
"permission": "direct",
|
||||
"auto_execute_rules": [
|
||||
{
|
||||
"parameter": "action",
|
||||
"operator": "equals",
|
||||
"value": "read"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"timeout": 300
|
||||
},
|
||||
{
|
||||
"name": "image_gen",
|
||||
"display_name": "ImageGen",
|
||||
"description": "AI 图像生成工具,将文字描述转换为高质量图片。支持详细的中英文提示词,可指定尺寸和排除元素。生成的图片自动保存到 workspace 目录。适用场景:用户要求画图、生成图片、制作海报、设计插图等。",
|
||||
"permission": "direct",
|
||||
"auto_execute_rules": [],
|
||||
"enabled": true,
|
||||
"timeout": 300
|
||||
},
|
||||
{
|
||||
"name": "cron_manager",
|
||||
"display_name": "CronManager",
|
||||
"description": "定时任务管理工具。用于查看(含状态)、新增、修改、删除定时任务。支持三种触发方式:cron 表达式、固定间隔、一次性延时。适用场景:用户希望设置/查看/修改定时任务,或 Agent 在定时任务模式下需管理其他定时任务。",
|
||||
"permission": "direct",
|
||||
"auto_execute_rules": [],
|
||||
"enabled": true,
|
||||
"timeout": 300
|
||||
},
|
||||
{
|
||||
"name": "text2sql",
|
||||
"display_name": "Text2SQL",
|
||||
"description": "数据库操作工具(text2sql)。支持查看数据库结构(列出表、查看表结构)、查询表内容(SELECT),对数据库内容做增删改(INSERT/UPDATE/DELETE/DDL),以及建库建表(create_database/create_table)。查类与建库建表操作(list_tables/describe/query/create_database/create_table)直接执行;增删改(execute)需用户确认。连接配置复用 env_config.json 的 MYSQL_*(host/port/user/password),database 参数必填(用户需告知要操作的数据库名)。",
|
||||
"permission": "confirm",
|
||||
"auto_execute_rules": [
|
||||
{
|
||||
"parameter": "action",
|
||||
"operator": "equals",
|
||||
"value": "list_tables"
|
||||
},
|
||||
{
|
||||
"parameter": "action",
|
||||
"operator": "equals",
|
||||
"value": "describe"
|
||||
},
|
||||
{
|
||||
"parameter": "action",
|
||||
"operator": "equals",
|
||||
"value": "query"
|
||||
},
|
||||
{
|
||||
"parameter": "action",
|
||||
"operator": "equals",
|
||||
"value": "create_database"
|
||||
},
|
||||
{
|
||||
"parameter": "action",
|
||||
"operator": "equals",
|
||||
"value": "create_table"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"timeout": 300
|
||||
},
|
||||
{
|
||||
"name": "hard_excel_read",
|
||||
"display_name": "HardExcelRead",
|
||||
"description": "复杂Excel表结构读取工具。读取指定sheet(或所有sheet)前N行原始数据+辅助摘要(维度/合并区/列类型与样本/数据区范围建议)返回给大模型解析复杂表结构。不含数据区全量行。配合excel2sql完成解析→入库。",
|
||||
"permission": "direct",
|
||||
"auto_execute_rules": [],
|
||||
"enabled": true,
|
||||
"timeout": 300
|
||||
},
|
||||
{
|
||||
"name": "excel2sql",
|
||||
"display_name": "Excel2SQL",
|
||||
"description": "按大模型给定的数据行列范围,自动建库建表(表COMMENT存表格描述)并把Excel数据区流式批量入库MySQL。不做重清洗DSL,按范围直接入库;值级清洗入库后用text2sql(execute)完成。dry_run=true时直接执行复核,false需确认。",
|
||||
"permission": "direct",
|
||||
"auto_execute_rules": [
|
||||
{
|
||||
"parameter": "dry_run",
|
||||
"operator": "equals",
|
||||
"value": "true"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"timeout": 300
|
||||
}
|
||||
]
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"current": "C:\\Users\\86166\\Desktop\\新建文件夹",
|
||||
"list": [
|
||||
"C:\\Users\\86166\\Desktop\\新建文件夹",
|
||||
"C:\\Users\\86166\\Desktop",
|
||||
"C:\\Users\\86166\\Desktop\\Agent_Learning_minor\\Agent\\src\\workspace"
|
||||
],
|
||||
"access_mode": "full"
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
"""Agent 配置管理:从 JSON 文件加载/保存 agent、tool、env、model 配置。
|
||||
"""Agent 配置管理:从合并后的 config.json 加载/保存 agent、tool、env、model、gui 配置。
|
||||
|
||||
系统定位:
|
||||
提供统一接口读写 ``agent/config/`` 下的 JSON 配置文件,
|
||||
提供统一接口读写 ``agent/agents/config.json`` 下的配置,
|
||||
使前端手动配置的 JSON 可以替代 core 底下的相应 .py 硬编码。
|
||||
|
||||
配置文件:
|
||||
agent_config.json - Agent 运行时配置(名称、模型引用、工具列表、system_prompt)
|
||||
tool_config.json - 工具权限与参数配置
|
||||
env_config.json - 环境变量与 LLM 模型列表
|
||||
配置文件(单一文件,按分区组织):
|
||||
config.json - 合并后的唯一配置文件
|
||||
agents - Agent 运行时配置(名称、模型引用、工具列表、system_prompt)
|
||||
tools - 工具权限与参数配置
|
||||
env - 环境变量与 LLM 模型列表
|
||||
gui - GUI 显示器配置
|
||||
workspace - 工作空间配置(current/list/access_mode)
|
||||
theme - 主题/背景/头像配置
|
||||
|
||||
可扩展性:
|
||||
可增加版本号、校验 schema、多环境配置切换。
|
||||
@@ -23,10 +27,12 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# ---------- 配置文件路径 ----------
|
||||
_CONFIG_DIR = Path(__file__).resolve().parent.parent / "config"
|
||||
AGENT_CONFIG_PATH = _CONFIG_DIR / "agent_config.json"
|
||||
TOOL_CONFIG_PATH = _CONFIG_DIR / "tool_config.json"
|
||||
ENV_CONFIG_PATH = _CONFIG_DIR / "env_config.json"
|
||||
CONFIG_PATH = Path(__file__).resolve().parent.parent / "agents" / "config.json"
|
||||
# 兼容旧路径引用(API 返回值、前端提示用)
|
||||
AGENT_CONFIG_PATH = CONFIG_PATH
|
||||
TOOL_CONFIG_PATH = CONFIG_PATH
|
||||
ENV_CONFIG_PATH = CONFIG_PATH
|
||||
GUI_CONFIG_PATH = CONFIG_PATH
|
||||
|
||||
|
||||
# ---------- 模型配置 ----------
|
||||
@@ -47,7 +53,7 @@ class AgentConfig:
|
||||
description: str = "" # 简短能力描述,暴露给主Agent用于委派决策
|
||||
system_prompt: str = ""
|
||||
max_iterations: int = 200
|
||||
llm_model_id: str = "" # 引用 env_config.json models 列表中的模型 ID
|
||||
llm_model_id: str = "" # 引用 env 分区 models 列表中的模型 ID
|
||||
tools: list[str] = field(default_factory=list)
|
||||
enabled: bool = True
|
||||
role: str = "sub" # "main" | "sub"
|
||||
@@ -80,6 +86,7 @@ def _read_json(path: Path) -> dict | list | None:
|
||||
def _write_json(path: Path, data: Any) -> bool:
|
||||
"""写入 JSON 文件,成功返回 True。"""
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
return True
|
||||
@@ -87,6 +94,31 @@ def _write_json(path: Path, data: Any) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _read_config_file() -> dict:
|
||||
"""读取合并后的 config.json 全文({agents, tools, env, gui, workspace, theme})。"""
|
||||
raw = _read_json(CONFIG_PATH)
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
return raw
|
||||
|
||||
|
||||
def _write_config_file(data: dict) -> bool:
|
||||
"""整体写回 config.json。"""
|
||||
return _write_json(CONFIG_PATH, data)
|
||||
|
||||
|
||||
def _get_section(name: str) -> Any:
|
||||
"""读取 config.json 的某个分区(缺失返回 None)。"""
|
||||
return _read_config_file().get(name)
|
||||
|
||||
|
||||
def _set_section(name: str, value: Any) -> bool:
|
||||
"""读-改-写 config.json 的某个分区(保留其他分区)。"""
|
||||
data = _read_config_file()
|
||||
data[name] = value
|
||||
return _write_config_file(data)
|
||||
|
||||
|
||||
# ---------- Agent 配置 ----------
|
||||
def _default_agent_config() -> list[dict]:
|
||||
"""生成默认 agent 配置(单 agent: main)。"""
|
||||
@@ -109,17 +141,17 @@ def _default_agent_config() -> list[dict]:
|
||||
|
||||
|
||||
def load_agent_configs() -> list[AgentConfig]:
|
||||
"""从 agent_config.json 加载 agent 配置列表,无文件时返回默认。
|
||||
"""从 config.json 的 agents 分区加载 agent 配置列表,无文件时返回默认。
|
||||
|
||||
对于 role='main' 的 Agent,system_prompt 始终从 get_main_system_prompt() 动态获取
|
||||
(忽略 JSON 中的静态值),确保提示词可热更新且包含正确的动态路径。
|
||||
对于 role='sub' 的 Agent,若未指定 system_prompt 则自动使用默认子 Agent 提示词。
|
||||
"""
|
||||
from agent.memory.system_prompt import get_default_sub_agent_prompt, get_main_system_prompt
|
||||
raw = _read_json(AGENT_CONFIG_PATH)
|
||||
raw = _get_section("agents")
|
||||
if not raw or not isinstance(raw, list):
|
||||
raw = _default_agent_config()
|
||||
_write_json(AGENT_CONFIG_PATH, raw)
|
||||
_set_section("agents", raw)
|
||||
|
||||
configs = []
|
||||
for item in raw:
|
||||
@@ -147,8 +179,8 @@ def load_agent_configs() -> list[AgentConfig]:
|
||||
|
||||
|
||||
def save_agent_configs(configs: list[dict]) -> bool:
|
||||
"""将 agent 配置列表写入 agent_config.json。"""
|
||||
return _write_json(AGENT_CONFIG_PATH, configs)
|
||||
"""将 agent 配置列表写入 config.json 的 agents 分区。"""
|
||||
return _set_section("agents", configs)
|
||||
|
||||
|
||||
# ---------- Tool 配置 ----------
|
||||
@@ -157,6 +189,13 @@ def save_agent_configs(configs: list[dict]) -> bool:
|
||||
_TOOL_DEFAULT_AUTO_RULES: dict[str, list[dict]] = {
|
||||
"browser_control": [
|
||||
{"parameter": "action", "operator": "equals", "value": "open"},
|
||||
{"parameter": "action", "operator": "equals", "value": "list_tabs"},
|
||||
{"parameter": "action", "operator": "equals", "value": "switch_tab"},
|
||||
],
|
||||
"web_page": [
|
||||
{"parameter": "action", "operator": "equals", "value": "snapshot"},
|
||||
{"parameter": "action", "operator": "equals", "value": "extract"},
|
||||
{"parameter": "action", "operator": "equals", "value": "screenshot"},
|
||||
],
|
||||
"rag_tool": [
|
||||
{"parameter": "action_type", "operator": "equals", "value": "query"},
|
||||
@@ -242,11 +281,11 @@ def _default_tool_config() -> list[dict]:
|
||||
|
||||
|
||||
def load_tool_configs() -> list[ToolConfig]:
|
||||
"""从 tool_config.json 加载工具配置,无文件时返回默认;有新工具时自动补入。"""
|
||||
raw = _read_json(TOOL_CONFIG_PATH)
|
||||
"""从 config.json 的 tools 分区加载工具配置,无文件时返回默认;有新工具时自动补入。"""
|
||||
raw = _get_section("tools")
|
||||
if not raw or not isinstance(raw, list):
|
||||
raw = _default_tool_config()
|
||||
_write_json(TOOL_CONFIG_PATH, raw)
|
||||
_set_section("tools", raw)
|
||||
|
||||
# 补上代码中已注册但 JSON 中缺失的新工具
|
||||
registered = get_registered_tool_names()
|
||||
@@ -257,7 +296,7 @@ def load_tool_configs() -> list[ToolConfig]:
|
||||
for name in missing:
|
||||
if name in defaults:
|
||||
raw.append(defaults[name])
|
||||
_write_json(TOOL_CONFIG_PATH, raw)
|
||||
_set_section("tools", raw)
|
||||
|
||||
configs = []
|
||||
for item in raw:
|
||||
@@ -274,8 +313,8 @@ def load_tool_configs() -> list[ToolConfig]:
|
||||
|
||||
|
||||
def save_tool_configs(configs: list[dict]) -> bool:
|
||||
"""将 tool 配置列表写入 tool_config.json。"""
|
||||
ok = _write_json(TOOL_CONFIG_PATH, configs)
|
||||
"""将 tool 配置列表写入 config.json 的 tools 分区。"""
|
||||
ok = _set_section("tools", configs)
|
||||
invalidate_tool_timeout_cache() # 配置变更后清除超时缓存,确保新执行读到新超时
|
||||
return ok
|
||||
|
||||
@@ -351,35 +390,51 @@ def _default_models() -> list[dict]:
|
||||
|
||||
|
||||
def load_models() -> list[dict]:
|
||||
"""从 env_config.json 的 models 字段加载模型列表。"""
|
||||
raw = _read_json(ENV_CONFIG_PATH)
|
||||
if isinstance(raw, dict) and "models" in raw and isinstance(raw["models"], list) and raw["models"]:
|
||||
return raw["models"]
|
||||
"""从 config.json 的 env 分区 models 字段加载模型列表。"""
|
||||
env = load_env_config()
|
||||
models = env.get("models")
|
||||
if isinstance(models, list) and models:
|
||||
return models
|
||||
return _default_models()
|
||||
|
||||
|
||||
def save_models(models: list[dict]) -> bool:
|
||||
"""将模型列表写入 env_config.json(合并其他字段)。"""
|
||||
"""将模型列表写入 config.json 的 env 分区(合并其他字段)。"""
|
||||
current = load_env_config()
|
||||
current["models"] = models
|
||||
return _write_json(ENV_CONFIG_PATH, current)
|
||||
return save_env_config(current)
|
||||
|
||||
|
||||
def get_model_by_name(model_name: str) -> dict | None:
|
||||
"""按 model 字段(模型名)在模型列表中查找模型配置。
|
||||
|
||||
用于 ASR / TTS / RAG / ImageGen 等本地服务模型:这些服务在模型列表中注册,
|
||||
运行时按 model 名匹配取得其 base_url / api_key。
|
||||
|
||||
输入:
|
||||
model_name: 模型名(如 "Qwen3-ASR-1.7B")。
|
||||
|
||||
输出:
|
||||
匹配的模型配置 dict(含 id/name/model/api_key/base_url),未找到返回 None。
|
||||
"""
|
||||
for m in load_models():
|
||||
if str(m.get("model", "")).strip() == model_name:
|
||||
return dict(m)
|
||||
return None
|
||||
|
||||
|
||||
# ---------- Env 配置 ----------
|
||||
def _default_env_config() -> dict:
|
||||
"""生成默认 env 配置(不含 models,models 由 load_models 独立处理)。"""
|
||||
import os as _os
|
||||
_default_working = _os.path.dirname(_os.path.dirname(_os.path.dirname(_os.path.dirname(_os.path.abspath(__file__)))))
|
||||
"""生成默认 env 配置(不含 models,models 由 load_models 独立处理)。
|
||||
|
||||
WORKING_DIR / WORKSPACE_DIR / THINKING_LEVEL 已移除:
|
||||
工作空间默认取启动目录(由 workspace 分区/工作空间 API 管理),
|
||||
思考档位改为会话级变量(存于各会话的 session_meta.json)。
|
||||
"""
|
||||
return {
|
||||
"WORKING_DIR": _default_working,
|
||||
"WORKSPACE_DIR": "",
|
||||
"STREAMING_TTS_URL": "",
|
||||
"ASR_BASE_URL": "",
|
||||
"RAG_BASE_URL": "",
|
||||
"IMAGE_GEN_URL": "http://localhost:8904",
|
||||
"USER_PYTHON_PATH": "",
|
||||
"LLM_CONTEXT_WINDOW": "262144",
|
||||
"COMPRESS_RATE": "0.6",
|
||||
"THINKING_LEVEL": "low",
|
||||
"IMG_SIZE": "768",
|
||||
"RAG_CHUNK_SIZE": "500",
|
||||
"RAG_CHUNK_OVERLAP": "50",
|
||||
@@ -393,32 +448,50 @@ def _default_env_config() -> dict:
|
||||
"MYSQL_USER": "root",
|
||||
"MYSQL_PASSWORD": "",
|
||||
"MYSQL_DATABASE": "agent_history",
|
||||
# 存储数据库配置(STORAGE_DB_*,缺省回退 MYSQL_*;path 为空时
|
||||
# 由 db.load_storage_db_config 解析为 history/sqlite/storage.sqlite)
|
||||
"STORAGE_DB_TYPE": "",
|
||||
"STORAGE_DB_HOST": "",
|
||||
"STORAGE_DB_PORT": "",
|
||||
"STORAGE_DB_USER": "",
|
||||
"STORAGE_DB_PASSWORD": "",
|
||||
"STORAGE_DB_DATABASE": "",
|
||||
"STORAGE_DB_PATH": "",
|
||||
# 工具数据库配置(TOOL_DB_*,与存储后端完全隔离,不回退 MYSQL_*;
|
||||
# path 为空时由 db.load_tool_db_config 解析为 history/sqlite/tool 目录)
|
||||
"TOOL_DB_TYPE": "mysql",
|
||||
"TOOL_DB_HOST": "localhost",
|
||||
"TOOL_DB_PORT": "3306",
|
||||
"TOOL_DB_USER": "root",
|
||||
"TOOL_DB_PASSWORD": "",
|
||||
"TOOL_DB_DATABASE": "",
|
||||
"TOOL_DB_PATH": "",
|
||||
"EMAIL_ADDRESS": "",
|
||||
"EMAIL_AUTH_CODE": "",
|
||||
}
|
||||
|
||||
|
||||
def load_env_config() -> dict:
|
||||
"""从 env_config.json 加载环境变量配置,无文件时返回默认。"""
|
||||
raw = _read_json(ENV_CONFIG_PATH)
|
||||
if not raw or not isinstance(raw, dict):
|
||||
raw = _default_env_config()
|
||||
raw["models"] = _default_models()
|
||||
_write_json(ENV_CONFIG_PATH, raw)
|
||||
return raw
|
||||
"""从 config.json 的 env 分区加载环境变量配置,无文件时返回默认。"""
|
||||
env = _get_section("env")
|
||||
if not env or not isinstance(env, dict):
|
||||
env = _default_env_config()
|
||||
env["models"] = _default_models()
|
||||
_set_section("env", env)
|
||||
return env
|
||||
# 确保有 models 字段
|
||||
if "models" not in raw or not isinstance(raw.get("models"), list):
|
||||
raw["models"] = _default_models()
|
||||
return raw
|
||||
if "models" not in env or not isinstance(env.get("models"), list):
|
||||
env["models"] = _default_models()
|
||||
return env
|
||||
|
||||
|
||||
def save_env_config(config: dict) -> bool:
|
||||
"""将 env 配置写入 env_config.json(保留已有 models)。"""
|
||||
"""将 env 配置写入 config.json 的 env 分区(保留已有 models)。"""
|
||||
# 保留已有的 models 列表(由 models API 单独管理)
|
||||
existing = _read_json(ENV_CONFIG_PATH)
|
||||
if isinstance(existing, dict) and "models" in existing:
|
||||
existing = _get_section("env")
|
||||
if isinstance(existing, dict) and "models" in existing and "models" not in config:
|
||||
config["models"] = existing["models"]
|
||||
return _write_json(ENV_CONFIG_PATH, config)
|
||||
return _set_section("env", config)
|
||||
|
||||
|
||||
# ---------- 获取所有已注册工具的基础信息 ----------
|
||||
@@ -429,9 +502,6 @@ def get_registered_tool_names() -> list[str]:
|
||||
|
||||
|
||||
# ---------- GUI 显示器配置 ----------
|
||||
GUI_CONFIG_PATH = _CONFIG_DIR / "gui_config.json"
|
||||
|
||||
|
||||
def _default_gui_config() -> dict:
|
||||
"""生成默认 GUI 显示器配置(主显示器)。"""
|
||||
try:
|
||||
@@ -492,23 +562,23 @@ def _get_live_monitors() -> list[dict]:
|
||||
|
||||
|
||||
def load_gui_config() -> dict:
|
||||
"""从 gui_config.json 加载 GUI 显示器配置,无文件时返回默认。"""
|
||||
raw = _read_json(GUI_CONFIG_PATH)
|
||||
"""从 config.json 的 gui 分区加载 GUI 显示器配置,无文件时返回默认。"""
|
||||
raw = _get_section("gui")
|
||||
if not raw or not isinstance(raw, dict):
|
||||
raw = _default_gui_config()
|
||||
_write_json(GUI_CONFIG_PATH, raw)
|
||||
_set_section("gui", raw)
|
||||
return raw
|
||||
|
||||
|
||||
def save_gui_config(config: dict) -> bool:
|
||||
"""将 GUI 显示器配置写入 gui_config.json。"""
|
||||
return _write_json(GUI_CONFIG_PATH, config)
|
||||
"""将 GUI 显示器配置写入 config.json 的 gui 分区。"""
|
||||
return _set_section("gui", config)
|
||||
|
||||
|
||||
def get_gui_model_config() -> dict | None:
|
||||
"""获取 GUI 工具专用的模型配置。
|
||||
|
||||
从 gui_config.json 读取 gui_model_id,在 env_config.json 的 models 列表中查找对应模型。
|
||||
从 config.json 的 gui 分区读取 gui_model_id,在 env 分区的 models 列表中查找对应模型。
|
||||
若 gui_model_id 为空或找不到对应模型,返回 None(表示使用 agent 主模型)。
|
||||
|
||||
返回:
|
||||
@@ -528,7 +598,7 @@ def get_gui_model_config() -> dict | None:
|
||||
def get_gui_llm():
|
||||
"""获取 GUI 工具专用的 LLM 实例。
|
||||
|
||||
若 gui_config.json 中 gui_model_id 已配置且在模型列表中存在,返回对应的 Multimodel_LLM 实例;
|
||||
若 config.json 中 gui_model_id 已配置且在模型列表中存在,返回对应的 Multimodel_LLM 实例;
|
||||
否则返回 None(调用方应回退到全局 llm 单例)。
|
||||
|
||||
返回:
|
||||
@@ -538,7 +608,8 @@ def get_gui_llm():
|
||||
if model_cfg is None:
|
||||
return None
|
||||
from agent.core.llm import Multimodel_LLM
|
||||
# 深度思考由全局 THINKING_LEVEL 档位控制(与主 Agent 一致),取代 per-model enable_thinking
|
||||
# 深度思考由会话级思考档位动态控制(每轮按当前会话解析,见 llm.Multimodel_LLM._generate),
|
||||
# 构建时使用全局默认档位作为兜底
|
||||
from agent.utils.env_utils import get_thinking_extra_body
|
||||
return Multimodel_LLM(
|
||||
model=model_cfg["model"],
|
||||
|
||||
@@ -1,26 +1,39 @@
|
||||
"""共享数据库连接与建表工具。
|
||||
|
||||
系统定位:
|
||||
为 cron 任务存储与普通会话历史存储提供统一的 MySQL 连接、配置读取、
|
||||
幂等建表与后端选择入口,避免两处存储后端配置漂移。
|
||||
为 cron 任务存储与普通会话历史存储提供统一的数据库连接、配置读取、
|
||||
幂等建表与后端选择入口,避免多处存储后端配置漂移。
|
||||
|
||||
配置模型(env_config.json):
|
||||
- 存储数据库: ``STORAGE_DB_*``(host/port/user/password/database/path),
|
||||
type 由 ``STORAGE_DB_TYPE`` 或 ``STORAGE_BACKEND`` 决定;缺省回退旧
|
||||
``MYSQL_*``(同一角色,零迁移)。SQLite 文件默认
|
||||
``<HISTORY_ROOT>/sqlite/storage.sqlite``。
|
||||
- 工具数据库: ``TOOL_DB_*``,与存储后端**完全隔离**(不回退 MYSQL_*),
|
||||
默认 mysql@localhost:3306/root。SQLite 默认 ``<HISTORY_ROOT>/sqlite/tool/``
|
||||
目录(目录模式下每个 database 名 = 一个 ``<目录>/<库名>.sqlite`` 文件)。
|
||||
|
||||
可扩展性:
|
||||
- 新增存储后端时复用 mysql_conn / ensure_tables / get_storage_backend。
|
||||
- 新增存储后端时复用 mysql_conn / ensure_tables / get_storage_backend /
|
||||
sqlite_conn / ensure_sqlite_tables。
|
||||
- ensure_tables 按表名细粒度幂等,cron 表与 session 表互不影响。
|
||||
- 连接配置统一读 MYSQL_*(cron 与 session 共用同一数据库)。
|
||||
- 向后兼容:旧配置中的 CRON_MYSQL_* 仍可读取(逐步迁移)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
|
||||
# ---------- 配置加载 ----------
|
||||
def load_mysql_config() -> dict[str, Any]:
|
||||
"""从 env_config.json 读取 MySQL 连接配置(MYSQL_*,cron 与 session 共用)。
|
||||
"""读取 env_config.json 的 MySQL 连接配置(MYSQL_*,存储后端旧键)。
|
||||
|
||||
向后兼容:优先读 MYSQL_*,回退到旧的 CRON_MYSQL_*。
|
||||
存储后端新键见 load_storage_db_config;工具数据库见 load_tool_db_config。
|
||||
"""
|
||||
try:
|
||||
from agent.core.config_manager import load_env_config
|
||||
@@ -36,28 +49,171 @@ def load_mysql_config() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def mysql_conn():
|
||||
def load_storage_db_config() -> dict[str, Any]:
|
||||
"""读取存储数据库连接配置(STORAGE_DB_*,缺省回退 MYSQL_*)。
|
||||
|
||||
输出:
|
||||
{type, host, port, user, password, database, path}
|
||||
type: "mysql" | "sqlite"(由 STORAGE_DB_TYPE 显式指定,或按
|
||||
STORAGE_BACKEND 推导:sqlite 后端 → sqlite,否则 mysql)
|
||||
path: sqlite 数据库文件路径(仅 sqlite 使用);缺省
|
||||
``<HISTORY_ROOT>/sqlite/storage.sqlite``。
|
||||
"""
|
||||
try:
|
||||
from agent.core.config_manager import load_env_config
|
||||
cfg = load_env_config() or {}
|
||||
except Exception:
|
||||
cfg = {}
|
||||
backend = str(cfg.get("STORAGE_BACKEND") or cfg.get("CRON_STORAGE_BACKEND") or "json").lower()
|
||||
db_type = str(cfg.get("STORAGE_DB_TYPE") or "").lower()
|
||||
if db_type not in ("mysql", "sqlite"):
|
||||
db_type = "sqlite" if backend == "sqlite" else "mysql"
|
||||
return {
|
||||
"type": db_type,
|
||||
"host": str(cfg.get("STORAGE_DB_HOST") or cfg.get("MYSQL_HOST")
|
||||
or cfg.get("CRON_MYSQL_HOST") or "localhost"),
|
||||
"port": int(cfg.get("STORAGE_DB_PORT") or cfg.get("MYSQL_PORT")
|
||||
or cfg.get("CRON_MYSQL_PORT") or 3306),
|
||||
"user": str(cfg.get("STORAGE_DB_USER") or cfg.get("MYSQL_USER")
|
||||
or cfg.get("CRON_MYSQL_USER") or "root"),
|
||||
"password": str(cfg.get("STORAGE_DB_PASSWORD") or cfg.get("MYSQL_PASSWORD")
|
||||
or cfg.get("CRON_MYSQL_PASSWORD") or ""),
|
||||
"database": str(cfg.get("STORAGE_DB_DATABASE") or cfg.get("MYSQL_DATABASE")
|
||||
or cfg.get("CRON_MYSQL_DATABASE") or "agent_history"),
|
||||
"path": str(cfg.get("STORAGE_DB_PATH") or "") or _default_storage_sqlite_path(),
|
||||
}
|
||||
|
||||
|
||||
def load_tool_db_config() -> dict[str, Any]:
|
||||
"""读取工具数据库连接配置(TOOL_DB_*,与存储后端完全隔离,不回退 MYSQL_*)。
|
||||
|
||||
输出:
|
||||
{type, host, port, user, password, database, path}
|
||||
type: "mysql" | "sqlite"(TOOL_DB_TYPE,缺省 mysql)
|
||||
path: sqlite 数据库路径(仅 sqlite 使用);缺省
|
||||
``<HISTORY_ROOT>/sqlite/tool``(目录:每个库名一个 .sqlite 文件)。
|
||||
"""
|
||||
try:
|
||||
from agent.core.config_manager import load_env_config
|
||||
cfg = load_env_config() or {}
|
||||
except Exception:
|
||||
cfg = {}
|
||||
db_type = str(cfg.get("TOOL_DB_TYPE") or "").lower()
|
||||
if db_type not in ("mysql", "sqlite"):
|
||||
db_type = "mysql"
|
||||
return {
|
||||
"type": db_type,
|
||||
"host": str(cfg.get("TOOL_DB_HOST") or "localhost"),
|
||||
"port": int(cfg.get("TOOL_DB_PORT") or 3306),
|
||||
"user": str(cfg.get("TOOL_DB_USER") or "root"),
|
||||
"password": str(cfg.get("TOOL_DB_PASSWORD") or ""),
|
||||
"database": str(cfg.get("TOOL_DB_DATABASE") or ""),
|
||||
"path": str(cfg.get("TOOL_DB_PATH") or "") or _default_tool_sqlite_dir(),
|
||||
}
|
||||
|
||||
|
||||
def _default_storage_sqlite_path() -> str:
|
||||
"""存储库默认 SQLite 文件:<HISTORY_ROOT>/sqlite/storage.sqlite。"""
|
||||
from agent.utils.agent_utils import HISTORY_ROOT
|
||||
return os.path.join(HISTORY_ROOT, "sqlite", "storage.sqlite")
|
||||
|
||||
|
||||
def _default_tool_sqlite_dir() -> str:
|
||||
"""工具库默认 SQLite 目录:<HISTORY_ROOT>/sqlite/tool(每库一个文件)。"""
|
||||
from agent.utils.agent_utils import HISTORY_ROOT
|
||||
return os.path.join(HISTORY_ROOT, "sqlite", "tool")
|
||||
|
||||
|
||||
# ---------- MySQL 连接 ----------
|
||||
def mysql_conn(cfg: dict[str, Any] | None = None, database: str | None = None):
|
||||
"""创建一个 MySQL 连接(DictCursor,手动提交)。调用方负责 close。
|
||||
|
||||
输入:
|
||||
cfg: 连接配置(默认读存储数据库配置 load_storage_db_config)。
|
||||
database: 覆盖目标库名(默认取 cfg 的 database)。
|
||||
|
||||
系统定位:
|
||||
所有 MySQL 仓库(cron / session)获取连接的统一入口。
|
||||
存储仓库(cron / session)获取 MySQL 连接的统一入口;
|
||||
工具侧请使用 load_tool_db_config + connect_db。
|
||||
"""
|
||||
import pymysql
|
||||
from pymysql.cursors import DictCursor
|
||||
|
||||
cfg = load_mysql_config()
|
||||
cfg = cfg or load_storage_db_config()
|
||||
return pymysql.connect(
|
||||
host=cfg["host"],
|
||||
port=cfg["port"],
|
||||
user=cfg["user"],
|
||||
password=cfg["password"],
|
||||
database=cfg["database"],
|
||||
database=database or cfg["database"],
|
||||
charset="utf8mb4",
|
||||
cursorclass=DictCursor,
|
||||
autocommit=False,
|
||||
)
|
||||
|
||||
|
||||
# ---------- SQLite 连接 ----------
|
||||
def sqlite_conn(path: str) -> sqlite3.Connection:
|
||||
"""创建一个 SQLite 连接(row_factory=Row,WAL 模式,busy_timeout 5s)。
|
||||
|
||||
输入:
|
||||
path: 数据库文件路径(不存在则自动创建;父目录需已存在,
|
||||
可用 ensure_sqlite_dir 预先创建)。
|
||||
|
||||
输出:
|
||||
sqlite3.Connection。调用方负责 close。
|
||||
"""
|
||||
conn = sqlite3.connect(path, timeout=5.0)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA busy_timeout=5000")
|
||||
try:
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
except Exception:
|
||||
pass
|
||||
return conn
|
||||
|
||||
|
||||
def sqlite_db_path(cfg: dict[str, Any], database: str | None = None) -> str:
|
||||
"""解析 SQLite 工具库的目标文件路径。
|
||||
|
||||
- cfg["path"] 为目录(或不存在且无 .sqlite 后缀)→ 每个库名一个文件:
|
||||
``<目录>/<库名>.sqlite``(database 必填)。
|
||||
- cfg["path"] 为 .sqlite 文件 → 所有操作使用该文件(database 忽略)。
|
||||
"""
|
||||
base = str(cfg.get("path") or "")
|
||||
if base.lower().endswith(".sqlite"):
|
||||
return base
|
||||
db = (database or "").strip()
|
||||
if not db:
|
||||
raise ValueError("SQLite 目录模式下 database 参数必填(库名 = 文件名)")
|
||||
return os.path.join(base, f"{db}.sqlite")
|
||||
|
||||
|
||||
def ensure_sqlite_dir(path: str) -> None:
|
||||
"""确保 SQLite 路径的父目录存在(幂等)。"""
|
||||
parent = os.path.dirname(os.path.abspath(path))
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
|
||||
|
||||
def connect_db(cfg: dict[str, Any], database: str | None = None):
|
||||
"""按数据库类型创建连接(统一分发入口)。
|
||||
|
||||
输入:
|
||||
cfg: load_tool_db_config() / load_storage_db_config() 的输出。
|
||||
database: 目标库名(mysql 覆盖 cfg.database;sqlite 目录模式为文件名)。
|
||||
|
||||
输出:
|
||||
pymysql 连接或 sqlite3.Connection。调用方负责 close。
|
||||
"""
|
||||
if cfg.get("type") == "sqlite":
|
||||
path = sqlite_db_path(cfg, database)
|
||||
ensure_sqlite_dir(path)
|
||||
return sqlite_conn(path)
|
||||
return mysql_conn(cfg, database)
|
||||
|
||||
|
||||
# ---------- MySQL 建表 ----------
|
||||
_ensured_tables: set[str] = set()
|
||||
_database_ensured: bool = False
|
||||
_ensure_lock = threading.Lock()
|
||||
@@ -83,7 +239,7 @@ def ensure_database() -> None:
|
||||
import pymysql
|
||||
from pymysql.cursors import DictCursor
|
||||
|
||||
cfg = load_mysql_config()
|
||||
cfg = load_storage_db_config()
|
||||
try:
|
||||
conn = pymysql.connect(
|
||||
host=cfg["host"],
|
||||
@@ -145,10 +301,134 @@ def ensure_tables(ddls: list[str]) -> None:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------- SQLite 建库建表 ----------
|
||||
def ensure_sqlite_database(path: str) -> None:
|
||||
"""幂等创建 SQLite 数据库(数据库文件,等价 MySQL 的 CREATE DATABASE IF NOT EXISTS)。
|
||||
|
||||
输入:
|
||||
path: 数据库文件路径(父目录自动创建)。
|
||||
|
||||
输出: 无。
|
||||
|
||||
系统定位:
|
||||
在 ensure_sqlite_tables 之前调用,确保数据库文件存在;
|
||||
不存在时创建空文件,已存在时为 no-op。
|
||||
"""
|
||||
ensure_sqlite_dir(path)
|
||||
if not os.path.isfile(path):
|
||||
# sqlite3.connect 即创建空数据库文件(SQLite 无 CREATE DATABASE 语句)
|
||||
conn = sqlite_conn(path)
|
||||
conn.close()
|
||||
|
||||
|
||||
def ensure_sqlite_tables(path: str, ddls: list[str]) -> None:
|
||||
"""幂等创建 SQLite 表(CREATE TABLE/INDEX IF NOT EXISTS)。
|
||||
|
||||
输入:
|
||||
path: 数据库文件路径(不存在则先建库,镜像 MySQL ensure_tables →
|
||||
ensure_database 的顺序)。
|
||||
ddls: DDL 语句列表(含索引 DDL,全部 IF NOT EXISTS 幂等)。
|
||||
|
||||
输出: 无。
|
||||
|
||||
系统定位:
|
||||
SqliteRecordRepository / SqliteTaskConfigRepository 初始化时调用,
|
||||
传入各自的建表 DDL。
|
||||
"""
|
||||
# 先确保数据库文件存在,否则建表连接会创建空库后才有表结构
|
||||
ensure_sqlite_database(path)
|
||||
conn = sqlite_conn(path)
|
||||
try:
|
||||
with conn:
|
||||
for ddl in ddls:
|
||||
conn.execute(ddl)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# SQLite 表 DDL(幂等,session / cron 互不影响)。
|
||||
# JSON 数据以 TEXT 列存储(sqlite 无原生 JSON 类型);sessions 直接含 extra 列。
|
||||
_SQLITE_SESSION_TABLE_DDLS = [
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
last_turn_id TEXT DEFAULT '',
|
||||
extra TEXT
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS session_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
turn_id TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL DEFAULT 0,
|
||||
message TEXT NOT NULL
|
||||
)
|
||||
""",
|
||||
"CREATE INDEX IF NOT EXISTS idx_sess_turn ON session_messages (session_id, turn_id)",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS session_tool_calls (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
turn_id TEXT NOT NULL,
|
||||
tool_calls TEXT NOT NULL,
|
||||
meta TEXT
|
||||
)
|
||||
""",
|
||||
"CREATE INDEX IF NOT EXISTS idx_sess_tc_turn ON session_tool_calls (session_id, turn_id)",
|
||||
]
|
||||
|
||||
_SQLITE_CRON_TABLE_DDLS = [
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS cron_tasks (
|
||||
task_id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
prompt TEXT NOT NULL,
|
||||
trigger_type TEXT NOT NULL,
|
||||
cron_expr TEXT DEFAULT '',
|
||||
interval_seconds INTEGER DEFAULT 0,
|
||||
run_at TEXT DEFAULT '',
|
||||
enabled INTEGER DEFAULT 1,
|
||||
timeout_seconds INTEGER DEFAULT 300,
|
||||
workspace TEXT DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
next_run_at TEXT DEFAULT '',
|
||||
last_run_at TEXT DEFAULT '',
|
||||
last_status TEXT DEFAULT 'pending',
|
||||
last_error TEXT,
|
||||
executions TEXT
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS cron_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT NOT NULL,
|
||||
turn_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
meta TEXT
|
||||
)
|
||||
""",
|
||||
"CREATE INDEX IF NOT EXISTS idx_task_turn ON cron_messages (task_id, turn_id)",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS cron_tool_calls (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT NOT NULL,
|
||||
turn_id TEXT NOT NULL,
|
||||
tool_calls TEXT NOT NULL,
|
||||
meta TEXT
|
||||
)
|
||||
""",
|
||||
"CREATE INDEX IF NOT EXISTS idx_task_tc_turn ON cron_tool_calls (task_id, turn_id)",
|
||||
]
|
||||
|
||||
|
||||
def get_storage_backend() -> str:
|
||||
"""读取 env_config.json 的 STORAGE_BACKEND,默认 json。
|
||||
|
||||
cron 任务与普通会话历史共用此后端开关:mysql 时两者都入库。
|
||||
cron 任务与普通会话历史共用此后端开关:mysql / sqlite 时两者都入库。
|
||||
向后兼容:优先读 STORAGE_BACKEND,回退到旧的 CRON_STORAGE_BACKEND。
|
||||
"""
|
||||
try:
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
- Vision 描述接口
|
||||
|
||||
依赖:
|
||||
env_utils 中的 LLM_* / ASR_BASE_URL
|
||||
env_utils 中的 LLM_* 常量与 ASR 服务模型配置
|
||||
|
||||
可扩展性:
|
||||
- 可子类化覆盖 _preprocess_messages、_create_chat_result
|
||||
@@ -218,8 +218,30 @@ class Multimodel_LLM(ChatOpenAI):
|
||||
可在此注入 token 统计回调、链路追踪 span。
|
||||
"""
|
||||
messages = self._preprocess_messages(messages)
|
||||
self._apply_session_extra_body()
|
||||
return super()._generate(messages, stop=stop, run_manager=run_manager, **kwargs)
|
||||
|
||||
def _apply_session_extra_body(self) -> None:
|
||||
"""按当前会话的思考档位动态设置 extra_body(会话级 THINKING_LEVEL)。
|
||||
|
||||
档位存于该会话的 session_meta.json(见 agent_utils.set_session_thinking_level);
|
||||
每轮图执行前由 runtime 设置当前会话(tool_call_recorder.set_current_session),
|
||||
因此此处按线程上下文中的当前会话解析档位并覆盖构造时的全局默认值。
|
||||
无会话上下文(cron 子进程、describe_image 等)保持构造时的 extra_body 不变。
|
||||
"""
|
||||
try:
|
||||
from agent.history.tool_call_recorder import get_current_session
|
||||
from agent.utils.env_utils import get_session_thinking_level, get_thinking_extra_body
|
||||
sid = get_current_session()
|
||||
if not sid:
|
||||
return
|
||||
lv = get_session_thinking_level(sid)
|
||||
if not lv:
|
||||
return
|
||||
self.extra_body = get_thinking_extra_body(session_id=sid)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _create_chat_result(self, response, generation_info=None) -> ChatResult:
|
||||
"""解析 API 响应:深度思考归一化、XML/JSON tool_call 提取。
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ from agent.core.loop_detector import build_loop_reflection_prompt
|
||||
from agent.core.state import AgentState
|
||||
from agent.memory.system_prompt import SUB_AGENT_OOM_PROMPT
|
||||
from agent.utils.image_utils import image_bytes_to_openai_image_url_part
|
||||
from agent.utils.tool_call_utils import normalize_tool_call, extract_reasoning_text
|
||||
from agent.utils.tool_call_utils import normalize_tool_call, extract_thought, extract_reflection
|
||||
from langchain_core.messages import SystemMessage
|
||||
|
||||
def _has_image_content(msg: HumanMessage) -> bool:
|
||||
@@ -105,7 +105,8 @@ def _filter_short_term_memory(messages: list, trajectory_rounds: int) -> list:
|
||||
|
||||
# 剥离历史模型思考文本:上一/几轮的思考不进入本轮上下文。
|
||||
# 带 tool_calls 的 AIMessage,其 content 仅是思维链,保留 tool_calls 即可
|
||||
# (思考内容已由 extract_reasoning_text 单独记录到前端 think 面板)。
|
||||
# (思考/反思已由 extract_thought/extract_reflection 单独记录到 think 面板,
|
||||
# 并由 build_tool_history_messages 以 [历史思考]/[历史反思] 形式注入)。
|
||||
cleaned: list = []
|
||||
for m in result:
|
||||
if isinstance(m, AIMessage) and getattr(m, "tool_calls", None) and getattr(m, "content", None):
|
||||
@@ -411,6 +412,51 @@ def maybe_compress_context(state: AgentState) -> dict:
|
||||
return {"_need_compress": False}
|
||||
|
||||
|
||||
def _inject_turn_thinking(messages: list[Any]) -> list[Any]:
|
||||
"""把本轮图消息中携带的思考/反思(reasoning_content / content)以合成消息插回上下文。
|
||||
|
||||
reasoning_content 不被 LLM API 回传(additional_kwargs 不在标准消息
|
||||
schema 内),跨步骤调用时模型会丢失自己的思考链;此处在其所属的
|
||||
工具调用 AIMessage 之前插入 ``[思考]`` / ``[反思]`` 合成消息
|
||||
(因果交错),使模型在后续步骤中仍能看到本轮已产生的思考与反思。
|
||||
|
||||
注意:
|
||||
- 仅注入到本次调用的 messages_for_llm,不进图状态,无副作用;
|
||||
- 思考与反思独立提取(extract_thought / extract_reflection),
|
||||
可同时存在(既有深度思考又有反思的通用情况),分别注入;
|
||||
- 思考文本与位置确定性构建(同一图状态下结果恒定),id 稳定,
|
||||
不破坏 LLM 前缀缓存;
|
||||
- trim_messages 已剥空 content 时反思自然跳过(思考存于
|
||||
additional_kwargs,不受影响)。
|
||||
"""
|
||||
out: list[Any] = []
|
||||
think_seq = 0
|
||||
reflect_seq = 0
|
||||
for m in messages:
|
||||
if isinstance(m, AIMessage) and getattr(m, "tool_calls", None):
|
||||
try:
|
||||
thought = extract_thought(m)
|
||||
reflection = extract_reflection(m)
|
||||
except Exception:
|
||||
thought = reflection = ""
|
||||
if thought:
|
||||
out.append(HumanMessage(
|
||||
content=f"[思考] {thought}",
|
||||
additional_kwargs={"synthetic": True},
|
||||
id=f"turn_think_{think_seq}",
|
||||
))
|
||||
think_seq += 1
|
||||
if reflection:
|
||||
out.append(HumanMessage(
|
||||
content=f"[反思] {reflection}",
|
||||
additional_kwargs={"synthetic": True},
|
||||
id=f"turn_reflect_{reflect_seq}",
|
||||
))
|
||||
reflect_seq += 1
|
||||
out.append(m)
|
||||
return out
|
||||
|
||||
|
||||
def make_call_model_node(
|
||||
model_with_tools: Any,
|
||||
trajectory_rounds: int = 3,
|
||||
@@ -454,6 +500,9 @@ def make_call_model_node(
|
||||
# 固定反思引导已并入系统提示词前缀,此处仅注入变化部分:
|
||||
# TodoList 状态与循环提醒;无 TodoList 且无循环提醒时不注入(省 token)。
|
||||
messages_for_llm = list(filtered_messages)
|
||||
# 注入本轮已产生的思考(reasoning_content 不被 API 回传,跨步骤需回插;
|
||||
# 因果交错:思考位于其工具调用 AIMessage 之前)
|
||||
messages_for_llm = _inject_turn_thinking(messages_for_llm)
|
||||
tail = _build_dynamic_tail_prompt(filtered_messages, _sid)
|
||||
if tail:
|
||||
# 长期记忆:持久化本轮动态尾部,供后续轮次注入查看
|
||||
@@ -495,21 +544,20 @@ def make_call_model_node(
|
||||
state["_need_compress"] = need_compress
|
||||
|
||||
# 提取模型产出的思考/反思内容,记录到前端 think 面板与后端记录
|
||||
# - 思考关闭:模型在反思提示引导下输出的 content(一句话反思)
|
||||
# - 思考开启:深度思考(additional_kwargs.reasoning_content 或内联 <think> 块,
|
||||
# 由 _create_chat_result 归一化到 reasoning_content)
|
||||
response_content = extract_reasoning_text(response)
|
||||
if response_content and isinstance(response_content, str) and response_content.strip():
|
||||
has_tool_calls = getattr(response, "tool_calls", None)
|
||||
if has_tool_calls:
|
||||
# 有 tool_calls 时 content 是思考过程,记录到 think 面板
|
||||
try:
|
||||
from agent.history.tool_call_recorder import record_reflection_live, get_current_session
|
||||
sid = get_current_session()
|
||||
if sid:
|
||||
record_reflection_live(sid, response_content.strip())
|
||||
except Exception:
|
||||
pass
|
||||
# - 思考 = reasoning_content(深度思考档位)
|
||||
# - 反思 = content(思考关闭档位为引导的一句话反思;思考开启时
|
||||
# 与思考可同时存在,独立存储、独立注入上下文)
|
||||
thought = extract_thought(response)
|
||||
reflection = extract_reflection(response)
|
||||
if (thought or reflection) and getattr(response, "tool_calls", None):
|
||||
# 有 tool_calls 时思考/反思是该轮工具调用的思维过程,记录到 think 面板
|
||||
try:
|
||||
from agent.history.tool_call_recorder import record_reflection_live, get_current_session
|
||||
sid = get_current_session()
|
||||
if sid:
|
||||
record_reflection_live(sid, thought, reflection)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if hasattr(response, "tool_calls") and response.tool_calls:
|
||||
for tc in response.tool_calls:
|
||||
|
||||
@@ -46,6 +46,11 @@ from agent.core.db import (
|
||||
mysql_conn,
|
||||
ensure_tables,
|
||||
get_storage_backend,
|
||||
sqlite_conn,
|
||||
ensure_sqlite_tables,
|
||||
load_storage_db_config,
|
||||
_SQLITE_SESSION_TABLE_DDLS,
|
||||
_SQLITE_CRON_TABLE_DDLS,
|
||||
)
|
||||
|
||||
# 定时任务根目录:history/cron
|
||||
@@ -80,7 +85,8 @@ class ConversationRecordRepository(Protocol):
|
||||
def load_turn_messages(self, session_id: str, turn_id: str) -> list[dict] | None: ...
|
||||
def list_turn_ids(self, session_id: str) -> list[str]: ...
|
||||
def save_tool_calls(
|
||||
self, session_id: str, turn_id: str, tool_calls: list[dict], meta: dict | None = None
|
||||
self, session_id: str, turn_id: str, tool_calls: list[dict], meta: dict | None = None,
|
||||
reflections: list[dict] | None = None,
|
||||
) -> None: ...
|
||||
def get_turn_record(self, session_id: str, turn_id: str) -> dict[str, Any] | None: ...
|
||||
def get_latest_turn_record(self, session_id: str) -> dict[str, Any] | None: ...
|
||||
@@ -160,6 +166,7 @@ _CRON_TABLE_DDLS = [
|
||||
run_at VARCHAR(32) DEFAULT '',
|
||||
enabled TINYINT(1) DEFAULT 1,
|
||||
timeout_seconds INT DEFAULT 300,
|
||||
workspace VARCHAR(512) DEFAULT '',
|
||||
created_at VARCHAR(32) NOT NULL,
|
||||
next_run_at VARCHAR(32) DEFAULT '',
|
||||
last_run_at VARCHAR(32) DEFAULT '',
|
||||
@@ -345,7 +352,8 @@ class JsonRecordRepository:
|
||||
|
||||
# ---- 工具调用记录 ----
|
||||
def save_tool_calls(
|
||||
self, session_id: str, turn_id: str, tool_calls: list[dict], meta: dict | None = None
|
||||
self, session_id: str, turn_id: str, tool_calls: list[dict], meta: dict | None = None,
|
||||
reflections: list[dict] | None = None,
|
||||
) -> None:
|
||||
if not session_id or not turn_id:
|
||||
return
|
||||
@@ -359,6 +367,10 @@ class JsonRecordRepository:
|
||||
}
|
||||
if meta:
|
||||
record.update(meta) # meta 全量透传,消费方新增字段无需改动存储层
|
||||
if reflections:
|
||||
# 尾部思考记录(未被任何工具调用消费的反思),随 turn 一并持久化,
|
||||
# 供后续轮次 build_tool_history_messages 注入上下文
|
||||
record["reflections"] = reflections
|
||||
with open(self._tool_path(session_id, turn_id), "w", encoding="utf-8") as f:
|
||||
json.dump(record, f, ensure_ascii=False, indent=2)
|
||||
|
||||
@@ -416,17 +428,25 @@ class JsonRecordRepository:
|
||||
json.dump(record, f, ensure_ascii=False, indent=2)
|
||||
|
||||
# ---- 会话元数据(extra = {tokens, token_breakdown, agent_meta}) ----
|
||||
# 统一存于 session_meta.json:{...agent_meta, tokens, breakdown}
|
||||
# 旧版 _session_tokens.json 在读取/写入时自动合并进 session_meta.json 并删除(迁移)。
|
||||
|
||||
def load_session_extra(self, session_id: str) -> dict[str, Any]:
|
||||
extra: dict[str, Any] = {}
|
||||
# agent_meta ↔ session_meta.json
|
||||
# agent_meta + tokens/breakdown ↔ session_meta.json(合并后的单一文件)
|
||||
mp = self._meta_path(session_id)
|
||||
if os.path.isfile(mp):
|
||||
try:
|
||||
with open(mp, "r", encoding="utf-8") as f:
|
||||
extra["agent_meta"] = json.load(f)
|
||||
data = json.load(f)
|
||||
extra["agent_meta"] = data
|
||||
if "tokens" in data:
|
||||
extra["tokens"] = data.get("tokens", 0)
|
||||
if "breakdown" in data and isinstance(data.get("breakdown"), dict):
|
||||
extra["token_breakdown"] = data["breakdown"]
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
# tokens / token_breakdown ↔ _session_tokens.json
|
||||
# 兼容迁移:旧 _session_tokens.json 仍存在时合并(其数据优先)并删除
|
||||
tf = self._token_path(session_id)
|
||||
if os.path.isfile(tf):
|
||||
try:
|
||||
@@ -436,8 +456,16 @@ class JsonRecordRepository:
|
||||
extra["tokens"] = data.get("tokens", 0)
|
||||
if "breakdown" in data and isinstance(data.get("breakdown"), dict):
|
||||
extra["token_breakdown"] = data["breakdown"]
|
||||
if "workspace" in data:
|
||||
meta = dict(extra.get("agent_meta") or {})
|
||||
meta["workspace"] = data.get("workspace") or ""
|
||||
extra["agent_meta"] = meta
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
try:
|
||||
os.remove(tf)
|
||||
except OSError:
|
||||
pass
|
||||
return extra
|
||||
|
||||
def save_session_extra(self, session_id: str, extra: dict[str, Any]) -> None:
|
||||
@@ -445,18 +473,35 @@ class JsonRecordRepository:
|
||||
return
|
||||
sess_dir = self._session_dir(session_id)
|
||||
os.makedirs(sess_dir, exist_ok=True)
|
||||
# 与磁盘已有内容合并,避免单字段更新(如仅写 agent_meta)覆盖 tokens
|
||||
merged: dict[str, Any] = {}
|
||||
mp = self._meta_path(session_id)
|
||||
if os.path.isfile(mp):
|
||||
try:
|
||||
with open(mp, "r", encoding="utf-8") as f:
|
||||
merged = json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
merged = {}
|
||||
if "agent_meta" in extra:
|
||||
with open(self._meta_path(session_id), "w", encoding="utf-8") as f:
|
||||
json.dump(extra.get("agent_meta") or {}, f, ensure_ascii=False, indent=2)
|
||||
if "tokens" in extra or "token_breakdown" in extra:
|
||||
payload: dict[str, Any] = {
|
||||
"session_id": session_id,
|
||||
"tokens": extra.get("tokens", 0),
|
||||
}
|
||||
if "token_breakdown" in extra and isinstance(extra.get("token_breakdown"), dict):
|
||||
payload["breakdown"] = extra["token_breakdown"]
|
||||
with open(self._token_path(session_id), "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, ensure_ascii=False, indent=2)
|
||||
merged.update(extra.get("agent_meta") or {})
|
||||
if "tokens" in extra:
|
||||
merged["tokens"] = extra.get("tokens", 0)
|
||||
if "token_breakdown" in extra:
|
||||
merged["breakdown"] = extra["token_breakdown"]
|
||||
else:
|
||||
# 显式传 tokens 但无 breakdown:视为清除旧的 breakdown
|
||||
merged.pop("breakdown", None)
|
||||
elif "token_breakdown" in extra:
|
||||
merged["breakdown"] = extra["token_breakdown"]
|
||||
with open(mp, "w", encoding="utf-8") as f:
|
||||
json.dump(merged, f, ensure_ascii=False, indent=2)
|
||||
# 迁移:不再使用 _session_tokens.json,删除旧文件
|
||||
tf = self._token_path(session_id)
|
||||
if os.path.isfile(tf):
|
||||
try:
|
||||
os.remove(tf)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# ---- 会话生命周期 ----
|
||||
def list_session_ids(self) -> list[str]:
|
||||
@@ -466,17 +511,14 @@ class JsonRecordRepository:
|
||||
entries: list[tuple[float, str]] = []
|
||||
for name in os.listdir(root):
|
||||
dir_path = os.path.join(root, name)
|
||||
# 会话根目录下存在目录即视为会话(含空会话——新建未发消息的会话
|
||||
# 也要出现在列表中;与 MySQL 后端按 sessions 表行列出对齐)
|
||||
if not os.path.isdir(dir_path):
|
||||
continue
|
||||
try:
|
||||
has_turns = any(
|
||||
f.startswith(TURN_JSON_PREFIX) and f.endswith(".json")
|
||||
for f in os.listdir(dir_path)
|
||||
)
|
||||
except OSError:
|
||||
has_turns = False
|
||||
if has_turns:
|
||||
entries.append((os.path.getmtime(dir_path), name))
|
||||
except OSError:
|
||||
continue
|
||||
entries.sort(key=lambda x: x[0], reverse=True)
|
||||
return [name for _, name in entries]
|
||||
|
||||
@@ -730,7 +772,8 @@ class MysqlRecordRepository:
|
||||
|
||||
# ---- 工具调用记录 ----
|
||||
def save_tool_calls(
|
||||
self, session_id: str, turn_id: str, tool_calls: list[dict], meta: dict | None = None
|
||||
self, session_id: str, turn_id: str, tool_calls: list[dict], meta: dict | None = None,
|
||||
reflections: list[dict] | None = None,
|
||||
) -> None:
|
||||
if not session_id or not turn_id:
|
||||
return
|
||||
@@ -743,13 +786,17 @@ class MysqlRecordRepository:
|
||||
(session_id, turn_id),
|
||||
)
|
||||
if self.scope == "cron":
|
||||
# cron_tool_calls 无 meta 列(与既有表结构一致)
|
||||
# cron_tool_calls 无 meta 列(与既有表结构一致),
|
||||
# 尾部思考记录无法入库;JSON 后端下完整持久化
|
||||
cur.execute(
|
||||
f"INSERT INTO {self._tool_table()} ({sid_col}, turn_id, tool_calls) "
|
||||
"VALUES (%s,%s,%s)",
|
||||
(session_id, turn_id, json.dumps(tool_calls or [], ensure_ascii=False)),
|
||||
)
|
||||
else:
|
||||
# 尾部思考并入 meta(_row_to_record 全量透传,读取时合并回记录顶层)
|
||||
if reflections:
|
||||
meta = {**(meta or {}), "reflections": reflections}
|
||||
cur.execute(
|
||||
f"INSERT INTO {self._tool_table()} "
|
||||
"(session_id, turn_id, tool_calls, meta) VALUES (%s,%s,%s,%s)",
|
||||
@@ -1053,6 +1100,465 @@ class MysqlRecordRepository:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------- SQLite 实现 ----------
|
||||
class SqliteRecordRepository:
|
||||
"""基于 SQLite(内置 sqlite3)的记录仓库。
|
||||
|
||||
session 与 cron 双作用域共用同一个数据库文件(默认
|
||||
``<HISTORY_ROOT>/sqlite/storage.sqlite``),表相互独立:
|
||||
session 作用域: sessions / session_messages / session_tool_calls;
|
||||
cron 作用域: cron_messages / cron_tool_calls。
|
||||
JSON 数据以 TEXT 列存储(sqlite 无原生 JSON 类型),读取时统一反序列化;
|
||||
消息与工具调用记录原样 JSON 入库,meta 全量透传(不白名单)。
|
||||
cron_tool_calls 含 meta 列(与 MySQL 表结构不同——SQLite 建表无迁移负担),
|
||||
尾部思考(reflections)可完整落库。
|
||||
"""
|
||||
|
||||
def __init__(self, scope: str = "session", db_path: str | None = None) -> None:
|
||||
self.scope = scope if scope == "cron" else "session"
|
||||
self._path_override = db_path
|
||||
if self.scope == "cron":
|
||||
ensure_sqlite_tables(self._db_path(), _SQLITE_CRON_TABLE_DDLS)
|
||||
else:
|
||||
ensure_sqlite_tables(self._db_path(), _SQLITE_SESSION_TABLE_DDLS)
|
||||
|
||||
# ---- 表名 / 连接 ----
|
||||
def _db_path(self) -> str:
|
||||
if self._path_override:
|
||||
return self._path_override
|
||||
return load_storage_db_config()["path"]
|
||||
|
||||
def _conn(self):
|
||||
return sqlite_conn(self._db_path())
|
||||
|
||||
def _messages_table(self) -> str:
|
||||
return "cron_messages" if self.scope == "cron" else "session_messages"
|
||||
|
||||
def _tool_table(self) -> str:
|
||||
return "cron_tool_calls" if self.scope == "cron" else "session_tool_calls"
|
||||
|
||||
def _sessions_table(self) -> str | None:
|
||||
return None if self.scope == "cron" else "sessions"
|
||||
|
||||
def _sid_col(self) -> str:
|
||||
return "task_id" if self.scope == "cron" else "session_id"
|
||||
|
||||
@staticmethod
|
||||
def _now_iso() -> str:
|
||||
from datetime import datetime
|
||||
return datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
|
||||
|
||||
@staticmethod
|
||||
def _loads(raw: Any) -> Any:
|
||||
"""TEXT 列统一反序列化(兼容已解析的 dict/list 输入)。"""
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return {}
|
||||
if isinstance(raw, (bytes, bytearray)):
|
||||
try:
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
return raw
|
||||
|
||||
@staticmethod
|
||||
def _dumps(obj: Any) -> str:
|
||||
return json.dumps(obj, ensure_ascii=False) if obj is not None else None
|
||||
|
||||
@staticmethod
|
||||
def _row_to_dict(row: Any) -> dict[str, Any]:
|
||||
"""sqlite3.Row → dict。"""
|
||||
return dict(row) if row is not None else {}
|
||||
|
||||
# ---- 消息 ----
|
||||
def save_turn(self, session_id: str, turn_id: str, messages: list[dict]) -> None:
|
||||
if not session_id or not turn_id:
|
||||
return
|
||||
sid_col = self._sid_col()
|
||||
conn = self._conn()
|
||||
try:
|
||||
with conn:
|
||||
conn.execute(
|
||||
f"DELETE FROM {self._messages_table()} WHERE {sid_col}=? AND turn_id=?",
|
||||
(session_id, turn_id),
|
||||
)
|
||||
if self.scope == "cron":
|
||||
# cron 表按 role/content/meta 分列存储,附件路径转相对 CRON_ROOT
|
||||
messages = [_to_rel_paths(m, session_id) for m in messages or []]
|
||||
for msg in messages:
|
||||
content = msg.get("content")
|
||||
meta = msg.get("meta")
|
||||
conn.execute(
|
||||
f"INSERT INTO {self._messages_table()} "
|
||||
f"({sid_col}, turn_id, role, content, meta) VALUES (?,?,?,?,?)",
|
||||
(
|
||||
session_id, turn_id, msg.get("role", "user"),
|
||||
self._dumps(content),
|
||||
self._dumps(meta) if meta is not None else None,
|
||||
),
|
||||
)
|
||||
else:
|
||||
for seq, msg in enumerate(messages or []):
|
||||
conn.execute(
|
||||
f"INSERT INTO {self._messages_table()} "
|
||||
f"(session_id, turn_id, seq, message) VALUES (?,?,?,?)",
|
||||
(session_id, turn_id, seq, self._dumps(msg)),
|
||||
)
|
||||
now = self._now_iso()
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO sessions (session_id, created_at, updated_at, last_turn_id)
|
||||
VALUES (?,?,?,?)
|
||||
ON CONFLICT(session_id) DO UPDATE SET
|
||||
updated_at=excluded.updated_at, last_turn_id=excluded.last_turn_id
|
||||
""",
|
||||
(session_id, now, now, turn_id),
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def load_messages(self, session_id: str) -> list[dict]:
|
||||
sid_col = self._sid_col()
|
||||
conn = self._conn()
|
||||
try:
|
||||
if self.scope == "cron":
|
||||
rows = conn.execute(
|
||||
f"SELECT turn_id, role, content, meta FROM {self._messages_table()} "
|
||||
f"WHERE {sid_col}=? ORDER BY id",
|
||||
(session_id,),
|
||||
).fetchall() or []
|
||||
msgs: list[dict] = []
|
||||
for r in rows:
|
||||
row = self._row_to_dict(r)
|
||||
content = self._loads(row.get("content"))
|
||||
meta = self._loads(row.get("meta"))
|
||||
msg: dict[str, Any] = {"role": row.get("role", "user"), "content": content}
|
||||
if isinstance(meta, dict) and meta:
|
||||
msg["meta"] = meta
|
||||
msgs.append(_resolve_msg_paths(msg, session_id))
|
||||
return msgs
|
||||
rows = conn.execute(
|
||||
f"SELECT message FROM {self._messages_table()} "
|
||||
f"WHERE session_id=? ORDER BY turn_id ASC, seq ASC",
|
||||
(session_id,),
|
||||
).fetchall() or []
|
||||
return [self._loads(self._row_to_dict(r)["message"]) for r in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def load_turn_messages(self, session_id: str, turn_id: str) -> list[dict] | None:
|
||||
sid_col = self._sid_col()
|
||||
conn = self._conn()
|
||||
try:
|
||||
if self.scope == "cron":
|
||||
rows = conn.execute(
|
||||
f"SELECT turn_id, role, content, meta FROM {self._messages_table()} "
|
||||
f"WHERE {sid_col}=? AND turn_id=? ORDER BY id",
|
||||
(session_id, turn_id),
|
||||
).fetchall() or []
|
||||
if not rows:
|
||||
return None
|
||||
msgs: list[dict] = []
|
||||
for r in rows:
|
||||
row = self._row_to_dict(r)
|
||||
content = self._loads(row.get("content"))
|
||||
meta = self._loads(row.get("meta"))
|
||||
msg: dict[str, Any] = {"role": row.get("role", "user"), "content": content}
|
||||
if isinstance(meta, dict) and meta:
|
||||
msg["meta"] = meta
|
||||
msgs.append(_resolve_msg_paths(msg, session_id))
|
||||
return msgs
|
||||
rows = conn.execute(
|
||||
f"SELECT message FROM {self._messages_table()} "
|
||||
f"WHERE session_id=? AND turn_id=? ORDER BY seq ASC",
|
||||
(session_id, turn_id),
|
||||
).fetchall() or []
|
||||
if not rows:
|
||||
return None
|
||||
return [self._loads(self._row_to_dict(r)["message"]) for r in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def list_turn_ids(self, session_id: str) -> list[str]:
|
||||
sid_col = self._sid_col()
|
||||
conn = self._conn()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
f"SELECT DISTINCT turn_id FROM {self._messages_table()} "
|
||||
f"WHERE {sid_col}=? ORDER BY turn_id",
|
||||
(session_id,),
|
||||
).fetchall() or []
|
||||
return [self._row_to_dict(r)["turn_id"] for r in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# ---- 工具调用记录 ----
|
||||
def save_tool_calls(
|
||||
self, session_id: str, turn_id: str, tool_calls: list[dict], meta: dict | None = None,
|
||||
reflections: list[dict] | None = None,
|
||||
) -> None:
|
||||
if not session_id or not turn_id:
|
||||
return
|
||||
sid_col = self._sid_col()
|
||||
# 尾部思考并入 meta(_row_to_record 全量透传,读取时合并回记录顶层)
|
||||
if reflections:
|
||||
meta = {**(meta or {}), "reflections": reflections}
|
||||
conn = self._conn()
|
||||
try:
|
||||
with conn:
|
||||
conn.execute(
|
||||
f"DELETE FROM {self._tool_table()} WHERE {sid_col}=? AND turn_id=?",
|
||||
(session_id, turn_id),
|
||||
)
|
||||
conn.execute(
|
||||
f"INSERT INTO {self._tool_table()} "
|
||||
f"({sid_col}, turn_id, tool_calls, meta) VALUES (?,?,?,?)",
|
||||
(
|
||||
session_id,
|
||||
turn_id,
|
||||
self._dumps(tool_calls or []),
|
||||
self._dumps(meta) if meta else None,
|
||||
),
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@classmethod
|
||||
def _row_to_record(cls, row: dict[str, Any], session_id: str, turn_id: str,
|
||||
meta_key: str | None = None) -> dict[str, Any]:
|
||||
"""把工具调用表行还原为与 tool_*.json 同构的 record dict。
|
||||
|
||||
meta 全量透传(不做字段白名单):消费方新增 meta 字段无需改动存储层。
|
||||
"""
|
||||
tool_calls = cls._loads(row.get("tool_calls")) or []
|
||||
record: dict[str, Any] = {
|
||||
"session_id": session_id,
|
||||
"turn_id": turn_id,
|
||||
"tool_calls": tool_calls,
|
||||
"timestamp": turn_id,
|
||||
}
|
||||
if meta_key and row.get(meta_key) is not None:
|
||||
meta = cls._loads(row.get(meta_key))
|
||||
if isinstance(meta, dict):
|
||||
record.update(meta)
|
||||
return record
|
||||
|
||||
def get_turn_record(self, session_id: str, turn_id: str) -> dict[str, Any] | None:
|
||||
sid_col = self._sid_col()
|
||||
conn = self._conn()
|
||||
try:
|
||||
row = conn.execute(
|
||||
f"SELECT turn_id, tool_calls, meta FROM {self._tool_table()} "
|
||||
f"WHERE {sid_col}=? AND turn_id=?",
|
||||
(session_id, turn_id),
|
||||
).fetchone()
|
||||
row = self._row_to_dict(row)
|
||||
if not row:
|
||||
return None
|
||||
return self._row_to_record(row, session_id, turn_id, meta_key="meta")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_latest_turn_record(self, session_id: str) -> dict[str, Any] | None:
|
||||
sid_col = self._sid_col()
|
||||
conn = self._conn()
|
||||
try:
|
||||
row = conn.execute(
|
||||
f"SELECT turn_id, tool_calls, meta FROM {self._tool_table()} "
|
||||
f"WHERE {sid_col}=? ORDER BY turn_id DESC LIMIT 1",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
row = self._row_to_dict(row)
|
||||
if not row:
|
||||
return None
|
||||
return self._row_to_record(row, session_id, row["turn_id"], meta_key="meta")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def list_turn_records(self, session_id: str, limit: int = 5) -> list[dict[str, Any]]:
|
||||
"""按 turn_id 倒序返回最近 limit 条 turn 记录(与 tool_*.json 同构)。"""
|
||||
sid_col = self._sid_col()
|
||||
conn = self._conn()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
f"SELECT turn_id, tool_calls, meta FROM {self._tool_table()} "
|
||||
f"WHERE {sid_col}=? ORDER BY turn_id DESC LIMIT ?",
|
||||
(session_id, limit),
|
||||
).fetchall() or []
|
||||
return [
|
||||
self._row_to_record(self._row_to_dict(r), session_id, r["turn_id"], meta_key="meta")
|
||||
for r in rows
|
||||
]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def persist_live_records(
|
||||
self, session_id: str, turn_id: str, records: list[dict], started_at: float | None = None
|
||||
) -> None:
|
||||
"""实时工具记录不增量入库(由 save_tool_calls 在 turn 结束时统一落库)。"""
|
||||
|
||||
# ---- 会话元数据(extra) ----
|
||||
def load_session_extra(self, session_id: str) -> dict[str, Any]:
|
||||
"""读取 sessions.extra 列(含 tokens + agent_meta);cron 作用域无此表 → {}。"""
|
||||
table = self._sessions_table()
|
||||
if table is None or not session_id:
|
||||
return {}
|
||||
conn = self._conn()
|
||||
try:
|
||||
row = conn.execute("SELECT extra FROM sessions WHERE session_id=?", (session_id,)).fetchone()
|
||||
row = self._row_to_dict(row)
|
||||
raw = row.get("extra") if row else None
|
||||
return self._loads(raw) if raw is not None else {}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def save_session_extra(self, session_id: str, extra: dict[str, Any]) -> None:
|
||||
"""写入 sessions.extra 列(与既有 extra 增量合并,不覆盖未涉及的键)。
|
||||
|
||||
与 json 实现按 key 拆分的语义一致:消费方既可 load-modify-save 全量写入,
|
||||
也可只传增量键。cron 作用域无此表 → no-op。
|
||||
"""
|
||||
table = self._sessions_table()
|
||||
if table is None or not session_id:
|
||||
return
|
||||
conn = self._conn()
|
||||
try:
|
||||
with conn:
|
||||
row = conn.execute("SELECT extra FROM sessions WHERE session_id=?", (session_id,)).fetchone()
|
||||
row = self._row_to_dict(row)
|
||||
existing = self._loads(row.get("extra")) if row else {}
|
||||
if not isinstance(existing, dict):
|
||||
existing = {}
|
||||
existing.update(extra or {})
|
||||
now = self._now_iso()
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO sessions (session_id, created_at, updated_at, extra)
|
||||
VALUES (?,?,?,?)
|
||||
ON CONFLICT(session_id) DO UPDATE SET
|
||||
updated_at=excluded.updated_at, extra=excluded.extra
|
||||
""",
|
||||
(session_id, now, now, self._dumps(existing)),
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# ---- 会话生命周期 ----
|
||||
def list_session_ids(self) -> list[str]:
|
||||
conn = self._conn()
|
||||
try:
|
||||
if self.scope == "cron":
|
||||
rows = conn.execute(
|
||||
"SELECT task_id AS sid FROM cron_messages "
|
||||
"GROUP BY task_id ORDER BY MAX(id) DESC"
|
||||
).fetchall() or []
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT session_id AS sid FROM sessions ORDER BY updated_at DESC"
|
||||
).fetchall() or []
|
||||
return [self._row_to_dict(r)["sid"] for r in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_last_turn_id(self, session_id: str) -> str:
|
||||
sid_col = self._sid_col()
|
||||
conn = self._conn()
|
||||
try:
|
||||
if self.scope == "cron":
|
||||
row = conn.execute(
|
||||
f"SELECT MAX(turn_id) AS m FROM {self._messages_table()} WHERE {sid_col}=?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
row = self._row_to_dict(row)
|
||||
return (row or {}).get("m") or ""
|
||||
row = conn.execute(
|
||||
"SELECT last_turn_id FROM sessions WHERE session_id=?", (session_id,)
|
||||
).fetchone()
|
||||
row = self._row_to_dict(row)
|
||||
if row and row.get("last_turn_id"):
|
||||
return row["last_turn_id"]
|
||||
row2 = conn.execute(
|
||||
"SELECT MAX(turn_id) AS m FROM session_messages WHERE session_id=?", (session_id,)
|
||||
).fetchone()
|
||||
row2 = self._row_to_dict(row2)
|
||||
return (row2 or {}).get("m") or ""
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def delete_turns_after(self, session_id: str, keep_until_turn_id: str) -> int:
|
||||
if not session_id:
|
||||
return 0
|
||||
sid_col = self._sid_col()
|
||||
conn = self._conn()
|
||||
try:
|
||||
with conn:
|
||||
conn.execute(
|
||||
f"DELETE FROM {self._messages_table()} "
|
||||
f"WHERE {sid_col}=? AND turn_id > ?",
|
||||
(session_id, keep_until_turn_id),
|
||||
)
|
||||
conn.execute(
|
||||
f"DELETE FROM {self._tool_table()} "
|
||||
f"WHERE {sid_col}=? AND turn_id > ?",
|
||||
(session_id, keep_until_turn_id),
|
||||
)
|
||||
if self.scope == "session":
|
||||
row = conn.execute(
|
||||
"SELECT MAX(turn_id) AS m FROM session_messages WHERE session_id=?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
row = self._row_to_dict(row)
|
||||
last = (row or {}).get("m") or ""
|
||||
conn.execute(
|
||||
"UPDATE sessions SET last_turn_id=?, updated_at=? WHERE session_id=?",
|
||||
(last, self._now_iso(), session_id),
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
return 0
|
||||
|
||||
def delete_tool_records(self, session_id: str, keep_turn_ids: set[str] | None = None) -> None:
|
||||
if not session_id:
|
||||
return
|
||||
sid_col = self._sid_col()
|
||||
conn = self._conn()
|
||||
try:
|
||||
with conn:
|
||||
if keep_turn_ids:
|
||||
placeholders = ",".join(["?"] * len(keep_turn_ids))
|
||||
conn.execute(
|
||||
f"DELETE FROM {self._tool_table()} "
|
||||
f"WHERE {sid_col}=? AND turn_id NOT IN ({placeholders})",
|
||||
(session_id, *sorted(keep_turn_ids)),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
f"DELETE FROM {self._tool_table()} WHERE {sid_col}=?", (session_id,)
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def delete_session(self, session_id: str) -> None:
|
||||
if not session_id:
|
||||
return
|
||||
sid_col = self._sid_col()
|
||||
conn = self._conn()
|
||||
try:
|
||||
with conn:
|
||||
conn.execute(
|
||||
f"DELETE FROM {self._messages_table()} WHERE {sid_col}=?", (session_id,)
|
||||
)
|
||||
conn.execute(
|
||||
f"DELETE FROM {self._tool_table()} WHERE {sid_col}=?", (session_id,)
|
||||
)
|
||||
if self.scope == "session":
|
||||
conn.execute("DELETE FROM sessions WHERE session_id=?", (session_id,))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------- JSON 任务仓库 ----------
|
||||
class JsonTaskConfigRepository:
|
||||
"""基于 JSON 文件的任务仓库。
|
||||
@@ -1167,6 +1673,7 @@ def _row_to_task(row: dict[str, Any]) -> CronTask:
|
||||
},
|
||||
"enabled": bool(row.get("enabled", 1)),
|
||||
"timeout_seconds": int(row.get("timeout_seconds", 300) or 300),
|
||||
"workspace": row.get("workspace", "") or "",
|
||||
"created_at": row.get("created_at", "") or "",
|
||||
"next_run_at": row.get("next_run_at", "") or "",
|
||||
"last_run_at": row.get("last_run_at", "") or "",
|
||||
@@ -1186,6 +1693,21 @@ class MysqlTaskConfigRepository:
|
||||
|
||||
def __init__(self) -> None:
|
||||
_ensure_cron_tables()
|
||||
self._ensure_workspace_column()
|
||||
|
||||
def _ensure_workspace_column(self) -> None:
|
||||
"""幂等添加 cron_tasks.workspace 列(存量表迁移)。"""
|
||||
conn = mysql_conn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"ALTER TABLE cron_tasks ADD COLUMN workspace VARCHAR(512) DEFAULT ''"
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass # 列已存在 → 忽略
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def list_tasks(self) -> list[CronTask]:
|
||||
conn = mysql_conn()
|
||||
@@ -1219,21 +1741,22 @@ class MysqlTaskConfigRepository:
|
||||
"""
|
||||
INSERT INTO cron_tasks
|
||||
(task_id, name, prompt, trigger_type, cron_expr, interval_seconds, run_at,
|
||||
enabled, timeout_seconds, created_at, next_run_at, last_run_at,
|
||||
enabled, timeout_seconds, workspace, created_at, next_run_at, last_run_at,
|
||||
last_status, last_error, executions)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name=VALUES(name), prompt=VALUES(prompt), trigger_type=VALUES(trigger_type),
|
||||
cron_expr=VALUES(cron_expr), interval_seconds=VALUES(interval_seconds),
|
||||
run_at=VALUES(run_at), enabled=VALUES(enabled),
|
||||
timeout_seconds=VALUES(timeout_seconds), next_run_at=VALUES(next_run_at),
|
||||
timeout_seconds=VALUES(timeout_seconds), workspace=VALUES(workspace),
|
||||
next_run_at=VALUES(next_run_at),
|
||||
last_run_at=VALUES(last_run_at), last_status=VALUES(last_status),
|
||||
last_error=VALUES(last_error), executions=VALUES(executions)
|
||||
""",
|
||||
(
|
||||
task.task_id, task.name, task.prompt, trig.type, trig.cron,
|
||||
trig.interval_seconds, trig.run_at, int(task.enabled),
|
||||
task.timeout_seconds, task.created_at, task.next_run_at,
|
||||
task.timeout_seconds, task.workspace, task.created_at, task.next_run_at,
|
||||
task.last_run_at, task.last_status, task.last_error, execs_json,
|
||||
),
|
||||
)
|
||||
@@ -1314,6 +1837,152 @@ class MysqlTaskConfigRepository:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------- SQLite 任务仓库 ----------
|
||||
class SqliteTaskConfigRepository:
|
||||
"""基于 SQLite(内置 sqlite3)的任务仓库。
|
||||
|
||||
表: cron_tasks(与 SqliteRecordRepository 共用同一数据库文件)。
|
||||
trigger 拆为 trigger_type/cron_expr/interval_seconds/run_at 列,
|
||||
executions 存为 TEXT 列(JSON)。
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: str | None = None) -> None:
|
||||
self._path_override = db_path
|
||||
ensure_sqlite_tables(self._db_path(), _SQLITE_CRON_TABLE_DDLS)
|
||||
|
||||
def _db_path(self) -> str:
|
||||
if self._path_override:
|
||||
return self._path_override
|
||||
return load_storage_db_config()["path"]
|
||||
|
||||
def _conn(self):
|
||||
return sqlite_conn(self._db_path())
|
||||
|
||||
@staticmethod
|
||||
def _loads(raw: Any) -> Any:
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return raw
|
||||
return raw
|
||||
|
||||
def list_tasks(self) -> list[CronTask]:
|
||||
conn = self._conn()
|
||||
try:
|
||||
rows = conn.execute("SELECT * FROM cron_tasks ORDER BY created_at DESC").fetchall() or []
|
||||
return [_row_to_task(dict(r)) for r in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_task(self, task_id: str) -> CronTask | None:
|
||||
conn = self._conn()
|
||||
try:
|
||||
row = conn.execute("SELECT * FROM cron_tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
return _row_to_task(dict(row)) if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def save_task(self, task: CronTask) -> None:
|
||||
if not task.task_id:
|
||||
return
|
||||
trig = task.trigger
|
||||
execs_json = json.dumps([e.to_dict() for e in task.executions], ensure_ascii=False)
|
||||
conn = self._conn()
|
||||
try:
|
||||
with conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO cron_tasks
|
||||
(task_id, name, prompt, trigger_type, cron_expr, interval_seconds, run_at,
|
||||
enabled, timeout_seconds, workspace, created_at, next_run_at, last_run_at,
|
||||
last_status, last_error, executions)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(task_id) DO UPDATE SET
|
||||
name=excluded.name, prompt=excluded.prompt,
|
||||
trigger_type=excluded.trigger_type, cron_expr=excluded.cron_expr,
|
||||
interval_seconds=excluded.interval_seconds, run_at=excluded.run_at,
|
||||
enabled=excluded.enabled, timeout_seconds=excluded.timeout_seconds,
|
||||
workspace=excluded.workspace, next_run_at=excluded.next_run_at,
|
||||
last_run_at=excluded.last_run_at, last_status=excluded.last_status,
|
||||
last_error=excluded.last_error, executions=excluded.executions
|
||||
""",
|
||||
(
|
||||
task.task_id, task.name, task.prompt, trig.type, trig.cron,
|
||||
trig.interval_seconds, trig.run_at, int(task.enabled),
|
||||
task.timeout_seconds, task.workspace, task.created_at, task.next_run_at,
|
||||
task.last_run_at, task.last_status, task.last_error, execs_json,
|
||||
),
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def delete_task(self, task_id: str) -> None:
|
||||
if not task_id:
|
||||
return
|
||||
conn = self._conn()
|
||||
try:
|
||||
with conn:
|
||||
conn.execute("DELETE FROM cron_tasks WHERE task_id=?", (task_id,))
|
||||
conn.execute("DELETE FROM cron_messages WHERE task_id=?", (task_id,))
|
||||
conn.execute("DELETE FROM cron_tool_calls WHERE task_id=?", (task_id,))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def update_status(
|
||||
self,
|
||||
task_id: str,
|
||||
status: str,
|
||||
*,
|
||||
error: str = "",
|
||||
last_run_at: str = "",
|
||||
next_run_at: str = "",
|
||||
) -> None:
|
||||
sets: list[str] = ["last_status=?"]
|
||||
vals: list[Any] = [status]
|
||||
if error:
|
||||
sets.append("last_error=?")
|
||||
vals.append(error)
|
||||
elif status != "failed":
|
||||
sets.append("last_error=?")
|
||||
vals.append("")
|
||||
if last_run_at:
|
||||
sets.append("last_run_at=?")
|
||||
vals.append(last_run_at)
|
||||
sets.append("next_run_at=?")
|
||||
vals.append(next_run_at)
|
||||
vals.append(task_id)
|
||||
conn = self._conn()
|
||||
try:
|
||||
with conn:
|
||||
conn.execute(
|
||||
f"UPDATE cron_tasks SET {', '.join(sets)} WHERE task_id=?",
|
||||
vals,
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def append_execution(self, task_id: str, exec_log: dict[str, Any]) -> None:
|
||||
conn = self._conn()
|
||||
try:
|
||||
with conn:
|
||||
row = conn.execute(
|
||||
"SELECT executions FROM cron_tasks WHERE task_id=?", (task_id,)
|
||||
).fetchone()
|
||||
execs: list = []
|
||||
if row:
|
||||
raw = self._loads(dict(row).get("executions"))
|
||||
if isinstance(raw, list):
|
||||
execs = raw
|
||||
execs.append(exec_log)
|
||||
conn.execute(
|
||||
"UPDATE cron_tasks SET executions=? WHERE task_id=?",
|
||||
(json.dumps(execs, ensure_ascii=False), task_id),
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------- 工厂 ----------
|
||||
_repo_cache: dict[str, Any] = {}
|
||||
|
||||
@@ -1329,18 +1998,24 @@ def get_record_repository(scope: str | None = None) -> ConversationRecordReposit
|
||||
if key not in _repo_cache:
|
||||
if backend == "mysql":
|
||||
_repo_cache[key] = MysqlRecordRepository(scope)
|
||||
elif backend == "sqlite":
|
||||
_repo_cache[key] = SqliteRecordRepository(scope)
|
||||
else:
|
||||
_repo_cache[key] = JsonRecordRepository(scope)
|
||||
return _repo_cache[key] # type: ignore[return-value]
|
||||
|
||||
|
||||
def get_task_repository() -> TaskConfigRepository:
|
||||
"""获取任务仓库实例(按配置选择 json / mysql)。"""
|
||||
"""获取任务仓库实例(按配置选择 json / mysql / sqlite)。"""
|
||||
backend = get_storage_backend()
|
||||
if backend == "mysql":
|
||||
if "task_mysql" not in _repo_cache:
|
||||
_repo_cache["task_mysql"] = MysqlTaskConfigRepository()
|
||||
return _repo_cache["task_mysql"] # type: ignore[return-value]
|
||||
if backend == "sqlite":
|
||||
if "task_sqlite" not in _repo_cache:
|
||||
_repo_cache["task_sqlite"] = SqliteTaskConfigRepository()
|
||||
return _repo_cache["task_sqlite"] # type: ignore[return-value]
|
||||
if "task_json" not in _repo_cache:
|
||||
_repo_cache["task_json"] = JsonTaskConfigRepository()
|
||||
return _repo_cache["task_json"] # type: ignore[return-value]
|
||||
@@ -1402,11 +2077,12 @@ def list_turn_ids(session_id: str) -> list[str]:
|
||||
|
||||
|
||||
def save_tool_calls(
|
||||
session_id: str, turn_id: str, tool_calls: list[dict], meta: dict | None = None
|
||||
session_id: str, turn_id: str, tool_calls: list[dict], meta: dict | None = None,
|
||||
reflections: list[dict] | None = None,
|
||||
) -> None:
|
||||
"""持久化一轮工具调用记录(meta 全量透传)。"""
|
||||
"""持久化一轮工具调用记录(meta 全量透传;reflections 为尾部思考记录)。"""
|
||||
try:
|
||||
_repo().save_tool_calls(session_id, turn_id, tool_calls, meta)
|
||||
_repo().save_tool_calls(session_id, turn_id, tool_calls, meta, reflections)
|
||||
except Exception as e:
|
||||
print(f"[storage] save_tool_calls 失败: {e}", file=sys.stderr)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
(doc_tool 的 create/update/delete)与终端文件操作(terminal_execute 的
|
||||
working_dir / 命令变更目标)做路径沙箱校验,把"提示词劝导"变为"工具层强制"。
|
||||
|
||||
访问模式 (access_mode,持久化于 workspace_config.json):
|
||||
访问模式 (access_mode,持久化于 config.json 的 workspace 分区):
|
||||
- restricted: 越界直接拦截,返回错误文案给 Agent 自行纠正。
|
||||
- approval: 越界时暂停图,交由前端人工审批(复用 HITL 审批卡片)。
|
||||
- full: 不做越界审查。
|
||||
@@ -37,7 +37,7 @@ from typing import Any, Literal, Optional
|
||||
_ACCESS_MODES: tuple[str, ...] = ("restricted", "approval", "full")
|
||||
_DEFAULT_MODE: str = "restricted"
|
||||
|
||||
_WS_CONFIG_PATH = pathlib.Path(__file__).resolve().parents[1] / "config" / "workspace_config.json"
|
||||
_WS_CONFIG_PATH = pathlib.Path(__file__).resolve().parents[1] / "agents" / "config.json"
|
||||
|
||||
# 无人值守标记环境变量(cron 子进程设置)
|
||||
_HEADLESS_ENV = "AGENT_HEADLESS"
|
||||
@@ -48,21 +48,31 @@ _mode_cache: str | None = None
|
||||
|
||||
|
||||
def _read_ws_config() -> dict:
|
||||
"""读取 workspace_config.json;失败返回空字典。"""
|
||||
"""读取 config.json 的 workspace 分区;失败返回空字典。"""
|
||||
if _WS_CONFIG_PATH.is_file():
|
||||
try:
|
||||
with open(_WS_CONFIG_PATH, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
merged = json.load(f)
|
||||
cfg = merged.get("workspace") if isinstance(merged, dict) else None
|
||||
if isinstance(cfg, dict):
|
||||
return cfg
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def _write_ws_config(data: dict) -> bool:
|
||||
"""写入 workspace_config.json;失败返回 False。"""
|
||||
"""写入 config.json 的 workspace 分区(保留其他分区);失败返回 False。"""
|
||||
try:
|
||||
merged = {}
|
||||
if _WS_CONFIG_PATH.is_file():
|
||||
with open(_WS_CONFIG_PATH, "r", encoding="utf-8") as f:
|
||||
loaded = json.load(f)
|
||||
if isinstance(loaded, dict):
|
||||
merged = loaded
|
||||
merged["workspace"] = data
|
||||
with open(_WS_CONFIG_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
json.dump(merged, f, ensure_ascii=False, indent=2)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
@@ -86,7 +96,7 @@ def get_access_mode() -> str:
|
||||
|
||||
|
||||
def set_access_mode(mode: str) -> bool:
|
||||
"""设置访问模式并持久化到 workspace_config.json。
|
||||
"""设置访问模式并持久化到 config.json 的 workspace 分区。
|
||||
|
||||
输入:
|
||||
mode: restricted | approval | full。
|
||||
|
||||
@@ -109,6 +109,7 @@ class CronTask:
|
||||
trigger: 触发配置。
|
||||
enabled: 是否启用。
|
||||
timeout_seconds: 单次执行超时秒数。
|
||||
workspace: 任务执行时的工作区(文件操作沙箱)目录;空表示使用默认工作区。
|
||||
created_at: 创建时间(ISO 字符串)。
|
||||
next_run_at: 下次预计运行时间(ISO 字符串,空表示不再运行)。
|
||||
last_run_at: 上次运行时间(ISO 字符串)。
|
||||
@@ -123,6 +124,7 @@ class CronTask:
|
||||
trigger: Trigger = field(default_factory=Trigger)
|
||||
enabled: bool = True
|
||||
timeout_seconds: int = 300
|
||||
workspace: str = ""
|
||||
created_at: str = ""
|
||||
next_run_at: str = ""
|
||||
last_run_at: str = ""
|
||||
@@ -138,6 +140,7 @@ class CronTask:
|
||||
"trigger": self.trigger.to_dict(),
|
||||
"enabled": self.enabled,
|
||||
"timeout_seconds": self.timeout_seconds,
|
||||
"workspace": self.workspace,
|
||||
"created_at": self.created_at,
|
||||
"next_run_at": self.next_run_at,
|
||||
"last_run_at": self.last_run_at,
|
||||
@@ -156,6 +159,7 @@ class CronTask:
|
||||
trigger=Trigger.from_dict(data.get("trigger")),
|
||||
enabled=bool(data.get("enabled", True)),
|
||||
timeout_seconds=int(data.get("timeout_seconds", 300) or 300),
|
||||
workspace=data.get("workspace", ""),
|
||||
created_at=data.get("created_at", ""),
|
||||
next_run_at=data.get("next_run_at", ""),
|
||||
last_run_at=data.get("last_run_at", ""),
|
||||
|
||||
@@ -177,11 +177,13 @@ def main() -> int:
|
||||
parser.add_argument("--trigger", default="schedule", choices=["schedule", "manual"],
|
||||
help="触发来源:schedule(调度)| manual(手动/追问)")
|
||||
parser.add_argument("--timeout", type=int, default=300, help="单次执行超时秒数")
|
||||
parser.add_argument("--workspace", default="", help="任务绑定的工作区目录(空则用默认工作区)")
|
||||
args = parser.parse_args()
|
||||
|
||||
task_id = args.task_id
|
||||
prompt = args.prompt
|
||||
timeout = max(int(args.timeout or 300), 10)
|
||||
workspace = str(args.workspace or "").strip()
|
||||
|
||||
started_iso = ""
|
||||
try:
|
||||
@@ -207,6 +209,20 @@ def main() -> int:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 1.2 应用任务绑定的工作区(须在构建 runtime 前:系统提示词在构建时注入)
|
||||
# 与主进程 POST /api/workspace/select 的联动保持一致。
|
||||
if workspace:
|
||||
try:
|
||||
from agent.memory.system_prompt import set_current_workspace
|
||||
set_current_workspace(workspace)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
import agent.utils.env_utils as env_utils
|
||||
env_utils.WORKSPACE_DIR = workspace
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. 构建 AgentRuntime
|
||||
try:
|
||||
from agent.agents.agent_runtime import build_all_agent_runtimes, get_main_agent_runtime
|
||||
@@ -224,6 +240,19 @@ def main() -> int:
|
||||
# 3. 启动 live 转发线程
|
||||
stop_forwarder = _start_live_forwarder(task_id)
|
||||
|
||||
# 3.1 预持久化本轮 user 消息(与 _chat_start_core 对齐):
|
||||
# 崩溃/超时时 user 消息不丢,turn 结构与 chat 完全同构。
|
||||
# turn_id 经 pending 机制交给 execute_agent 复用,避免重复生成。
|
||||
try:
|
||||
from datetime import datetime as _dt
|
||||
from agent.utils.agent_utils import set_pending_turn_id, save_turn_messages
|
||||
_turn_id = _dt.now().strftime("%Y%m%d_%H%M%S")
|
||||
set_pending_turn_id(task_id, _turn_id)
|
||||
_user_item = {"role": "user", "user": {"content": [{"type": "text", "text": prompt}]}}
|
||||
save_turn_messages(task_id, _turn_id, [_user_item])
|
||||
except Exception as e:
|
||||
print(f"[cron.runner] 预持久化 user 消息失败(不阻断执行): {e}", file=sys.stderr)
|
||||
|
||||
# 4. 执行任务(agent_mode="cron",直接走 core.runtime 绕开 {"agent","plan"} 归一化)
|
||||
from agent.core.runtime import execute_agent as execute_runtime_agent
|
||||
from agent.utils.agent_utils import get_last_turn_id
|
||||
|
||||
@@ -406,6 +406,7 @@ class CronScheduler:
|
||||
"--prompt", use_prompt,
|
||||
"--trigger", trigger_source,
|
||||
"--timeout", str(task.timeout_seconds),
|
||||
"--workspace", task.workspace or "",
|
||||
]
|
||||
env = dict(os.environ)
|
||||
# 确保 src 在 PYTHONPATH(与 cwd 配合,-m 已能定位 agent 包)
|
||||
|
||||
@@ -21,7 +21,7 @@ from typing import Any
|
||||
from langchain_core.messages import BaseMessage, AIMessage, HumanMessage, ToolMessage
|
||||
|
||||
from agent.utils.agent_utils import SESSIONS_ROOT
|
||||
from agent.utils.tool_call_utils import normalize_tool_call, format_args_summary, extract_reasoning_text
|
||||
from agent.utils.tool_call_utils import normalize_tool_call, format_args_summary, extract_thought, extract_reflection
|
||||
|
||||
TOOL_CALLING_ROOT = Path(SESSIONS_ROOT)
|
||||
|
||||
@@ -95,6 +95,8 @@ def sub_agent_context(tool_call_id: str | None = None):
|
||||
_sub_live_records.pop(key, None)
|
||||
with _sub_pending_thinking_lock:
|
||||
_sub_pending_thinking.pop(key, None)
|
||||
with _sub_pending_reflection_lock:
|
||||
_sub_pending_reflection.pop(key, None)
|
||||
yield
|
||||
finally:
|
||||
_sub_agent_context.reset(token)
|
||||
@@ -109,10 +111,12 @@ _live_records: dict[str, list[dict[str, Any]]] = {}
|
||||
_sub_live_records: dict[tuple[str, str], list[dict[str, Any]]] = {}
|
||||
_sub_live_lock = threading.Lock()
|
||||
|
||||
# 子 Agent 待附加 thinking(与主 Agent 的 _pending_thinking 隔离),
|
||||
# 子 Agent 待附加 thinking / reflection(与主 Agent 的 pending 隔离),
|
||||
# key 与 _sub_live_records 一致:(session_id, tool_call_id)。
|
||||
_sub_pending_thinking: dict[tuple[str, str], str] = {}
|
||||
_sub_pending_thinking_lock = threading.Lock()
|
||||
_sub_pending_reflection: dict[tuple[str, str], str] = {}
|
||||
_sub_pending_reflection_lock = threading.Lock()
|
||||
|
||||
# 轮次开始时间(key = session_id),用于计时
|
||||
_turn_start_times: dict[str, float] = {} # key = session_id
|
||||
@@ -200,15 +204,22 @@ def clear_session_tokens(session_id: str) -> None:
|
||||
_session_token_breakdowns.pop(session_id, None)
|
||||
from agent.history import session_storage
|
||||
existing = session_storage.load_session_extra(session_id) or {}
|
||||
if not existing:
|
||||
# 会话存储已不存在(如会话已被删除):清内存即可,禁止回写——
|
||||
# save_session_extra 会 mkdir/建行,把刚删除的会话目录重建出来
|
||||
return
|
||||
existing["tokens"] = 0
|
||||
existing.pop("token_breakdown", None)
|
||||
session_storage.save_session_extra(session_id, existing)
|
||||
|
||||
# ---------- 待附加的 thinking ----------
|
||||
# 当 record_reflection_live 被调用时,thinking 暂存于此;
|
||||
# 随后的 record_tool_call_live 会将其附加到工具调用记录中。
|
||||
# ---------- 待附加的 thinking / reflection ----------
|
||||
# 当 record_reflection_live 被调用时,思考(thought)与反思(reflection)
|
||||
# 分别暂存于此;随后的 record_tool_call_live 会将其附加到工具调用记录中
|
||||
# (思考 = reasoning_content,反思 = content,二者独立存储)。
|
||||
_pending_thinking: dict[str, str] = {}
|
||||
_pending_thinking_lock = threading.Lock()
|
||||
_pending_reflection: dict[str, str] = {}
|
||||
_pending_reflection_lock = threading.Lock()
|
||||
|
||||
|
||||
def set_current_turn(turn_id: str) -> None:
|
||||
@@ -256,11 +267,13 @@ def init_live_turn_with_id(session_id: str, turn_id: str) -> str:
|
||||
_active_sessions.add(session_id)
|
||||
with _pending_thinking_lock:
|
||||
_pending_thinking.pop(session_id, None)
|
||||
with _pending_reflection_lock:
|
||||
_pending_reflection.pop(session_id, None)
|
||||
return turn_id
|
||||
|
||||
|
||||
def record_tool_call_live(session_id: str, name: str, args: dict[str, Any], tool_call_id: str | None = None) -> None:
|
||||
"""图节点中调用:记录工具调用开始。同时消费 pending thinking。
|
||||
"""图节点中调用:记录工具调用开始。同时消费 pending thinking/reflection。
|
||||
|
||||
输入:
|
||||
session_id: 会话 ID。
|
||||
@@ -278,6 +291,8 @@ def record_tool_call_live(session_id: str, name: str, args: dict[str, Any], tool
|
||||
key = _sub_key(session_id)
|
||||
with _sub_pending_thinking_lock:
|
||||
thinking = _sub_pending_thinking.pop(key, None)
|
||||
with _sub_pending_reflection_lock:
|
||||
reflection = _sub_pending_reflection.pop(key, None)
|
||||
with _sub_live_lock:
|
||||
if key not in _sub_live_records:
|
||||
_sub_live_records[key] = []
|
||||
@@ -291,11 +306,15 @@ def record_tool_call_live(session_id: str, name: str, args: dict[str, Any], tool
|
||||
}
|
||||
if thinking:
|
||||
entry["thinking"] = thinking
|
||||
if reflection:
|
||||
entry["reflection"] = reflection
|
||||
_sub_live_records[key].append(entry)
|
||||
_notify_live(session_id)
|
||||
return
|
||||
with _pending_thinking_lock:
|
||||
thinking = _pending_thinking.pop(session_id, None)
|
||||
with _pending_reflection_lock:
|
||||
reflection = _pending_reflection.pop(session_id, None)
|
||||
with _live_lock:
|
||||
if session_id not in _live_records:
|
||||
_live_records[session_id] = []
|
||||
@@ -309,6 +328,8 @@ def record_tool_call_live(session_id: str, name: str, args: dict[str, Any], tool
|
||||
}
|
||||
if thinking:
|
||||
entry["thinking"] = thinking
|
||||
if reflection:
|
||||
entry["reflection"] = reflection
|
||||
_live_records[session_id].append(entry)
|
||||
_notify_live(session_id)
|
||||
|
||||
@@ -509,15 +530,25 @@ def _artifact_file_infos(artifact: Any | None) -> list[dict[str, Any]] | None:
|
||||
|
||||
|
||||
def end_live_turn(session_id: str) -> list[dict[str, Any]]:
|
||||
"""结束实时记录并返回该 turn 所有记录(同时清空反思与 pending thinking)。"""
|
||||
"""结束实时记录并返回该 turn 所有记录(同时清空反思与 pending thinking)。
|
||||
|
||||
清空反思前先把本轮全部思考暂存(_persisted_reflections),
|
||||
供随后 save_turn 持久化为 turn 记录的 reflections 字段(尾部思考注入用)。
|
||||
"""
|
||||
_stash_turn_reflections(session_id)
|
||||
set_current_turn("")
|
||||
clear_live_reflections(session_id)
|
||||
with _pending_thinking_lock:
|
||||
_pending_thinking.pop(session_id, None)
|
||||
with _pending_reflection_lock:
|
||||
_pending_reflection.pop(session_id, None)
|
||||
with _sub_pending_thinking_lock:
|
||||
# 清理该会话下所有子 Agent 隔离槽(并行子 Agent 按 (session, tc_id) 分槽)
|
||||
for k in [k for k in _sub_pending_thinking if k[0] == session_id]:
|
||||
_sub_pending_thinking.pop(k, None)
|
||||
with _sub_pending_reflection_lock:
|
||||
for k in [k for k in _sub_pending_reflection if k[0] == session_id]:
|
||||
_sub_pending_reflection.pop(k, None)
|
||||
with _sub_live_lock:
|
||||
for k in [k for k in _sub_live_records if k[0] == session_id]:
|
||||
_sub_live_records.pop(k, None)
|
||||
@@ -603,6 +634,7 @@ def save_aborted_turn(session_id: str, turn_id: str) -> None:
|
||||
"status": r.get("status", "done" if r.get("result_text") else "running"),
|
||||
"result_text": r.get("result_text", ""),
|
||||
"thinking": r.get("thinking", ""),
|
||||
"reflection": r.get("reflection", ""),
|
||||
"result_images": r.get("result_images", []),
|
||||
"result_audio": r.get("result_audio"),
|
||||
"result_file_info": r.get("result_file_info"),
|
||||
@@ -700,44 +732,81 @@ def get_turn_started_at(session_id: str) -> float | None:
|
||||
return None
|
||||
|
||||
|
||||
# ---------- 反思内容共享存储 ----------
|
||||
# Agent 反思(think)内容通过此 store 实时推送到前端展示
|
||||
# 反思内容共享存储
|
||||
_think_store: dict[str, list[dict[str, Any]]] = {}
|
||||
_think_lock = threading.Lock()
|
||||
|
||||
# 轮次结束暂存:end_live_turn 清空 _think_store 前把尾部思考暂存,
|
||||
# 供随后执行的 save_turn 持久化(save_turn 在 end_live_turn 之后调用)
|
||||
_persisted_reflections: dict[str, list[dict[str, Any]]] = {}
|
||||
_persisted_reflections_lock = threading.Lock()
|
||||
|
||||
def record_reflection_live(session_id: str, content: str) -> None:
|
||||
"""记录一次反思内容到共享存储,同时设为 pending thinking 供后续工具调用附加。
|
||||
|
||||
def _stash_turn_reflections(session_id: str) -> None:
|
||||
"""把当前轮次全部思考记录暂存(end_live_turn 清空内存前调用)。
|
||||
|
||||
无思考时清除旧暂存(防止上一轮 aborted 残留被下一轮 save_turn 误持久化)。
|
||||
"""
|
||||
with _think_lock:
|
||||
refs = list(_think_store.get(session_id, []))
|
||||
with _persisted_reflections_lock:
|
||||
if refs:
|
||||
_persisted_reflections[session_id] = refs
|
||||
else:
|
||||
_persisted_reflections.pop(session_id, None)
|
||||
|
||||
|
||||
def _take_stashed_reflections(session_id: str) -> list[dict[str, Any]]:
|
||||
"""取出并清除暂存的思考记录(save_turn 持久化时调用)。"""
|
||||
with _persisted_reflections_lock:
|
||||
return _persisted_reflections.pop(session_id, [])
|
||||
|
||||
|
||||
def record_reflection_live(session_id: str, thought: str = "", reflection: str = "") -> None:
|
||||
"""记录一次思考/反思到共享存储,同时设为 pending 供后续工具调用附加。
|
||||
|
||||
输入:
|
||||
session_id: 会话 ID。
|
||||
content: 反思提示文本。
|
||||
thought: 深度思考文本(reasoning_content;思考开启档位)。
|
||||
reflection: 反思文本(content;思考关闭档位为引导的一句话反思)。
|
||||
二者可同时存在(既有思考又有反思的通用情况),独立存储。
|
||||
|
||||
输出: 无。
|
||||
"""
|
||||
if not session_id or not content:
|
||||
if not session_id or not (thought or reflection):
|
||||
return
|
||||
# 子 Agent 上下文:thinking 存入 _sub_pending_thinking(按 (session, tc_id) 隔离),
|
||||
# 子 Agent 上下文:思考/反思存入 _sub_pending_*(按 (session, tc_id) 隔离),
|
||||
# 由 record_tool_call_live 消费后附加到子工具调用条目。
|
||||
if is_sub_agent_context():
|
||||
with _sub_pending_thinking_lock:
|
||||
_sub_pending_thinking[_sub_key(session_id)] = content
|
||||
if thought:
|
||||
with _sub_pending_thinking_lock:
|
||||
_sub_pending_thinking[_sub_key(session_id)] = thought
|
||||
if reflection:
|
||||
with _sub_pending_reflection_lock:
|
||||
_sub_pending_reflection[_sub_key(session_id)] = reflection
|
||||
# 不添加到 _think_store(子 Agent 思考在主 Agent 思考面板中不独立显示,
|
||||
# 而是嵌套在 call_subagent 展开的各个子工具调用条目的 thinking 字段中)
|
||||
return
|
||||
# 设为 pending thinking:下一个 record_tool_call_live 会消费它
|
||||
with _pending_thinking_lock:
|
||||
_pending_thinking[session_id] = content
|
||||
# 设为 pending:下一个 record_tool_call_live 会消费它
|
||||
if thought:
|
||||
with _pending_thinking_lock:
|
||||
_pending_thinking[session_id] = thought
|
||||
if reflection:
|
||||
with _pending_reflection_lock:
|
||||
_pending_reflection[session_id] = reflection
|
||||
with _think_lock:
|
||||
if session_id not in _think_store:
|
||||
_think_store[session_id] = []
|
||||
# 避免重复记录相同内容
|
||||
# 避免重复记录相同内容(思考与反思分别去重)
|
||||
merged = (thought or reflection) or ""
|
||||
for entry in _think_store[session_id]:
|
||||
if entry["content"] == content:
|
||||
if entry.get("content") == merged:
|
||||
return
|
||||
entry = {
|
||||
"content": content,
|
||||
"summary": content[:60].replace("\n", " "),
|
||||
"content": merged, # 前端展示用(思考优先,无思考用反思)
|
||||
"thought": thought, # 深度思考(独立存储)
|
||||
"reflection": reflection, # 反思(独立存储)
|
||||
"summary": merged[:60].replace("\n", " "),
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
_think_store[session_id].append(entry)
|
||||
@@ -773,10 +842,12 @@ def extract_tool_calls_from_messages(messages: list[BaseMessage]) -> list[dict[s
|
||||
name_counter: dict[str, int] = {}
|
||||
for msg in messages:
|
||||
if isinstance(msg, AIMessage) and msg.tool_calls:
|
||||
# 提取该 AIMessage 的 thinking,仅附加到第一个 tool_call
|
||||
# 提取规则见 extract_reasoning_text:开启思考取深度思考(reasoning_content/
|
||||
# 内联 <think> 块),关闭思考取 content 中的一句话反思
|
||||
thinking = extract_reasoning_text(msg)
|
||||
# 提取该 AIMessage 的思考与反思,仅附加到第一个 tool_call:
|
||||
# thinking = reasoning_content(深度思考)
|
||||
# reflection = content(反思)
|
||||
# 二者独立存储,可同时存在;仅在带 tool_calls 的消息上提取
|
||||
thinking = extract_thought(msg)
|
||||
reflection = extract_reflection(msg)
|
||||
first = True
|
||||
for tc in msg.tool_calls:
|
||||
d = normalize_tool_call(tc)
|
||||
@@ -790,8 +861,11 @@ def extract_tool_calls_from_messages(messages: list[BaseMessage]) -> list[dict[s
|
||||
"result_files": [],
|
||||
"result_images": [],
|
||||
}
|
||||
if thinking and first:
|
||||
entry["thinking"] = thinking
|
||||
if first:
|
||||
if thinking:
|
||||
entry["thinking"] = thinking
|
||||
if reflection:
|
||||
entry["reflection"] = reflection
|
||||
first = False
|
||||
pending.append(entry)
|
||||
elif isinstance(msg, ToolMessage):
|
||||
@@ -807,10 +881,10 @@ def extract_tool_calls_from_messages(messages: list[BaseMessage]) -> list[dict[s
|
||||
result_files, bytes_map = _save_artifact_files(artifact, unique_name)
|
||||
entry["result_files"] = result_files
|
||||
entry["_all_bytes"] = bytes_map # 供 save_turn 落盘(图片 + 文件)
|
||||
# 提取图片 data URL(供前端直接展示)
|
||||
images = _artifact_image_data_urls(artifact)
|
||||
if images:
|
||||
entry["result_images"] = images
|
||||
# 图片不内嵌 base64(避免 tool json 膨胀数 MB):
|
||||
# PNG 统一落盘为 _artifact_*.png,回放时由
|
||||
# _tool_calls_from_turn_record 按 result_files 生成 /api/tool-image URL
|
||||
# 实时展示仍走 live 记录中的 data URL(record_tool_result_live)
|
||||
# 提取音频信息(不含字节,仅路径元数据)
|
||||
audio_info = _artifact_audio_info(artifact)
|
||||
if audio_info:
|
||||
@@ -946,7 +1020,10 @@ def save_turn(session_id: str, turn_id: str, user_message: dict[str, Any],
|
||||
"ended_at": ended_at,
|
||||
"duration": (ended_at - started_at) if started_at else None,
|
||||
}
|
||||
session_storage.save_tool_calls(session_id, turn_id, tool_calls, meta=_meta)
|
||||
# 尾部思考(未被任何工具调用消费的反思):end_live_turn 暂存 → 此处持久化,
|
||||
# 供后续轮次 build_tool_history_messages 注入上下文(历史思考)。
|
||||
_reflections = _take_stashed_reflections(session_id)
|
||||
session_storage.save_tool_calls(session_id, turn_id, tool_calls, meta=_meta, reflections=_reflections)
|
||||
return None
|
||||
|
||||
|
||||
@@ -954,7 +1031,7 @@ def _merge_extracted_with_live(
|
||||
extracted: list[dict[str, Any]],
|
||||
live_calls: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""将 extracted 的 artifact 数据合并到 live 记录上(保留 live 的 thinking/args_summary)。
|
||||
"""将 extracted 的 artifact 数据合并到 live 记录上(live 的 thinking/reflection 优先)。
|
||||
|
||||
匹配策略:按索引位置匹配(同一轮次中工具调用顺序应一致)。
|
||||
如果数量不一致,回退到按 name+顺序 匹配。
|
||||
@@ -979,10 +1056,14 @@ def _merge_extracted_with_live(
|
||||
candidates = live_by_name.get(name, [])
|
||||
lc = candidates[idx] if idx < len(candidates) else {}
|
||||
merged = dict(ext)
|
||||
# 从 live 记录补充 thinking / args_summary(单条工具计时已移除,不再合并 timestamp/ended_at)
|
||||
for field in ("thinking", "args_summary"):
|
||||
if lc.get(field) is not None and not merged.get(field):
|
||||
# 思考/反思以 live 记录为准(调用时刻原样记录;extracted 为消息解析值,
|
||||
# 压缩/裁剪后可能缺失或不完整);args_summary 仅在 extracted 缺失时补充
|
||||
# (单条工具计时已移除,不再合并 timestamp/ended_at)
|
||||
for field in ("thinking", "reflection"):
|
||||
if lc.get(field):
|
||||
merged[field] = lc[field]
|
||||
if not merged.get("args_summary") and lc.get("args_summary") is not None:
|
||||
merged["args_summary"] = lc["args_summary"]
|
||||
result.append(merged)
|
||||
return result
|
||||
|
||||
@@ -1113,10 +1194,14 @@ def _tool_calls_from_turn_record(record: dict[str, Any], session_id: str) -> lis
|
||||
image_files.append(f"/api/tool-image?path={fp}&session={turn_id}")
|
||||
else:
|
||||
other_files.append(f"/api/tool-file?path={fp}&session={turn_id}")
|
||||
# 保留 live 记录中已有的 result_images(data URLs 来自实时录制),
|
||||
# 仅在无 data URL 时才用磁盘文件 URL 回退,避免覆盖导致前端无法渲染
|
||||
# result_images:落盘记录不再内嵌 base64(体积原因),
|
||||
# 统一按 result_files 生成 /api/tool-image URL;
|
||||
# 若历史数据仍带 data URL(旧版记录)则保留以兼容
|
||||
existing_images = entry.get("result_images")
|
||||
if (not existing_images or not isinstance(existing_images, list) or len(existing_images) == 0):
|
||||
has_data_url = any(
|
||||
isinstance(u, str) and u.startswith("data:") for u in (existing_images or [])
|
||||
)
|
||||
if not has_data_url:
|
||||
entry["result_images"] = image_files
|
||||
if other_files:
|
||||
entry["result_download_files"] = other_files
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"hours": {"2026-08-12T22": {"total": 41613, "by_model": {"Qwen3.5-122B-A10B": 41613}}, "2026-08-14T09": {"total": 535476, "by_model": {"Qwen3.5-122B-A10B": 535476}}, "2026-08-14T10": {"total": 91339, "by_model": {"Qwen3.5-122B-A10B": 91339}}, "2026-08-14T11": {"total": 67389, "by_model": {"Qwen3.5-122B-A10B": 67389}}}}
|
||||
{"hours": {"2026-08-12T22": {"total": 41613, "by_model": {"Qwen3.5-122B-A10B": 41613}}, "2026-08-14T09": {"total": 535476, "by_model": {"Qwen3.5-122B-A10B": 535476}}, "2026-08-14T10": {"total": 91339, "by_model": {"Qwen3.5-122B-A10B": 91339}}, "2026-08-14T11": {"total": 67389, "by_model": {"Qwen3.5-122B-A10B": 67389}}, "2026-08-16T19": {"total": 558037, "by_model": {"Qwen3.5-122B-A10B": 558037}}, "2026-08-16T20": {"total": 212587, "by_model": {"Qwen3.5-122B-A10B": 212587}}, "2026-08-16T22": {"total": 5894, "by_model": {"Qwen3.5-122B-A10B": 5894}}, "2026-08-17T07": {"total": 100546, "by_model": {"Qwen3.5-122B-A10B": 100546}}, "2026-08-17T08": {"total": 135785, "by_model": {"Qwen3.5-122B-A10B": 135785}}, "2026-08-17T17": {"total": 5753, "by_model": {"Qwen3.5-122B-A10B": 5753}}, "2026-08-18T14": {"total": 27472, "by_model": {"Qwen3.5-122B-A10B": 27472}}, "2026-08-18T15": {"total": 45565, "by_model": {"Qwen3.5-122B-A10B": 45565}}, "2026-08-19T08": {"total": 6141, "by_model": {"Qwen3.5-122B-A10B": 6141}}, "2026-08-19T11": {"total": 13137, "by_model": {"Qwen3.5-122B-A10B": 13137}}, "2026-08-19T12": {"total": 53109, "by_model": {"Qwen3.5-122B-A10B": 53109}}}}
|
||||
@@ -1,6 +1,7 @@
|
||||
from .web_search import WebSearch
|
||||
from .date_query import DateQuery
|
||||
from .browser_control import BrowserControl
|
||||
from .web_page import WebPage
|
||||
from .human_interaction import HumanInteraction
|
||||
from .directory_listing import DirectoryListing
|
||||
from .todo_list import TodoList
|
||||
@@ -23,6 +24,7 @@ _all_tools = [
|
||||
WebSearch(),
|
||||
DateQuery(),
|
||||
BrowserControl(),
|
||||
WebPage(),
|
||||
HumanInteraction(),
|
||||
SoftwareControl(),
|
||||
RAGTool(),
|
||||
|
||||
@@ -1,187 +1,588 @@
|
||||
"""浏览器控制工具与 Playwright 会话管理器。
|
||||
|
||||
本模块包含两部分:
|
||||
1. BrowserSession —— Playwright 会话管理器(非工具,供本工具与 web_page 共用)
|
||||
2. BrowserControl —— 浏览器控制 LangChain 工具(开/关浏览器、标签页管理)
|
||||
|
||||
会话架构(分离进程 + CDP 连接):
|
||||
- 浏览器以独立进程启动(--remote-debugging-port),Agent 退出后窗口保留
|
||||
- Agent 重启后通过 CDP 自动重连同一浏览器(登录态、标签页不丢)
|
||||
- 专用后台线程运行 asyncio loop,所有 Playwright 异步操作在该线程串行执行,
|
||||
规避 sync API 的线程亲和问题
|
||||
|
||||
可扩展性:
|
||||
- 端口与 profile 目录取环境变量 BROWSER_DEBUG_PORT / BROWSER_PROFILE_DIR 覆盖
|
||||
- 新的页面级操作经 BrowserSession.call_async 提交协程即可,无需改动线程模型
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import sys
|
||||
import time
|
||||
import webbrowser
|
||||
import asyncio
|
||||
from typing import Optional, Literal, Tuple
|
||||
import atexit
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
from concurrent.futures import TimeoutError as _FutureTimeoutError
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Literal, Optional, Tuple
|
||||
|
||||
import pyautogui
|
||||
from PIL import Image
|
||||
from langchain.tools import BaseTool
|
||||
from pydantic import BaseModel, Field, create_model
|
||||
|
||||
import agent.utils.env_utils as env_utils
|
||||
|
||||
# ==================== 第一部分:BrowserSession 会话管理器 ====================
|
||||
|
||||
# ---------- 配置常量 ----------
|
||||
_AGENT_DIR = Path(__file__).resolve().parents[1]
|
||||
BROWSER_DEBUG_PORT = int(os.getenv("BROWSER_DEBUG_PORT", "9222"))
|
||||
BROWSER_PROFILE_DIR = str(Path(os.getenv("BROWSER_PROFILE_DIR", str(_AGENT_DIR / "data" / "browser_profile"))))
|
||||
_CDP_URL = f"http://127.0.0.1:{BROWSER_DEBUG_PORT}"
|
||||
_LAUNCH_TIMEOUT = 15.0 # 分离启动后等待 CDP 就绪的秒数
|
||||
_DEFAULT_OP_TIMEOUT = 45.0 # 单次操作的默认外层超时
|
||||
|
||||
# Windows 分离进程标志:DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_BREAKAWAY_FROM_JOB
|
||||
# 使浏览器进程独立于 Agent 进程存活(Agent 退出后浏览器窗口保留)
|
||||
_DETACHED_FLAGS = 0x00000008 | 0x00000200 | 0x01000000
|
||||
|
||||
|
||||
_SCHEME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.\-]*:")
|
||||
|
||||
|
||||
def normalize_url(url: str) -> str:
|
||||
"""补全 URL 协议头:无协议时默认 https://(about:blank、edge:// 等已有 scheme 的不处理)。"""
|
||||
u = (url or "").strip()
|
||||
if u and "://" not in u and not _SCHEME_RE.match(u):
|
||||
u = "https://" + u
|
||||
return u
|
||||
|
||||
|
||||
def _candidate_paths(browser: Optional[str]) -> list[str]:
|
||||
"""按优先级返回浏览器可执行文件候选路径列表。"""
|
||||
b = (browser or "edge").lower().strip()
|
||||
if b == "chrome":
|
||||
order = ["chrome", "edge", "chromium"]
|
||||
elif b in ("chromium", "playwright"):
|
||||
order = ["chromium", "edge", "chrome"]
|
||||
else: # 默认 edge
|
||||
order = ["edge", "chrome", "chromium"]
|
||||
pf64 = os.getenv("ProgramFiles", r"C:\Program Files")
|
||||
pf86 = os.getenv("ProgramFiles(x86)", r"C:\Program Files (x86)")
|
||||
local = os.getenv("LOCALAPPDATA", "")
|
||||
known: dict[str, list[str]] = {
|
||||
"edge": [
|
||||
os.path.join(pf86, "Microsoft", "Edge", "Application", "msedge.exe"),
|
||||
os.path.join(pf64, "Microsoft", "Edge", "Application", "msedge.exe"),
|
||||
],
|
||||
"chrome": [
|
||||
os.path.join(pf64, "Google", "Chrome", "Application", "chrome.exe"),
|
||||
os.path.join(pf86, "Google", "Chrome", "Application", "chrome.exe"),
|
||||
os.path.join(local, "Google", "Chrome", "Application", "chrome.exe") if local else "",
|
||||
],
|
||||
"chromium": sorted(
|
||||
glob.glob(os.path.join(local, "ms-playwright", "chromium-*", "chrome-win", "chrome.exe")),
|
||||
reverse=True,
|
||||
) if local else [],
|
||||
}
|
||||
out: list[str] = []
|
||||
for name in order:
|
||||
out.extend(p for p in known.get(name, []) if p)
|
||||
return out
|
||||
|
||||
|
||||
def _resolve_executable(browser: Optional[str]) -> str:
|
||||
"""解析第一个存在的浏览器可执行文件路径,找不到时抛出明确错误。"""
|
||||
for p in _candidate_paths(browser):
|
||||
if os.path.isfile(p):
|
||||
return p
|
||||
raise RuntimeError(
|
||||
"未找到可用的浏览器(Edge / Chrome / Playwright 内置 Chromium)。"
|
||||
"请安装 Edge/Chrome,或运行 `playwright install chromium`。"
|
||||
)
|
||||
|
||||
|
||||
def _cdp_alive() -> Optional[dict]:
|
||||
"""探测 CDP 调试端口是否存活,存活返回 /json/version 内容。"""
|
||||
try:
|
||||
with urllib.request.urlopen(f"{_CDP_URL}/json/version", timeout=2) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
return data if isinstance(data, dict) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
class BrowserSession:
|
||||
"""浏览器会话单例:持有 CDP 连接与当前标签页,对外提供同步门面方法。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._thread_lock = threading.Lock()
|
||||
# 以下状态仅由会话线程读写
|
||||
self._playwright = None
|
||||
self._browser = None # connect_over_cdp 得到的 Browser
|
||||
self._context = None # 默认 BrowserContext(持久 profile)
|
||||
self._page = None # 当前活动标签页
|
||||
self._proc: Optional[subprocess.Popen] = None
|
||||
self._watched: set[int] = set() # 已注册 close 监听的 page id
|
||||
# ref 注册表(snapshot 序号 -> CSS 选择器)
|
||||
self._refs: dict[int, str] = {}
|
||||
self._refs_page = None
|
||||
self._launch_browser_pref: Optional[str] = None
|
||||
atexit.register(self._atexit_shutdown)
|
||||
|
||||
# ---------- 线程模型 ----------
|
||||
def _ensure_thread(self) -> None:
|
||||
"""惰性启动会话线程(daemon,随进程退出)。
|
||||
|
||||
loop 在调用线程创建后再交给线程运行,避免 _loop 赋值竞态。
|
||||
"""
|
||||
if self._thread and self._thread.is_alive():
|
||||
return
|
||||
with self._thread_lock:
|
||||
if self._thread and self._thread.is_alive():
|
||||
return
|
||||
loop = asyncio.new_event_loop()
|
||||
self._loop = loop
|
||||
self._thread = threading.Thread(target=self._thread_main, args=(loop,), name="browser-session", daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def _thread_main(self, loop: asyncio.AbstractEventLoop) -> None:
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
loop.run_forever()
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
def call_async(self, coro_fn: Callable[["BrowserSession"], Any], timeout: float = _DEFAULT_OP_TIMEOUT) -> Any:
|
||||
"""提交协程到会话线程执行并阻塞等待结果。
|
||||
|
||||
coro_fn: 接收 session 的函数,返回 coroutine(在会话线程的事件循环中运行)。
|
||||
"""
|
||||
self._ensure_thread()
|
||||
assert self._loop is not None
|
||||
fut = asyncio.run_coroutine_threadsafe(coro_fn(self), self._loop)
|
||||
try:
|
||||
return fut.result(timeout=timeout)
|
||||
except _FutureTimeoutError:
|
||||
fut.cancel()
|
||||
raise TimeoutError(f"浏览器操作超时(>{timeout:.0f}s)") from None
|
||||
|
||||
# ---------- 内部异步实现(全部在会话线程运行) ----------
|
||||
async def _ensure_connected(self) -> None:
|
||||
"""确保 CDP 连接与当前页可用;浏览器未运行时分离启动。"""
|
||||
if self._browser is not None and self._browser.is_connected() and self._context is not None:
|
||||
await self._ensure_current_page()
|
||||
return
|
||||
await self._cleanup_connection()
|
||||
if _cdp_alive() is None:
|
||||
await self._launch_detached()
|
||||
try:
|
||||
from playwright.async_api import async_playwright
|
||||
except ImportError as e:
|
||||
raise RuntimeError("playwright 未安装,请先 `pip install playwright`") from e
|
||||
self._playwright = await async_playwright().start()
|
||||
self._browser = await self._playwright.chromium.connect_over_cdp(_CDP_URL)
|
||||
contexts = self._browser.contexts
|
||||
self._context = contexts[0] if contexts else await self._browser.new_context()
|
||||
try:
|
||||
self._context.set_default_timeout(10_000) # 动作超时 10s
|
||||
self._context.set_default_navigation_timeout(30_000) # 导航超时 30s
|
||||
except Exception:
|
||||
pass
|
||||
await self._ensure_current_page()
|
||||
|
||||
async def _ensure_current_page(self) -> None:
|
||||
"""确保 self._page 指向一个存活标签页(优先沿用当前页,否则取最后一页/新建)。"""
|
||||
pages = list(self._context.pages)
|
||||
if self._page in pages:
|
||||
page = self._page
|
||||
elif pages:
|
||||
page = pages[-1]
|
||||
else:
|
||||
page = await self._context.new_page()
|
||||
self._page = page
|
||||
self._watch_page(page)
|
||||
try:
|
||||
await page.bring_to_front()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _watch_page(self, page) -> None:
|
||||
"""为标签页注册 close 监听(幂等),关闭后自动回退当前页并清理 ref。"""
|
||||
if id(page) in self._watched:
|
||||
return
|
||||
self._watched.add(id(page))
|
||||
|
||||
def _on_close(p=page):
|
||||
if self._page is p:
|
||||
self._page = None
|
||||
if self._refs_page is p:
|
||||
self._refs.clear()
|
||||
self._refs_page = None
|
||||
|
||||
try:
|
||||
page.on("close", _on_close)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _launch_detached(self) -> None:
|
||||
"""以分离进程启动浏览器并等待 CDP 就绪。"""
|
||||
exe = _resolve_executable(self._launch_browser_pref)
|
||||
Path(BROWSER_PROFILE_DIR).mkdir(parents=True, exist_ok=True)
|
||||
cmd = [
|
||||
exe,
|
||||
f"--remote-debugging-port={BROWSER_DEBUG_PORT}",
|
||||
f"--user-data-dir={BROWSER_PROFILE_DIR}",
|
||||
"--start-maximized",
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--hide-crash-restore-bubble",
|
||||
"about:blank",
|
||||
]
|
||||
kwargs: dict[str, Any] = {}
|
||||
if sys.platform == "win32":
|
||||
kwargs["creationflags"] = _DETACHED_FLAGS
|
||||
else:
|
||||
kwargs["start_new_session"] = True
|
||||
self._proc = subprocess.Popen(
|
||||
cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, close_fds=True, **kwargs
|
||||
)
|
||||
deadline = time.monotonic() + _LAUNCH_TIMEOUT
|
||||
while time.monotonic() < deadline:
|
||||
if _cdp_alive() is not None:
|
||||
return
|
||||
if self._proc.poll() is not None:
|
||||
break
|
||||
await asyncio.sleep(0.3)
|
||||
raise RuntimeError(
|
||||
"浏览器启动失败:CDP 调试端口未就绪"
|
||||
f"(端口 {BROWSER_DEBUG_PORT},profile 可能被其他浏览器实例占用,可关闭残留浏览器进程后重试)"
|
||||
)
|
||||
|
||||
async def _cleanup_connection(self) -> None:
|
||||
"""断开 CDP 连接(不关闭浏览器进程)。"""
|
||||
self._page = None
|
||||
self._context = None
|
||||
self._refs.clear()
|
||||
self._refs_page = None
|
||||
if self._browser is not None:
|
||||
try:
|
||||
await self._browser.close() # CDP 模式下仅断开连接
|
||||
except Exception:
|
||||
pass
|
||||
self._browser = None
|
||||
if self._playwright is not None:
|
||||
try:
|
||||
await self._playwright.stop()
|
||||
except Exception:
|
||||
pass
|
||||
self._playwright = None
|
||||
|
||||
# ---------- 页面信息 ----------
|
||||
async def _page_meta(self) -> dict[str, Any]:
|
||||
page = self._page
|
||||
if page is None:
|
||||
return {"title": "", "url": ""}
|
||||
try:
|
||||
title = await page.title()
|
||||
except Exception:
|
||||
title = ""
|
||||
return {"title": title, "url": page.url}
|
||||
|
||||
# ---------- 会话级异步操作(供 call_async 调用) ----------
|
||||
async def ensure(self, browser: Optional[str] = None) -> dict[str, Any]:
|
||||
"""确保浏览器就绪并返回当前页信息(不新建标签页)。"""
|
||||
if browser:
|
||||
self._launch_browser_pref = browser
|
||||
await self._ensure_connected()
|
||||
return await self._page_meta()
|
||||
|
||||
async def open_tab(self, url: Optional[str] = None, browser: Optional[str] = None) -> dict[str, Any]:
|
||||
"""新建标签页并可选导航到 url。"""
|
||||
if browser:
|
||||
self._launch_browser_pref = browser
|
||||
await self._ensure_connected()
|
||||
page = await self._context.new_page()
|
||||
self._page = page
|
||||
self._watch_page(page)
|
||||
try:
|
||||
await page.bring_to_front()
|
||||
except Exception:
|
||||
pass
|
||||
if url:
|
||||
await page.goto(normalize_url(url), wait_until="domcontentloaded")
|
||||
return await self._page_meta()
|
||||
|
||||
async def refresh(self) -> dict[str, Any]:
|
||||
"""刷新当前标签页。"""
|
||||
await self._ensure_connected()
|
||||
try:
|
||||
await self._page.reload(wait_until="domcontentloaded")
|
||||
except Exception:
|
||||
await self._page.goto(self._page.url, wait_until="domcontentloaded")
|
||||
return await self._page_meta()
|
||||
|
||||
async def switch_tab(self, index: int) -> dict[str, Any]:
|
||||
"""切换到第 index 个标签页(从 0 开始)。"""
|
||||
await self._ensure_connected()
|
||||
pages = list(self._context.pages)
|
||||
if not (0 <= index < len(pages)):
|
||||
raise ValueError(f"标签页索引 {index} 越界,当前共 {len(pages)} 个标签页(0~{len(pages) - 1})")
|
||||
self._page = pages[index]
|
||||
self._watch_page(self._page)
|
||||
try:
|
||||
await self._page.bring_to_front()
|
||||
except Exception:
|
||||
pass
|
||||
return await self._page_meta()
|
||||
|
||||
async def close_tab(self) -> str:
|
||||
"""关闭当前标签页(若为最后一页则保留浏览器,自动补开空白页)。"""
|
||||
await self._ensure_connected()
|
||||
cur = self._page
|
||||
others = [p for p in self._context.pages if p is not cur]
|
||||
try:
|
||||
await cur.close()
|
||||
except Exception:
|
||||
pass
|
||||
if others:
|
||||
self._page = others[-1]
|
||||
else:
|
||||
# 最后一个标签页关闭会导致浏览器退出,补开空白页保持会话存活
|
||||
try:
|
||||
self._page = await self._context.new_page()
|
||||
except Exception:
|
||||
await self._cleanup_connection()
|
||||
return "已关闭最后一个标签页,浏览器已退出"
|
||||
try:
|
||||
await self._page.bring_to_front()
|
||||
except Exception:
|
||||
pass
|
||||
meta = await self._page_meta()
|
||||
return f"已关闭标签页,当前页: {meta['title'] or meta['url'] or 'about:blank'}"
|
||||
|
||||
async def close_browser(self) -> str:
|
||||
"""真正关闭浏览器进程(CDP Browser.close 命令)。"""
|
||||
was_connected = self._browser is not None and self._browser.is_connected()
|
||||
if was_connected:
|
||||
try:
|
||||
cdp = await self._browser.new_browser_cdp_session()
|
||||
await cdp.send("Browser.close")
|
||||
except Exception:
|
||||
pass
|
||||
await self._cleanup_connection()
|
||||
proc, self._proc = self._proc, None
|
||||
if proc is not None:
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except Exception:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
return "已关闭浏览器" if was_connected else "浏览器未在运行(已清理本地连接状态)"
|
||||
|
||||
async def list_tabs(self) -> list[dict[str, Any]]:
|
||||
"""列出全部标签页(索引、标题、URL),当前页带标记。"""
|
||||
await self._ensure_connected()
|
||||
tabs = []
|
||||
for i, p in enumerate(self._context.pages):
|
||||
try:
|
||||
title = await p.title()
|
||||
except Exception:
|
||||
title = ""
|
||||
tabs.append({
|
||||
"index": i,
|
||||
"title": title,
|
||||
"url": p.url,
|
||||
"current": p is self._page,
|
||||
})
|
||||
return tabs
|
||||
|
||||
async def _settle_current(self) -> None:
|
||||
"""等待当前页稳定(截图前调用),避免截到加载动画而非动作结果。
|
||||
|
||||
三段式:主文档加载完成(4s 上限)→ 网络空闲(3s 上限,覆盖 AJAX;
|
||||
长轮询页面会超时直接放行)→ 0.3s 渲染缓冲。
|
||||
"""
|
||||
page = self._page
|
||||
if page is None:
|
||||
return
|
||||
try:
|
||||
await page.wait_for_load_state("domcontentloaded", timeout=4000)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await page.wait_for_load_state("networkidle", timeout=3000)
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
async def screenshot(self, full_page: bool = False) -> bytes:
|
||||
"""当前标签页截图(先等待页面稳定),返回 PNG 字节。"""
|
||||
await self._ensure_connected()
|
||||
await self._settle_current()
|
||||
return await self._page.screenshot(full_page=full_page)
|
||||
|
||||
# ---------- ref 注册表(供 web_page 在会话线程内使用) ----------
|
||||
def set_refs(self, refs: dict[int, str], page) -> None:
|
||||
self._refs = refs
|
||||
self._refs_page = page
|
||||
|
||||
def get_ref_selector(self, ref: int) -> Optional[str]:
|
||||
return self._refs.get(ref)
|
||||
|
||||
# ---------- 同步门面(工具线程/主线程调用,内部转投会话线程) ----------
|
||||
def open(self, url: Optional[str] = None, browser: Optional[str] = None) -> dict[str, Any]:
|
||||
return self.call_async(lambda s: s.ensure(browser) if url is None else s.open_tab(url, browser),
|
||||
timeout=_DEFAULT_OP_TIMEOUT + _LAUNCH_TIMEOUT)
|
||||
|
||||
def refresh_sync(self) -> dict[str, Any]:
|
||||
return self.call_async(lambda s: s.refresh())
|
||||
|
||||
def switch_tab_sync(self, index: int) -> dict[str, Any]:
|
||||
return self.call_async(lambda s: s.switch_tab(index))
|
||||
|
||||
def close_tab_sync(self) -> str:
|
||||
return self.call_async(lambda s: s.close_tab())
|
||||
|
||||
def close_browser_sync(self) -> str:
|
||||
return self.call_async(lambda s: s.close_browser())
|
||||
|
||||
def list_tabs_sync(self) -> list[dict[str, Any]]:
|
||||
return self.call_async(lambda s: s.list_tabs())
|
||||
|
||||
def screenshot_sync(self, full_page: bool = False) -> bytes:
|
||||
return self.call_async(lambda s: s.screenshot(full_page))
|
||||
|
||||
# ---------- 进程退出 ----------
|
||||
def _atexit_shutdown(self) -> None:
|
||||
"""Agent 退出时仅断开连接,浏览器窗口保留(独立进程可被下次重连)。"""
|
||||
try:
|
||||
if self._loop and self._thread and self._thread.is_alive():
|
||||
fut = asyncio.run_coroutine_threadsafe(self._cleanup_connection(), self._loop)
|
||||
try:
|
||||
fut.result(timeout=3)
|
||||
except Exception:
|
||||
fut.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ---------- 模块级单例 ----------
|
||||
_session: Optional[BrowserSession] = None
|
||||
_session_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_browser_session() -> BrowserSession:
|
||||
"""获取浏览器会话单例(browser_control 与 web_page 共用)。"""
|
||||
global _session
|
||||
if _session is None:
|
||||
with _session_lock:
|
||||
if _session is None:
|
||||
_session = BrowserSession()
|
||||
return _session
|
||||
|
||||
|
||||
# ==================== 第二部分:BrowserControl 工具 ====================
|
||||
|
||||
class BrowserControl(BaseTool):
|
||||
"""统一浏览器控制工具:支持打开/关闭浏览器、刷新/关闭标签页、打开指定 URL"""
|
||||
"""统一浏览器控制工具(Playwright 分离进程 + CDP 架构)。
|
||||
|
||||
浏览器以独立进程运行(Agent 退出后窗口保留,重启自动重连),
|
||||
登录态通过持久化 profile 保存。
|
||||
"""
|
||||
|
||||
args_schema: type[BaseModel] = create_model(
|
||||
"BrowserControlInput",
|
||||
action=(Literal["open", "close"], Field(
|
||||
action=(Literal["open", "close", "list_tabs", "switch_tab"], Field(
|
||||
default="open",
|
||||
description="操作类型:'open' 打开/刷新,'close' 关闭"
|
||||
description="操作类型:'open' 打开/刷新,'close' 关闭,'list_tabs' 列出标签页,'switch_tab' 切换标签页"
|
||||
)),
|
||||
url=(Optional[str], Field(
|
||||
default=None,
|
||||
description="URL 地址、'current' 表示当前页,留空则操作整个浏览器窗口"
|
||||
description="URL 地址、'current' 表示刷新当前页;action=open 且留空表示打开浏览器(新标签空白页)"
|
||||
)),
|
||||
browser=(Optional[str], Field(
|
||||
default=None,
|
||||
description="浏览器名称,如 'edge', 'chrome', 'firefox',不指定则使用系统默认"
|
||||
description="浏览器引擎:'edge'(默认)/ 'chrome' / 'chromium'(Playwright 内置)"
|
||||
)),
|
||||
tab_index=(Optional[int], Field(
|
||||
default=None,
|
||||
description="标签页索引(从 0 开始),switch_tab 时必填,可用 list_tabs 查询"
|
||||
)),
|
||||
)
|
||||
name: str = "browser_control"
|
||||
description: str = (
|
||||
"统一控制浏览器:打开/关闭浏览器、刷新/关闭标签页、打开指定 URL。\n"
|
||||
"- action='open', url=None: 打开浏览器首页\n"
|
||||
"控制浏览器(独立进程,跨会话存活):打开 URL、刷新、关闭浏览器/标签页、列出与切换标签页。\n"
|
||||
"- action='open', url=None: 打开浏览器(若未运行则启动,聚焦当前页)\n"
|
||||
"- action='open', url='current': 刷新当前标签页\n"
|
||||
"- action='open', url='https://...': 在新标签页打开指定 URL\n"
|
||||
"- action='close', url=None: 关闭整个浏览器窗口\n"
|
||||
"- action='open', url='https://...': 新标签页打开指定 URL\n"
|
||||
"- action='close', url=None: 关闭整个浏览器\n"
|
||||
"- action='close', url='任意非空字符(例如current)': 关闭当前标签页\n"
|
||||
"操作完成后会返回当前屏幕截图,帮助判断操作是否成功。"
|
||||
"- action='list_tabs': 列出全部标签页(索引、标题、URL)\n"
|
||||
"- action='switch_tab', tab_index=N: 切换到第 N 个标签页\n"
|
||||
"操作完成后返回当前页面截图。打开页面后的元素级操作请使用 web_page 工具。\n"
|
||||
"仅用于进行浏览器操作时调用,其余任何情况严禁调用。"
|
||||
)
|
||||
response_format: Literal["content", "content_and_artifact"] = "content_and_artifact"
|
||||
|
||||
# ---------- 辅助方法(与原版相同,无改动) ----------
|
||||
def _get_browser_info(self, browser_name: Optional[str]):
|
||||
"""解析浏览器名称,返回 webbrowser 控制器与窗口标题关键字。"""
|
||||
if browser_name is None:
|
||||
return webbrowser.get(), None
|
||||
name_lower = browser_name.lower().strip()
|
||||
if name_lower not in env_utils.BROWSER_MAP:
|
||||
raise ValueError(f"不支持的浏览器: {browser_name},支持: {', '.join(env_utils.BROWSER_MAP.keys())}")
|
||||
reg_name, title_keyword = env_utils.BROWSER_MAP[name_lower]
|
||||
try:
|
||||
controller = webbrowser.get(reg_name)
|
||||
except webbrowser.Error:
|
||||
controller = webbrowser.get(browser_name)
|
||||
return controller, title_keyword
|
||||
|
||||
def _activate_window(self, title_keyword: str) -> bool:
|
||||
"""在 Windows 上按标题关键字激活浏览器窗口(非 Win32 返回 False)。"""
|
||||
if sys.platform != "win32":
|
||||
return False
|
||||
try:
|
||||
time.sleep(0.3)
|
||||
windows = pyautogui.getWindowsWithTitle(title_keyword)
|
||||
if windows:
|
||||
win = windows[0]
|
||||
if win.isMinimized:
|
||||
win.restore()
|
||||
win.activate()
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
def _send_keys(self, *keys):
|
||||
"""通过 pyautogui 发送组合键。"""
|
||||
pyautogui.hotkey(*keys)
|
||||
|
||||
# ---------- 核心操作(返回操作结果描述) ----------
|
||||
def _try_activate_any_browser(self) -> bool:
|
||||
"""尝试激活任意已知浏览器窗口(不指定 browser 时的回退方案)。"""
|
||||
for _reg_name, title_keyword in env_utils.BROWSER_MAP.values():
|
||||
if self._activate_window(title_keyword):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _open_action(self, url: Optional[str], browser: Optional[str]) -> str:
|
||||
"""执行打开首页、刷新当前页或打开指定 URL。"""
|
||||
controller, title_keyword = self._get_browser_info(browser)
|
||||
if url is None:
|
||||
controller.open("about:blank")
|
||||
if title_keyword:
|
||||
self._activate_window(title_keyword)
|
||||
else:
|
||||
self._try_activate_any_browser()
|
||||
browser_info = f" (使用 {browser})" if browser else ""
|
||||
return f"已打开浏览器首页{browser_info}"
|
||||
elif url.lower() == "current":
|
||||
if title_keyword:
|
||||
if not self._activate_window(title_keyword):
|
||||
return f"未找到 {browser or '默认'} 浏览器窗口,无法刷新"
|
||||
time.sleep(0.2)
|
||||
if sys.platform == "darwin":
|
||||
self._send_keys("command", "r")
|
||||
else:
|
||||
self._send_keys("f5")
|
||||
return "已刷新当前标签页"
|
||||
else:
|
||||
controller.open(url)
|
||||
if title_keyword:
|
||||
self._activate_window(title_keyword)
|
||||
else:
|
||||
self._try_activate_any_browser()
|
||||
# 等待浏览器窗口出现并开始加载
|
||||
time.sleep(1.0)
|
||||
return f"已尝试打开 URL: {url}"
|
||||
|
||||
def _close_action(self, url: Optional[str], browser: Optional[str]) -> str:
|
||||
"""执行关闭浏览器窗口或当前标签页。"""
|
||||
_, title_keyword = self._get_browser_info(browser)
|
||||
if url is None:
|
||||
if title_keyword:
|
||||
if not self._activate_window(title_keyword):
|
||||
return f"未找到 {browser or '默认'} 浏览器窗口,无法关闭"
|
||||
else:
|
||||
self._try_activate_any_browser()
|
||||
time.sleep(0.2)
|
||||
if sys.platform == "darwin":
|
||||
self._send_keys("command", "q")
|
||||
else:
|
||||
self._send_keys("alt", "f4")
|
||||
return "已尝试关闭浏览器窗口"
|
||||
else:
|
||||
if title_keyword:
|
||||
if not self._activate_window(title_keyword):
|
||||
return f"未找到 {browser or '默认'} 浏览器窗口,无法关闭标签页"
|
||||
else:
|
||||
self._try_activate_any_browser()
|
||||
time.sleep(0.2)
|
||||
if sys.platform == "darwin":
|
||||
self._send_keys("command", "w")
|
||||
else:
|
||||
self._send_keys("ctrl", "w")
|
||||
return "已尝试关闭当前标签页"
|
||||
|
||||
# ---------- 同步主逻辑,返回 (描述, artifact) ----------
|
||||
def _run(self, action: str = "open", url: Optional[str] = None, browser: Optional[str] = None) -> Tuple[str, dict]:
|
||||
def _run(self, action: str = "open", url: Optional[str] = None,
|
||||
browser: Optional[str] = None, tab_index: Optional[int] = None) -> Tuple[str, dict]:
|
||||
session = get_browser_session()
|
||||
try:
|
||||
if action == "open":
|
||||
msg = self._open_action(url, browser)
|
||||
if url is None:
|
||||
meta = session.open(browser=browser)
|
||||
msg = f"浏览器已就绪,当前页: {meta.get('title') or meta.get('url') or 'about:blank'}"
|
||||
elif url.lower() == "current":
|
||||
meta = session.refresh_sync()
|
||||
msg = f"已刷新当前页: {meta.get('title') or meta.get('url') or 'about:blank'}"
|
||||
else:
|
||||
meta = session.open(url=url, browser=browser)
|
||||
msg = f"已在新标签页打开: {meta.get('url') or url}({meta.get('title') or ''})"
|
||||
elif action == "close":
|
||||
msg = self._close_action(url, browser)
|
||||
if url is None:
|
||||
msg = session.close_browser_sync()
|
||||
else:
|
||||
msg = session.close_tab_sync()
|
||||
elif action == "list_tabs":
|
||||
tabs: list[dict[str, Any]] = session.list_tabs_sync()
|
||||
if not tabs:
|
||||
msg = "当前没有打开的标签页"
|
||||
else:
|
||||
rows = [
|
||||
f"[{t['index']}]{'(当前)' if t['current'] else ''} {t['title'][:40]} | {t['url'][:80]}"
|
||||
for t in tabs
|
||||
]
|
||||
msg = f"共 {len(tabs)} 个标签页:\n" + "\n".join(rows)
|
||||
elif action == "switch_tab":
|
||||
if tab_index is None:
|
||||
msg = "switch_tab 需要 tab_index 参数(可先用 list_tabs 查询索引)"
|
||||
else:
|
||||
meta = session.switch_tab_sync(tab_index)
|
||||
msg = f"已切换到标签页[{tab_index}]: {meta.get('title') or meta.get('url') or 'about:blank'}"
|
||||
else:
|
||||
msg = f"不支持的操作类型: {action}"
|
||||
except Exception as e:
|
||||
msg = f"执行浏览器操作时出错: {e}"
|
||||
return f"执行浏览器操作时出错: {e}", {}
|
||||
|
||||
# 无论操作是否成功,都尝试截屏(等待窗口切换)
|
||||
time.sleep(0.3)
|
||||
# 关闭整个浏览器后无页面可截,其余情况返回当前页截图
|
||||
if action == "close" and url is None:
|
||||
return msg, {}
|
||||
try:
|
||||
# 先尝试获取 GUI 操作区域,限定截屏范围
|
||||
try:
|
||||
from agent.core.config_manager import get_active_monitor_region
|
||||
region = get_active_monitor_region()
|
||||
except Exception:
|
||||
region = None
|
||||
if region:
|
||||
pil_img = pyautogui.screenshot(region=(region["x"], region["y"], region["width"], region["height"]))
|
||||
else:
|
||||
pil_img = pyautogui.screenshot()
|
||||
pil_img = pil_img.resize((env_utils.GROUNDING_WIDTH, env_utils.GROUNDING_HEIGHT), Image.Resampling.LANCZOS)
|
||||
buf = io.BytesIO()
|
||||
pil_img.save(buf, format="PNG")
|
||||
raw_png = buf.getvalue()
|
||||
return msg, {"image_bytes": raw_png}
|
||||
png = session.screenshot_sync()
|
||||
return msg, {"image_bytes": png}
|
||||
except Exception as e:
|
||||
return f"{msg}\n(截屏失败: {e})", {}
|
||||
|
||||
# ---------- 异步入口 ----------
|
||||
async def _arun(self, action: str = "open", url: Optional[str] = None, browser: Optional[str] = None) -> Tuple[str, dict]:
|
||||
return await asyncio.to_thread(self._run, action, url, browser)
|
||||
async def _arun(self, action: str = "open", url: Optional[str] = None,
|
||||
browser: Optional[str] = None, tab_index: Optional[int] = None) -> Tuple[str, dict]:
|
||||
return await asyncio.to_thread(self._run, action, url, browser, tab_index)
|
||||
@@ -671,7 +671,7 @@ class DocTool(BaseTool):
|
||||
" * docx 插入图片: insert_paragraph_at + insert_image_path 在指定段落前插入图片。\n"
|
||||
"- delete: 删除文档。支持所有类型。\n\n"
|
||||
"【文档类型识别】\n"
|
||||
" plain_text: .txt .json .md .cpp .c .py .m .java .html .svg 等\n"
|
||||
" plain_text: 所有 UTF-8 文本/代码文件(.txt .md .json .py .js .ts .html .css .xml .yaml .sql .sh 等)\n"
|
||||
" docx: .docx\n"
|
||||
" pptx: .pptx(read 提取文本和图片;delete 删除)。"
|
||||
" pdf: .pdf\n"
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"""excel2sql 工具:按大模型给定的数据区范围,自动建库建表并把 Excel 数据批量入库 MySQL。
|
||||
"""excel2sql 工具:按大模型给定的数据区范围,自动建库建表并把 Excel 数据批量入库。
|
||||
|
||||
系统定位:
|
||||
配合 hard_excel_read 完成"解析 → 入库"链路。hard_excel_read 让大模型理解复杂表结构、
|
||||
产出 data_region 与 table_description;本工具按该范围流式读取真正的数据区,
|
||||
自动建库建表(表 COMMENT 存表格描述)并批量入库。
|
||||
自动建库建表(表 COMMENT 存表格描述,仅 MySQL;SQLite 无 COMMENT 概念)并批量入库。
|
||||
|
||||
连接使用设置中的**工具数据库配置**(TOOL_DB_*,MySQL 或 SQLite),
|
||||
与存储后端配置完全隔离。SQLite 目录模式下库名 = 文件名(<目录>/<库名>.sqlite)。
|
||||
|
||||
不做重清洗 DSL,按范围直接入库(结构清洗已由大模型定范围完成)。
|
||||
值级清洗入库后用 text2sql 的 execute(UPDATE/DELETE) 完成。
|
||||
@@ -19,12 +22,30 @@ import datetime
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from contextlib import contextmanager
|
||||
from typing import Any
|
||||
|
||||
from langchain.tools import BaseTool
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _cursor(conn: Any):
|
||||
"""统一游标上下文:pymysql 与 sqlite3 的 cursor 行为差异。
|
||||
|
||||
pymysql cursor 支持 with(自动 close),sqlite3 cursor 不支持
|
||||
context manager 协议,需手动 close。本助手对两者统一处理。
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
yield cur
|
||||
finally:
|
||||
try:
|
||||
cur.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# data_region 必须包含的键
|
||||
_REGION_KEYS = ("header_row", "data_start", "data_end", "col_start", "col_end")
|
||||
|
||||
@@ -55,13 +76,32 @@ _DB_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
_TABLE_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
|
||||
|
||||
def _db_type() -> str:
|
||||
"""当前工具数据库类型:"mysql" | "sqlite"(读 TOOL_DB_*,与存储后端隔离)。"""
|
||||
from agent.core.db import load_tool_db_config
|
||||
return load_tool_db_config().get("type", "mysql")
|
||||
|
||||
|
||||
def _ensure_database_exists(db_name: str) -> tuple[bool, str]:
|
||||
"""幂等创建目标 MySQL 数据库(连服务器,不指定 database)。"""
|
||||
"""幂等创建目标数据库。
|
||||
|
||||
- mysql: 连服务器(不指定 database)执行 CREATE DATABASE IF NOT EXISTS;
|
||||
- sqlite: 库 = 文件,幂等创建(复用 ensure_sqlite_database,不存在则建空文件)。
|
||||
"""
|
||||
if _db_type() == "sqlite":
|
||||
from agent.core.db import load_tool_db_config, sqlite_db_path, ensure_sqlite_database
|
||||
try:
|
||||
cfg = load_tool_db_config()
|
||||
path = sqlite_db_path(cfg, db_name)
|
||||
ensure_sqlite_database(path)
|
||||
return True, "ok"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
import pymysql
|
||||
from pymysql.cursors import DictCursor
|
||||
from agent.core.db import load_mysql_config
|
||||
from agent.core.db import load_tool_db_config
|
||||
|
||||
cfg = load_mysql_config()
|
||||
cfg = load_tool_db_config()
|
||||
try:
|
||||
conn = pymysql.connect(
|
||||
host=cfg["host"], port=cfg["port"], user=cfg["user"],
|
||||
@@ -69,7 +109,7 @@ def _ensure_database_exists(db_name: str) -> tuple[bool, str]:
|
||||
cursorclass=DictCursor, autocommit=True,
|
||||
)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
with _cursor(conn) as cur:
|
||||
cur.execute(
|
||||
f"CREATE DATABASE IF NOT EXISTS `{db_name}` "
|
||||
f"CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci"
|
||||
@@ -82,17 +122,28 @@ def _ensure_database_exists(db_name: str) -> tuple[bool, str]:
|
||||
|
||||
|
||||
def _connect(db_name: str):
|
||||
"""连接目标库(复用 text2sql 的连接模式)。"""
|
||||
import pymysql
|
||||
from pymysql.cursors import DictCursor
|
||||
from agent.core.db import load_mysql_config
|
||||
"""连接目标库(按工具数据库配置分发 mysql/sqlite)。"""
|
||||
from agent.core.db import connect_db, load_tool_db_config
|
||||
|
||||
cfg = load_mysql_config()
|
||||
return pymysql.connect(
|
||||
host=cfg["host"], port=cfg["port"], user=cfg["user"],
|
||||
password=cfg["password"], database=db_name, charset="utf8mb4",
|
||||
cursorclass=DictCursor, autocommit=False,
|
||||
)
|
||||
cfg = load_tool_db_config()
|
||||
return connect_db(cfg, db_name)
|
||||
|
||||
|
||||
def _table_exists(conn: Any, table: str) -> bool:
|
||||
"""判断目标表是否已存在(mysql: SHOW TABLES LIKE;sqlite: sqlite_master)。"""
|
||||
if _db_type() == "sqlite":
|
||||
cur = conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)
|
||||
)
|
||||
return cur.fetchone() is not None
|
||||
with _cursor(conn) as cur:
|
||||
cur.execute(f"SHOW TABLES LIKE '{table}'")
|
||||
return cur.fetchone() is not None
|
||||
|
||||
|
||||
def _placeholder() -> str:
|
||||
"""SQL 占位符:mysql 用 %s,sqlite 用 ?。"""
|
||||
return "?" if _db_type() == "sqlite" else "%s"
|
||||
|
||||
|
||||
def _normalize_value(v: Any) -> Any:
|
||||
@@ -213,8 +264,16 @@ def _infer_columns(header_values: list, sample_rows: list) -> list[dict]:
|
||||
|
||||
|
||||
def _build_ddl(table: str, col_defs: list[dict], description: str,
|
||||
drop_if_exists: bool) -> str:
|
||||
"""生成建表 DDL(含列定义/主键/索引/表 COMMENT)。"""
|
||||
drop_if_exists: bool, db_type: str | None = None) -> str:
|
||||
"""生成建表 DDL(按数据库类型区分语法)。
|
||||
|
||||
- mysql: 列 COMMENT / 表 COMMENT / ENGINE=InnoDB DEFAULT CHARSET / 行内 KEY 索引;
|
||||
- sqlite: 无 COMMENT / ENGINE / 行内 KEY(索引改为建表后独立
|
||||
CREATE INDEX IF NOT EXISTS 语句)。
|
||||
列类型(BIGINT/DECIMAL/DATETIME/DATE/TEXT/VARCHAR)两者通用(sqlite 亲和类型)。
|
||||
"""
|
||||
db_type = db_type or _db_type()
|
||||
is_sqlite = db_type == "sqlite"
|
||||
lines: list[str] = []
|
||||
if drop_if_exists:
|
||||
lines.append(f"DROP TABLE IF EXISTS `{table}`;")
|
||||
@@ -225,7 +284,7 @@ def _build_ddl(table: str, col_defs: list[dict], description: str,
|
||||
parts = [f" `{c['target']}` {c['sql_type']}"]
|
||||
if not c.get("nullable", True):
|
||||
parts.append("NOT NULL")
|
||||
if c.get("comment"):
|
||||
if c.get("comment") and not is_sqlite:
|
||||
esc = str(c["comment"]).replace("'", "''")[:500]
|
||||
parts.append(f"COMMENT '{esc}'")
|
||||
col_lines.append(" ".join(parts))
|
||||
@@ -235,21 +294,35 @@ def _build_ddl(table: str, col_defs: list[dict], description: str,
|
||||
col_lines.append(
|
||||
f" PRIMARY KEY ({', '.join(f'`{p}`' for p in pks)})"
|
||||
)
|
||||
for c in col_defs:
|
||||
if c.get("index") and not c.get("primary_key"):
|
||||
idx_name = f"idx_{c['target']}"[:64]
|
||||
col_lines.append(f" KEY `{idx_name}` (`{c['target']}`)")
|
||||
if not is_sqlite:
|
||||
for c in col_defs:
|
||||
if c.get("index") and not c.get("primary_key"):
|
||||
idx_name = f"idx_{c['target']}"[:64]
|
||||
col_lines.append(f" KEY `{idx_name}` (`{c['target']}`)")
|
||||
|
||||
desc_clause = ""
|
||||
if description:
|
||||
esc = description.replace("'", "''")[:1000]
|
||||
desc_clause = f" COMMENT='{esc}'"
|
||||
|
||||
ddl = (
|
||||
f"CREATE TABLE IF NOT EXISTS `{table}` (\n"
|
||||
+ ",\n".join(col_lines)
|
||||
+ f"\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4{desc_clause}"
|
||||
)
|
||||
if is_sqlite:
|
||||
ddl = (
|
||||
f"CREATE TABLE IF NOT EXISTS `{table}` (\n"
|
||||
+ ",\n".join(col_lines)
|
||||
+ "\n)"
|
||||
)
|
||||
# sqlite 索引在表外单独建(IF NOT EXISTS 幂等)
|
||||
for c in col_defs:
|
||||
if c.get("index") and not c.get("primary_key"):
|
||||
idx_name = f"idx_{c['target']}"[:64]
|
||||
lines.append(
|
||||
f"CREATE INDEX IF NOT EXISTS `{idx_name}` ON `{table}` (`{c['target']}`);"
|
||||
)
|
||||
else:
|
||||
desc_clause = ""
|
||||
if description:
|
||||
esc = description.replace("'", "''")[:1000]
|
||||
desc_clause = f" COMMENT='{esc}'"
|
||||
ddl = (
|
||||
f"CREATE TABLE IF NOT EXISTS `{table}` (\n"
|
||||
+ ",\n".join(col_lines)
|
||||
+ f"\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4{desc_clause}"
|
||||
)
|
||||
lines.append(ddl)
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -276,7 +349,7 @@ class Excel2SQLInput(BaseModel):
|
||||
"",
|
||||
description="表格描述(数据区上方的标题/表单/说明等),存为表 COMMENT",
|
||||
)
|
||||
target_database: str = Field(..., description="目标 MySQL 库名(不存在自动建)")
|
||||
target_database: str = Field(..., description="目标库名(不存在自动建;连接使用工具数据库配置,MySQL 或 SQLite)")
|
||||
target_table: str = Field(..., description="目标表名(不存在自动建)")
|
||||
columns: list[dict] | None = Field(
|
||||
None,
|
||||
@@ -301,8 +374,9 @@ class Excel2SQLTool(BaseTool):
|
||||
args_schema: type[BaseModel] = Excel2SQLInput
|
||||
name: str = "excel2sql"
|
||||
description: str = (
|
||||
"Excel 批量入库工具(excel2sql)。按大模型给定的数据行列范围,自动建库建表(表 COMMENT 存表格描述),"
|
||||
"并把 Excel 数据区流式批量入库 MySQL。\n"
|
||||
"Excel 批量入库工具(excel2sql)。按大模型给定的数据行列范围,自动建库建表"
|
||||
"(表描述存为表 COMMENT,仅 MySQL;SQLite 无此概念),"
|
||||
"并把 Excel 数据区流式批量入库(MySQL 或 SQLite,连接使用工具数据库配置,与存储库隔离)。\n"
|
||||
"适用场景:hard_excel_read 已解析出 data_region 与 table_description 后,把数据真正写入数据库。\n"
|
||||
"不做重清洗 DSL,按范围直接入库;值级清洗入库后用 text2sql(execute) 完成。\n"
|
||||
"参数说明:\n"
|
||||
@@ -310,7 +384,7 @@ class Excel2SQLTool(BaseTool):
|
||||
"- sheet: 要入库的 sheet 名\n"
|
||||
"- data_region: {header_row, data_start, data_end, col_start, col_end}(1-indexed)\n"
|
||||
"- table_description: 表格描述,存为表 COMMENT\n"
|
||||
"- target_database: 目标 MySQL 库名(不存在自动建)\n"
|
||||
"- target_database: 目标库名(不存在自动建)\n"
|
||||
"- target_table: 目标表名(不存在自动建)\n"
|
||||
"- columns: 列定义 [{source,target,sql_type,nullable,primary_key,index,comment}];None=表头+自动推断\n"
|
||||
"- batch_size: 批量入库大小,默认 2000\n"
|
||||
@@ -411,10 +485,8 @@ class Excel2SQLTool(BaseTool):
|
||||
if ok:
|
||||
conn = _connect(target_database)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f"SHOW TABLES LIKE '{target_table}'")
|
||||
if cur.fetchone():
|
||||
return json.dumps({
|
||||
if _table_exists(conn, target_table):
|
||||
return json.dumps({
|
||||
"error": (
|
||||
f"表 `{target_database}`.`{target_table}` 已存在,"
|
||||
f"而当前传入的 columns 定义了新的列 schema,"
|
||||
@@ -465,7 +537,7 @@ class Excel2SQLTool(BaseTool):
|
||||
sample_dicts: list[dict] = []
|
||||
batch: list = []
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
with _cursor(conn) as cur:
|
||||
# 执行 DDL(可能含 DROP + CREATE)
|
||||
for stmt in ddl.split(";"):
|
||||
s = stmt.strip()
|
||||
@@ -473,10 +545,10 @@ class Excel2SQLTool(BaseTool):
|
||||
cur.execute(s)
|
||||
# 入库
|
||||
cols_sql = ", ".join(f"`{c['target']}`" for c in col_defs)
|
||||
placeholders = ", ".join(["%s"] * len(col_defs))
|
||||
placeholders = ", ".join([_placeholder()] * len(col_defs))
|
||||
insert_sql = f"INSERT INTO `{target_table}` ({cols_sql}) VALUES ({placeholders})"
|
||||
|
||||
with conn.cursor() as cur:
|
||||
with _cursor(conn) as cur:
|
||||
for row in ws.iter_rows(min_row=data_start, max_row=data_end,
|
||||
min_col=col_start, max_col=col_end, values_only=True):
|
||||
# 整行空跳过
|
||||
@@ -583,18 +655,16 @@ class Excel2SQLTool(BaseTool):
|
||||
if ok:
|
||||
conn = _connect(target_database)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f"SHOW TABLES LIKE '{target_table}'")
|
||||
if cur.fetchone():
|
||||
return json.dumps({
|
||||
"error": (
|
||||
f"表 `{target_database}`.`{target_table}` 已存在,"
|
||||
f"而当前传入的 columns 定义了新的列 schema,"
|
||||
f"CREATE TABLE IF NOT EXISTS 不会修改已有表结构。"
|
||||
f"请设置 drop_if_exists: true 以删表重建。"
|
||||
),
|
||||
"hint": "设置 drop_if_exists: true",
|
||||
}, ensure_ascii=False)
|
||||
if _table_exists(conn, target_table):
|
||||
return json.dumps({
|
||||
"error": (
|
||||
f"表 `{target_database}`.`{target_table}` 已存在,"
|
||||
f"而当前传入的 columns 定义了新的列 schema,"
|
||||
f"CREATE TABLE IF NOT EXISTS 不会修改已有表结构。"
|
||||
f"请设置 drop_if_exists: true 以删表重建。"
|
||||
),
|
||||
"hint": "设置 drop_if_exists: true",
|
||||
}, ensure_ascii=False)
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
@@ -633,16 +703,16 @@ class Excel2SQLTool(BaseTool):
|
||||
sample_dicts: list[dict] = []
|
||||
batch: list = []
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
with _cursor(conn) as cur:
|
||||
for stmt in ddl.split(";"):
|
||||
s = stmt.strip()
|
||||
if s:
|
||||
cur.execute(s)
|
||||
cols_sql = ", ".join(f"`{c['target']}`" for c in col_defs)
|
||||
placeholders = ", ".join(["%s"] * len(col_defs))
|
||||
placeholders = ", ".join([_placeholder()] * len(col_defs))
|
||||
insert_sql = f"INSERT INTO `{target_table}` ({cols_sql}) VALUES ({placeholders})"
|
||||
|
||||
with conn.cursor() as cur:
|
||||
with _cursor(conn) as cur:
|
||||
for r in range(data_start - 1, min(data_end, total_rows)):
|
||||
row_vals = tuple(_xlrd_cell_to_value(ws, r, c)
|
||||
for c in range(col_start - 1, min(col_end, ws.ncols)))
|
||||
|
||||
@@ -2,81 +2,40 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import asyncio
|
||||
from typing import Optional, Literal
|
||||
from typing import Optional
|
||||
|
||||
from langchain.tools import BaseTool
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class HumanInteractionInput(BaseModel):
|
||||
interaction_type: Literal["information", "selection"] = Field(
|
||||
default="information",
|
||||
description="交互类型:information=人工补充信息,selection=人工选择。",
|
||||
)
|
||||
title: str = Field(default="需要人工处理", description="展示给人类的交互标题。")
|
||||
prompt: str = Field(default="请处理后继续。", description="需要人类理解并响应的具体问题或说明。")
|
||||
prompt: str = Field(default="请处理后继续。", description="需要人类理解并响应的具体问题。")
|
||||
options: Optional[list[str] | str] = Field(
|
||||
default=None,
|
||||
description="selection 可选项。优先传字符串数组;如果传 JSON 字符串,系统会自动解析。",
|
||||
)
|
||||
suggested_response: Optional[str] = Field(default=None, description="可选的建议回复、默认选择或默认审批意见。")
|
||||
questions: Optional[list[dict]] = Field(
|
||||
default=None,
|
||||
description="selection 类型时的问题列表。每项含 question(问题文本) 和 options(该问题的选项列表)。"
|
||||
"支持多个问题,每个问题有独立选项及一个可编辑的'其他'输入框。"
|
||||
"例:[{'question': '问题1', 'options': ['A', 'B']}, {'question': '问题2', 'options': ['是', '否']}]",
|
||||
description="可选项。优先传字符串数组;JSON 字符串或逗号分隔会被自动解析。"
|
||||
"可空——人类也可直接在补充输入框中输入任意信息。",
|
||||
)
|
||||
|
||||
|
||||
def normalize_human_interaction_args(args: dict) -> dict:
|
||||
"""规范化 human_interaction 工具参数为固定 schema。
|
||||
|
||||
输入:
|
||||
args: 模型传入的原始参数字典。
|
||||
|
||||
输出:
|
||||
含 interaction_type、title、prompt、options、suggested_response、questions 的字典;
|
||||
interaction_type 限定为 information | selection。
|
||||
"""
|
||||
"""规范化 human_interaction 工具参数为固定 schema(title / prompt / options)。"""
|
||||
normalized = dict(args or {})
|
||||
normalized["interaction_type"] = str(normalized.get("interaction_type") or "information")
|
||||
if normalized["interaction_type"] not in {"information", "selection"}:
|
||||
normalized["interaction_type"] = "information"
|
||||
normalized["title"] = str(normalized.get("title") or "需要人工处理")
|
||||
normalized["prompt"] = str(normalized.get("prompt") or "请处理后继续。")
|
||||
normalized["options"] = HumanInteraction.normalize_options(normalized.get("options"))
|
||||
normalized["suggested_response"] = str(normalized.get("suggested_response") or "")
|
||||
# 规范化 questions 字段
|
||||
questions = normalized.get("questions")
|
||||
if isinstance(questions, str):
|
||||
try:
|
||||
questions = json.loads(questions)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
questions = None
|
||||
if isinstance(questions, list):
|
||||
normalized_questions = []
|
||||
for q in questions:
|
||||
if isinstance(q, dict):
|
||||
nq = {
|
||||
"question": str(q.get("question", "")),
|
||||
"options": HumanInteraction.normalize_options(q.get("options")),
|
||||
}
|
||||
normalized_questions.append(nq)
|
||||
normalized["questions"] = normalized_questions if normalized_questions else None
|
||||
else:
|
||||
normalized["questions"] = None
|
||||
return normalized
|
||||
|
||||
|
||||
class HumanInteraction(BaseTool):
|
||||
"""向人类请求补充信息或选择;调用时阻塞等待前端浮窗应答,答案作为普通工具返回值。"""
|
||||
"""向人类请求补充信息或选择;调用时阻塞等待前端通知条应答,答案作为普通工具返回值。"""
|
||||
|
||||
args_schema: type[BaseModel] = HumanInteractionInput
|
||||
name: str = "human_interaction"
|
||||
description: str = (
|
||||
"当任务缺少关键信息或需要人类选择路径时调用。"
|
||||
"两种类型分别是:information=请求人类补充缺失信息;selection=让人类在多个选项中选择且可编辑,"
|
||||
"可通过 questions 字段支持多个问题(每问题含 question 和 options)。"
|
||||
"通过 options 提供可选项(可空),人类可以从选项中选择,"
|
||||
"也可在补充输入框中输入任意信息——输入内容将直接作为本工具的返回值。"
|
||||
"工具返回必须被视为用户原始意图的纯字符串反馈。其余情况严禁调用。"
|
||||
)
|
||||
|
||||
@@ -99,45 +58,15 @@ class HumanInteraction(BaseTool):
|
||||
return [str(item) for item in options]
|
||||
|
||||
@staticmethod
|
||||
def format_result(interaction_type: str, options: list[str] | None = None, response: str = "") -> str:
|
||||
"""根据交互类型与用户决策生成结果文本。
|
||||
|
||||
输入:
|
||||
interaction_type: information | selection。
|
||||
options: selection 的可选项(用于默认值)。
|
||||
response: 用户输入的文本(补充信息、选择项、建议回复等)。
|
||||
|
||||
输出:
|
||||
供 ToolMessage 使用的文本内容。
|
||||
"""
|
||||
def format_result(response: str) -> str:
|
||||
"""将用户输入(选中的选项或补充输入框内容)直接作为工具返回值。"""
|
||||
text = (response or "").strip()
|
||||
if interaction_type == "selection":
|
||||
# response 可能是 JSON 格式的多问题答案: {"问题1": "答案", ...}
|
||||
if text:
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
if isinstance(parsed, dict):
|
||||
lines = []
|
||||
for q, a in parsed.items():
|
||||
lines.append(f" - {q}: {a}")
|
||||
return "用户选择:\n" + "\n".join(lines) if lines else "用户选择:(无)"
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
selected = text or (options[0] if options else "")
|
||||
return f"用户选择:{selected}" if selected else "用户选择:"
|
||||
if text.startswith("用户拒绝") or text.startswith("用户已跳过"):
|
||||
return text
|
||||
return f"用户补充信息如下:{text}" if text else "用户没有补充信息"
|
||||
return text or "用户未提供信息"
|
||||
|
||||
@staticmethod
|
||||
def build_request_meta(args: dict) -> dict:
|
||||
"""将规范化后的工具参数构造成前端浮窗渲染数据(与旧 pending_action 形状一致)。"""
|
||||
"""将规范化后的工具参数构造成前端通知条渲染数据。"""
|
||||
args = normalize_human_interaction_args(args)
|
||||
interaction_type = args["interaction_type"]
|
||||
default_titles = {
|
||||
"information": "请补充信息",
|
||||
"selection": "请选择一个选项",
|
||||
}
|
||||
agent_name = "主Agent"
|
||||
try:
|
||||
from agent.core.human_request import get_current_agent_name
|
||||
@@ -147,18 +76,14 @@ class HumanInteraction(BaseTool):
|
||||
pass
|
||||
return {
|
||||
"type": "human_interaction",
|
||||
"interaction_type": interaction_type,
|
||||
"title": args["title"] or default_titles.get(interaction_type, "需要人工处理"),
|
||||
"title": args["title"] or "需要人工处理",
|
||||
"prompt": args["prompt"] or "请处理后继续。",
|
||||
"tool_name": "human_interaction",
|
||||
"args": {
|
||||
"交互类型": interaction_type,
|
||||
"说明": args["prompt"] or "请处理后继续。",
|
||||
"可选项": args["options"] or [],
|
||||
},
|
||||
"instruction": args["suggested_response"] or "",
|
||||
"options": args["options"] or [],
|
||||
"questions": args["questions"] or None,
|
||||
"agent_name": agent_name,
|
||||
"is_sub_agent": agent_name != "主Agent",
|
||||
"resolved": False,
|
||||
@@ -166,21 +91,15 @@ class HumanInteraction(BaseTool):
|
||||
|
||||
def _run(
|
||||
self,
|
||||
interaction_type: str = "information",
|
||||
title: str = "需要人工处理",
|
||||
prompt: str = "请处理后继续。",
|
||||
options: Optional[list[str] | str] = None,
|
||||
suggested_response: Optional[str] = None,
|
||||
questions: Optional[list[dict]] = None,
|
||||
) -> str:
|
||||
try:
|
||||
args = {
|
||||
"interaction_type": interaction_type,
|
||||
"title": title,
|
||||
"prompt": prompt,
|
||||
"options": options,
|
||||
"suggested_response": suggested_response,
|
||||
"questions": questions,
|
||||
}
|
||||
normalized = normalize_human_interaction_args(args)
|
||||
from agent.core.human_request import ask_human
|
||||
@@ -190,7 +109,7 @@ class HumanInteraction(BaseTool):
|
||||
# 拒绝/跳过:原样返回用户决策文本,让 Agent 自行调整,不结束整轮
|
||||
if answer.get("decision") in ("reject", "skip"):
|
||||
return f"用户{('拒绝' if answer['decision'] == 'reject' else '已跳过')}此次交互:{answer.get('instruction') or ''}".strip()
|
||||
return self.format_result(interaction_type, normalized.get("options") or [], answer.get("instruction") or "")
|
||||
return self.format_result(answer.get("instruction") or "")
|
||||
except RuntimeError:
|
||||
raise # ABORTED_BY_USER 冒泡到 runtime 统一按暂停处理
|
||||
except Exception as e:
|
||||
@@ -198,11 +117,8 @@ class HumanInteraction(BaseTool):
|
||||
|
||||
async def _arun(
|
||||
self,
|
||||
interaction_type: str = "information",
|
||||
title: str = "需要人工处理",
|
||||
prompt: str = "请处理后继续。",
|
||||
options: Optional[list[str] | str] = None,
|
||||
suggested_response: Optional[str] = None,
|
||||
questions: Optional[list[dict]] = None,
|
||||
) -> str:
|
||||
return await asyncio.to_thread(self._run, interaction_type, title, prompt, options, suggested_response, questions)
|
||||
return await asyncio.to_thread(self._run, title, prompt, options)
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
依赖:
|
||||
- Z-Image-Turbo 服务(由 llm_server/image_gen_server.py 提供)
|
||||
- env_config.json 中的 IMAGE_GEN_URL 配置
|
||||
- env_config.json 的 models 列表中注册的 "Z-Image-Turbo" 模型(base_url 配置)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -18,18 +18,6 @@ from langchain.tools import BaseTool
|
||||
from pydantic import BaseModel, Field, create_model
|
||||
|
||||
|
||||
def _read_config(key: str, default: str = "") -> str:
|
||||
"""从 env_config.json 读取配置项。"""
|
||||
try:
|
||||
from agent.core.config_manager import load_env_config
|
||||
config = load_env_config()
|
||||
if isinstance(config, dict):
|
||||
return str(config.get(key, default))
|
||||
except Exception:
|
||||
pass
|
||||
return os.environ.get(key, default)
|
||||
|
||||
|
||||
def _get_workspace_dir() -> str:
|
||||
"""获取 workspace 绝对路径。优先使用当前选中的工作空间,回退到 env_config 配置。"""
|
||||
try:
|
||||
@@ -89,7 +77,8 @@ class ImageGen(BaseTool):
|
||||
response_format: Literal["content", "content_and_artifact"] = "content_and_artifact"
|
||||
|
||||
def _get_url(self) -> str:
|
||||
return _read_config("IMAGE_GEN_URL", "http://localhost:8904")
|
||||
from agent.utils.env_utils import get_service_model_config
|
||||
return get_service_model_config("image_gen")["base_url"]
|
||||
|
||||
def _generate_one(self, base_url: str, inp: ImageGenInput, idx: int) -> Tuple[str, dict[str, Any]]:
|
||||
"""生成单张图片,返回 (文本结果, artifact_dict)。"""
|
||||
@@ -102,7 +91,10 @@ class ImageGen(BaseTool):
|
||||
if not (256 <= w <= 2048) or not (256 <= h <= 2048):
|
||||
return (f"[ERROR] 第{idx}张: 尺寸须在 [256, 2048] 之间", {})
|
||||
|
||||
from agent.utils.env_utils import get_service_model_config
|
||||
img_cfg = get_service_model_config("image_gen")
|
||||
payload: dict = {
|
||||
"model": img_cfg["model"],
|
||||
"prompt": inp.prompt,
|
||||
"width": w,
|
||||
"height": h,
|
||||
@@ -111,11 +103,15 @@ class ImageGen(BaseTool):
|
||||
"seed": seed,
|
||||
"return_format": "base64",
|
||||
}
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if img_cfg["api_key"]:
|
||||
headers["Authorization"] = f"Bearer {img_cfg['api_key']}"
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{base_url}/generate",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=300,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
@@ -15,7 +15,7 @@ from langchain.tools import BaseTool
|
||||
from pydantic import BaseModel, Field, create_model
|
||||
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
||||
|
||||
from agent.utils.env_utils import RAG_BASE_URL, RAG_CHUNK_SIZE, RAG_CHUNK_OVERLAP
|
||||
from agent.utils.env_utils import RAG_CHUNK_SIZE, RAG_CHUNK_OVERLAP, get_service_model_config
|
||||
from agent.utils.agent_utils import Documents_process
|
||||
from agent.core.llm import llm
|
||||
|
||||
@@ -144,13 +144,18 @@ def get_global_collection():
|
||||
|
||||
def embed_texts(queries: List[str], documents: List[str] = None) -> tuple:
|
||||
"""调用 RAG 嵌入服务,返回 (query_embeddings, doc_embeddings)。"""
|
||||
rag_cfg = get_service_model_config("rag")
|
||||
payload = {
|
||||
"task_description": "Given a web search query, retrieve relevant passages that answer the query",
|
||||
"model": rag_cfg["model"],
|
||||
"queries": queries,
|
||||
"documents": documents or []
|
||||
}
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if rag_cfg["api_key"]:
|
||||
headers["Authorization"] = f"Bearer {rag_cfg['api_key']}"
|
||||
try:
|
||||
resp = requests.post(f"{RAG_BASE_URL}/embed", json=payload, timeout=30)
|
||||
resp = requests.post(f"{rag_cfg['base_url']}/embed", json=payload, headers=headers, timeout=30)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return data["query_embeddings"], data.get("doc_embeddings", [])
|
||||
@@ -160,13 +165,18 @@ def embed_texts(queries: List[str], documents: List[str] = None) -> tuple:
|
||||
|
||||
def rerank_documents(query: str, documents: List[str]) -> List[float]:
|
||||
"""调用 RAG 重排序服务,返回与 documents 对齐的相关度分数列表。"""
|
||||
rag_cfg = get_service_model_config("rag")
|
||||
payload = {
|
||||
"task_description": "Given a web search query, retrieve relevant passages that answer the query",
|
||||
"model": rag_cfg["model"],
|
||||
"query": query,
|
||||
"documents": documents
|
||||
}
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if rag_cfg["api_key"]:
|
||||
headers["Authorization"] = f"Bearer {rag_cfg['api_key']}"
|
||||
try:
|
||||
resp = requests.post(f"{RAG_BASE_URL}/rerank", json=payload, timeout=30)
|
||||
resp = requests.post(f"{rag_cfg['base_url']}/rerank", json=payload, headers=headers, timeout=30)
|
||||
resp.raise_for_status()
|
||||
return resp.json()["scores"]
|
||||
except Exception as e:
|
||||
|
||||
@@ -47,11 +47,11 @@ class TerminalExecute(BaseTool):
|
||||
"- command: 必填,要执行的命令字符串\n"
|
||||
"- working_dir: 可选,命令执行的工作目录\n"
|
||||
"- timeout: 超时时间(秒),默认 120 秒\n\n"
|
||||
"【数据作图标准流程】(本工具替代 data_plot,用于基于已入库 MySQL 数据作图)\n"
|
||||
"【数据作图标准流程】(本工具替代 data_plot,用于基于已入库数据库数据作图)\n"
|
||||
"当用户要求对已入库的数据画图/出图表时,按以下流程用本工具执行一个 Python 脚本:\n"
|
||||
"1. 查库:脚本内用 pymysql 连接 MySQL。连接参数从 env_config.json 的\n"
|
||||
" MYSQL_HOST/MYSQL_PORT/MYSQL_USER/MYSQL_PASSWORD 读取,或直接\n"
|
||||
" `from agent.core.db import load_mysql_config` 获取;执行只读 SELECT 取数。\n"
|
||||
"1. 查库:脚本内连接数据库(MySQL 或 SQLite)。连接参数从 env_config.json 的\n"
|
||||
" 工具数据库配置(TOOL_DB_*)读取,或直接\n"
|
||||
" `from agent.core.db import load_tool_db_config` 获取;执行只读 SELECT 取数。\n"
|
||||
"2. 画图:用 matplotlib。脚本开头必须 `import matplotlib; matplotlib.use('Agg')`\n"
|
||||
" (无头模式,避免弹窗阻塞);中文需设字体\n"
|
||||
" `plt.rcParams['font.sans-serif']=['SimHei','Microsoft YaHei','DejaVu Sans']`、\n"
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
让 Agent 用自然语言/SQL 操作数据库:查看库结构、查看表结构、查询表内容(查),
|
||||
以及对库内容做增删改(INSERT/UPDATE/DELETE/DDL)。
|
||||
|
||||
连接配置复用 env_config.json 的 MYSQL_*(host/port/user/password),
|
||||
但 database 必须由调用方显式指定(用户需告知 Agent 要操作的数据库名),
|
||||
避免误操作系统存储库(agent_history)。
|
||||
连接使用设置中的**工具数据库配置**(TOOL_DB_*,MySQL 或 SQLite),
|
||||
与存储后端配置完全隔离。MySQL 的 database 必须由调用方显式指定
|
||||
(用户需告知 Agent 要操作的数据库名),避免误操作系统存储库(agent_history);
|
||||
SQLite 目录模式下库名 = 文件名(<工具库目录>/<库名>.sqlite),
|
||||
建库即幂等创建该文件。
|
||||
|
||||
权限策略(见 tool_config.json):
|
||||
- action=list_tables / describe / query → 查类,直接执行(auto_execute_rules 升级为 direct)
|
||||
@@ -67,6 +69,7 @@ class Text2SQLTool(BaseTool):
|
||||
description: str = (
|
||||
"数据库操作工具(text2sql)。支持查看数据库结构、查询表内容、对数据库内容做增删改,以及建库建表。\n"
|
||||
"适用场景:用户希望用自然语言查询/操作数据库,或 Agent 需要读取/修改数据库内容,或需创建新库/新表。\n"
|
||||
"连接使用设置中的工具数据库配置(MySQL 或 SQLite,与存储库隔离);SQLite 目录模式下每个库名对应一个 .sqlite 文件。\n"
|
||||
"参数说明:\n"
|
||||
"- action: list_tables(列出所有表)/ describe(查看表结构)/ query(只读查询)/ execute(增删改/DDL)/ create_database(建库,幂等)/ create_table(建表,先确保库存在)\n"
|
||||
"- database: 目标数据库名(必填,用户需告知要操作的数据库)\n"
|
||||
@@ -80,7 +83,7 @@ class Text2SQLTool(BaseTool):
|
||||
'- 查询数据: {"action": "query", "database": "my_agent_data", "sql": "SELECT * FROM users LIMIT 10"}\n'
|
||||
'- 新增数据: {"action": "execute", "database": "my_agent_data", "sql": "INSERT INTO users (name) VALUES (\'test\')"}\n'
|
||||
'- 建库: {"action": "create_database", "database": "my_agent_data"}\n'
|
||||
'- 建表: {"action": "create_table", "database": "my_agent_data", "sql": "CREATE TABLE IF NOT EXISTS users (id INT PRIMARY KEY, name VARCHAR(50))"}\n'
|
||||
'- 建表: {"action": "create_table", "database": "my_agent_data", "sql": "CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)"}\n'
|
||||
)
|
||||
|
||||
def _run(
|
||||
@@ -120,42 +123,54 @@ class Text2SQLTool(BaseTool):
|
||||
return json.dumps({"error": f"text2sql 执行失败: {str(e)}"}, ensure_ascii=False)
|
||||
|
||||
# ---------- 连接 ----------
|
||||
@staticmethod
|
||||
def _tool_cfg() -> dict:
|
||||
"""读取工具数据库配置(TOOL_DB_*,与存储后端完全隔离)。"""
|
||||
from agent.core.db import load_tool_db_config
|
||||
return load_tool_db_config()
|
||||
|
||||
@classmethod
|
||||
def _db_type(cls) -> str:
|
||||
"""当前工具数据库类型:"mysql" | "sqlite"。"""
|
||||
return cls._tool_cfg().get("type", "mysql")
|
||||
|
||||
@staticmethod
|
||||
def _connect(database: str):
|
||||
"""创建 MySQL 连接(复用 MYSQL_* 的 host/port/user/password,database 由调用方指定)。
|
||||
"""创建数据库连接(按工具数据库配置分发 mysql/sqlite)。
|
||||
|
||||
- mysql: 复用 TOOL_DB_* 的 host/port/user/password,database 由调用方指定;
|
||||
- sqlite: 目录模式库名 = 文件名(<目录>/<库名>.sqlite),单文件模式用配置路径。
|
||||
|
||||
调用方负责 close。
|
||||
"""
|
||||
import pymysql
|
||||
from pymysql.cursors import DictCursor
|
||||
from agent.core.db import load_mysql_config
|
||||
from agent.core.db import connect_db
|
||||
|
||||
cfg = load_mysql_config()
|
||||
cfg = Text2SQLTool._tool_cfg()
|
||||
# database 由调用方指定,不使用配置中的默认库
|
||||
return pymysql.connect(
|
||||
host=cfg["host"],
|
||||
port=cfg["port"],
|
||||
user=cfg["user"],
|
||||
password=cfg["password"],
|
||||
database=database,
|
||||
charset="utf8mb4",
|
||||
cursorclass=DictCursor,
|
||||
autocommit=False,
|
||||
)
|
||||
return connect_db(cfg, database)
|
||||
|
||||
# ---------- 查类 ----------
|
||||
def _list_tables(self, database: str) -> str:
|
||||
conn = self._connect(database)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SHOW TABLES")
|
||||
rows = cur.fetchall()
|
||||
cur.execute("SELECT DATABASE() AS db")
|
||||
db_name = (cur.fetchone() or {}).get("db") or ""
|
||||
names: list[str] = []
|
||||
for r in rows:
|
||||
for v in r.values():
|
||||
names.append(str(v))
|
||||
if self._db_type() == "sqlite":
|
||||
cur = conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' "
|
||||
"AND name NOT LIKE 'sqlite_%' ORDER BY name"
|
||||
)
|
||||
rows = [dict(r) for r in cur.fetchall()]
|
||||
names = [str(r.get("name", "")) for r in rows]
|
||||
db_name = database
|
||||
else:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SHOW TABLES")
|
||||
rows = cur.fetchall()
|
||||
cur.execute("SELECT DATABASE() AS db")
|
||||
db_name = (cur.fetchone() or {}).get("db") or ""
|
||||
names: list[str] = []
|
||||
for r in rows:
|
||||
for v in r.values():
|
||||
names.append(str(v))
|
||||
return json.dumps(
|
||||
{"action": "list_tables", "database": db_name, "tables": names, "count": len(names)},
|
||||
ensure_ascii=False,
|
||||
@@ -171,9 +186,24 @@ class Text2SQLTool(BaseTool):
|
||||
return json.dumps({"error": f"非法表名: {table}"}, ensure_ascii=False)
|
||||
conn = self._connect(database)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f"SHOW FULL COLUMNS FROM `{table}`")
|
||||
cols = cur.fetchall()
|
||||
if self._db_type() == "sqlite":
|
||||
# PRAGMA table_info → 与 SHOW FULL COLUMNS 同构的列结构
|
||||
cur = conn.execute(f"PRAGMA table_info(`{table}`)")
|
||||
cols = []
|
||||
for r in cur.fetchall():
|
||||
d = dict(r)
|
||||
cols.append({
|
||||
"Field": d.get("name", ""),
|
||||
"Type": d.get("type", ""),
|
||||
"Null": "NO" if d.get("notnull") else "YES",
|
||||
"Key": "PRI" if d.get("pk") else "",
|
||||
"Default": d.get("dflt_value"),
|
||||
"Extra": "",
|
||||
})
|
||||
else:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f"SHOW FULL COLUMNS FROM `{table}`")
|
||||
cols = cur.fetchall()
|
||||
return json.dumps(
|
||||
{"action": "describe", "database": database, "table": table, "columns": cols, "count": len(cols)},
|
||||
ensure_ascii=False,
|
||||
@@ -195,9 +225,12 @@ class Text2SQLTool(BaseTool):
|
||||
row_limit = max(1, min(int(limit or 100), _MAX_ROWS))
|
||||
conn = self._connect(database)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(stmt)
|
||||
rows = cur.fetchmany(row_limit)
|
||||
if self._db_type() == "sqlite":
|
||||
rows = self._query_sqlite(conn, stmt, row_limit)
|
||||
else:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(stmt)
|
||||
rows = cur.fetchmany(row_limit)
|
||||
columns = list(rows[0].keys()) if rows else []
|
||||
return json.dumps(
|
||||
{
|
||||
@@ -214,16 +247,50 @@ class Text2SQLTool(BaseTool):
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@staticmethod
|
||||
def _query_sqlite(conn: Any, stmt: str, row_limit: int) -> list[dict]:
|
||||
"""SQLite 只读查询:SHOW TABLES / DESCRIBE / DESC 翻译为 sqlite 等价语法。"""
|
||||
up = stmt.lstrip("(").lstrip().upper()
|
||||
if up.startswith("SHOW TABLES"):
|
||||
cur = conn.execute(
|
||||
"SELECT name AS `Tables_in_db` FROM sqlite_master "
|
||||
"WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
|
||||
)
|
||||
return [dict(r) for r in cur.fetchmany(row_limit)]
|
||||
if up.startswith(("DESCRIBE", "DESC")):
|
||||
parts = stmt.split()
|
||||
t = parts[1].strip('`"[]') if len(parts) > 1 else ""
|
||||
if not t:
|
||||
return []
|
||||
cur = conn.execute(f"PRAGMA table_info(`{t}`)")
|
||||
cols = []
|
||||
for r in cur.fetchall():
|
||||
d = dict(r)
|
||||
cols.append({
|
||||
"Field": d.get("name", ""),
|
||||
"Type": d.get("type", ""),
|
||||
"Null": "NO" if d.get("notnull") else "YES",
|
||||
"Key": "PRI" if d.get("pk") else "",
|
||||
"Default": d.get("dflt_value"),
|
||||
"Extra": "",
|
||||
})
|
||||
return cols[:row_limit]
|
||||
cur = conn.execute(stmt)
|
||||
return [dict(r) for r in cur.fetchmany(row_limit)]
|
||||
|
||||
# ---------- 增删改类 ----------
|
||||
def _execute(self, sql: str | None, database: str) -> str:
|
||||
if not sql or not sql.strip():
|
||||
return json.dumps({"error": "execute 操作需提供 sql"}, ensure_ascii=False)
|
||||
conn = self._connect(database)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql.strip())
|
||||
affected = cur.rowcount
|
||||
last_id = cur.lastrowid
|
||||
if self._db_type() == "sqlite":
|
||||
cur = conn.execute(sql.strip())
|
||||
else:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql.strip())
|
||||
affected = cur.rowcount
|
||||
last_id = cur.lastrowid
|
||||
conn.commit()
|
||||
return json.dumps(
|
||||
{
|
||||
@@ -247,17 +314,32 @@ class Text2SQLTool(BaseTool):
|
||||
|
||||
# ---------- 建库建表 ----------
|
||||
def _create_database(self, database: str) -> str:
|
||||
"""幂等创建 MySQL 数据库。
|
||||
"""幂等创建数据库。
|
||||
|
||||
连接 MySQL 服务器(不指定 database,避免库不存在时连接失败),
|
||||
执行 CREATE DATABASE IF NOT EXISTS。逻辑与 db.ensure_database() 一致,
|
||||
但库名由调用方指定。
|
||||
- mysql: CREATE DATABASE IF NOT EXISTS(连服务器,不指定 database);
|
||||
- sqlite: 库 = 文件,幂等创建(建父目录 + 空文件),无 CREATE DATABASE 语句。
|
||||
"""
|
||||
if self._db_type() == "sqlite":
|
||||
from agent.core.db import load_tool_db_config, sqlite_db_path, ensure_sqlite_database
|
||||
try:
|
||||
cfg = load_tool_db_config()
|
||||
path = sqlite_db_path(cfg, database)
|
||||
ensure_sqlite_database(path)
|
||||
except Exception as e:
|
||||
return json.dumps(
|
||||
{"error": f"create_database 失败: {str(e)}", "database": database},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
return json.dumps(
|
||||
{"action": "create_database", "database": database, "success": True,
|
||||
"note": "sqlite 库即文件,已幂等创建"},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
import pymysql
|
||||
from pymysql.cursors import DictCursor
|
||||
from agent.core.db import load_mysql_config
|
||||
from agent.core.db import load_tool_db_config
|
||||
|
||||
cfg = load_mysql_config()
|
||||
cfg = load_tool_db_config()
|
||||
try:
|
||||
conn = pymysql.connect(
|
||||
host=cfg["host"],
|
||||
@@ -300,8 +382,11 @@ class Text2SQLTool(BaseTool):
|
||||
pass
|
||||
conn = self._connect(database)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql.strip())
|
||||
if self._db_type() == "sqlite":
|
||||
conn.execute(sql.strip())
|
||||
else:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql.strip())
|
||||
conn.commit()
|
||||
return json.dumps(
|
||||
{"action": "create_table", "database": database, "success": True, "ddl": sql.strip()},
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
"""web_page 工具:对当前浏览器页面执行 DOM 级批量操作。
|
||||
|
||||
定位方式(无需视觉 grounding,毫秒级命中):
|
||||
- ref: snapshot 返回的元素序号
|
||||
- selector: Playwright 选择器(CSS / text=登录 / role=button / xpath= 等,
|
||||
纯中文或含空格的字符串会自动按 text= 处理)
|
||||
|
||||
批量语义:
|
||||
actions 列表顺序执行,首错即停并报告失败步骤;每次调用结束返回页面截图
|
||||
artifact(复用 nodes.py 的 image_bytes 注入链路)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from typing import Any, Literal, Optional, Tuple
|
||||
|
||||
from langchain.tools import BaseTool
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .browser_control import get_browser_session, normalize_url
|
||||
|
||||
# ---------- snapshot / extract 注入页面的 JS ----------
|
||||
_SNAPSHOT_JS = """() => {
|
||||
const SELECTOR = 'a[href], button, input, select, textarea, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [onclick], [contenteditable="true"]';
|
||||
const els = Array.from(document.querySelectorAll(SELECTOR));
|
||||
const out = [];
|
||||
const isVisible = (el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width < 1 || r.height < 1) return false;
|
||||
const s = getComputedStyle(el);
|
||||
return s.visibility !== 'hidden' && s.display !== 'none';
|
||||
};
|
||||
const cssPath = (el) => {
|
||||
if (el.id) return '#' + CSS.escape(el.id);
|
||||
const parts = [];
|
||||
let cur = el, depth = 0;
|
||||
while (cur && cur.nodeType === 1 && depth < 6) {
|
||||
let seg = cur.tagName.toLowerCase();
|
||||
if (seg === 'html' || seg === 'body') break;
|
||||
const parent = cur.parentElement;
|
||||
if (parent) {
|
||||
const same = Array.from(parent.children).filter(c => c.tagName === cur.tagName);
|
||||
if (same.length > 1) seg += ':nth-of-type(' + (same.indexOf(cur) + 1) + ')';
|
||||
}
|
||||
parts.unshift(seg);
|
||||
cur = parent;
|
||||
depth += 1;
|
||||
if (cur && cur.id) { parts.unshift('#' + CSS.escape(cur.id)); break; }
|
||||
}
|
||||
return parts.join(' > ');
|
||||
};
|
||||
for (const el of els) {
|
||||
try {
|
||||
if (!isVisible(el)) continue;
|
||||
const text = (el.innerText || '').trim().replace(/\\s+/g, ' ').slice(0, 40);
|
||||
out.push({
|
||||
sel: cssPath(el),
|
||||
tag: el.tagName.toLowerCase(),
|
||||
text: text,
|
||||
aria: (el.getAttribute('aria-label') || '').slice(0, 40),
|
||||
ph: (el.getAttribute('placeholder') || '').slice(0, 40),
|
||||
href: (el.getAttribute('href') || '').slice(0, 80),
|
||||
type: el.getAttribute('type') || ''
|
||||
});
|
||||
if (out.length >= 80) break;
|
||||
} catch (e) {}
|
||||
}
|
||||
return { title: document.title, url: location.href, elements: out };
|
||||
}"""
|
||||
|
||||
_EXTRACT_JS = """(sel) => {
|
||||
const el = sel ? document.querySelector(sel) : document.body;
|
||||
if (!el) return null;
|
||||
return { title: document.title, url: location.href, text: el.innerText || '' };
|
||||
}"""
|
||||
|
||||
|
||||
# 选择器结构字符:出现任意一个即视为"已是选择器语法",不做 text= 自动转换
|
||||
_SELECTOR_META_RE = re.compile(r"[\[\]#.:>~+*@=()]")
|
||||
|
||||
|
||||
def _normalize_selector(sel: str) -> str:
|
||||
"""选择器归一化:仅对不含结构字符的纯中文短语自动加 text= 前缀。
|
||||
|
||||
注意:
|
||||
- input[placeholder="用户名"]、button:has-text("提交") 等是合法 CSS /
|
||||
Playwright 引擎语法(中文出现在属性值/伪类参数里),必须原样透传,
|
||||
不能因"含中文"被误转成 text=xxx
|
||||
- 含空格的 ASCII 串可能是 CSS 后代选择器(如 "h2 a"),同样不转换
|
||||
"""
|
||||
s = (sel or "").strip()
|
||||
if not s:
|
||||
raise ValueError("selector 不能为空")
|
||||
# 已带 Playwright 选择器引擎前缀
|
||||
if re.match(r"^(css|text|xpath|id|role|placeholder|label|alt|title|testid|data-testid|nth|visible)=|^(>>|internal:)", s):
|
||||
return s
|
||||
if s.startswith("//") or s.startswith(".."):
|
||||
return "xpath=" + s
|
||||
# 纯中文短语(无任何选择器结构字符)→ text= 引擎匹配
|
||||
if re.search(r"[\u4e00-\u9fff]", s) and not _SELECTOR_META_RE.search(s):
|
||||
return "text=" + s
|
||||
return s
|
||||
|
||||
|
||||
# ---------- 动作定义 ----------
|
||||
WebPageAction = Literal[
|
||||
"goto", "snapshot", "click", "fill", "hover", "select",
|
||||
"scroll", "press", "wait", "extract", "back", "forward",
|
||||
"reload", "screenshot", "eval_js",
|
||||
]
|
||||
|
||||
|
||||
class WebPageActionItem(BaseModel):
|
||||
"""单个页面动作。"""
|
||||
action: WebPageAction = Field(..., description="动作类型。")
|
||||
url: str = Field(default="", description="目标 URL(goto 用,无协议头自动补 https://)。")
|
||||
ref: Optional[int] = Field(default=None, description="snapshot 返回的元素序号(优先于 selector)。")
|
||||
selector: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Playwright 选择器,合法写法原样传入即可:CSS(#id、.cls、input[placeholder=\"用户名\"])、"
|
||||
"text=登录、placeholder=用户名、role=button[name=\"提交\"]、xpath=//div。"
|
||||
"仅纯中文短语会自动按 text= 匹配;不要给 CSS/引擎语法外再套 text= 前缀。",
|
||||
)
|
||||
text: str = Field(default="", description="fill 要输入的文本。")
|
||||
press_enter: bool = Field(default=False, description="fill 输入完成后按回车(触发表单提交/搜索)。")
|
||||
button: Literal["left", "right"] = Field(default="left", description="鼠标按键(click 用)。")
|
||||
double_click: bool = Field(default=False, description="是否双击(click 用)。")
|
||||
key: str = Field(default="", description="按键(press 用),如 Enter、Control+a、ArrowDown。")
|
||||
clicks: int = Field(default=0, description="滚轮量(scroll 用,与 gui_tool 一致:负数为向下)。")
|
||||
scroll_to: Literal["", "top", "bottom"] = Field(default="", description="滚动到页面顶部/底部(scroll 用,优先于 clicks)。")
|
||||
value: str = Field(default="", description="select 下拉选项的 value 或可见文本。")
|
||||
wait_seconds: float = Field(default=1.0, description="等待秒数(wait 用)。")
|
||||
wait_selector: str = Field(default="", description="等待该选择器元素出现(wait 用,优先于固定秒数)。")
|
||||
full_page: bool = Field(default=False, description="是否整页截图(screenshot 用)。")
|
||||
max_length: int = Field(default=3000, description="extract 文本最大返回长度。")
|
||||
js: str = Field(default="", description="eval_js 要执行的 JS 表达式或箭头函数。")
|
||||
|
||||
|
||||
def _short_url(url: str, limit: int = 100) -> str:
|
||||
"""结果文本中的 URL 截断(data: 或超长 query 的 URL 全量回显会污染上下文)。"""
|
||||
u = url or ""
|
||||
return u if len(u) <= limit else u[:limit] + "..."
|
||||
|
||||
|
||||
class WebPageInput(BaseModel):
|
||||
actions: list[WebPageActionItem] = Field(
|
||||
default_factory=list,
|
||||
description="有序页面动作列表,按顺序依次执行;空列表仅返回当前页面截图。",
|
||||
)
|
||||
|
||||
|
||||
class WebPage(BaseTool):
|
||||
"""DOM 级网页操作工具:通过 snapshot 序号或选择器精准操作页面元素。"""
|
||||
|
||||
args_schema: type[BaseModel] = WebPageInput
|
||||
name: str = "web_page"
|
||||
description: str = (
|
||||
"对浏览器当前页面执行 DOM 级精准操作(毫秒级,优于 gui_tool 视觉定位)。\n"
|
||||
"典型流程:browser_control 打开 URL → web_page snapshot 获取元素清单 → 按 ref/selector 批量操作。\n"
|
||||
"支持动作:goto/snapshot/click/fill/hover/select/scroll/press/wait/extract/back/forward/reload/screenshot/eval_js。\n"
|
||||
"元素定位:优先用 snapshot 返回的 ref 序号;用 selector 时合法语法原样传入"
|
||||
"(CSS:input[placeholder=\"用户名\"];引擎:text=登录 / placeholder=用户名 / role=button[name=\"提交\"] / xpath=//div)。\n"
|
||||
"同一页面的连续操作必须合并为一次调用(actions 传多个动作),页面跳转后需重新 snapshot。\n"
|
||||
"改变页面状态的操作会附操作后截图;纯观察批次(snapshot/extract)不附图。\n"
|
||||
"仅用于已打开的浏览器页面操作,其余情况严禁调用。"
|
||||
)
|
||||
response_format: Literal["content", "content_and_artifact"] = "content_and_artifact"
|
||||
|
||||
# ---------- 单个动作执行(会话线程内运行) ----------
|
||||
async def _exec_action(self, session, item: WebPageActionItem) -> str:
|
||||
page = session._page
|
||||
|
||||
def _resolve() -> str:
|
||||
"""解析元素选择器:ref 优先,其次 selector(归一化)。"""
|
||||
if item.ref is not None:
|
||||
sel = session.get_ref_selector(item.ref)
|
||||
if sel is None:
|
||||
raise ValueError(f"ref={item.ref} 不存在(页面可能已跳转),请重新执行 snapshot")
|
||||
return sel
|
||||
if not item.selector:
|
||||
raise ValueError("该动作需要提供 ref 或 selector 定位元素")
|
||||
return _normalize_selector(item.selector)
|
||||
|
||||
async def _loc_op(op, sel: str):
|
||||
"""执行 locator 操作,定位超时时给出 snapshot+ref 引导提示。"""
|
||||
try:
|
||||
return await op()
|
||||
except Exception as e:
|
||||
if type(e).__name__ == "TimeoutError":
|
||||
raise ValueError(
|
||||
f"元素定位超时(10s): {sel}。建议先执行 snapshot 获取元素清单,再用 ref 序号定位"
|
||||
) from e
|
||||
raise
|
||||
|
||||
if item.action == "goto":
|
||||
if not item.url:
|
||||
raise ValueError("goto 需要 url")
|
||||
await page.goto(normalize_url(item.url), wait_until="domcontentloaded")
|
||||
return f"已打开 {_short_url(page.url)}({await page.title()})"
|
||||
|
||||
if item.action == "snapshot":
|
||||
data = await page.evaluate(_SNAPSHOT_JS)
|
||||
elements = data.get("elements", [])
|
||||
refs = {i + 1: e["sel"] for i, e in enumerate(elements)}
|
||||
session.set_refs(refs, page)
|
||||
lines = [f"页面: {data.get('title', '')} | {data.get('url', '')}"]
|
||||
lines.append(f"可交互元素共 {len(elements)} 个(click/fill 等用 ref 序号定位):")
|
||||
for i, e in enumerate(elements, 1):
|
||||
tag = e["tag"]
|
||||
if tag == "input" and e.get("type"):
|
||||
tag += f"[{e['type']}]"
|
||||
parts = [f"[{i}] {tag}"]
|
||||
if e.get("text"):
|
||||
parts.append(f"\"{e['text']}\"")
|
||||
if e.get("aria"):
|
||||
parts.append(f"aria=\"{e['aria']}\"")
|
||||
if e.get("ph"):
|
||||
parts.append(f"placeholder=\"{e['ph']}\"")
|
||||
if e.get("href"):
|
||||
parts.append(f"-> {e['href']}")
|
||||
lines.append(" ".join(parts))
|
||||
return "\n".join(lines)
|
||||
|
||||
if item.action == "click":
|
||||
sel = _resolve()
|
||||
loc = page.locator(sel)
|
||||
if item.double_click:
|
||||
await _loc_op(lambda: loc.dblclick(button=item.button), sel)
|
||||
else:
|
||||
await _loc_op(lambda: loc.click(button=item.button), sel)
|
||||
return f"已点击元素({item.ref and f'ref={item.ref}' or item.selector})"
|
||||
|
||||
if item.action == "fill":
|
||||
sel = _resolve()
|
||||
loc = page.locator(sel)
|
||||
await _loc_op(lambda: loc.fill(item.text), sel)
|
||||
if item.press_enter:
|
||||
await page.keyboard.press("Enter")
|
||||
return f"已输入并回车: \"{item.text[:50]}\""
|
||||
return f"已输入: \"{item.text[:50]}\""
|
||||
|
||||
if item.action == "hover":
|
||||
sel = _resolve()
|
||||
loc = page.locator(sel)
|
||||
await _loc_op(lambda: loc.hover(), sel)
|
||||
return "已悬停元素"
|
||||
|
||||
if item.action == "select":
|
||||
if not item.value:
|
||||
raise ValueError("select 需要 value(选项 value 或可见文本)")
|
||||
sel = _resolve()
|
||||
loc = page.locator(sel)
|
||||
try:
|
||||
await _loc_op(lambda: loc.select_option(value=item.value), sel)
|
||||
except Exception:
|
||||
await _loc_op(lambda: loc.select_option(label=item.value), sel)
|
||||
return f"已选择: {item.value[:50]}"
|
||||
|
||||
if item.action == "scroll":
|
||||
if item.scroll_to == "top":
|
||||
await page.evaluate("window.scrollTo(0, 0)")
|
||||
return "已滚动到顶部"
|
||||
if item.scroll_to == "bottom":
|
||||
await page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
|
||||
return "已滚动到底部"
|
||||
# 与 gui_tool 一致:负数向下(Playwright wheel 正 deltaY 向下)
|
||||
await page.mouse.wheel(0, -item.clicks * 100)
|
||||
return f"已滚动(滚轮量 {item.clicks})"
|
||||
|
||||
if item.action == "press":
|
||||
if not item.key:
|
||||
raise ValueError("press 需要 key(如 Enter、Control+a)")
|
||||
await page.keyboard.press(item.key)
|
||||
return f"已按键: {item.key}"
|
||||
|
||||
if item.action == "wait":
|
||||
if item.wait_selector:
|
||||
await page.wait_for_selector(
|
||||
_normalize_selector(item.wait_selector),
|
||||
timeout=max(item.wait_seconds, 1.0) * 1000,
|
||||
)
|
||||
return f"已等到元素出现: {item.wait_selector}"
|
||||
await asyncio.sleep(max(item.wait_seconds, 0.0))
|
||||
return f"已等待 {item.wait_seconds}s"
|
||||
|
||||
if item.action == "extract":
|
||||
try:
|
||||
title = await page.title()
|
||||
except Exception:
|
||||
title = ""
|
||||
if item.selector:
|
||||
# 走 locator(支持 CSS/text=/xpath= 等全部引擎;多匹配时拼接前 20 个)
|
||||
sel = _normalize_selector(item.selector)
|
||||
loc = page.locator(sel)
|
||||
count = await loc.count()
|
||||
if count == 0:
|
||||
raise ValueError(f"未找到元素: {item.selector}")
|
||||
texts = []
|
||||
for i in range(min(count, 20)):
|
||||
try:
|
||||
t = (await loc.nth(i).inner_text()).strip()
|
||||
except Exception:
|
||||
t = ""
|
||||
if t:
|
||||
texts.append(t)
|
||||
text = "\n".join(texts)
|
||||
else:
|
||||
data = await page.evaluate(_EXTRACT_JS, None)
|
||||
text = ((data or {}).get("text") or "").strip()
|
||||
limit = min(max(item.max_length, 200), 10000)
|
||||
head = f"页面: {title} | {_short_url(page.url)}\n"
|
||||
body = text[:limit] + ("...(已截断)" if len(text) > limit else "")
|
||||
return head + (body or "(无文本内容)")
|
||||
|
||||
if item.action == "back":
|
||||
await page.go_back(wait_until="domcontentloaded")
|
||||
return f"已后退到 {page.url}"
|
||||
|
||||
if item.action == "forward":
|
||||
await page.go_forward(wait_until="domcontentloaded")
|
||||
return f"已前进到 {page.url}"
|
||||
|
||||
if item.action == "reload":
|
||||
await page.reload(wait_until="domcontentloaded")
|
||||
return f"已刷新({await page.title()})"
|
||||
|
||||
if item.action == "screenshot":
|
||||
return f"已请求截图({'整页' if item.full_page else '视口'})"
|
||||
|
||||
if item.action == "eval_js":
|
||||
if not item.js:
|
||||
raise ValueError("eval_js 需要 js 表达式")
|
||||
result = await page.evaluate(item.js)
|
||||
return f"JS 执行结果: {str(result)[:2000]}"
|
||||
|
||||
raise ValueError(f"不支持的动作类型: {item.action}")
|
||||
|
||||
# ---------- 批量执行(一次 call_async 提交,会话线程内串行) ----------
|
||||
# 纯观察型动作:结果本身就是页面状态文本,无需再附截图(省视觉 token)
|
||||
_OBSERVE_ONLY = {"snapshot", "extract", "eval_js", "wait"}
|
||||
|
||||
async def _run_batch(self, session, actions: list[WebPageActionItem]) -> Tuple[str, Optional[bytes]]:
|
||||
await session._ensure_connected()
|
||||
lines: list[str] = []
|
||||
png: Optional[bytes] = None
|
||||
want_full = False
|
||||
screenshot_requested = False
|
||||
prev_observe = False
|
||||
for idx, item in enumerate(actions, 1):
|
||||
if item.action == "screenshot":
|
||||
want_full = item.full_page
|
||||
screenshot_requested = True
|
||||
# 观察型动作(snapshot/extract 等)执行前等待页面稳定,
|
||||
# 避免 JS 未渲染完就读到旧状态;连续观察只等待一次
|
||||
is_observe = item.action in self._OBSERVE_ONLY
|
||||
if is_observe and not prev_observe:
|
||||
try:
|
||||
await session._settle_current()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
line = await self._exec_action(session, item)
|
||||
lines.append(f"步骤{idx} [{item.action}] {line}")
|
||||
prev_observe = is_observe
|
||||
except Exception as e:
|
||||
lines.append(f"步骤{idx} [{item.action}] 失败: {e}")
|
||||
break
|
||||
# 操作结束截图:空动作或含非观察型动作(点击/输入等改变页面状态的操作)时附加;
|
||||
# 纯观察批次(snapshot/extract 等)文本结果已含页面信息,跳过截图。
|
||||
# 截图统一走 session.screenshot(内含页面稳定等待,避免截到加载动画)
|
||||
need_shot = screenshot_requested or (not actions) or any(
|
||||
a.action not in self._OBSERVE_ONLY for a in actions
|
||||
)
|
||||
if need_shot:
|
||||
try:
|
||||
png = await session.screenshot(want_full) if session._page else None
|
||||
except Exception:
|
||||
png = None
|
||||
if screenshot_requested:
|
||||
lines.append("截图见随附图片")
|
||||
msg = "\n".join(lines) if lines else "无动作(已返回当前页面截图)"
|
||||
return msg, png
|
||||
|
||||
# ---------- 同步主逻辑 ----------
|
||||
def _run(self, actions: list[WebPageActionItem] | list[dict] | None = None) -> Tuple[str, dict]:
|
||||
session = get_browser_session()
|
||||
# LangChain 调用路径传入 pydantic 模型实例;直连/测试路径可能传 dict,两者兼容
|
||||
items = [
|
||||
a if isinstance(a, WebPageActionItem) else WebPageActionItem(**a)
|
||||
for a in (actions or [])
|
||||
]
|
||||
try:
|
||||
msg, png = session.call_async(
|
||||
lambda s: self._run_batch(s, items),
|
||||
timeout=180.0,
|
||||
)
|
||||
except Exception as e:
|
||||
return f"web_page 执行出错: {e}", {}
|
||||
artifact: dict = {}
|
||||
if png:
|
||||
artifact["image_bytes"] = png
|
||||
return msg, artifact
|
||||
|
||||
async def _arun(self, actions: list[WebPageActionItem] | list[dict] | None = None) -> Tuple[str, dict]:
|
||||
return await asyncio.to_thread(self._run, actions)
|
||||
@@ -33,11 +33,23 @@ from agent.utils.image_utils import IMAGE_FILE_EXTENSIONS
|
||||
AUDIO_FILE_EXTENSIONS = frozenset({".wav", ".mp3", ".flac", ".ogg", ".m4a", ".aac", ".wma", ".opus", ".aiff", ".ape", ".amr", ".au"})
|
||||
|
||||
# 扩展名到内部类型标签的映射
|
||||
# plain_text 覆盖所有 UTF-8 文本/代码文件(与前端附件分类保持一致)
|
||||
DOC_EXT_MAP = {
|
||||
".txt": "plain_text", ".json": "plain_text", ".md": "plain_text",".cpp": "plain_text",
|
||||
".c": "plain_text", ".py": "plain_text", ".m": "plain_text", ".java": "plain_text",".html": "plain_text",
|
||||
".svg": "plain_text",
|
||||
".txt": "plain_text", ".json": "plain_text", ".md": "plain_text", ".markdown": "plain_text",
|
||||
".js": "plain_text", ".mjs": "plain_text", ".cjs": "plain_text", ".ts": "plain_text",
|
||||
".tsx": "plain_text", ".jsx": "plain_text", ".vue": "plain_text", ".svelte": "plain_text",
|
||||
".py": "plain_text", ".pyw": "plain_text", ".html": "plain_text", ".htm": "plain_text",
|
||||
".css": "plain_text", ".scss": "plain_text", ".less": "plain_text", ".sass": "plain_text",
|
||||
".xml": "plain_text", ".yaml": "plain_text", ".yml": "plain_text", ".toml": "plain_text",
|
||||
".ini": "plain_text", ".cfg": "plain_text", ".conf": "plain_text", ".log": "plain_text",
|
||||
".env": "plain_text", ".sh": "plain_text", ".bat": "plain_text", ".cmd": "plain_text",
|
||||
".ps1": "plain_text", ".sql": "plain_text", ".r": "plain_text", ".go": "plain_text",
|
||||
".rs": "plain_text", ".rb": "plain_text", ".php": "plain_text", ".swift": "plain_text",
|
||||
".kt": "plain_text", ".scala": "plain_text", ".cpp": "plain_text", ".cc": "plain_text",
|
||||
".c": "plain_text", ".h": "plain_text", ".hpp": "plain_text", ".java": "plain_text",
|
||||
".m": "plain_text", ".svg": "plain_text",
|
||||
".docx": "docx",
|
||||
".docm": "docx",
|
||||
".pptx": "pptx",
|
||||
".pdf": "pdf",
|
||||
".csv": "tabular",
|
||||
@@ -182,6 +194,31 @@ def update_session_meta(session_id: str, agent_name: str,
|
||||
_save_session_meta(session_id, meta)
|
||||
|
||||
|
||||
# ---------- 会话级思考档位(存于 session_meta.json 顶层) ----------
|
||||
def get_session_thinking_level(session_id: str) -> str:
|
||||
"""读取会话级思考档位(顶层 thinking_level 键)。
|
||||
|
||||
输入:
|
||||
session_id: 会话 ID。
|
||||
|
||||
输出:
|
||||
档位字符串(low | high | xhigh | max | ultra);未配置返回 ""。
|
||||
"""
|
||||
if not session_id:
|
||||
return ""
|
||||
meta = _load_session_meta(session_id)
|
||||
return str(meta.get("thinking_level", "") or "")
|
||||
|
||||
|
||||
def set_session_thinking_level(session_id: str, level: str) -> None:
|
||||
"""写入会话级思考档位(顶层 thinking_level 键,不覆盖 agent_meta / tokens)。"""
|
||||
if not session_id:
|
||||
return
|
||||
meta = _load_session_meta(session_id)
|
||||
meta["thinking_level"] = level
|
||||
_save_session_meta(session_id, meta)
|
||||
|
||||
|
||||
# ---------- 动态尾部长期记忆(TodoList 状态 + 循环提醒) ----------
|
||||
def update_session_dynamic_tail(session_id: str, agent_key: str, text: str, max_entries: int = 100) -> None:
|
||||
"""持久化当前轮的动态尾部提示(TodoList 状态 + 循环提醒)为长期记忆。
|
||||
@@ -226,7 +263,7 @@ def get_session_dynamic_tail_history(session_id: str, agent_key: str) -> list[st
|
||||
|
||||
|
||||
def build_tool_history_messages(session_id: str, agent_key: str = "main") -> list:
|
||||
"""构建压缩游标之后的"历史工具调用与返回值"合成消息列表。
|
||||
"""构建压缩游标之后的"历史思考与工具调用"合成消息列表。
|
||||
|
||||
输入:
|
||||
session_id: 会话 ID。
|
||||
@@ -236,10 +273,21 @@ def build_tool_history_messages(session_id: str, agent_key: str = "main") -> lis
|
||||
synthetic HumanMessage(带 ``tool_ctx`` 标记)列表;无历史或
|
||||
游标之后无内容时为空列表。
|
||||
|
||||
上下文拼接格式(前缀缓存友好,确定性构建):
|
||||
[历史思考] {thinking} ← 该工具调用前模型产出的深度思考(reasoning_content)
|
||||
[历史反思] {reflection} ← 该工具调用前模型产出的反思(content)
|
||||
[历史工具调用] {name}({args}) → {result}
|
||||
(思考、反思、工具调用按因果顺序交错;思考/反思独立注入——工具无
|
||||
结果文本(失败/无返回值)时仍保留;每轮末尾未被工具调用消费的
|
||||
尾部思考/反思去重后追加在该轮工具调用之后)
|
||||
|
||||
系统定位:
|
||||
runtime.execute_agent 构建上下文时调用,替代原"最近5轮"注入,
|
||||
改为注入压缩游标之后的所有轮次工具调用历史(游标之前的部分已包含在
|
||||
压缩摘要中,由 ``compressed_content`` 承载)。
|
||||
改为注入压缩游标之后的所有轮次思考+工具调用历史(游标之前的
|
||||
部分已包含在压缩摘要中,由 ``compressed_content`` 承载)。
|
||||
所有消息从磁盘确定性构建、id 稳定(think_ctx_{idx}/
|
||||
reflect_ctx_{idx}/tool_ctx_{idx}),满足 LLM 前缀缓存条件;
|
||||
compress 节点按 id 精确移除。
|
||||
"""
|
||||
if not session_id:
|
||||
return []
|
||||
@@ -252,11 +300,35 @@ def build_tool_history_messages(session_id: str, agent_key: str = "main") -> lis
|
||||
turns.reverse() # 转为时间正序
|
||||
idx = 0
|
||||
for turn in turns:
|
||||
# 本轮已被工具调用消费的思考/反思内容(用于尾部去重;
|
||||
# 含游标之前的工具调用——被摘要覆盖的思考同样不得重复注入)
|
||||
consumed_thinkings: set[str] = set()
|
||||
consumed_reflections: set[str] = set()
|
||||
for tc in turn.get("tool_calls") or []:
|
||||
_think = str(tc.get("thinking", "") or "").strip()
|
||||
_reflect = str(tc.get("reflection", "") or "").strip()
|
||||
if _think:
|
||||
consumed_thinkings.add(_think)
|
||||
if _reflect:
|
||||
consumed_reflections.add(_reflect)
|
||||
if idx >= cursor:
|
||||
tn = tc.get("name", "unknown")
|
||||
ta = json.dumps(tc.get("args", {}), ensure_ascii=False)[:300]
|
||||
tr = str(tc.get("result_text", "") or "")[:500]
|
||||
# 思考/反思独立注入:即使工具无结果文本(失败/无返回值),
|
||||
# 模型的思考/反思仍有上下文价值
|
||||
if _think:
|
||||
result.append(HumanMessage(
|
||||
content=f"[历史思考] {_think[:500]}",
|
||||
additional_kwargs={"synthetic": True, "tool_ctx": True},
|
||||
id=f"think_ctx_{idx}",
|
||||
))
|
||||
if _reflect:
|
||||
result.append(HumanMessage(
|
||||
content=f"[历史反思] {_reflect[:500]}",
|
||||
additional_kwargs={"synthetic": True, "tool_ctx": True},
|
||||
id=f"reflect_ctx_{idx}",
|
||||
))
|
||||
if tn and tr:
|
||||
# 分配稳定 id(基于全局工具调用序号),供压缩节点 RemoveMessage 精确移除
|
||||
result.append(HumanMessage(
|
||||
@@ -265,6 +337,38 @@ def build_tool_history_messages(session_id: str, agent_key: str = "main") -> lis
|
||||
id=f"tool_ctx_{idx}",
|
||||
))
|
||||
idx += 1
|
||||
# 尾部思考/反思:该轮末尾未被任何工具调用消费的(分别去重后追加)
|
||||
if idx > cursor:
|
||||
think_tail_idx = 0
|
||||
reflect_tail_idx = 0
|
||||
for ref in turn.get("reflections") or []:
|
||||
_thought = str(ref.get("thought", "") or "").strip()
|
||||
_reflection = str(ref.get("reflection", "") or "").strip()
|
||||
if _thought and _thought not in consumed_thinkings:
|
||||
result.append(HumanMessage(
|
||||
content=f"[历史思考] {_thought[:500]}",
|
||||
additional_kwargs={"synthetic": True, "tool_ctx": True},
|
||||
id=f"think_tail_{turn.get('turn_id', idx)}_{think_tail_idx}",
|
||||
))
|
||||
think_tail_idx += 1
|
||||
if _reflection and _reflection not in consumed_reflections:
|
||||
result.append(HumanMessage(
|
||||
content=f"[历史反思] {_reflection[:500]}",
|
||||
additional_kwargs={"synthetic": True, "tool_ctx": True},
|
||||
id=f"reflect_tail_{turn.get('turn_id', idx)}_{reflect_tail_idx}",
|
||||
))
|
||||
reflect_tail_idx += 1
|
||||
# 旧格式兼容(仅 content,无 thought/reflection 字段):
|
||||
# 视为思考注入(与旧行为一致)
|
||||
if not _thought and not _reflection:
|
||||
rc = str(ref.get("content", "") or "").strip()
|
||||
if rc and rc not in consumed_thinkings:
|
||||
result.append(HumanMessage(
|
||||
content=f"[历史思考] {rc[:500]}",
|
||||
additional_kwargs={"synthetic": True, "tool_ctx": True},
|
||||
id=f"think_tail_{turn.get('turn_id', idx)}_{think_tail_idx}",
|
||||
))
|
||||
think_tail_idx += 1
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
@@ -913,21 +1017,26 @@ def audio_file_to_text(audio_path: str) -> str:
|
||||
except ImportError:
|
||||
raise RuntimeError("非 WAV 音频需要安装 pydub 和 ffmpeg: pip install pydub")
|
||||
|
||||
asr_cfg = _env_utils.get_service_model_config("asr")
|
||||
try:
|
||||
with open(wav_path, "rb") as f:
|
||||
audio_bytes = f.read()
|
||||
b64 = _base64.b64encode(audio_bytes).decode("ascii")
|
||||
data_url = f"data:audio/wav;base64,{b64}"
|
||||
payload = {
|
||||
"model": asr_cfg["model"],
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [{"type": "audio_url", "audio_url": {"url": data_url}}]
|
||||
}]
|
||||
}
|
||||
endpoint = f"{_env_utils.ASR_BASE_URL.rstrip('/')}/v1/chat/completions"
|
||||
endpoint = f"{asr_cfg['base_url']}/v1/chat/completions"
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if asr_cfg["api_key"]:
|
||||
headers["Authorization"] = f"Bearer {asr_cfg['api_key']}"
|
||||
resp = _requests.post(
|
||||
endpoint,
|
||||
headers={"Content-Type": "application/json"},
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""环境变量加载与全局配置常量。
|
||||
|
||||
系统定位:
|
||||
项目配置入口,模块导入时从 ``config/env_config.json`` 加载配置,
|
||||
项目配置入口,模块导入时从 ``agents/config.json`` 的 env 分区加载配置,
|
||||
并向 ``core/llm``、``utils/agent_utils``、``utils/image_utils`` 等模块
|
||||
提供 LLM、压缩策略、RAG、浏览器等运行时参数。
|
||||
|
||||
@@ -48,27 +48,29 @@ def _get_env(name: str, default: str | None = None, cast: type | None = None, re
|
||||
|
||||
|
||||
def _refresh_globals():
|
||||
"""刷新模块级常量(热重载用)。"""
|
||||
"""刷新模块级常量(热重载用)。
|
||||
|
||||
WORKING_DIR / WORKSPACE_DIR 已从环境变量移除:工作空间默认取启动目录
|
||||
(os.getcwd()),实际工作空间由 workspace 分区与工作空间 API 管理。
|
||||
THINKING_LEVEL 已改为会话级变量(存于各会话 session_meta.json),
|
||||
此处仅保留全局默认档位作为无会话上下文(cron、describe_image 等)的兜底。
|
||||
"""
|
||||
global WORKING_DIR, WORKSPACE_DIR, USER_PYTHON_PATH
|
||||
global LLM_BASE_URL, LLM_API_KEY, LLM_MODEL, LLM_TIMEOUT
|
||||
global STREAMING_TTS_URL, ASR_BASE_URL, RAG_BASE_URL
|
||||
global LLM_CONTEXT_WINDOW, COMPRESS_RATE, THINKING_LEVEL
|
||||
global IMG_SIZE, RAG_CHUNK_SIZE, RAG_CHUNK_OVERLAP, GROUNDING_WIDTH, GROUNDING_HEIGHT
|
||||
global SEND_FILE_SIZE_LIMIT
|
||||
|
||||
WORKING_DIR = _get_env("WORKING_DIR", default=os.getcwd())
|
||||
WORKING_DIR = os.getcwd()
|
||||
# 将相对路径解析为绝对路径,确保无论从哪个目录启动服务,WORKING_DIR 始终指向项目根目录
|
||||
if WORKING_DIR and not os.path.isabs(WORKING_DIR):
|
||||
WORKING_DIR = os.path.abspath(WORKING_DIR)
|
||||
WORKSPACE_DIR = _get_env("WORKSPACE_DIR", default="")
|
||||
WORKSPACE_DIR = ""
|
||||
USER_PYTHON_PATH = _get_env("USER_PYTHON_PATH", default="")
|
||||
LLM_BASE_URL = _get_env("LLM_BASE_URL", required=True)
|
||||
LLM_API_KEY = _get_env("LLM_API_KEY", required=True)
|
||||
LLM_MODEL = _get_env("LLM_MODEL", required=True)
|
||||
LLM_TIMEOUT = _get_env("LLM_TIMEOUT", default="60", cast=float)
|
||||
STREAMING_TTS_URL = _get_env("STREAMING_TTS_URL")
|
||||
ASR_BASE_URL = _get_env("ASR_BASE_URL")
|
||||
RAG_BASE_URL = _get_env("RAG_BASE_URL")
|
||||
LLM_CONTEXT_WINDOW = int(os.getenv("LLM_CONTEXT_WINDOW", 262144))
|
||||
COMPRESS_RATE = float(os.getenv("COMPRESS_RATE", 0.6))
|
||||
THINKING_LEVEL = _get_env("THINKING_LEVEL", default="low")
|
||||
@@ -87,27 +89,103 @@ def _refresh_globals():
|
||||
# ultra 为未来 react→审批图预留占位,暂按"agent+思考"处理
|
||||
THINKING_LEVELS: tuple[str, ...] = ("low", "high", "xhigh", "max", "ultra")
|
||||
_THINKING_ENABLED_LEVELS: frozenset[str] = frozenset({"xhigh", "max", "ultra"})
|
||||
# 全局默认档位:仅作为无会话上下文(cron 等)的兜底;会话级档位见 get_session_thinking_level
|
||||
THINKING_LEVEL: str = "low"
|
||||
|
||||
|
||||
def thinking_enabled(level: str | None = None) -> bool:
|
||||
"""判断思考档位是否启用深度思考。"""
|
||||
lv = (level or THINKING_LEVEL or "low").lower()
|
||||
return lv in _THINKING_ENABLED_LEVELS
|
||||
def get_session_thinking_level(session_id: str | None) -> str:
|
||||
"""读取会话级思考档位(存于该会话的 session_meta.json)。
|
||||
|
||||
输入:
|
||||
session_id: 会话 ID;为空或读取失败时返回全局默认档位。
|
||||
|
||||
输出:
|
||||
合法档位字符串(low | high | xhigh | max | ultra)。
|
||||
"""
|
||||
if session_id:
|
||||
try:
|
||||
from agent.utils.agent_utils import get_session_thinking_level as _get_session_level
|
||||
lv = _get_session_level(session_id)
|
||||
if lv in THINKING_LEVELS:
|
||||
return lv
|
||||
except Exception:
|
||||
pass
|
||||
return THINKING_LEVEL
|
||||
|
||||
|
||||
def get_thinking_extra_body(level: str | None = None) -> dict | None:
|
||||
def thinking_enabled(level: str | None = None, session_id: str | None = None) -> bool:
|
||||
"""判断思考档位是否启用深度思考(level 优先,其次按会话档位,最后全局默认)。"""
|
||||
if level:
|
||||
lv = level
|
||||
elif session_id:
|
||||
lv = get_session_thinking_level(session_id)
|
||||
else:
|
||||
lv = THINKING_LEVEL
|
||||
return (lv or "low").lower() in _THINKING_ENABLED_LEVELS
|
||||
|
||||
|
||||
def get_thinking_extra_body(level: str | None = None, session_id: str | None = None) -> dict | None:
|
||||
"""根据思考档位返回 Multimodel_LLM 的 extra_body。
|
||||
|
||||
返回空 dict {} 表示恢复模型原生行为(启用思考);None 表示禁用思考(默认)。
|
||||
传入 session_id 时按该会话的会话级档位解析(供运行时每轮动态应用)。
|
||||
"""
|
||||
return {} if thinking_enabled(level) else None
|
||||
return {} if thinking_enabled(level, session_id) else None
|
||||
|
||||
|
||||
# ---------- 本地服务模型(ASR / TTS / RAG / ImageGen) ----------
|
||||
# 这四个服务在 env_config.json 的 models 列表中注册(model 字段为模型名),
|
||||
# 运行时按模型名匹配取得 base_url / api_key;未注册时回退到默认本地端口。
|
||||
SERVICE_MODELS: dict[str, str] = {
|
||||
"asr": "Qwen3-ASR-1.7B",
|
||||
"tts": "VoxCPM1.5",
|
||||
"rag": "Qwen3-Embedding-0.6B",
|
||||
"image_gen": "Z-Image-Turbo",
|
||||
}
|
||||
SERVICE_DEFAULT_URLS: dict[str, str] = {
|
||||
"asr": "http://localhost:8901",
|
||||
"tts": "http://localhost:8902",
|
||||
"rag": "http://localhost:8903",
|
||||
"image_gen": "http://localhost:8904",
|
||||
}
|
||||
|
||||
|
||||
def get_service_model_config(service: str) -> dict:
|
||||
"""获取本地服务(asr/tts/rag/image_gen)的模型配置。
|
||||
|
||||
优先在 env_config.json 的 models 列表中按 model 名匹配(含 base_url / api_key / model),
|
||||
未注册时回退到默认本地端口(api_key 为空)。
|
||||
|
||||
输入:
|
||||
service: 服务标识,SERVICE_MODELS 中的键(asr/tts/rag/image_gen)。
|
||||
|
||||
输出:
|
||||
{"model": str, "base_url": str, "api_key": str}
|
||||
"""
|
||||
model_name = SERVICE_MODELS.get(service, "")
|
||||
default_url = SERVICE_DEFAULT_URLS.get(service, "")
|
||||
try:
|
||||
from agent.core.config_manager import get_model_by_name
|
||||
cfg = get_model_by_name(model_name)
|
||||
if cfg and cfg.get("base_url"):
|
||||
return {
|
||||
"model": str(cfg.get("model", model_name)),
|
||||
"base_url": str(cfg["base_url"]).rstrip("/"),
|
||||
"api_key": str(cfg.get("api_key", "") or ""),
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"model": model_name,
|
||||
"base_url": default_url.rstrip("/"),
|
||||
"api_key": "",
|
||||
}
|
||||
|
||||
|
||||
def _load_config() -> dict:
|
||||
"""加载 env_config.json,不存在时回退到 .env 文件。
|
||||
"""加载 config.json 的 env 分区,不存在时回退到 .env 文件。
|
||||
|
||||
优先级: env_config.json(通过 config_manager 统一读取) > .env > 默认值
|
||||
优先级: agents/config.json(通过 config_manager 统一读取) > .env > 默认值
|
||||
"""
|
||||
# 优先通过 config_manager 读取(单一数据源,避免重复解析同一文件)
|
||||
try:
|
||||
@@ -132,12 +210,13 @@ def _load_config() -> dict:
|
||||
pass
|
||||
|
||||
# 回退:直接读取文件(config_manager 不可用时)
|
||||
_config_path = Path(__file__).resolve().parents[1] / "config" / "env_config.json"
|
||||
_config_path = Path(__file__).resolve().parents[1] / "agents" / "config.json"
|
||||
|
||||
if _config_path.is_file():
|
||||
try:
|
||||
with open(_config_path, "r", encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
merged = json.load(f)
|
||||
config = merged.get("env") if isinstance(merged, dict) else None
|
||||
if isinstance(config, dict):
|
||||
for key, value in config.items():
|
||||
if key != "models" and value is not None:
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
"""工作区 git 集成工具(元数据目录 .minorAgent,替代自研 snapshot 体系)。
|
||||
|
||||
系统定位:
|
||||
- 为 Edit 文件树提供 git 状态(新增绿/修改橙),为会话回撤提供文件恢复
|
||||
- 元数据目录用工作区内的 ``.minorAgent``(GIT_DIR 环境变量方式,工作区不出现 .git)
|
||||
- 所有操作 try/except 包裹:git 不可用/非仓库时静默跳过,不影响主流程
|
||||
|
||||
可扩展性:
|
||||
- 提交时机由调用方决定(轮次结束落盘时 commit_turn,回撤时 reset_to_commit)
|
||||
- 新的 git 子命令可复用 _run / _run_lines 封装
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from typing import Optional
|
||||
|
||||
# 元数据目录名(工作区内)
|
||||
GIT_META_DIR = ".minorAgent"
|
||||
_INIT_MSG = "minor: init"
|
||||
_TURN_MSG_PREFIX = "minor: "
|
||||
|
||||
_SAFE_ENV = dict(os.environ)
|
||||
|
||||
|
||||
def _ws_git_dir(workspace: str) -> str:
|
||||
return os.path.join(workspace, GIT_META_DIR)
|
||||
|
||||
|
||||
def _git_env(workspace: str) -> dict:
|
||||
"""为指定工作区构造 GIT_DIR/GIT_WORK_TREE 环境(元数据放 .minorAgent)。"""
|
||||
env = dict(_SAFE_ENV)
|
||||
env["GIT_DIR"] = _ws_git_dir(workspace)
|
||||
env["GIT_WORK_TREE"] = workspace
|
||||
return env
|
||||
|
||||
|
||||
def _run(workspace: str, *args: str, timeout: float = 20.0) -> tuple[int, str]:
|
||||
"""在指定工作区执行 git 命令,返回 (returncode, stdout)。
|
||||
|
||||
注意:不做 strip——porcelain 输出行首的空格是状态码的一部分
|
||||
(如 " D path" 表示 worktree 删除),剥离会破坏解析。
|
||||
"""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["git", *args],
|
||||
cwd=workspace,
|
||||
env=_git_env(workspace),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
return proc.returncode, proc.stdout
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
||||
return -1, ""
|
||||
|
||||
|
||||
def is_repo(workspace: str) -> bool:
|
||||
"""工作区是否已初始化为 git 仓库。"""
|
||||
if not workspace or not os.path.isdir(workspace):
|
||||
return False
|
||||
return os.path.isdir(_ws_git_dir(workspace))
|
||||
|
||||
|
||||
def ensure_repo(workspace: str) -> bool:
|
||||
"""确保工作区为 git 仓库(首次自动 init + 初始 commit),失败返回 False。
|
||||
|
||||
初始 commit 让回撤有最底层的恢复点;.minorAgent 写入 info/exclude,
|
||||
避免 git status 把元数据目录自身显示为未跟踪。
|
||||
"""
|
||||
if not workspace or not os.path.isdir(workspace):
|
||||
return False
|
||||
if is_repo(workspace):
|
||||
return True
|
||||
try:
|
||||
rc, _ = _run(workspace, "init", "--quiet")
|
||||
if rc != 0:
|
||||
return False
|
||||
# 本地 user 配置(仅作用于该仓库)
|
||||
_run(workspace, "config", "user.name", "minor-agent")
|
||||
_run(workspace, "config", "user.email", "minor-agent@local")
|
||||
# 排除元数据目录自身(append 模式句柄不可读,需先读后写)
|
||||
exclude = os.path.join(_ws_git_dir(workspace), "info", "exclude")
|
||||
try:
|
||||
os.makedirs(os.path.dirname(exclude), exist_ok=True)
|
||||
existing = ""
|
||||
if os.path.isfile(exclude):
|
||||
with open(exclude, "r", encoding="utf-8") as f:
|
||||
existing = f.read()
|
||||
if GIT_META_DIR not in existing:
|
||||
with open(exclude, "a", encoding="utf-8") as f:
|
||||
f.write(f"\n{GIT_META_DIR}/\n")
|
||||
except OSError:
|
||||
pass
|
||||
# 初始 commit(空改动也提交,保证 HEAD 存在)
|
||||
_run(workspace, "add", "-A")
|
||||
rc, _ = _run(workspace, "commit", "-m", _INIT_MSG, "--allow-empty")
|
||||
return rc == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _porcelain_state(code: str) -> str:
|
||||
"""git status --porcelain 单字符状态码 → 业务状态。"""
|
||||
if code.startswith("??"):
|
||||
return "untracked"
|
||||
# 第一列 X=index(暂存),第二列 Y=worktree:任一非空格即视为 modified/deleted
|
||||
if len(code) >= 2:
|
||||
x, y = code[0], code[1]
|
||||
if "D" in (x, y):
|
||||
return "deleted"
|
||||
if x != " " or y != " ":
|
||||
return "modified"
|
||||
return "modified"
|
||||
|
||||
|
||||
def status(workspace: str) -> list[dict]:
|
||||
"""返回工作区 git 状态列表:[{path, state}],非仓库/失败返回 []。
|
||||
|
||||
state: untracked(新增) | modified(修改/暂存) | deleted(删除)
|
||||
"""
|
||||
if not is_repo(workspace):
|
||||
return []
|
||||
rc, out = _run(workspace, "status", "--porcelain", "-z")
|
||||
if rc != 0 or not out:
|
||||
return []
|
||||
result: list[dict] = []
|
||||
# -z 模式:每项为 "XY path\0",重命名/复制为 "XY old -> new\0"
|
||||
for entry in out.split("\0"):
|
||||
if not entry or len(entry) < 3:
|
||||
continue
|
||||
code, path = entry[:2], entry[3:]
|
||||
if code.startswith("R") or code.startswith("C"):
|
||||
path = path.split(" -> ")[-1] if " -> " in path else path
|
||||
result.append({"path": path, "state": _porcelain_state(code)})
|
||||
return result
|
||||
|
||||
|
||||
def commit_turn(workspace: str, session_id: str, turn_id: str) -> bool:
|
||||
"""提交轮次结束后的工作区快照,message 编码会话与回合(供回撤定位)。"""
|
||||
if not workspace or not os.path.isdir(workspace):
|
||||
return False
|
||||
if not ensure_repo(workspace):
|
||||
return False
|
||||
try:
|
||||
_run(workspace, "add", "-A")
|
||||
rc, _ = _run(workspace, "commit", "-m", f"{_TURN_MSG_PREFIX}{session_id} {turn_id}")
|
||||
return rc == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def find_restore_commit(workspace: str, session_id: str, keep_turn_id: str) -> Optional[str]:
|
||||
"""定位回撤恢复点 commit:该会话 < keep_turn_id 的最近一次轮次提交,无则初始提交。
|
||||
|
||||
回撤到某条消息时,文件应恢复到"该消息之前"的状态 =
|
||||
上一个已完成轮次的提交(turn_id 严格小于 keep_turn_id)。
|
||||
"""
|
||||
if not is_repo(workspace):
|
||||
return None
|
||||
rc, out = _run(workspace, "log", "--format=%H%x09%s")
|
||||
if rc != 0 or not out:
|
||||
return None
|
||||
best: Optional[str] = None
|
||||
for line in out.splitlines():
|
||||
parts = line.split("\t", 1)
|
||||
if len(parts) != 2:
|
||||
continue
|
||||
commit_hash, msg = parts[0], parts[1]
|
||||
if msg == _INIT_MSG:
|
||||
best = best or commit_hash # 兜底:初始提交
|
||||
continue
|
||||
if not msg.startswith(_TURN_MSG_PREFIX):
|
||||
continue
|
||||
m = re.match(rf"^{re.escape(_TURN_MSG_PREFIX)}(\S+)\s+(\S+)$", msg)
|
||||
if not m or m.group(1) != session_id:
|
||||
continue
|
||||
tid = m.group(2)
|
||||
if tid < keep_turn_id: # turn_id 为时间戳字符串,字典序即时间序
|
||||
best = commit_hash
|
||||
return best
|
||||
|
||||
|
||||
def reset_to_commit(workspace: str, commit: str) -> bool:
|
||||
"""将工作区与 git 状态恢复到指定 commit(丢弃该点之后的改动与未跟踪文件)。"""
|
||||
if not is_repo(workspace) or not commit:
|
||||
return False
|
||||
try:
|
||||
rc, _ = _run(workspace, "reset", "--hard", commit)
|
||||
if rc != 0:
|
||||
return False
|
||||
_run(workspace, "clean", "-fd")
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def rollback_workspace_to(workspace: str, session_id: str, keep_turn_id: str) -> bool:
|
||||
"""会话回撤时恢复工作区文件与 git 状态(无恢复点返回 False,调用方跳过)。"""
|
||||
if not workspace:
|
||||
return False
|
||||
commit = find_restore_commit(workspace, session_id, keep_turn_id)
|
||||
if not commit:
|
||||
return False
|
||||
return reset_to_commit(workspace, commit)
|
||||
@@ -274,43 +274,58 @@ def split_inline_thinking(text: str) -> tuple[str, str]:
|
||||
return thinking, reply
|
||||
|
||||
|
||||
def extract_reasoning_text(msg: Any) -> str:
|
||||
"""提取模型的思考/反思内容,根据思考档位区分提取来源。
|
||||
def extract_thought(msg: Any) -> str:
|
||||
"""提取模型的深度思考(thought):``additional_kwargs.reasoning_content``。
|
||||
|
||||
功能描述:
|
||||
- 开启思考(xhigh/max/ultra):优先 ``additional_kwargs.reasoning_content``
|
||||
(独立字段,或由 ``_create_chat_result`` 从内联 ``<think>`` 块归一化而来),
|
||||
其次 content 内联 ``<think>`` 块;无标记返回 ""。
|
||||
- 关闭思考(low/high):模型在反思提示引导下,content 即一句话反思,
|
||||
直接返回 content。
|
||||
思考仅在深度思考开启时存在(独立字段,或由 ``_create_chat_result`` 从
|
||||
内联 ``<think>`` 块归一化而来);无思考返回空字符串。
|
||||
|
||||
输入:
|
||||
msg: AIMessage 对象(需有 content / additional_kwargs 属性)。
|
||||
msg: AIMessage 对象(需有 additional_kwargs 属性)。
|
||||
|
||||
输出:
|
||||
思考/反思内容文本(已 strip),无内容时返回空字符串。
|
||||
|
||||
系统定位:
|
||||
供 ``core/nodes`` 提取反思内容、``history/tool_call_recorder`` 提取思维链展示文本。
|
||||
思考文本(已 strip),无思考时返回 ""。
|
||||
"""
|
||||
from agent.utils.env_utils import thinking_enabled
|
||||
|
||||
extra = getattr(msg, "additional_kwargs", None) or {}
|
||||
rc = extra.get("reasoning_content") if isinstance(extra, dict) else None
|
||||
if rc and isinstance(rc, str) and rc.strip():
|
||||
return rc.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def extract_reflection(msg: Any) -> str:
|
||||
"""提取模型的反思(reflection):``content``。
|
||||
|
||||
反思 = 模型返回的 content(思考关闭档位下为引导输出的一句话反思;
|
||||
思考开启档位下若 content 非空同样保留为反思)。与思考独立存储,
|
||||
二者可同时存在。
|
||||
|
||||
输入:
|
||||
msg: AIMessage 对象(需有 content 属性)。
|
||||
|
||||
输出:
|
||||
反思文本(已 strip),无内容时返回 ""。
|
||||
"""
|
||||
content = getattr(msg, "content", "")
|
||||
if content and isinstance(content, str) and content.strip():
|
||||
if thinking_enabled():
|
||||
# 开启思考:仅提取内联 <think> 块,无标记则无思考内容
|
||||
thinking, _ = split_inline_thinking(content)
|
||||
return thinking if thinking else ""
|
||||
# 关闭思考:content 即反思内容
|
||||
return content.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def extract_reasoning_text(msg: Any) -> str:
|
||||
"""提取模型的思考/反思合并文本(兼容入口:有思考用思考,否则用反思)。
|
||||
|
||||
规则:
|
||||
思考 = 深度思考开启时的 ``reasoning_content``;
|
||||
反思 = ``content``。
|
||||
优先取思考,无思考时回退使用反思。
|
||||
|
||||
注意: 需要同时保留思考与反思时请分别使用 extract_thought /
|
||||
extract_reflection(存储与上下文注入按此区分)。
|
||||
"""
|
||||
return extract_thought(msg) or extract_reflection(msg)
|
||||
|
||||
|
||||
def format_args_summary(args: dict, max_len: int = 80) -> str:
|
||||
"""将工具参数 dict 序列化为截断的 JSON 字符串摘要。
|
||||
|
||||
|
||||
@@ -6,9 +6,8 @@
|
||||
@import url("./css/composer.css");
|
||||
@import url("./css/settings.css");
|
||||
@import url("./css/pending.css");
|
||||
@import url("./css/pending-overlay.css");
|
||||
@import url("./css/toolcalls.css");
|
||||
@import url("./css/todo.css");
|
||||
@import url("./css/notify-strip.css");
|
||||
@import url("./css/skills.css");
|
||||
@import url("./css/edit-mode.css");
|
||||
@import url("./css/cron.css");
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
// app.js -- 主入口:导入所有模块,绑定 DOM 事件
|
||||
// ES 模块重构版:原单文件 3800+ 行已拆分为 js/ 目录下各司其职的小模块
|
||||
//
|
||||
// !! 单实例约束:本文件必须全页面只被求值一次。js/ 下模块通过 import('../app.js')
|
||||
// 取本文件的导出函数,入口脚本若带版本查询串(如 /app.js?v=N)会与无串路径形成
|
||||
// 两个模块实例 → 监听/初始化双份注册 → toggle 类按钮一次点击"开+关"互相抵消
|
||||
// (左栏/终端按钮曾因此表现为点击无反应)。入口已隔离为 js/boot.js(版本参数只加
|
||||
// 在它身上);今后切勿让任何 import 或 <script> 以带查询串的路径加载本文件。
|
||||
|
||||
import { $, escapeHtml, inferUploadKind, buildOptimisticUserContent, revokeBlobUrlStack, showToast, dedupePendingFiles } from './js/utils.js';
|
||||
import { $, escapeHtml, inferUploadKind, buildOptimisticUserContent, revokeBlobUrlStack, showToast, dedupePendingFiles, filterOutUnsupported } from './js/utils.js';
|
||||
import { api } from './js/api.js';
|
||||
import { state } from './js/state.js';
|
||||
|
||||
@@ -11,7 +17,7 @@ import { setRenderAttachmentChips as audioSetRenderChips } from './js/audio.js';
|
||||
|
||||
// ---- 聊天渲染 ----
|
||||
import {
|
||||
renderMessages, renderMessage, renderAttachmentChips, scrollChatToBottom,
|
||||
renderMessages, renderMessagesIncremental, renderMessage, renderAttachmentChips, scrollChatToBottom,
|
||||
appendThinkingIndicator, streamAssistantMessage,
|
||||
renderAudioBubble, addTextQuote, initChatQuoteMenu,
|
||||
} from './js/chat-render.js';
|
||||
@@ -51,7 +57,7 @@ import { setRenderSkills } from './js/settings.js';
|
||||
import { renderSkills, bindSkillEditorEvents, bindImportMenu } from './js/skills.js';
|
||||
|
||||
// ---- Edit 模式 ----
|
||||
import { initEditMode, sendEditChat, switchToMode, acceptAllModifications, rejectAllModifications, getDocModifications, getCurrentTaskModifications, renderDocTabs, loadDocContent, setCronModeHooks } from './js/edit-mode.js';
|
||||
import { initEditMode, sendEditChat, switchToMode, renderDocTabs, loadDocContent, setCronModeHooks } from './js/edit-mode.js';
|
||||
|
||||
// ---- Cron 定时任务模式 ----
|
||||
import { initCronMode, enterCronMode, leaveCronMode, sendCronChat, abortCronTask, isCronRunning } from './js/cron.js';
|
||||
@@ -63,14 +69,13 @@ import { bindFilePreviewEvents, closeFilePreview } from './js/file-preview.js';
|
||||
import { initActionBar, deactivateAllActionPanels, setActionBadge } from './js/action-bar.js';
|
||||
|
||||
// ---- 文档修改面板 ----
|
||||
import { toggleDocModPanel, setDocModifications } from './js/doc-mod-panel.js';
|
||||
|
||||
// ---- 统一回撤 ----
|
||||
import { rollbackChat, setRollbackDeps } from './js/rollback.js';
|
||||
import { setRollbackChatFn } from './js/chat-render.js';
|
||||
|
||||
// ---- 文档快照 ----
|
||||
import { saveDocSnapshot, undoDocSnapshot, getDocSnapshots, setDocContent, getDocContent, renderDocTree } from './js/doc-tree.js';
|
||||
import { setDocContent, getDocContent, renderDocTree } from './js/doc-tree.js';
|
||||
|
||||
// ---- 主题 & 语言 ----
|
||||
import { initTheme } from './js/themes.js';
|
||||
@@ -111,6 +116,7 @@ setSessionDeps({
|
||||
setSendDeps({
|
||||
renderSessions,
|
||||
renderMessages,
|
||||
renderMessagesIncremental,
|
||||
renderAttachmentChips,
|
||||
appendThinkingIndicator,
|
||||
injectLiveToolCalls,
|
||||
@@ -134,9 +140,6 @@ setRollbackDeps({
|
||||
renderSessions,
|
||||
renderAttachmentChips,
|
||||
clearTodoOverlay,
|
||||
undoDocSnapshot,
|
||||
getDocSnapshots,
|
||||
saveDocSnapshot,
|
||||
setDocContent,
|
||||
getDocContent,
|
||||
renderDocTree,
|
||||
@@ -259,8 +262,6 @@ async function renderRecentFolders() {
|
||||
|
||||
function openFolderFromRecent(folderPath) {
|
||||
// 切换到 Edit 模式并打开文件夹
|
||||
const modeEditBtn = document.getElementById('modeEditBtn');
|
||||
// 切换模式
|
||||
import('./js/edit-mode.js').then((m) => {
|
||||
m.switchToMode("edit");
|
||||
// 打开文件夹
|
||||
@@ -469,8 +470,6 @@ function setupInlineDropdown(triggerId, panelId, onSelect) {
|
||||
|
||||
// ===== DOM 事件绑定 =====
|
||||
const menuBtn = $("menuBtn");
|
||||
const collapseBtn = $("collapseBtn");
|
||||
const expandBtn = $("expandBtn");
|
||||
const drawerBackdrop = $("drawerBackdrop");
|
||||
const sendBtn = $("sendBtn");
|
||||
const micBtn = $("micBtn");
|
||||
@@ -480,10 +479,10 @@ const textInput = $("textInput");
|
||||
const fileInput = $("fileInput");
|
||||
const appRoot = $("app");
|
||||
|
||||
// 文件上传监听
|
||||
// 文件上传监听(视频暂不支持,自动过滤并提示)
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener("change", () => {
|
||||
const files = Array.from(fileInput.files || []);
|
||||
const files = filterOutUnsupported(Array.from(fileInput.files || []));
|
||||
state.pendingFiles = dedupePendingFiles(state.pendingFiles.concat(files));
|
||||
fileInput.value = "";
|
||||
renderAttachmentChips();
|
||||
@@ -500,7 +499,7 @@ folderInput.hidden = true;
|
||||
folderInput.style.display = "none";
|
||||
document.body.appendChild(folderInput);
|
||||
folderInput.addEventListener("change", () => {
|
||||
const files = Array.from(folderInput.files || []);
|
||||
const files = filterOutUnsupported(Array.from(folderInput.files || []));
|
||||
if (files.length > 0) {
|
||||
const firstPath = files[0].webkitRelativePath || "";
|
||||
const folderName = firstPath.split("/")[0] || "文件夹";
|
||||
@@ -583,7 +582,8 @@ function _setupComposerDrop() {
|
||||
const resp = await fetch(fileUrl);
|
||||
const blob = await resp.blob();
|
||||
const file = new File([blob], fileName, { type: blob.type || "application/octet-stream" });
|
||||
state.pendingFiles = dedupePendingFiles(state.pendingFiles.concat([file]));
|
||||
const accepted = filterOutUnsupported([file]);
|
||||
state.pendingFiles = dedupePendingFiles(state.pendingFiles.concat(accepted));
|
||||
renderAttachmentChips();
|
||||
if (textInput) textInput.focus();
|
||||
} catch {
|
||||
@@ -639,7 +639,7 @@ function _setupComposerDrop() {
|
||||
folders.forEach((f) => {
|
||||
files.push(new File([], f.name, { type: "folder/" }));
|
||||
});
|
||||
state.pendingFiles = dedupePendingFiles(state.pendingFiles.concat(files));
|
||||
state.pendingFiles = dedupePendingFiles(state.pendingFiles.concat(filterOutUnsupported(files)));
|
||||
renderAttachmentChips();
|
||||
if (textInput) textInput.focus();
|
||||
}
|
||||
@@ -710,6 +710,17 @@ if (micBtn) {
|
||||
// 工作空间选择器
|
||||
bindWorkspaceEvents();
|
||||
|
||||
// 输入区虚线遮罩(无工作区/无会话引导态):点击选择工作区以开始对话
|
||||
const composerEmptyOverlay = $("composerEmptyOverlay");
|
||||
if (composerEmptyOverlay) {
|
||||
composerEmptyOverlay.addEventListener("click", () => {
|
||||
import('./js/workspace.js').then((m) => m.addWorkspace()).catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
// 内置 PowerShell 终端
|
||||
import('./js/terminal.js').then((m) => m.initTerminal()).catch(() => {});
|
||||
|
||||
// 文件预览事件
|
||||
bindFilePreviewEvents();
|
||||
|
||||
@@ -723,37 +734,9 @@ if (canvasBtn) {
|
||||
});
|
||||
}
|
||||
|
||||
// 操作图标按钮栏
|
||||
initActionBar({
|
||||
onToggleTodo: (show) => {
|
||||
if (show) {
|
||||
// 直接查 DOM 显示,不依赖异步导入
|
||||
const el = (state._currentTodoOverlay && state._currentTodoOverlay.parentNode)
|
||||
? state._currentTodoOverlay
|
||||
: document.querySelector(".todo-list-overlay");
|
||||
if (el) el.hidden = false;
|
||||
} else {
|
||||
clearTodoOverlay();
|
||||
}
|
||||
},
|
||||
onTogglePending: (show) => {
|
||||
if (show) {
|
||||
const el = (state._currentPendingOverlay && state._currentPendingOverlay.parentNode)
|
||||
? state._currentPendingOverlay
|
||||
: document.querySelector(".pending-overlay");
|
||||
if (el) el.hidden = false;
|
||||
// 同步布局
|
||||
import('./js/pending-overlay.js').then(m => { if (m.updateOverlayLayout) m.updateOverlayLayout(); }).catch(() => {});
|
||||
} else {
|
||||
import('./js/pending-overlay.js').then(m => m.clearPendingOverlay());
|
||||
}
|
||||
},
|
||||
onToggleDoc: (show) => {
|
||||
const mods = getDocModifications();
|
||||
setDocModifications(mods);
|
||||
toggleDocModPanel(show);
|
||||
},
|
||||
});
|
||||
// 操作按钮栏:todo / 人机交互已迁移至输入区上方通知条(notify-strip.js),
|
||||
// 静态按钮与浮窗 toggle 机制废弃;保留 initActionBar 调用以维持初始化时序。
|
||||
initActionBar({});
|
||||
|
||||
// 工作空间访问模式(图标触发器,document 委托,含连击保护)
|
||||
bindAccessModeEvents();
|
||||
@@ -874,65 +857,7 @@ if (newSessionBtn) {
|
||||
async function handleNewSessionWithConfirm() {
|
||||
// Agent 运行中禁止新建会话
|
||||
if (state.sending) return;
|
||||
|
||||
// 检查是否有未处理的文件变更
|
||||
let pendingMods = {};
|
||||
try {
|
||||
const { getDocModifications } = await import('./js/edit-mode.js');
|
||||
pendingMods = getDocModifications && getDocModifications() || {};
|
||||
} catch {}
|
||||
|
||||
const entries = Object.values(pendingMods).filter(m => m.type !== 'original');
|
||||
if (entries.length > 0) {
|
||||
// 有未处理的变更,弹出确认弹窗
|
||||
showNewSessionConfirm(entries.length);
|
||||
} else {
|
||||
doNewSession();
|
||||
}
|
||||
}
|
||||
|
||||
function showNewSessionConfirm(count) {
|
||||
const overlay = $('newSessionConfirm');
|
||||
const countEl = $('confirmPendingCount');
|
||||
if (!overlay) return;
|
||||
if (countEl) countEl.textContent = count;
|
||||
overlay.hidden = false;
|
||||
|
||||
// 取消
|
||||
const cancelBtn = $('confirmCancel');
|
||||
cancelBtn.onclick = () => { overlay.hidden = true; };
|
||||
|
||||
// 全部撤销并新建
|
||||
const rejectBtn = $('confirmRejectAll');
|
||||
rejectBtn.onclick = async () => {
|
||||
overlay.hidden = true;
|
||||
try {
|
||||
const { rejectFileModification, getDocModifications } = await import('./js/edit-mode.js');
|
||||
const mods = getDocModifications();
|
||||
for (const fp of Object.keys(mods)) {
|
||||
if (mods[fp].type !== 'original') await rejectFileModification(fp);
|
||||
}
|
||||
const { setDocModifications } = await import('./js/doc-mod-panel.js');
|
||||
setDocModifications({});
|
||||
} catch {}
|
||||
doNewSession();
|
||||
};
|
||||
|
||||
// 全部接受并新建
|
||||
const acceptBtn = $('confirmAcceptAll');
|
||||
acceptBtn.onclick = async () => {
|
||||
overlay.hidden = true;
|
||||
try {
|
||||
const { acceptFileModification, getDocModifications } = await import('./js/edit-mode.js');
|
||||
const mods = getDocModifications();
|
||||
for (const fp of Object.keys(mods)) {
|
||||
if (mods[fp].type !== 'original') await acceptFileModification(fp);
|
||||
}
|
||||
const { setDocModifications } = await import('./js/doc-mod-panel.js');
|
||||
setDocModifications({});
|
||||
} catch {}
|
||||
doNewSession();
|
||||
};
|
||||
doNewSession();
|
||||
}
|
||||
|
||||
async function doNewSession() {
|
||||
@@ -955,15 +880,62 @@ async function doNewSession() {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// 侧栏展开/收起
|
||||
if (collapseBtn) {
|
||||
collapseBtn.addEventListener("click", () => {
|
||||
if (appRoot && appRoot.classList.contains("is-mobile")) setDrawerOpen(false);
|
||||
else setSidebarCollapsed(true);
|
||||
});
|
||||
// 顶栏图标按钮(左栏/终端/右栏):与窗口控制按钮同模式直接绑定,
|
||||
// 并同时监听 pointerup 作为 click 被抑制时的兜底(300ms 去重防双触发)。
|
||||
// 窗口拖拽已收窄到独立 .header-drag-spacer 空白条,按钮不在任何 drag 区域内。
|
||||
const sidebarToggleBtn = $("sidebarToggleBtn");
|
||||
const rightBarBtnEl = $("rightBarBtn");
|
||||
const terminalBtnEl = $("terminalBtn");
|
||||
const rightPanelCloseEl = $("rightPanelClose");
|
||||
|
||||
/** 生成带防抖去重的切换处理器:click 与 pointerup 双通道只生效一次 */
|
||||
function headerToggle(fn) {
|
||||
let lastRun = 0;
|
||||
return () => {
|
||||
const now = Date.now();
|
||||
if (now - lastRun < 300) return;
|
||||
lastRun = now;
|
||||
fn();
|
||||
};
|
||||
}
|
||||
if (expandBtn) {
|
||||
expandBtn.addEventListener("click", () => setSidebarCollapsed(false));
|
||||
|
||||
const toggleSidebar = headerToggle(() => {
|
||||
const wasCollapsed = state.sidebarCollapsed;
|
||||
if (!wasCollapsed) {
|
||||
// 开右栏时点左栏:先关右栏再折叠
|
||||
import('./js/edit-mode.js').then((m) => m.setRightPanel(false)).catch(() => {});
|
||||
}
|
||||
setSidebarCollapsed(wasCollapsed ? false : true);
|
||||
});
|
||||
|
||||
const toggleRightBar = headerToggle(() => {
|
||||
const isOpen = appRoot.classList.contains("right-panel-open") || false;
|
||||
import('./js/edit-mode.js').then((m) => m.setRightPanel(!isOpen)).catch(() => {});
|
||||
});
|
||||
|
||||
const closeRightBar = headerToggle(() => {
|
||||
import('./js/edit-mode.js').then((m) => m.setRightPanel(false)).catch(() => {});
|
||||
});
|
||||
|
||||
const toggleTerm = headerToggle(() => {
|
||||
import('./js/terminal.js').then((m) => m.toggleTerminal()).catch(() => {});
|
||||
});
|
||||
|
||||
if (sidebarToggleBtn) {
|
||||
sidebarToggleBtn.addEventListener("click", (e) => { e.stopPropagation(); toggleSidebar(); });
|
||||
sidebarToggleBtn.addEventListener("pointerup", (e) => { e.stopPropagation(); toggleSidebar(); });
|
||||
}
|
||||
if (rightBarBtnEl) {
|
||||
rightBarBtnEl.addEventListener("click", (e) => { e.stopPropagation(); toggleRightBar(); });
|
||||
rightBarBtnEl.addEventListener("pointerup", (e) => { e.stopPropagation(); toggleRightBar(); });
|
||||
}
|
||||
if (terminalBtnEl) {
|
||||
terminalBtnEl.addEventListener("click", (e) => { e.stopPropagation(); toggleTerm(); });
|
||||
terminalBtnEl.addEventListener("pointerup", (e) => { e.stopPropagation(); toggleTerm(); });
|
||||
}
|
||||
if (rightPanelCloseEl) {
|
||||
rightPanelCloseEl.addEventListener("click", (e) => { e.stopPropagation(); closeRightBar(); });
|
||||
rightPanelCloseEl.addEventListener("pointerup", (e) => { e.stopPropagation(); closeRightBar(); });
|
||||
}
|
||||
|
||||
// 移动端菜单
|
||||
@@ -991,11 +963,8 @@ setCronModeHooks({ onEnter: enterCronMode, onLeave: leaveCronMode });
|
||||
// Cron 模式初始化(绑定列表/弹窗事件)
|
||||
initCronMode();
|
||||
|
||||
// Cron 模式切换按钮
|
||||
const modeCronBtn = $("modeCronBtn");
|
||||
modeCronBtn?.addEventListener("click", () => {
|
||||
if (state.mode !== "cron") switchToMode("cron");
|
||||
});
|
||||
// 顶栏"定时任务"按钮切换由 edit-mode.js 的 initEditMode 统一绑定
|
||||
// (cron ↔ chat 切换 + 按下状态同步)
|
||||
|
||||
// 启动后自动打开工作空间
|
||||
import('./js/edit-mode.js').then(m => m.autoOpenWorkspace()).catch(() => {});
|
||||
@@ -1007,7 +976,7 @@ if (window.electronAPI?.minimizeWindow) {
|
||||
tbMax?.addEventListener("click", () => window.electronAPI.maximizeWindow());
|
||||
tbClose?.addEventListener("click", () => window.electronAPI.closeWindow());
|
||||
tbSettings?.addEventListener("click", () => {
|
||||
document.getElementById("settingsBtn")?.click();
|
||||
import('./js/settings.js').then((m) => m.openSettings()).catch(() => {});
|
||||
});
|
||||
window.electronAPI.onMaximizeChanged((maximized) => {
|
||||
if (tbMax) tbMax.innerHTML = maximized ? '\uE923' : '\uE922';
|
||||
@@ -1017,6 +986,8 @@ if (window.electronAPI?.minimizeWindow) {
|
||||
// 主题 & 语言初始化
|
||||
initTheme();
|
||||
initI18n();
|
||||
// 左栏底部用户区(头像 / 用户名 / 邮箱 + 设置按钮)
|
||||
import('./js/user-area.js').then((m) => m.initUserArea()).catch(() => {});
|
||||
|
||||
// ===== 启动玻璃层过渡(毛玻璃 + 中央图标) =====
|
||||
// 加载完成后:图标向下滑出、毛玻璃渐变消失,露出完整应用界面
|
||||
@@ -1031,11 +1002,12 @@ function hideAppSplash() {
|
||||
}
|
||||
|
||||
// ===== 启动 =====
|
||||
bootstrap().then(() => {
|
||||
// 先加载工作区列表再 bootstrap:会话列表按工作区分组依赖 workspaceList,
|
||||
// 否则首帧渲染时列表为空,会话退化为无卡片平铺(点击后才补渲染分组)。
|
||||
initWorkspace().then(() => bootstrap()).then(() => {
|
||||
initTokenRing();
|
||||
initUsageCard();
|
||||
populateModelSelect();
|
||||
initWorkspace();
|
||||
initAccessMode();
|
||||
initThinkLevel();
|
||||
loadNickname();
|
||||
|
||||
@@ -23,8 +23,6 @@
|
||||
.session-item-action-btn,
|
||||
.mode-switch-item,
|
||||
.settings-tab,
|
||||
.rail-btn,
|
||||
.collapse-fab,
|
||||
.menu-btn,
|
||||
.settings-btn,
|
||||
.header-toggle,
|
||||
|
||||
@@ -82,33 +82,59 @@ body {
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
|
||||
/* ===== 自定义标题栏(仅 Electron 无边框模式,颜色跟随主题) ===== */
|
||||
.title-bar {
|
||||
/* ===== 窗口控制按钮(无边框 Electron 模式下置于 main-header 右侧,与侧栏品牌行平行) ===== */
|
||||
.win-controls {
|
||||
display: none;
|
||||
height: 36px;
|
||||
align-items: center;
|
||||
padding: 0 8px 0 14px;
|
||||
background: var(--titlebar-bg, var(--surface, #141424));
|
||||
-webkit-app-region: drag;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
z-index: 9999;
|
||||
}
|
||||
.is-electron .title-bar { display: flex; }
|
||||
|
||||
.title-bar-name {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted, #94a3b8);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.title-bar-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin-left: auto;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
.is-electron .win-controls {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* ===== 顶栏图标按钮(左栏/终端/右栏/设置) ===== */
|
||||
.top-icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.top-icon-btn img {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
display: block; /* flex 项内 block 化,确保 no-drag 生效 */
|
||||
pointer-events: none; /* 事件命中始终落在按钮本体(no-drag 元素)上 */
|
||||
}
|
||||
|
||||
.top-icon-btn:hover {
|
||||
background: var(--surface-hover, rgba(0, 0, 0, 0.05));
|
||||
}
|
||||
|
||||
/* 按下状态(cron/chat 模式切换按钮处于 cron 时保持高亮) */
|
||||
.top-icon-btn.is-active,
|
||||
.top-icon-btn[aria-pressed="true"] {
|
||||
background: color-mix(in srgb, var(--accent, #0e7490) 18%, transparent);
|
||||
color: var(--accent, #0e7490);
|
||||
}
|
||||
.top-icon-btn.is-active:hover,
|
||||
.top-icon-btn[aria-pressed="true"]:hover {
|
||||
background: color-mix(in srgb, var(--accent, #0e7490) 26%, transparent);
|
||||
}
|
||||
|
||||
.top-icon-btn.is-placeholder {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.title-bar-btn {
|
||||
width: 36px;
|
||||
@@ -134,14 +160,36 @@ body {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.title-bar-btn--settings {
|
||||
font-family: inherit;
|
||||
font-size: 0.85rem;
|
||||
/* Electron 无边框模式:标题栏已移除,main-header 承担顶部栏。
|
||||
关键:-webkit-app-region: drag 只放在 header-drag-spacer 空白条上,
|
||||
整个顶栏不再作为 drag 区域——按钮/设置/窗口控制彻底脱离 drag 区域命中路径,
|
||||
点击事件不会被无边框窗口的拖拽逻辑吞掉 */
|
||||
.is-electron .main-header {
|
||||
-webkit-app-region: no-drag;
|
||||
user-select: none;
|
||||
}
|
||||
.is-electron .main-header button,
|
||||
.is-electron .main-header img,
|
||||
.is-electron .main-header .main-header-ws {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
/* Electron 模式下隐藏 main-header,让对话区变大 */
|
||||
.is-electron .main-header {
|
||||
display: none;
|
||||
/* 顶栏空白拖拽条:Electron 下可拖动窗口(含双击最大化);Web 下为普通空白 */
|
||||
.header-drag-spacer {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
align-self: stretch;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
.is-electron .header-drag-spacer {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
/* 顶栏图标按钮(左栏/终端/右栏/设置)显式声明 no-drag + 事件穿透 */
|
||||
.is-electron .main-header .top-icon-btn,
|
||||
.is-electron .main-header .settings-btn {
|
||||
-webkit-app-region: no-drag;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.is-electron .main {
|
||||
padding-top: 0.25rem;
|
||||
|
||||
@@ -381,6 +381,72 @@
|
||||
overflow-x: visible;
|
||||
}
|
||||
|
||||
/* 隐藏聊天区垂直滚动条(保留滚动能力),由右侧回合导航条提供位置指引 */
|
||||
.chat-messages {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE / Edge 旧版 */
|
||||
}
|
||||
.chat-messages::-webkit-scrollbar {
|
||||
display: none; /* Chromium / Safari */
|
||||
}
|
||||
|
||||
/* ===== 回合导航条(右侧居中灰色透明小横条,点击跳转回合,当前回合高亮) ===== */
|
||||
.chat-shell {
|
||||
position: relative;
|
||||
}
|
||||
.turn-nav {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 14px;
|
||||
max-height: 60%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
z-index: 5;
|
||||
pointer-events: none; /* 容器不拦截点击,仅横条可点 */
|
||||
}
|
||||
.turn-nav-bar {
|
||||
pointer-events: auto;
|
||||
flex: 1 1 auto;
|
||||
width: 10px;
|
||||
min-height: 3px;
|
||||
max-height: 7px;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
padding: 0;
|
||||
background: color-mix(in srgb, var(--muted, #94a3b8) 35%, transparent);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
.turn-nav-bar:hover {
|
||||
background: color-mix(in srgb, var(--muted, #94a3b8) 65%, transparent);
|
||||
transform: scaleX(1.3);
|
||||
transform-origin: center;
|
||||
}
|
||||
.turn-nav-tip {
|
||||
position: absolute;
|
||||
right: calc(100% + 6px);
|
||||
transform: translateY(-50%);
|
||||
max-width: 200px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.4;
|
||||
color: var(--text);
|
||||
background: color-mix(in srgb, var(--muted, #94a3b8) 22%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--muted, #94a3b8) 32%, transparent);
|
||||
backdrop-filter: blur(4px);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
pointer-events: none;
|
||||
z-index: 6;
|
||||
}
|
||||
|
||||
/* ===== 消息操作按钮行(复制 / 撤回 / 重试,无边框图标按钮) ===== */
|
||||
.msg.user {
|
||||
flex-direction: column;
|
||||
|
||||
@@ -185,8 +185,10 @@
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.composer--centered .composer-row {
|
||||
max-width: 700px;
|
||||
/* 输入区宽度为原 700px 的 70%,两种状态统一居中 */
|
||||
.composer--centered .composer-row,
|
||||
.composer--bottom .composer-row {
|
||||
max-width: 490px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
@@ -198,6 +200,51 @@
|
||||
flex: 0;
|
||||
}
|
||||
|
||||
/* ===== 无工作区/无会话引导态:悬停输入框时以透明虚线遮罩提示选择工作区 ===== */
|
||||
.composer-empty-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
border: 1.5px dashed color-mix(in srgb, var(--muted, #94a3b8) 55%, transparent);
|
||||
border-radius: inherit;
|
||||
background: color-mix(in srgb, var(--muted, #94a3b8) 8%, transparent);
|
||||
color: var(--muted, #94a3b8);
|
||||
cursor: pointer;
|
||||
opacity: 0; /* 默认透明,悬停输入框时显现 */
|
||||
transition: opacity 0.18s ease, background 0.18s ease, border-color 0.18s ease, color 0.18s ease;
|
||||
z-index: 10;
|
||||
pointer-events: auto; /* 不可见时也拦截点击 → 打开工作区选择 */
|
||||
}
|
||||
.composer-empty-overlay[hidden] {
|
||||
display: none; /* 覆盖 .composer-empty-overlay 的 display:flex */
|
||||
}
|
||||
.composer--no-workspace .composer-input-shell:hover .composer-empty-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
.composer-empty-overlay:hover {
|
||||
background: color-mix(in srgb, var(--accent, #0e7490) 12%, transparent);
|
||||
border-color: var(--accent, #0e7490);
|
||||
color: var(--accent, #0e7490);
|
||||
}
|
||||
.composer-empty-icon {
|
||||
font-size: 1.6rem;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
}
|
||||
.composer-empty-text {
|
||||
font-size: 0.85rem;
|
||||
user-select: none;
|
||||
}
|
||||
/* 引导态下输入区不可直接输入,点击一律落到遮罩层 */
|
||||
.composer--no-workspace .composer-input-shell textarea,
|
||||
.composer--no-workspace .composer-input-bottom-row {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ===== Composer Row ===== */
|
||||
.composer-row {
|
||||
display: flex;
|
||||
@@ -1146,189 +1193,6 @@
|
||||
}
|
||||
|
||||
/* ===== 文档修改面板 ===== */
|
||||
.doc-mod-panel {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 320px;
|
||||
background: #fff;
|
||||
box-shadow: -4px 0 20px rgba(0, 0, 0, 0.1);
|
||||
z-index: 950;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: slideInRight 0.2s ease;
|
||||
}
|
||||
.doc-mod-panel[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from { transform: translateX(100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
|
||||
.doc-mod-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.7rem 1rem;
|
||||
border-bottom: 1px solid var(--bg-tertiary);
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.doc-mod-close {
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
color: var(--text-tertiary);
|
||||
padding: 0.2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.doc-mod-close:hover { color: var(--danger); }
|
||||
|
||||
.doc-mod-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.doc-mod-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.45rem 1rem;
|
||||
font-size: 0.8rem;
|
||||
border-bottom: 1px solid #f8fafc;
|
||||
}
|
||||
|
||||
.doc-mod-item-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.doc-mod-item--new .doc-mod-item-name { color: var(--success); }
|
||||
.doc-mod-item--modified .doc-mod-item-name { color: var(--warning); }
|
||||
.doc-mod-item--deleted .doc-mod-item-name { color: var(--danger); text-decoration: line-through; }
|
||||
|
||||
.doc-mod-item-actions {
|
||||
display: flex;
|
||||
gap: 0.2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.doc-mod-accept,
|
||||
.doc-mod-reject {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
|
||||
.doc-mod-accept {
|
||||
color: var(--success);
|
||||
background: transparent;
|
||||
}
|
||||
.doc-mod-accept:hover { background: rgba(34,197,94,0.1); }
|
||||
|
||||
.doc-mod-reject {
|
||||
color: var(--danger);
|
||||
background: transparent;
|
||||
}
|
||||
.doc-mod-reject:hover { background: var(--danger-bg); }
|
||||
|
||||
.doc-mod-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-top: 1px solid var(--bg-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.doc-mod-actions .btn {
|
||||
flex: 1;
|
||||
font-size: 0.78rem;
|
||||
padding: 0.35rem;
|
||||
}
|
||||
|
||||
.doc-mod-item-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.doc-mod-item-tag {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
padding: 0.08rem 0.35rem;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.doc-mod-item--new .doc-mod-item-tag { background: #dcfce7; color: var(--success); }
|
||||
.doc-mod-item--modified .doc-mod-item-tag { background: #fef9c3; color: var(--warning); }
|
||||
.doc-mod-item--deleted .doc-mod-item-tag { background: #fee2e2; color: var(--danger); }
|
||||
|
||||
.doc-mod-item-diff {
|
||||
margin-top: 0.4rem;
|
||||
padding: 0.35rem;
|
||||
background: #f8fafc;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.diff-line {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
padding: 0.08rem 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.diff-ln {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-tertiary);
|
||||
min-width: 3rem;
|
||||
text-align: right;
|
||||
font-size: 0.66rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.diff-add { color: var(--warning); background: var(--warning-bg); }
|
||||
.diff-add .diff-ln { color: var(--warning); }
|
||||
|
||||
.diff-remove { color: var(--danger); background: var(--danger-bg); }
|
||||
.diff-remove .diff-ln { color: var(--danger); }
|
||||
|
||||
.diff-text { flex: 1; min-width: 0; }
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* ===== 语音开关按钮(文字/语音)===== */
|
||||
.voice-toggle-btn {
|
||||
@@ -1601,3 +1465,206 @@
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ===== 内置 PowerShell 终端(颜色全部随主题变量;铺满下方 UI,无圆角/边框色差) ===== */
|
||||
.terminal-dock {
|
||||
flex-shrink: 0;
|
||||
/* 负 margin 抵消 .main 的水平/底部内边距:终端栏铺满下方 UI,左右两头接边 */
|
||||
margin: 0 -0.75rem -0.75rem;
|
||||
border-radius: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: color-mix(in srgb, var(--surface, #fff) 90%, #000);
|
||||
border-top: 1px solid var(--border, rgba(0, 0, 0, 0.12));
|
||||
animation: terminal-dock-in 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* 默认隐藏(.terminal-dock 的 display:flex 会覆盖 hidden 属性,需显式补回) */
|
||||
.terminal-dock[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@keyframes terminal-dock-in {
|
||||
from { opacity: 0; transform: translateY(14px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.terminal-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 5px 6px 0;
|
||||
background: color-mix(in srgb, var(--surface, #fff) 90%, #000);
|
||||
overflow-x: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.term-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 8px 5px 10px;
|
||||
border-radius: 7px 7px 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
max-width: 200px;
|
||||
background: transparent;
|
||||
user-select: none;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.term-tab:hover {
|
||||
background: var(--surface-hover, rgba(0, 0, 0, 0.05));
|
||||
}
|
||||
|
||||
.term-tab.active {
|
||||
background: var(--surface, #fff);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.term-tab--dead .term-tab-title {
|
||||
text-decoration: line-through;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.term-tab-ws {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.term-tab-close,
|
||||
.term-dock-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 0.65rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.term-tab-close:hover {
|
||||
background: var(--danger-bg, rgba(239, 68, 68, 0.12));
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.term-dock-close {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.term-dock-close:hover {
|
||||
background: var(--danger-bg, rgba(239, 68, 68, 0.12));
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* 标签居左排列;+ 号与"关闭全部"× 一并靠右(+ 在 × 左侧) */
|
||||
.term-tab-add {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.term-tab-add:hover {
|
||||
background: var(--surface-hover, rgba(0, 0, 0, 0.05));
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.terminal-body {
|
||||
flex: 1;
|
||||
min-height: 140px;
|
||||
max-height: 45vh;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.term-pane {
|
||||
display: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow-y: auto;
|
||||
padding: 8px 10px;
|
||||
background: color-mix(in srgb, var(--surface, #fff) 90%, #000);
|
||||
}
|
||||
|
||||
.term-pane.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.term-out {
|
||||
font-family: "Cascadia Mono", Consolas, "Courier New", monospace;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.45;
|
||||
color: var(--text);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ANSI 颜色 → 主题色 */
|
||||
.t-fg-muted { color: var(--muted); }
|
||||
.t-fg-danger { color: var(--danger, #ef4444); }
|
||||
.t-fg-success { color: var(--success, #22c55e); }
|
||||
.t-fg-warning { color: var(--warning, #eab308); }
|
||||
.t-fg-accent { color: var(--accent, #0e7490); }
|
||||
.t-fg-purple { color: #c084fc; }
|
||||
.t-fg-cyan { color: #22d3ee; }
|
||||
.t-bg-dark { background: rgba(0, 0, 0, 0.25); }
|
||||
.t-bg-danger { background: var(--danger-bg, rgba(239, 68, 68, 0.12)); }
|
||||
.t-bg-success { background: var(--success-bg, rgba(34, 197, 94, 0.12)); }
|
||||
.t-bg-warning { background: var(--warning-bg, rgba(234, 179, 8, 0.12)); }
|
||||
.t-bg-accent { background: color-mix(in srgb, var(--accent) 18%, transparent); }
|
||||
|
||||
/* 命令行(提示符 + 输入)作为终端输出的最后一行,随内容滚动,观感等同真实终端 */
|
||||
.terminal-input-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.terminal-prompt {
|
||||
font-family: "Cascadia Mono", Consolas, "Courier New", monospace;
|
||||
font-size: 0.8rem;
|
||||
color: var(--success, #22c55e);
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.terminal-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
caret-color: var(--success, #22c55e);
|
||||
font-family: "Cascadia Mono", Consolas, "Courier New", monospace;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.45;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.terminal-input:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
@@ -70,6 +70,49 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ===== 按工作区分组(复用 layout.css 的 ws-group / ws-card 视觉) ===== */
|
||||
.cron-list .ws-group {
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
/* 组内任务:与工作区卡片名称左对齐(同 session-item 规则) */
|
||||
.cron-group-tasks {
|
||||
list-style: none;
|
||||
margin: 1px 0 2px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.cron-group-tasks .cron-item {
|
||||
padding-left: 30px;
|
||||
}
|
||||
|
||||
.cron-group-tasks--plain {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.cron-group-tasks--plain .cron-item {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* 工作区卡片右侧任务计数徽标 */
|
||||
.ws-card-count {
|
||||
margin-left: auto;
|
||||
padding: 0 0.42em;
|
||||
min-width: 1.1em;
|
||||
height: 1.1em;
|
||||
border-radius: 0.55em;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
}
|
||||
|
||||
/* ===== 单个任务条目 ===== */
|
||||
.cron-item {
|
||||
font-size: 0.76rem;
|
||||
|
||||
@@ -180,32 +180,19 @@
|
||||
background: rgba(14, 116, 144, 0.25) !important;
|
||||
}
|
||||
|
||||
/* git 状态着色:新增绿 / 修改橙 / 删除橙+删除线 */
|
||||
.doc-tree-file--modified {
|
||||
color: #eab308 !important;
|
||||
color: #f59e0b !important;
|
||||
}
|
||||
|
||||
.doc-tree-file--new {
|
||||
color: #22c55e !important;
|
||||
}
|
||||
|
||||
.doc-tree-file--externally-added {
|
||||
color: #22c55e !important;
|
||||
}
|
||||
|
||||
.doc-tree-file--externally-modified {
|
||||
color: #eab308 !important;
|
||||
}
|
||||
|
||||
.doc-tree-file--externally-deleted {
|
||||
color: #ef4444 !important;
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: #ef4444;
|
||||
}
|
||||
|
||||
.doc-tree-file--deleted {
|
||||
color: #ef4444 !important;
|
||||
color: #f59e0b !important;
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: #ef4444;
|
||||
text-decoration-color: #f59e0b;
|
||||
}
|
||||
|
||||
.doc-tree-file--deleted:hover {
|
||||
|
||||
@@ -53,24 +53,6 @@
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.expand-rail {
|
||||
width: var(--rail-w);
|
||||
min-width: var(--rail-w);
|
||||
border-right: none;
|
||||
background: transparent;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-self: stretch;
|
||||
padding: 0.5rem 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.app-root.sidebar-collapsed .expand-rail {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.app-root.sidebar-collapsed .sidebar {
|
||||
width: 0;
|
||||
min-width: 0;
|
||||
@@ -79,34 +61,13 @@
|
||||
border: none;
|
||||
}
|
||||
|
||||
.rail-btn,
|
||||
.collapse-fab {
|
||||
width: 26px;
|
||||
min-width: 26px;
|
||||
height: 52px;
|
||||
padding: 0;
|
||||
border-radius: 0 14px 14px 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
box-shadow: 2px 0 16px var(--shadow);
|
||||
font-size: 1rem;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rail-btn:hover,
|
||||
.collapse-fab:hover {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: var(--sidebar-w);
|
||||
min-width: var(--sidebar-w);
|
||||
border-right: none;
|
||||
background: transparent;
|
||||
background: var(--surface, rgba(255, 255, 255, 0.94));
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.5rem 0 0.5rem 0.5rem;
|
||||
@@ -139,6 +100,196 @@
|
||||
padding-right: 0.35rem;
|
||||
}
|
||||
|
||||
/* 品牌区:logo 下方为全宽"新增工作区"横条按钮 */
|
||||
.sidebar-brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
margin: 0.65rem 0 0.55rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
height: 46px;
|
||||
width: auto;
|
||||
max-width: 90%;
|
||||
object-fit: contain;
|
||||
opacity: 0.95;
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.18));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.brand-new-workspace,
|
||||
.sidebar-settings-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--border, rgba(0, 0, 0, 0.1));
|
||||
border-radius: 9px;
|
||||
background: var(--surface-hover, rgba(0, 0, 0, 0.04));
|
||||
color: var(--muted);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.brand-new-workspace:hover,
|
||||
.sidebar-settings-btn:hover {
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
color: var(--accent);
|
||||
border-color: color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
}
|
||||
|
||||
.brand-new-workspace:active,
|
||||
.sidebar-settings-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.brand-new-workspace img,
|
||||
.sidebar-settings-btn img {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
/* ===== 左栏底部用户区:设置按钮 + 头像/用户名/邮箱 ===== */
|
||||
.sidebar-user-zone {
|
||||
flex-shrink: 0;
|
||||
padding: 0.55rem 0.65rem 0.2rem 0;
|
||||
border-top: 1px solid var(--border, rgba(0, 0, 0, 0.08));
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.sidebar-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.55rem 0.15rem 0.35rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sidebar-user-avatar {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: color-mix(in srgb, var(--accent) 25%, transparent);
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.sidebar-user-avatar:hover {
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
}
|
||||
|
||||
.sidebar-user-avatar:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
.sidebar-user-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sidebar-user-avatar-letter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: #fff;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
background: color-mix(in srgb, var(--accent) 70%, #64748b);
|
||||
}
|
||||
|
||||
.sidebar-user-meta {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.sidebar-user-name {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sidebar-user-email {
|
||||
font-size: 0.68rem;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 顶栏工作区 chip(顶栏最左;文件夹图标点击打开资源管理器,名称点击打开文件树) */
|
||||
.main-header-ws {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 9px;
|
||||
border-radius: 8px;
|
||||
background: var(--surface-hover, rgba(0, 0, 0, 0.05));
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
max-width: 240px;
|
||||
overflow: hidden;
|
||||
transition: background 0.15s ease;
|
||||
/* 剩余空间由 .header-drag-spacer(flex:1) 吸收;不能用 margin-right:auto,
|
||||
否则 auto margin 会优先抢占自由空间导致拖拽条塌缩为 0 */
|
||||
}
|
||||
|
||||
.main-header-ws[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.main-header-ws:hover {
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.main-header-ws-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
padding: 2px;
|
||||
border-radius: 5px;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.main-header-ws-icon:hover {
|
||||
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
}
|
||||
|
||||
.main-header-ws-icon img {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.main-header-ws-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 0.82rem;
|
||||
@@ -154,13 +305,214 @@
|
||||
padding-right: 0.35rem;
|
||||
}
|
||||
|
||||
/* ===== 右栏(Edit 文件树抽屉,位于顶栏下方:上沿精确对齐顶栏下沿,顶栏保持全宽) ===== */
|
||||
.right-panel {
|
||||
position: fixed;
|
||||
/* 顶栏高 42px - 顶栏负上边距 0.5rem(桌面)→ 上沿恰好贴住顶栏下沿 */
|
||||
top: calc(42px - 0.5rem);
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 320px;
|
||||
max-width: 88vw;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface, rgba(255, 255, 255, 0.94));
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-left: 1px solid var(--border, rgba(0, 0, 0, 0.1));
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
z-index: 60;
|
||||
box-shadow: -8px 0 24px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
/* Electron 无边框下 .main 上内边距为 0.25rem,顶栏下沿相应下移 */
|
||||
.is-electron .right-panel {
|
||||
top: calc(42px - 0.25rem);
|
||||
}
|
||||
|
||||
.app-root.right-panel-open .right-panel {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.right-panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.6rem 0.75rem 0.4rem;
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.right-panel-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.right-panel-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.right-panel-close:hover {
|
||||
background: var(--surface-hover, rgba(0, 0, 0, 0.05));
|
||||
}
|
||||
|
||||
.right-panel .doc-tree-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 0 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
/* ===== 会话列表按工作区分组(紧凑:卡片与卡片间距小) ===== */
|
||||
.ws-group {
|
||||
list-style: none;
|
||||
margin: 0 0 0.12rem;
|
||||
}
|
||||
|
||||
/* 工作区卡片:不可点击、无高亮差异(仅选中的会话有高亮),操作按钮 hover 时出现 */
|
||||
.ws-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 9px;
|
||||
font-size: 0.76rem;
|
||||
color: var(--muted);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.ws-card:hover {
|
||||
background: var(--surface-hover, rgba(0, 0, 0, 0.05));
|
||||
border-color: var(--border, rgba(0, 0, 0, 0.1));
|
||||
}
|
||||
|
||||
/* 资源管理器按钮:恒显于名称左侧(不在 hover 隐藏的 actions 内) */
|
||||
.ws-card-explorer {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 1px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.ws-card-explorer img {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ws-card-explorer:hover {
|
||||
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
}
|
||||
|
||||
.ws-card-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ws-card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.ws-card:hover .ws-card-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 无会话提示 */
|
||||
.ws-group-empty {
|
||||
list-style: none;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
padding: 6px 4px;
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ws-card-action {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 2px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
opacity: 0.55;
|
||||
border-radius: 4px;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.ws-card-action:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ws-card-action img {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
/* 组内会话:会话名称与工作区卡片名称左对齐
|
||||
(8px 卡片内边距 + 16px 图标按钮 + 6px 间距 = 30px),去掉缩进与虚线 */
|
||||
.ws-group-sessions {
|
||||
list-style: none;
|
||||
margin: 1px 0 2px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ws-group-sessions .session-item {
|
||||
padding-left: 30px;
|
||||
}
|
||||
|
||||
/* 未归属会话:置顶直排,无卡片无缩进 */
|
||||
.ws-group-sessions--plain {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ws-group-sessions--plain .session-item {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.session-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
gap: 0.08rem;
|
||||
}
|
||||
|
||||
/* Agent 运行中锁定会话列表:禁止点击但保留视觉 */
|
||||
@@ -172,24 +524,44 @@
|
||||
.session-item {
|
||||
font-size: 0.76rem;
|
||||
border-radius: 8px;
|
||||
padding: 0.4rem 0.55rem;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.22rem 0.5rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
color: var(--muted);
|
||||
background: transparent;
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.session-item:hover {
|
||||
background: rgba(255, 255, 255, 0.28);
|
||||
background: var(--surface-hover, rgba(0, 0, 0, 0.04));
|
||||
}
|
||||
|
||||
/* 选中会话:无边框,名称变主题色 */
|
||||
.session-item.active {
|
||||
border-color: color-mix(in srgb, var(--accent) 65%, transparent);
|
||||
background: rgba(236, 254, 255, 0.35);
|
||||
color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||
}
|
||||
|
||||
.session-item.active .session-item-name {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 会话图标:以文档按钮 SVG 为蒙版,随文字/主题着色 */
|
||||
.session-item-icon {
|
||||
flex: none;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
background-color: var(--muted);
|
||||
-webkit-mask: url("/image/文档按钮.svg") center / contain no-repeat;
|
||||
mask: url("/image/文档按钮.svg") center / contain no-repeat;
|
||||
}
|
||||
|
||||
.session-item.active .session-item-icon {
|
||||
background-color: var(--accent);
|
||||
}
|
||||
|
||||
.session-item-name {
|
||||
@@ -255,23 +627,6 @@
|
||||
text-shadow: 0 1px 1px rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.sidebar-collapse {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 34px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.collapse-fab {
|
||||
border-radius: 14px 0 0 14px;
|
||||
border-right: none;
|
||||
border-left: none;
|
||||
box-shadow: -2px 0 16px var(--shadow);
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -295,11 +650,42 @@
|
||||
|
||||
.main-header {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 0.35rem;
|
||||
height: 42px; /* 与 .right-panel 的 top 对齐:右栏整个位于顶栏之下 */
|
||||
box-sizing: border-box;
|
||||
/* 负 margin 抵消 .main 的 padding,让顶栏左右贴满窗口边缘(无圆角矩形) */
|
||||
margin: -0.5rem -0.75rem 0.4rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.4rem;
|
||||
background: var(--surface, rgba(255, 255, 255, 0.94));
|
||||
border-bottom: 1px solid var(--border, rgba(0, 0, 0, 0.1));
|
||||
}
|
||||
|
||||
.main-header .menu-btn {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
/* 右栏位于顶栏下方:顶栏保持全宽不变,仅顶栏以下的内容(聊天/欢迎/输入区/终端)同步收窄 */
|
||||
.main > .main-content-wrap {
|
||||
transition: flex 0.35s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
margin-right 0.28s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.main > .welcome-view,
|
||||
.main > .terminal-dock {
|
||||
transition: margin-right 0.28s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.main > .composer {
|
||||
transition: padding 0.25s ease, margin-top 0.25s ease,
|
||||
margin-right 0.28s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.app-root.right-panel-open .main > .main-content-wrap,
|
||||
.app-root.right-panel-open .main > .welcome-view,
|
||||
.app-root.right-panel-open .main > .composer,
|
||||
.app-root.right-panel-open .main > .terminal-dock {
|
||||
margin-right: 320px;
|
||||
}
|
||||
|
||||
.main-header-text {
|
||||
@@ -333,12 +719,7 @@
|
||||
background: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
.main.header-collapsed .main-header-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.main.header-collapsed .main-header {
|
||||
margin-bottom: 0.35rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
/* notify-strip.css -- 通知条(输入区上方长条列表,替代 todo/pending 浮窗)
|
||||
*
|
||||
* 设计语言:与输入框同构的玻璃拟态(同色系半透明白 + blur),主题变量驱动。
|
||||
* 结构:
|
||||
* .notify-strip > .notify-strip-items > .strip-item(--todo/--pending)
|
||||
* .strip-item-row(行:左侧按钮 + 内容)
|
||||
* .strip-item-btn / .strip-item-content
|
||||
* .strip-expand-area(展开区,垂直位于行容器下方)
|
||||
*/
|
||||
|
||||
/* ===== 容器:空态完全隐藏(零占位,composer 紧贴聊天区) ===== */
|
||||
.notify-strip {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
max-width: 484px; /* 略窄于输入框(composer-row max-width 490px) */
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
transform: translateY(6px);
|
||||
transition: opacity 0.2s ease, max-height 0.25s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
/* 贴近输入框:负下边距抵消 composer 的 margin-top,收窄间距 */
|
||||
.notify-strip--visible {
|
||||
opacity: 1;
|
||||
max-height: 46vh;
|
||||
transform: translateY(0);
|
||||
margin-bottom: -0.3rem;
|
||||
}
|
||||
|
||||
.notify-strip-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
padding: 0.3rem 0.15rem 0.15rem;
|
||||
}
|
||||
|
||||
/* ===== 单条通知 ===== */
|
||||
.strip-item {
|
||||
display: flex;
|
||||
flex-direction: column; /* 展开区垂直位于下方(上下展开) */
|
||||
border-radius: 12px; /* 与输入框圆角一致 */
|
||||
background: color-mix(in srgb, var(--surface) 45%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
box-shadow: 0 1px 6px rgba(15, 23, 42, 0.05);
|
||||
overflow: hidden;
|
||||
animation: strip-item-in 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes strip-item-in {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* 行容器:左侧按钮 + 内容(水平排列) */
|
||||
.strip-item-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
/* 展开态:隐藏折叠时的简略摘要行(只展示展开内容 + 收起按钮) */
|
||||
.strip-item--expanded .strip-item-row {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 左侧按钮:点击把该条收入输入区 */
|
||||
.strip-item-btn {
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
width: 1.85rem;
|
||||
height: 1.85rem;
|
||||
margin-left: 0.45rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.strip-item-btn:hover {
|
||||
opacity: 0.9;
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.strip-item-btn img {
|
||||
width: 0.95rem;
|
||||
height: 0.95rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 内容行(点击展开/收起) */
|
||||
.strip-item-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.42rem 0.6rem 0.42rem 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.strip-item-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 计数 chip */
|
||||
.strip-item-count {
|
||||
flex-shrink: 0;
|
||||
padding: 0.05rem 0.45rem;
|
||||
border-radius: 0.6rem;
|
||||
font-size: 0.64rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
}
|
||||
|
||||
/* ===== Todo 图标:mask 着色(灰=未完成 / 绿=完成) ===== */
|
||||
.strip-todo-icon {
|
||||
flex-shrink: 0;
|
||||
width: 0.9rem;
|
||||
height: 0.9rem;
|
||||
background-color: var(--text-tertiary);
|
||||
-webkit-mask: url(/image/todostate.svg) center / contain no-repeat;
|
||||
mask: url(/image/todostate.svg) center / contain no-repeat;
|
||||
}
|
||||
|
||||
.strip-todo-icon--done {
|
||||
background-color: var(--success);
|
||||
}
|
||||
|
||||
/* Pending 图标 */
|
||||
.strip-pending-icon {
|
||||
flex-shrink: 0;
|
||||
width: 0.9rem;
|
||||
height: 0.9rem;
|
||||
background-color: var(--accent);
|
||||
-webkit-mask: url(/image/人机交互.svg) center / contain no-repeat;
|
||||
mask: url(/image/人机交互.svg) center / contain no-repeat;
|
||||
}
|
||||
|
||||
/* ===== 展开区(垂直位于行容器下方) ===== */
|
||||
.strip-expand-area {
|
||||
padding: 0.35rem 0.6rem 0.5rem 0.6rem;
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
animation: strip-expand-in 0.22s ease;
|
||||
}
|
||||
|
||||
@keyframes strip-expand-in {
|
||||
from { opacity: 0; transform: translateY(-4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* 展开区头部:标签 + 收起按钮 */
|
||||
.strip-expand-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.strip-expand-label {
|
||||
font-size: 0.74rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.strip-collapse-btn {
|
||||
flex-shrink: 0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.15rem 0.55rem;
|
||||
font-size: 0.68rem;
|
||||
color: var(--muted);
|
||||
background: var(--bg-tertiary);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.strip-collapse-btn:hover {
|
||||
background: var(--surface-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Todo 步骤列表 */
|
||||
.strip-todo-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0.15rem 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.28rem;
|
||||
}
|
||||
|
||||
.strip-todo-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.76rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.strip-todo-step--done .strip-todo-label {
|
||||
color: var(--muted);
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: rgba(148, 163, 184, 0.6);
|
||||
}
|
||||
|
||||
.strip-todo-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* ===== Pending 卡片 ===== */
|
||||
.strip-card {
|
||||
padding: 0.15rem 0 0.2rem;
|
||||
}
|
||||
|
||||
/* 卡片头部:标题 + 收起按钮 */
|
||||
.strip-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.strip-card-head .strip-card-title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.strip-card-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.strip-card-note {
|
||||
font-size: 0.72rem;
|
||||
color: var(--warning);
|
||||
background: var(--warning-bg);
|
||||
border-radius: 6px;
|
||||
padding: 0.28rem 0.5rem;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.strip-card-content {
|
||||
font-size: 0.76rem;
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
margin-bottom: 0.35rem;
|
||||
max-height: 7.5rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.strip-card-args {
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
padding: 0.3rem 0.5rem;
|
||||
margin-bottom: 0.35rem;
|
||||
max-height: 5rem;
|
||||
overflow-y: auto;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* 选项胶囊 */
|
||||
.strip-card-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.strip-option-pill {
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 0.22rem 0.7rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text);
|
||||
background: var(--bg-tertiary);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.strip-option-pill:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.strip-option-pill.is-selected {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 补充输入框(无边框简约风) */
|
||||
.strip-card-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: none;
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
min-height: 2rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text);
|
||||
font-size: 0.76rem;
|
||||
font-family: var(--font-family);
|
||||
line-height: 1.45;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.strip-card-input:focus {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
/* 卡片底部按钮行(右对齐) */
|
||||
.strip-card-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.strip-card-btn {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.3rem 0.95rem;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease, transform 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.strip-card-btn:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.strip-card-btn--continue,
|
||||
.strip-card-btn--approve {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.strip-card-btn--continue:hover,
|
||||
.strip-card-btn--approve:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.strip-card-btn--reject {
|
||||
background: var(--danger-bg);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.strip-card-btn--reject:hover {
|
||||
background: var(--danger-bg);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.strip-card-btn.is-submitting {
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ===== 多卡片左右导航 ===== */
|
||||
.strip-card-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.6rem;
|
||||
padding-top: 0.35rem;
|
||||
}
|
||||
|
||||
.strip-nav-arrow {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s ease, opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.strip-nav-arrow:hover:not(:disabled) {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.strip-nav-arrow:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.strip-nav-indicator {
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
min-width: 2.2em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ===== 输入区动态按钮(由 notify-strip 生成) ===== */
|
||||
.strip-composer-btn {
|
||||
opacity: 0;
|
||||
transform: scale(0.6);
|
||||
transition: opacity 0.15s ease, transform 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.strip-composer-btn.is-visible {
|
||||
opacity: 0.5;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.strip-composer-btn.is-visible:hover {
|
||||
opacity: 0.85;
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.strip-composer-btn.is-leaving {
|
||||
opacity: 0;
|
||||
transform: scale(0.6);
|
||||
}
|
||||
@@ -94,8 +94,18 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 与其它配置输入框保持一致的样式(边框/圆角/内边距/背景/字体) */
|
||||
.bg-input-row input[type="text"] {
|
||||
flex: 1;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.42rem 0.55rem;
|
||||
font: inherit;
|
||||
font-size: 0.84rem;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.bg-picker-btn {
|
||||
@@ -122,31 +132,31 @@
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ===== 设置按钮 ===== */
|
||||
/* ===== 设置按钮(顶栏扁平图标,无圆圈) ===== */
|
||||
.settings-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.2rem;
|
||||
height: 2.2rem;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
background: var(--surface);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 1.15rem;
|
||||
color: var(--muted);
|
||||
transition: transform 0.15s, background 0.15s, box-shadow 0.15s;
|
||||
box-shadow: 0 2px 8px var(--shadow);
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.settings-btn:hover {
|
||||
background: var(--surface);
|
||||
box-shadow: 0 4px 14px var(--shadow);
|
||||
transform: rotate(30deg);
|
||||
background: var(--surface-hover, rgba(0, 0, 0, 0.05));
|
||||
}
|
||||
|
||||
.settings-btn img {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ===== 设置模态框 ===== */
|
||||
@@ -183,83 +193,300 @@
|
||||
animation: slideUp 0.22s ease;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
/* ===== 左侧分组导航 + 右侧内容区布局 ===== */
|
||||
.settings-dialog {
|
||||
width: min(1200px, 96vw);
|
||||
height: min(860px, 92vh);
|
||||
height: min(860px, 92dvh);
|
||||
max-height: none;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.settings-nav {
|
||||
flex: 0 0 218px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.85rem 1.15rem;
|
||||
border-bottom: 1px solid var(--divider);
|
||||
}
|
||||
|
||||
.settings-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.15rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.settings-close-btn {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
color: var(--text-tertiary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.settings-close-btn:hover {
|
||||
background: var(--danger-bg);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.settings-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--divider);
|
||||
padding: 0 1.15rem;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--divider);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.settings-tab {
|
||||
padding: 0.55rem 1.15rem;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: none;
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.settings-tab:hover {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.settings-tab.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.settings-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem 1.15rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.settings-panel[hidden] {
|
||||
.settings-nav-head {
|
||||
padding: 0.9rem 0.9rem 0.5rem;
|
||||
}
|
||||
|
||||
.settings-nav-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
padding: 0.3rem 0.45rem;
|
||||
border-radius: 8px;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.settings-nav-back:hover {
|
||||
background: var(--surface-hover, rgba(0,0,0,0.05));
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.settings-nav-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 0.6rem 0.6rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.settings-nav-group {
|
||||
margin: 0.9rem 0.4rem 0.3rem;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.settings-nav-group:first-child {
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.settings-nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
padding: 0.48rem 0.6rem;
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.settings-nav-item svg {
|
||||
flex: none;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.settings-nav-item:hover {
|
||||
background: var(--surface-hover, rgba(0,0,0,0.05));
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.settings-nav-item.active {
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.settings-nav-foot {
|
||||
padding: 0.6rem 0.9rem;
|
||||
border-top: 1px solid var(--divider);
|
||||
min-height: 2.2rem;
|
||||
}
|
||||
|
||||
.settings-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
padding: 1.4rem 2.2rem 1.2rem;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.settings-page > h2 {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.settings-page[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ===== 设置行卡片(参考 ZCode 设置样式) ===== */
|
||||
.settings-card {
|
||||
border: 1px solid var(--divider);
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
margin-bottom: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1.2rem;
|
||||
padding: 0.85rem 1.1rem;
|
||||
}
|
||||
|
||||
.settings-row + .settings-row {
|
||||
border-top: 1px solid var(--divider);
|
||||
}
|
||||
|
||||
.settings-row--block {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.row-text {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.row-text h4 {
|
||||
margin: 0 0 0.2rem;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.row-text p {
|
||||
margin: 0;
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.row-control {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.row-control input[type="text"],
|
||||
.row-control input[type="number"],
|
||||
.row-control input[type="password"] {
|
||||
width: 220px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.42rem 0.55rem;
|
||||
font: inherit;
|
||||
font-size: 0.84rem;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.row-control input:focus,
|
||||
.row-control select:focus,
|
||||
.env-email-wrap input:focus,
|
||||
.env-email-wrap select:focus,
|
||||
.bg-input-row input:focus {
|
||||
border-color: var(--accent, #0e7490);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 8%, transparent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.row-control select {
|
||||
min-width: 170px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.42rem 0.55rem;
|
||||
font: inherit;
|
||||
font-size: 0.84rem;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.row-control input[type="range"] {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.row-control--stack {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.row-inline-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.settings-page-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.settings-page-actions--top {
|
||||
margin: 0 0 0.9rem;
|
||||
}
|
||||
|
||||
/* 邮箱组合控件 */
|
||||
.env-email-wrap {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.env-email-wrap input[type="text"] {
|
||||
width: 150px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.42rem 0.55rem;
|
||||
font: inherit;
|
||||
font-size: 0.84rem;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.env-email-wrap select {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.42rem 0.55rem;
|
||||
font: inherit;
|
||||
font-size: 0.84rem;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ===== 内容区底部操作条 ===== */
|
||||
.settings-main-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
padding-top: 0.8rem;
|
||||
}
|
||||
|
||||
.settings-status {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.settings-status.success {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.settings-status.error {
|
||||
color: var(--danger-hover);
|
||||
}
|
||||
|
||||
/* ===== 设置工具栏(模型添加等) ===== */
|
||||
.settings-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -269,10 +496,6 @@
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.settings-save-btn {
|
||||
margin-top: 0.85rem;
|
||||
}
|
||||
|
||||
/* 紧凑滑块(背景效果) */
|
||||
.compact-slider {
|
||||
-webkit-appearance: none;
|
||||
@@ -517,203 +740,12 @@
|
||||
gap: 0.12rem;
|
||||
}
|
||||
|
||||
/* ===== 环境变量配置 ===== */
|
||||
.env-config-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* 环境变量 "?" 帮助按钮与说明 popover */
|
||||
.env-help-btn {
|
||||
flex: none;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--text) 5%, transparent);
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
.env-help-btn:hover {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||
}
|
||||
.env-help-popover {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 6px);
|
||||
z-index: 80;
|
||||
width: 260px;
|
||||
padding: 0.55rem 0.65rem;
|
||||
border-radius: 10px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 8px 24px var(--shadow);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.env-config-item label {
|
||||
min-width: 150px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
|
||||
.env-config-item input {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.4rem 0.5rem;
|
||||
font: inherit;
|
||||
font-size: 0.84rem;
|
||||
background: var(--surface);
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.env-config-item input:focus {
|
||||
border-color: var(--accent, #0e7490);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 8%, transparent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* 下拉框与输入框同主题(修复存储后端下拉风格不一致) */
|
||||
.env-config-item select {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.4rem 0.5rem;
|
||||
font: inherit;
|
||||
font-size: 0.84rem;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.env-config-item select:focus {
|
||||
border-color: var(--accent, #0e7490);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 8%, transparent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* MySQL 连接配置子区块(仅选 mysql 时显示) */
|
||||
.env-mysql-section {
|
||||
flex-basis: 100%;
|
||||
margin: 0.2rem 0 0.5rem 0;
|
||||
padding: 0.6rem 0.7rem;
|
||||
border-radius: 10px;
|
||||
border: 1px dashed color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
background: color-mix(in srgb, var(--accent) 4%, transparent);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.env-mysql-section-title {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent, #0e7490);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.env-mysql-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.5rem 0.7rem;
|
||||
}
|
||||
|
||||
.env-mysql-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.env-mysql-field label {
|
||||
min-width: 0;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
|
||||
.env-mysql-field input {
|
||||
flex: none;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
padding: 0.35rem 0.5rem;
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.env-mysql-field input:focus {
|
||||
border-color: var(--accent, #0e7490);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 8%, transparent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.env-config-item .dir-pick-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 0.32rem 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: var(--surface);
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.env-config-item .dir-pick-btn:hover {
|
||||
background: color-mix(in srgb, var(--accent) 6%, transparent);
|
||||
color: var(--accent, #0e7490);
|
||||
}
|
||||
|
||||
/* ===== 设置页脚 ===== */
|
||||
.settings-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.7rem 1.15rem;
|
||||
border-top: 1px solid var(--divider);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.settings-status {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.settings-status.success {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.settings-status.error {
|
||||
color: var(--danger-hover);
|
||||
/* 应用状态小字(头像 / 背景) */
|
||||
.bg-preview-status {
|
||||
font-size: 11px;
|
||||
color: #6a6a6a;
|
||||
display: block;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
/* ===== 设置面板内联按钮 ===== */
|
||||
|
||||
@@ -417,6 +417,11 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.think-row-label--reflection {
|
||||
color: var(--accent, #4f8cff);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.think-row-detail {
|
||||
width: 100%;
|
||||
margin-top: 0.2rem;
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
--accent-hover: #155e75;
|
||||
--radius: 12px;
|
||||
--sidebar-w: 190px;
|
||||
--rail-w: 34px;
|
||||
--bg-dof: 0px;
|
||||
--bg-vignette: 0.35;
|
||||
--bubble-user: rgba(236, 254, 255, 0.35);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1787099998483" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="7301" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M512 1019.345455c-232.727273 0-418.909091-186.181818-418.909091-418.909091s186.181818-418.909091 418.909091-418.909091 418.909091 186.181818 418.909091 418.909091-186.181818 418.909091-418.909091 418.909091z m0-744.727273c-181.527273 0-325.818182 144.290909-325.818182 325.818182s144.290909 325.818182 325.818182 325.818181 325.818182-144.290909 325.818182-325.818181-144.290909-325.818182-325.818182-325.818182zM837.818182 186.181818c-51.2-41.890909-111.709091-74.472727-176.872727-93.090909l27.927272-88.436364c74.472727 23.272727 144.290909 60.509091 204.8 111.709091L837.818182 186.181818zM190.836364 186.181818L130.327273 111.709091C190.836364 60.509091 260.654545 23.272727 335.127273 0l27.927272 93.090909c-65.163636 18.618182-121.018182 51.2-172.218181 93.090909z" fill="#515151" p-id="7302"></path><path d="M656.290909 656.290909H432.872727V432.872727h93.090909v130.327273h130.327273z" fill="#515151" p-id="7303"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,34 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 340 96" width="340" height="96" role="img" aria-label="minor agent">
|
||||
<!-- minor agent 品牌标:透明底 + 奶油色调(暖白字标 + 可可色毛玻璃徽章) -->
|
||||
<defs>
|
||||
<!-- 奶油白渐变(无高光带,柔和平缓) -->
|
||||
<linearGradient id="cream" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#fffdf6"/>
|
||||
<stop offset="0.6" stop-color="#f6efdc"/>
|
||||
<stop offset="1" stop-color="#e9e2cd"/>
|
||||
</linearGradient>
|
||||
<!-- 奶油字标柔和暖影,保证浅/深底均可读 -->
|
||||
<filter id="creamShadow" x="-25%" y="-25%" width="150%" height="150%">
|
||||
<feDropShadow dx="0" dy="1.4" stdDeviation="1.4" flood-color="#7a6242" flood-opacity="0.4"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- 左侧缰绳徽标(icon.svg) -->
|
||||
<g filter="url(#creamShadow)">
|
||||
<svg x="58" y="20" width="70" height="56" viewBox="172 220 440 356">
|
||||
<g transform="translate(0.000000,800.000000) scale(0.100000,-0.100000)" fill="#f6efdd" stroke="none">
|
||||
<path d="M2465 5660 c-595 -6 -552 7 -574 -176 -14 -111 -14 -269 -1 -294 5 -10 38 -28 73 -39 81 -27 136 -63 167 -109 l25 -36 -3 -1008 c-2 -1095 0 -1047 -57 -1101 -29 -27 -71 -45 -167 -71 -86 -24 -91 -38 -86 -245 4 -180 11 -208 61 -245 28 -21 40 -21 510 -24 595 -4 657 3 702 71 17 26 55 270 55 353 0 40 -5 59 -20 74 -24 24 -40 27 -151 34 -106 6 -157 27 -182 76 -17 32 -18 55 -13 275 4 132 11 344 16 470 6 127 10 436 9 688 -1 252 1 460 4 463 2 3 11 -23 19 -58 28 -117 169 -645 233 -873 28 -97 69 -243 169 -605 124 -445 247 -872 262 -910 19 -48 41 -59 137 -71 90 -12 332 -4 384 12 67 21 69 24 184 444 105 383 226 810 352 1245 30 102 78 266 106 365 29 99 65 225 81 280 16 55 39 138 52 185 20 75 23 82 29 55 10 -46 10 -515 -1 -755 -5 -118 -14 -381 -20 -585 -12 -405 -23 -573 -40 -622 -14 -40 -59 -69 -131 -84 -79 -17 -104 -33 -129 -85 -21 -42 -22 -56 -17 -154 11 -185 30 -240 95 -267 49 -20 388 -32 945 -33 463 0 466 0 492 63 7 18 19 102 25 188 17 220 3 247 -135 269 -166 26 -196 73 -216 340 -17 221 -5 1727 14 1798 24 90 71 123 209 147 46 8 84 15 86 15 2 0 10 17 19 37 21 52 30 294 13 391 -17 103 -25 107 -228 116 -152 6 -1174 -9 -1299 -20 -33 -3 -69 -11 -78 -18 -10 -7 -45 -108 -91 -261 -93 -314 -88 -297 -179 -625 -138 -493 -163 -583 -211 -752 -26 -93 -51 -168 -55 -168 -5 0 -9 7 -9 15 0 14 -85 313 -155 545 -146 484 -296 985 -335 1123 -30 108 -54 141 -109 157 -20 5 -108 9 -196 8 -88 -1 -376 -5 -640 -8z m-135 -123 c-69 -18 -135 -38 -147 -45 -24 -13 -38 -67 -49 -194 -4 -47 -11 -79 -19 -82 -19 -7 -74 31 -85 58 -17 45 -23 171 -11 209 24 72 69 86 276 86 l160 0 -125 -32z m2356 7 c-14 -15 -26 -32 -26 -39 0 -6 15 -29 33 -51 32 -37 57 -76 57 -89 0 -3 -10 -1 -21 6 -37 19 -138 7 -199 -25 -30 -16 -60 -36 -67 -45 -11 -15 -12 -15 -13 0 0 23 56 158 78 189 23 32 116 80 155 80 l30 0 -27 -26z m1165 -5 c20 -5 43 -17 51 -26 25 -30 48 -101 48 -149 0 -40 -2 -45 -15 -34 -8 7 -15 18 -15 25 0 7 -18 32 -39 56 -31 36 -53 49 -107 67 -83 27 -190 48 -304 59 l-85 8 85 6 c108 7 334 0 381 -12z m-2587 -22 c14 -10 146 -380 146 -411 0 -19 -113 135 -175 238 -67 113 -79 157 -47 175 24 14 55 13 76 -2z m2590 -212 l6 -51 -60 1 -59 0 38 35 c21 19 41 46 46 59 10 32 21 15 29 -44z m-3594 -245 c0 -22 -4 -28 -15 -24 -15 6 -20 35 -8 47 14 15 23 6 23 -23z m3311 -103 c-16 -16 -26 40 -10 60 13 15 14 13 17 -17 2 -18 -1 -37 -7 -43z m-24 -4 c-3 -10 -13 -53 -22 -95 -9 -43 -19 -78 -22 -78 -8 0 5 134 17 164 11 29 34 36 27 9z m-1960 -2228 c40 -77 68 -103 134 -120 74 -20 123 -19 169 5 42 21 108 98 141 162 11 21 22 38 25 38 11 0 -6 -101 -28 -157 -21 -58 -96 -176 -130 -205 -16 -15 -39 -18 -122 -18 -55 0 -106 4 -111 8 -13 9 -57 77 -82 127 -44 87 -100 292 -117 430 -5 42 2 28 42 -80 27 -71 63 -157 79 -190z m2173 73 c0 -13 -23 -5 -28 10 -2 7 2 10 12 6 9 -3 16 -11 16 -16z m-3741 -130 c-1 -19 11 -56 27 -88 l29 -55 60 -5 c46 -4 54 -7 35 -12 -13 -4 -36 -5 -50 -1 l-25 5 20 -20 c11 -12 47 -33 80 -46 l60 -25 -70 -1 c-79 0 -194 16 -211 31 -8 6 -8 20 -1 47 7 27 6 50 -5 86 -13 43 -13 53 1 82 21 46 52 47 50 2z m2677 14 c6 -4 19 -32 27 -62 21 -71 59 -108 167 -160 l85 -41 -65 6 c-227 22 -244 24 -269 43 -29 21 -59 110 -60 180 l-1 42 53 0 c28 0 57 -4 63 -8z m1204 -254 c-1 -34 -3 -39 -11 -26 -8 15 -3 68 7 68 2 0 4 -19 4 -42z m-86 2 c-16 -26 -44 -48 -44 -34 0 5 52 63 58 64 2 0 -5 -13 -14 -30z"/>
|
||||
</g>
|
||||
</svg>
|
||||
</g>
|
||||
|
||||
<!-- 主字标 minor:奶油白衬线 -->
|
||||
<text x="212" y="65" text-anchor="middle" textLength="140" lengthAdjust="spacingAndGlyphs" font-family="Georgia, 'Times New Roman', 'Songti SC', serif" font-size="49" font-weight="700" letter-spacing="0.5" fill="url(#cream)" filter="url(#creamShadow)">minor</text>
|
||||
|
||||
<!-- 右上角小号徽章:可可色哑光,奶油文字 -->
|
||||
<g>
|
||||
<rect x="278" y="13" width="50" height="19" rx="6" fill="#3b3326" fill-opacity="0.92"/>
|
||||
<rect x="278.5" y="13.5" width="49" height="18" rx="5.5" fill="none" stroke="#f6efdd" stroke-opacity="0.35" stroke-width="1"/>
|
||||
</g>
|
||||
<text x="303" y="26" text-anchor="middle" font-family="'Segoe UI', system-ui, -apple-system, sans-serif" font-size="10.5" font-weight="600" letter-spacing="0.8" fill="#efe6d0">agent</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.0 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1786699600214" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="10722" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M821.457602 118.382249H205.725895c-48.378584 0-87.959995 39.583368-87.959996 87.963909v615.731707c0 48.378584 39.581411 87.959995 87.959996 87.959996h615.733664c48.380541 0 87.961952-39.581411 87.961952-87.959996V206.346158c-0.001957-48.378584-39.583368-87.963909-87.963909-87.963909zM493.962468 457.544987c-10.112054 32.545237-21.72487 82.872662-38.806571 124.248336-8.806957 22.378397-8.380404 18.480717-15.001764 32.609808l5.71738-1.851007c58.760658-16.443827 99.901532-20.519564 138.162194-27.561607-7.67796-6.06371-14.350194-10.751884-19.631237-15.586807-26.287817-29.101504-35.464584-34.570387-70.440002-111.862636v0.003913z m288.36767 186.413594c-7.476424 8.356924-20.670227 13.191847-40.019704 13.191847-33.427694 0-63.808858-9.229597-107.79277-31.660824-75.648648 8.356924-156.097 17.214754-201.399704 31.729308-2.199293 0.876587-4.832967 1.759043-7.916674 3.077836-54.536215 93.237125-95.031389 132.767663-130.621199 131.19646-11.286054-0.49895-27.694661-7.044-32.973748-10.11988l-6.52157-6.196764-2.29517-4.353583c-3.07588-7.91863-3.954423-15.395054-2.197337-23.751977 4.838837-23.309771 29.907651-60.251638 82.686779-93.237126 8.356924-6.159587 27.430511-15.897917 45.020944-24.25484 13.311204-21.177004 19.45905-34.744531 36.341171-72.259702 19.102937-45.324228 36.505531-99.492589 47.500041-138.191543v-0.44025c-16.267727-53.219378-25.945401-89.310095-9.67376-147.80856 3.958337-16.71189 18.46702-33.864031 34.748444-33.864031h10.552304c10.115967 0 19.791684 3.520043 26.829814 10.552304 29.029107 29.031064 15.39114 103.824649 0.8805 162.323113-0.8805 2.63563-1.322707 4.832967-1.761 6.153717 17.59239 49.697378 45.400538 98.774492 73.108895 121.647926 11.436717 8.791304 22.638634 18.899444 36.71098 26.814161 19.791684-2.20125 37.517128-4.11487 55.547812-4.11487 54.540128 0 87.525615 9.67963 100.279169 30.351814 4.400543 7.034217 6.595923 15.389184 5.281043 24.1844-0.44025 10.996467-4.39663 21.112434-12.31526 29.031064z m-27.796407-36.748157c-4.394673-4.398587-17.024957-16.936907-78.601259-16.936907-3.073923 0-10.622744-0.784623-14.57521 3.612007 32.104987 14.072347 62.830525 24.757704 83.058545 24.757703 3.083707 0 5.72325-0.442207 8.356923-0.876586h1.759044c2.20125-0.8805 3.520043-1.324663 3.960293-5.71738-0.87463-1.324663-1.757087-3.083707-3.958336-4.838837z m-387.124553 63.041845c-9.237424 5.27713-16.71189 10.112054-21.112433 13.634053-31.226444 28.586901-51.018128 57.616008-53.217422 74.331812 19.789727-6.59788 45.737084-35.626987 74.329855-87.961952v-0.003913z m125.574957-297.822284l2.197336-1.761c3.079793-14.072347 5.232127-29.189554 7.87167-38.869184l1.318794-7.036174c4.39663-25.070771 2.71781-39.720334-4.76057-50.272637l-6.59788-2.20125a57.381208 57.381208 0 0 0-3.079794 5.27713c-7.474467 18.47289-7.063567 55.283661 3.0524 94.865072l-0.001956-0.001957z" fill="#2c2c2c" p-id="10723"></path></svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1787104209026" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="11614" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M714.157419 221.316129h64.743226c13.212903 0 23.783226 10.570323 23.783226 23.783226v524.552258c0 13.212903-10.570323 23.783226-23.783226 23.783226H254.348387c-13.212903 0-23.783226-10.570323-23.783226-23.783226V245.099355c0-13.212903 10.570323-23.783226 23.783226-23.783226h52.851613v-47.566452h-52.851613c-39.63871 0-71.349677 31.710968-71.349677 71.349678v524.552258c0 39.63871 31.710968 71.349677 71.349677 71.349677h524.552258c39.63871 0 71.349677-31.710968 71.349678-71.349677V245.099355c0-39.63871-31.710968-71.349677-71.349678-71.349678h-64.743226v47.566452z m-284.738064 0h167.803871v-47.566452H429.419355v47.566452z" p-id="11615" fill="#515151"></path><path d="M363.354839 530.498065l92.490322 111.649032 240.474839-154.590968c11.230968-7.267097 14.534194-21.80129 7.267097-33.032258-7.267097-11.230968-21.80129-14.534194-33.032258-7.267097l-204.8 131.468387-66.064516-79.277419c-8.588387-9.909677-23.783226-11.230968-33.692904-3.303226-9.909677 9.249032-11.230968 24.443871-2.64258 34.353549z" p-id="11616" fill="#515151"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1786700531203" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="16155" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M518.4 277.333333l-74.666667-149.333333h-198.4l74.666667 149.333333h198.4z m260.266667 0L704 128h-198.4l74.666667 149.333333h198.4z m-597.333334-149.333333H106.666667C70.4 128 42.666667 155.733333 42.666667 192v85.333333h213.333333L181.333333 128zM42.666667 832c0 36.266667 27.733333 64 64 64h810.666666c36.266667 0 64-27.733333 64-64V320H42.666667v512z m358.4-369.066667c0-32 36.266667-53.333333 64-36.266666l194.133333 113.066666c27.733333 17.066667 27.733333 57.6 0 74.666667L465.066667 725.333333c-27.733333 17.066667-64-4.266667-64-36.266666V462.933333zM917.333333 128h-149.333333l74.666667 149.333333H981.333333V192c0-36.266667-27.733333-64-64-64z" fill="#2c2c2c" p-id="16156"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1786699619235" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="11826" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M682.666667 85.333333l213.333333 213.333334v597.674666a42.368 42.368 0 0 1-42.368 42.325334H170.368A42.666667 42.666667 0 0 1 128 896.341333V127.658667C128 104.277333 146.986667 85.333333 170.368 85.333333H682.666667z m-85.333334 256v212.864L512 469.333333l-84.906667 85.333334L426.666667 341.333333H341.333333v341.333334h85.333334l85.333333-85.333334 85.333333 85.333334h85.333334V341.333333h-85.333334z" fill="#2c2c2c" p-id="11827"></path></svg>
|
||||
|
After Width: | Height: | Size: 781 B |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1786700507480" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="14179" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M645.071238 97.52381L828.952381 281.258667V414.47619h48.761905v390.095239h-48.761905v48.761904a73.142857 73.142857 0 0 1-73.142857 73.142857H268.190476a73.142857 73.142857 0 0 1-73.142857-73.142857v-48.761904H146.285714V414.47619h48.761905V170.666667a73.142857 73.142857 0 0 1 73.142857-73.142857h376.880762zM755.809524 804.571429H268.190476v48.761904h487.619048v-48.761904zM460.824381 512h-136.362667v38.278095h85.504L321.828571 665.35619v36.815239h140.921905v-37.790477h-85.504l83.577905-111.713523V512z m68.778667 0h-47.762286v190.171429h47.786667V512z m96.743619 0h-75.239619v190.171429h47.786666v-57.880381h11.922286c11.459048 0 22.918095-1.048381 34.377143-3.120762a112.64 112.64 0 0 0 17.67619-5.266286c4.949333-2.218667 9.557333-4.87619 13.848381-7.899429 7.801905-5.90019 13.628952-13.214476 17.456762-21.991619 3.657143-9.264762 5.485714-18.895238 5.485714-28.94019a78.969905 78.969905 0 0 0-4.778666-25.84381 61.537524 61.537524 0 0 0-14.799238-21.77219 67.486476 67.486476 0 0 0-26.038857-13.872762A126.902857 126.902857 0 0 0 626.346667 512z m-4.559238 38.278095c4.315429 0.146286 8.533333 0.975238 12.678095 2.389334 3.657143 1.438476 6.92419 3.510857 9.776762 6.217142 5.12 5.583238 7.655619 12.117333 7.655619 19.626667 0 7.972571-2.950095 14.57981-8.850286 19.846095-3.169524 2.706286-6.753524 4.705524-10.727619 5.973334a52.053333 52.053333 0 0 1-13.165714 1.682285h-20.284953V550.278095h22.918096zM581.412571 170.642286L268.190476 170.666667v243.809523h487.619048v-49.956571h-174.372572L581.412571 170.666667z m73.142858 39.740952v80.993524h81.042285l-81.042285-80.993524z" p-id="14180" fill="#2c2c2c"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1786929594690" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="18273" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M85.312 320v384q0 97.216 68.736 165.952T320 938.688h384q97.216 0 165.952-68.736T938.688 704V320q0-97.216-68.736-165.952T704 85.312H320q-97.216 0-165.952 68.736T85.312 320z m129.088 489.6q-43.712-43.712-43.712-105.6V320q0-61.824 43.712-105.6 43.776-43.712 105.6-43.712h384q61.888 0 105.6 43.712 43.712 43.776 43.712 105.6v384q0 61.888-43.712 105.6-43.712 43.712-105.6 43.712H320q-61.824 0-105.6-43.712z" p-id="18274" fill="#515151"></path><path d="M640 128v768a42.688 42.688 0 0 0 85.312 0V128A42.688 42.688 0 1 0 640 128z" p-id="18275" fill="#515151"></path><path d="M618.688 938.688h128a42.688 42.688 0 1 0 0-85.376h-128a42.688 42.688 0 0 0 0 85.376zM618.688 170.688h128a42.688 42.688 0 1 0 0-85.376h-128a42.688 42.688 0 0 0 0 85.376z" p-id="18276" fill="#515151"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1786929617213" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="19352" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M85.333333 256v512q0 70.698667 50.005334 120.661333Q185.301333 938.666667 256 938.666667h512q70.698667 0 120.661333-50.005334Q938.666667 838.698667 938.666667 768V256q0-70.698667-50.005334-120.661333Q838.698667 85.333333 768 85.333333H256Q185.301333 85.333333 135.338667 135.338667 85.333333 185.301333 85.333333 256z m110.336 572.330667Q170.666667 803.328 170.666667 768V256q0-35.328 25.002666-60.330667T256 170.666667h512q35.328 0 60.330667 25.002666T853.333333 256v512q0 35.328-25.002666 60.330667T768 853.333333H256q-35.328 0-60.330667-25.002666z" p-id="19353" fill="#515151"></path><path d="M298.666667 128v768a42.666667 42.666667 0 1 0 85.333333 0V128a42.666667 42.666667 0 0 0-85.333333 0z" p-id="19354" fill="#515151"></path><path d="M277.333333 938.666667h128a42.666667 42.666667 0 0 0 0-85.333334h-128a42.666667 42.666667 0 1 0 0 85.333334zM277.333333 170.666667h128a42.666667 42.666667 0 0 0 0-85.333334h-128a42.666667 42.666667 0 1 0 0 85.333334z" p-id="19355" fill="#515151"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1786930110833" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9885" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M386.986667 335.36c17.493333 0 32-14.506667 32-32s-14.506667-32-32-32h-217.6V136.96c0-17.493333-14.506667-32-32-32s-32 14.506667-32 32v499.2c0 63.573333 51.626667 115.2 115.2 115.2h166.4c17.493333 0 32-14.506667 32-32s-14.506667-32-32-32h-166.4c-28.16 0-51.2-23.04-51.2-51.2V335.36h217.6zM823.893333 125.866667H738.133333c-3.413333 0-5.973333-1.706667-8.106666-4.266667l-17.493334-26.026667a73.386667 73.386667 0 0 0-61.013333-32h-77.226667c-52.053333 0-94.293333 42.24-94.293333 94.293334v208.213333c0 52.053333 42.24 94.293333 94.293333 94.293333h249.6c52.053333 0 94.293333-42.24 94.293334-94.293333V220.586667c0-52.053333-42.24-94.293333-94.293334-94.293334z m30.72 239.786666c0 16.64-13.653333 30.293333-30.293333 30.293334h-249.6c-16.64 0-30.293333-13.653333-30.293333-30.293334V157.866667c0-16.64 13.653333-30.293333 30.293333-30.293334h77.226667c3.413333 0 5.973333 1.706667 8.106666 4.266667l17.493334 26.026667c13.653333 20.053333 36.266667 32 61.013333 32h85.76c16.64 0 30.293333 13.653333 30.293333 30.293333v145.493333zM823.893333 583.253333H738.133333c-3.413333 0-5.973333-1.706667-8.106666-4.266666l-17.493334-26.026667c-13.653333-20.053333-36.266667-32.426667-61.013333-32.426667h-77.226667c-52.053333 0-94.293333 42.24-94.293333 94.293334v208.213333c0 52.053333 42.24 94.293333 94.293333 94.293333h249.6c52.053333 0 94.293333-42.24 94.293334-94.293333v-145.493333c0-52.053333-42.24-94.293333-94.293334-94.293334z m30.72 240.213334c0 16.64-13.653333 30.293333-30.293333 30.293333h-249.6c-16.64 0-30.293333-13.653333-30.293333-30.293333v-208.213334c0-16.64 13.653333-30.293333 30.293333-30.293333h77.226667c2.986667 0 5.973333 1.706667 8.106666 4.266667l17.493334 26.026666c13.653333 20.053333 36.266667 32.426667 61.013333 32.426667h85.76c16.64 0 30.293333 13.653333 30.293333 30.293333v145.493334z" p-id="9886" fill="#707070"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1786698948510" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8444" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M309.376 298.688a32 32 0 0 1 32-32H640a32 32 0 1 1 0 64H341.376a32 32 0 0 1-32-32zM341.376 437.312a32 32 0 0 0 0 64h128a32 32 0 1 0 0-64h-128z" fill="#2c2c2c" p-id="8445"></path><path d="M546.816 53.312h-37.12c-83.52 0-149.248 0-200.64 6.528-52.608 6.656-94.976 20.608-128.512 52.16-33.856 31.872-49.024 72.512-56.256 122.88-6.912 48.704-6.912 110.976-6.912 189.248v267.392c0 35.2 0 63.168 1.472 85.888 1.536 23.232 4.736 43.2 12.16 62.08a208.32 208.32 0 0 0 125.696 118.592c35.84 12.608 79.36 12.608 149.12 12.608h43.392c109.504-0.128 175.296-1.6 229.632-20.672 94.528-33.152 169.856-103.616 205.568-193.856 11.776-29.632 17.088-61.76 19.712-101.248 2.56-39.04 2.56-87.296 2.56-149.504V393.6c0-63.744 0-114.432-4.736-154.688-4.864-41.536-15.104-75.904-38.4-105.088a208.512 208.512 0 0 0-43.072-40.576c-30.528-21.504-66.176-30.976-109.568-35.456-42.496-4.48-96.064-4.48-164.096-4.48z m38.848 849.92a173.44 173.44 0 0 0 36.544-106.816c0-7.872-0.32-16-0.64-23.552l-0.192-2.88c-0.256-6.72-0.576-12.992-0.704-19.2a130.56 130.56 0 0 1 2.88-35.584 39.104 39.104 0 0 1 27.648-27.648c9.344-2.56 20.992-3.2 35.52-2.88 6.272 0.128 12.608 0.384 19.264 0.704l2.88 0.128c7.552 0.384 15.68 0.704 23.616 0.704 40.704 0 78.208-13.952 107.904-37.44l-0.128 1.92c-2.368 36.352-7.04 61.056-15.36 81.92-28.544 72.064-89.28 129.664-167.232 157.056-20.032 7.04-42.368 11.136-72 13.568z m257.024-392.064V512c0 60.864-49.344 110.208-110.208 110.208-6.272 0-13.056-0.256-20.608-0.64l-2.816-0.128c-6.592-0.256-13.824-0.64-20.992-0.768a192 192 0 0 0-53.376 5.12c-35.584 9.472-63.36 37.248-72.96 72.832a192.064 192.064 0 0 0-5.056 53.44c0.128 7.168 0.512 14.336 0.832 20.992l0.128 2.752c0.32 7.616 0.64 14.336 0.64 20.608 0 60.16-48.128 108.992-107.968 110.208h-37.184c-79.488 0-110.976-0.384-135.168-8.96a144.384 144.384 0 0 1-87.424-81.792c-3.968-10.048-6.464-22.592-7.808-42.688a1389.44 1389.44 0 0 1-1.344-82.752V426.688c0-81.408 0-139.072 6.272-182.784 6.08-42.56 17.472-67.136 36.736-85.248 19.584-18.432 46.528-29.44 92.672-35.328 46.912-5.952 108.608-6.016 194.944-6.016h33.024c70.208 0 120.448 0.064 159.232 4.096 38.272 3.968 61.568 11.584 79.36 24.128 11.392 8 21.44 17.536 29.888 28.16 12.864 16.192 20.736 37.312 24.896 72.64 4.224 36.16 4.288 83.072 4.288 149.312v115.52z" fill="#2c2c2c" p-id="8446"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1786929675592" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="21261" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M469.333333 60.693333a85.333333 85.333333 0 0 1 85.333334 0l326.826666 188.714667a85.333333 85.333333 0 0 1 42.666667 73.898667v377.386666a85.333333 85.333333 0 0 1-42.666667 73.898667L554.666667 963.306667a85.333333 85.333333 0 0 1-85.333334 0L142.506667 774.592a85.333333 85.333333 0 0 1-42.666667-73.898667v-377.386666a85.333333 85.333333 0 0 1 42.666667-73.898667z m42.666667 73.898667L185.173333 323.306667v377.386666L512 889.408l326.826667-188.714667v-377.386666L512 134.592zM512 341.333333a170.666667 170.666667 0 1 1 0 341.333334 170.666667 170.666667 0 0 1 0-341.333334z m0 85.333334a85.333333 85.333333 0 1 0 0 170.666666 85.333333 85.333333 0 0 0 0-170.666666z" fill="#515151" p-id="21262"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -18,7 +18,7 @@
|
||||
<!-- 本地字体(离线可用,不再依赖 Google Fonts CDN) -->
|
||||
<link rel="stylesheet" href="/fonts/fonts.css" />
|
||||
<link rel="icon" href="/image/favicon.svg" sizes="any" />
|
||||
<link rel="stylesheet" href="/app.css?v=6" />
|
||||
<link rel="stylesheet" href="/app.css?v=9" />
|
||||
<link rel="stylesheet" href="/css/splash.css?v=1" />
|
||||
</head>
|
||||
<body>
|
||||
@@ -31,46 +31,22 @@
|
||||
<div class="bg-vignette" aria-hidden="true"></div>
|
||||
</div>
|
||||
<div class="app-root" id="app">
|
||||
<!-- 自定义标题栏(仅 Electron 无边框模式) -->
|
||||
<header class="title-bar" id="titleBar">
|
||||
<span class="title-bar-name">minor Agent</span>
|
||||
<div class="title-bar-actions">
|
||||
<button class="title-bar-btn title-bar-btn--settings" id="tbSettings" title="系统配置" aria-label="打开系统配置">⚙</button>
|
||||
<button class="title-bar-btn" id="tbMin" title="最小化" aria-label="最小化"></button>
|
||||
<button class="title-bar-btn" id="tbMax" title="最大化" aria-label="最大化"></button>
|
||||
<button class="title-bar-btn title-bar-btn--close" id="tbClose" title="关闭" aria-label="关闭"></button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="app-body">
|
||||
<div class="drawer-backdrop" id="drawerBackdrop" aria-hidden="true"></div>
|
||||
<aside class="expand-rail" id="expandRail" aria-hidden="true">
|
||||
<button type="button" class="rail-btn" id="expandBtn" title="展开侧栏">⟩</button>
|
||||
</aside>
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="sidebar-resize-handle" id="sidebarResizeHandle" title="拖动调整侧栏宽度"></div>
|
||||
<div class="sidebar-head">
|
||||
<div class="mode-switch" id="modeSwitch">
|
||||
<div class="mode-switch-inner">
|
||||
<span class="mode-switch-item" data-mode="edit" id="modeEditBtn">Edit</span>
|
||||
<span class="mode-switch-sep">|</span>
|
||||
<span class="mode-switch-item" data-mode="cron" id="modeCronBtn">Cron</span>
|
||||
<span class="mode-switch-sep">|</span>
|
||||
<span class="mode-switch-item mode-switch-item--chat active" data-mode="chat" id="modeChatBtn">
|
||||
Chat
|
||||
<span class="mode-switch-expand" id="modeChatExpand">
|
||||
<img src="/image/新增会话.svg" alt="新增会话" width="20" height="20" class="new-chat-icon" id="newSessionBtn" title="新建会话" />
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="sidebar-brand" id="sidebarBrand" title="MinorHarness">
|
||||
<img src="/image/minorharness.svg" alt="MinorHarness" class="sidebar-logo" />
|
||||
<button type="button" class="brand-new-workspace" id="brandNewWorkspaceBtn" title="新增工作区" aria-label="新增工作区">
|
||||
<img src="/image/文件夹.svg" alt="" aria-hidden="true" />
|
||||
<span>新增工作区</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-list-wrap" id="sessionListWrap">
|
||||
<ul class="session-list" id="sessionList" role="listbox" aria-label="会话列表"></ul>
|
||||
</div>
|
||||
<!-- 文档树区域(Edit 模式) -->
|
||||
<div class="doc-tree-wrap mode-hidden" id="docTreeWrap">
|
||||
<div class="doc-tree" id="docTree"></div>
|
||||
</div>
|
||||
<!-- 定时任务列表(Cron 模式) -->
|
||||
<div class="cron-list-wrap mode-hidden" id="cronListWrap">
|
||||
<div class="cron-list-head">
|
||||
@@ -79,9 +55,32 @@
|
||||
</div>
|
||||
<ul class="cron-list" id="cronList" role="listbox" aria-label="定时任务列表"></ul>
|
||||
</div>
|
||||
<p class="sidebar-hint">鼠标悬停可重命名或删除会话。</p>
|
||||
<div class="sidebar-collapse">
|
||||
<button type="button" class="collapse-fab" id="collapseBtn" title="收起侧栏">⟨</button>
|
||||
<!-- 左栏底部用户区:设置按钮 + 头像/用户名/邮箱 -->
|
||||
<div class="sidebar-user-zone">
|
||||
<button type="button" class="sidebar-settings-btn" id="sidebarSettingsBtn" title="系统配置" aria-label="打开系统配置">
|
||||
<img src="/image/设置.svg" alt="" aria-hidden="true" />
|
||||
<span>设置</span>
|
||||
</button>
|
||||
<div class="sidebar-user" id="sidebarUser">
|
||||
<button type="button" class="sidebar-user-avatar" id="userAvatarBtn" title="更换头像" aria-label="更换头像">
|
||||
<img id="userAvatarImg" alt="头像" />
|
||||
<span class="sidebar-user-avatar-letter" id="userAvatarLetter" hidden></span>
|
||||
</button>
|
||||
<div class="sidebar-user-meta">
|
||||
<div class="sidebar-user-name" id="userName">—</div>
|
||||
<div class="sidebar-user-email" id="userEmail"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<!-- 右栏(Edit 文件树,由顶栏"右栏"按钮控制) -->
|
||||
<aside class="right-panel" id="rightPanel" aria-hidden="true">
|
||||
<div class="right-panel-head">
|
||||
<span class="right-panel-title">文件树</span>
|
||||
<button type="button" class="right-panel-close" id="rightPanelClose" title="关闭右栏" aria-label="关闭右栏">✕</button>
|
||||
</div>
|
||||
<div class="doc-tree-wrap" id="docTreeWrap">
|
||||
<div class="doc-tree" id="docTree"></div>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="main" id="mainPanel">
|
||||
@@ -96,18 +95,34 @@
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
<div class="main-header-text" id="mainHeaderText">
|
||||
<h1 class="title">多模态Agent</h1>
|
||||
<!-- 当前工作空间(顶栏最左;文件夹图标点击打开资源管理器,其余点击打开文件树) -->
|
||||
<div class="main-header-ws" id="mainHeaderWs" hidden>
|
||||
<span class="main-header-ws-icon" id="mainHeaderWsIcon" title="在资源管理器中打开">
|
||||
<img src="/image/文件夹.svg" alt="" aria-hidden="true" />
|
||||
</span>
|
||||
<span class="main-header-ws-name" id="mainHeaderWsName"></span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="settings-btn"
|
||||
id="settingsBtn"
|
||||
title="系统配置"
|
||||
aria-label="打开系统配置"
|
||||
>
|
||||
⚙
|
||||
<!-- 窗口拖拽区(仅 Electron):顶栏空白区域,按钮全部脱离 drag 区域,点击不受影响 -->
|
||||
<div class="header-drag-spacer" id="headerDragSpacer" title="双击最大化窗口" aria-hidden="true"></div>
|
||||
<!-- 顶栏图标(从左到右):定时任务 / 左栏 / 终端 / 右栏 / 设置 -->
|
||||
<button type="button" class="top-icon-btn" id="modeCronToggleBtn" title="定时任务" aria-label="定时任务" aria-pressed="false">
|
||||
<img src="/image/cron.svg" alt="" aria-hidden="true" />
|
||||
</button>
|
||||
<button type="button" class="top-icon-btn" id="sidebarToggleBtn" title="收起/展开侧栏" aria-label="收起/展开侧栏">
|
||||
<img src="/image/左栏_left-bar.svg" alt="" aria-hidden="true" />
|
||||
</button>
|
||||
<button type="button" class="top-icon-btn" id="terminalBtn" title="终端" aria-label="终端">
|
||||
<img src="/image/终端.svg" alt="" aria-hidden="true" />
|
||||
</button>
|
||||
<button type="button" class="top-icon-btn" id="rightBarBtn" title="右栏(文件树)" aria-label="右栏">
|
||||
<img src="/image/右栏_right-bar.svg" alt="" aria-hidden="true" />
|
||||
</button>
|
||||
<!-- 窗口控制按钮(仅 Electron 无边框模式显示) -->
|
||||
<div class="win-controls" id="winControls">
|
||||
<button class="title-bar-btn" id="tbMin" title="最小化" aria-label="最小化"></button>
|
||||
<button class="title-bar-btn" id="tbMax" title="最大化" aria-label="最大化"></button>
|
||||
<button class="title-bar-btn title-bar-btn--close" id="tbClose" title="关闭" aria-label="关闭"></button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="main-content-wrap" id="mainContentWrap">
|
||||
<!-- Chat 模式 -->
|
||||
@@ -118,6 +133,13 @@
|
||||
<p class="muted"></p>
|
||||
</div>
|
||||
<div class="chat-messages" id="chatMessages" hidden></div>
|
||||
<!-- Cron 模式独立消息容器(与 chatMessages 并列,切换模式只切显隐,不销毁 DOM) -->
|
||||
<div class="chat-placeholder cron-placeholder" id="cronPlaceholder" hidden>
|
||||
<h3>定时任务</h3>
|
||||
<p>选择左侧任务查看执行记录</p>
|
||||
<p class="muted">任务到点后自动投递给 Agent 执行</p>
|
||||
</div>
|
||||
<div class="chat-messages" id="cronMessages" data-placeholder-id="cronPlaceholder" hidden></div>
|
||||
</section>
|
||||
<!-- 文件预览浮窗 -->
|
||||
<div class="file-preview-overlay" id="filePreviewOverlay" hidden>
|
||||
@@ -133,22 +155,9 @@
|
||||
<div class="file-preview-body" id="filePreviewBody"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 文档修改面板 -->
|
||||
<div class="doc-mod-panel" id="docModPanel" hidden>
|
||||
<div class="doc-mod-header">
|
||||
<span>文档变更</span>
|
||||
<button type="button" class="doc-mod-close" id="docModClose">✕</button>
|
||||
</div>
|
||||
<div class="doc-mod-list" id="docModList"></div>
|
||||
<div class="doc-mod-actions" id="docModActions" hidden>
|
||||
<button type="button" class="btn btn-primary" id="docModAcceptAll">全部保留</button>
|
||||
<button type="button" class="btn btn-danger" id="docModRejectAll">全部撤销</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 欢迎页(空会话居中时显示,在 main-content-wrap 外防止被折叠) -->
|
||||
<div class="welcome-view" id="welcomeView" hidden>
|
||||
<div class="welcome-content">
|
||||
<div class="welcome-view" id="welcomeView" hidden> <div class="welcome-content">
|
||||
<div class="welcome-left">
|
||||
<img src="/image/icon.svg" alt="" class="welcome-icon" />
|
||||
</div>
|
||||
@@ -167,6 +176,10 @@
|
||||
<!-- Token 用量活跃度卡(GitHub 风格矩阵 / 按小时柱状图) -->
|
||||
<div class="usage-card" id="usageCard" hidden></div>
|
||||
</div>
|
||||
<!-- 通知条:输入区上方长条列表(todo / 人机交互 / 审查),空态完全隐藏 -->
|
||||
<div class="notify-strip" id="notifyStrip" hidden>
|
||||
<div class="notify-strip-items" id="notifyStripItems"></div>
|
||||
</div>
|
||||
<footer class="composer">
|
||||
<div class="composer-refs" id="composerRefs" hidden></div>
|
||||
<div class="composer-row">
|
||||
@@ -182,7 +195,7 @@
|
||||
placeholder="输入消息或添加附件,Enter 发送(Shift+Enter 换行)"
|
||||
></textarea>
|
||||
<div class="composer-input-bottom-row">
|
||||
<div class="composer-input-bottom-left">
|
||||
<div class="composer-input-bottom-left" id="composerInputBottomLeft">
|
||||
<button type="button" class="voice-toggle-btn" id="voiceToggleBtn" title="文字/语音回复开关" aria-label="语音回复开关" aria-pressed="false">
|
||||
<img src="/image/文本语音 .svg" alt="语音" aria-hidden="true" />
|
||||
</button>
|
||||
@@ -196,16 +209,8 @@
|
||||
<div class="inline-drop-item access-mode-item--full" data-value="full">完全访问</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="input-actions-sep"></span>
|
||||
<button type="button" class="action-icon-btn" id="actionTodoBtn" title="Todo List">
|
||||
<img src="/image/todolist.svg" alt="Todo" />
|
||||
</button>
|
||||
<button type="button" class="action-icon-btn" id="actionPendingBtn" title="人机交互">
|
||||
<img src="/image/人机交互.svg" alt="交互" />
|
||||
</button>
|
||||
<button type="button" class="action-icon-btn" id="actionDocBtn" title="文档变更">
|
||||
<img src="/image/文档按钮.svg" alt="文档" />
|
||||
</button>
|
||||
<!-- 画板紧跟权限按钮(原竖线分隔已去除);
|
||||
todo / 人机交互按钮由 notify-strip.js 按需动态生成于此容器末尾 -->
|
||||
<button type="button" class="action-icon-btn" id="actionCanvasBtn" title="画板">
|
||||
<img src="/image/画板.svg" alt="画板" />
|
||||
</button>
|
||||
@@ -245,18 +250,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ws-btn-wrap" id="wsBtnWrap">
|
||||
<button type="button" class="ws-btn" id="wsBtn" title="选择工作空间" aria-label="选择工作空间">
|
||||
<img src="/image/文件夹.svg" alt="" aria-hidden="true" />
|
||||
</button>
|
||||
<div class="ws-dropdown" id="wsDropdown" hidden>
|
||||
<div class="ws-dropdown-header">工作空间</div>
|
||||
<div class="ws-dropdown-list" id="wsDropdownList"></div>
|
||||
<div class="ws-dropdown-add">
|
||||
<button type="button" class="ws-add-btn" id="wsAddBtn" title="添加工作空间">+ 添加工作空间</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="mic-btn" id="micBtn" title="使用麦克风录音" aria-pressed="false" aria-label="使用麦克风录音">
|
||||
<img src="/image/microphone.svg" alt="" aria-hidden="true" />
|
||||
</button>
|
||||
@@ -269,29 +262,24 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 无工作区/无会话引导态:悬停输入框时以虚线遮罩提示选择工作区 -->
|
||||
<div class="composer-empty-overlay" id="composerEmptyOverlay" hidden>
|
||||
<span class="composer-empty-icon">+</span>
|
||||
<span class="composer-empty-text">请选择工作区以开始对话</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<!-- 内置 PowerShell 终端(顶栏终端按钮控制;出现时输入区上移,聊天区自适应) -->
|
||||
<!-- 命令行(提示符+输入)由 terminal.js 动态挂载到活动面板输出末尾,随内容滚动 -->
|
||||
<div class="terminal-dock" id="terminalDock" hidden>
|
||||
<div class="terminal-tabs" id="terminalTabs"></div>
|
||||
<div class="terminal-body" id="terminalBody"></div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 新建会话确认弹窗 -->
|
||||
<div class="confirm-overlay" id="newSessionConfirm" hidden>
|
||||
<div class="confirm-dialog">
|
||||
<div class="confirm-title">新建会话</div>
|
||||
<div class="confirm-body">
|
||||
当前工作区存在 <strong id="confirmPendingCount">0</strong> 个未处理的文件变更。
|
||||
请先接受或撤销这些变更后再新建会话。
|
||||
</div>
|
||||
<div class="confirm-actions">
|
||||
<button type="button" class="confirm-btn confirm-btn--secondary" id="confirmCancel">取消</button>
|
||||
<button type="button" class="confirm-btn confirm-btn--danger" id="confirmRejectAll">全部撤销并新建</button>
|
||||
<button type="button" class="confirm-btn confirm-btn--primary" id="confirmAcceptAll">全部接受并新建</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 定时任务编辑/新建弹窗 -->
|
||||
<div class="cron-edit-overlay" id="cronEditOverlay" hidden>
|
||||
<div class="cron-edit-dialog" role="dialog" aria-modal="true" aria-label="定时任务编辑">
|
||||
@@ -353,6 +341,11 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cron-field">
|
||||
<label class="cron-field-label" for="cronEditWorkspace">工作区</label>
|
||||
<select id="cronEditWorkspace" class="cron-edit-select"></select>
|
||||
<span class="cron-field-hint">任务执行时的文件操作沙箱目录(无人值守,建议选择已信任的工作区)。</span>
|
||||
</div>
|
||||
<div class="cron-field">
|
||||
<label class="cron-field-label" for="cronEditTimeout">单次执行超时(秒)</label>
|
||||
<input type="number" id="cronEditTimeout" placeholder="300" min="10" value="300" />
|
||||
@@ -369,49 +362,219 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 系统配置模态框 -->
|
||||
<!-- 系统配置模态框(左侧分组导航 + 右侧内容区) -->
|
||||
<div class="settings-overlay" id="settingsOverlay" hidden>
|
||||
<div class="settings-dialog" id="settingsDialog" role="dialog" aria-modal="true" aria-label="系统配置">
|
||||
<div class="settings-header">
|
||||
<h2>系统配置</h2>
|
||||
<button type="button" class="settings-close-btn" id="settingsCloseBtn" aria-label="关闭">✕</button>
|
||||
</div>
|
||||
<div class="settings-tabs">
|
||||
<button type="button" class="settings-tab active" data-tab="agent">Agent</button>
|
||||
<button type="button" class="settings-tab" data-tab="tool">工具</button>
|
||||
<button type="button" class="settings-tab" data-tab="env">环境变量</button>
|
||||
<button type="button" class="settings-tab" data-tab="skills">Skills</button>
|
||||
<button type="button" class="settings-tab" data-tab="other">GUI设置</button>
|
||||
<button type="button" class="settings-tab" data-tab="theme">主题/语言</button>
|
||||
</div>
|
||||
<div class="settings-body">
|
||||
<!-- Agent 配置面板 -->
|
||||
<div class="settings-panel active" data-panel="agent" id="agentPanel">
|
||||
<div class="settings-toolbar">
|
||||
<aside class="settings-nav">
|
||||
<div class="settings-nav-head">
|
||||
<button type="button" class="settings-nav-back" id="settingsCloseBtn">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
|
||||
<span>返回</span>
|
||||
</button>
|
||||
</div>
|
||||
<nav class="settings-nav-list">
|
||||
<div class="settings-nav-group">基础设置</div>
|
||||
<button type="button" class="settings-nav-item active" data-page="general">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 7h9M19 7h1M4 17h3M13 17h7"/><circle cx="16" cy="7" r="2.2"/><circle cx="10" cy="17" r="2.2"/></svg>
|
||||
<span>常规</span>
|
||||
</button>
|
||||
<button type="button" class="settings-nav-item" data-page="appearance">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><circle cx="9" cy="10" r="1.2" fill="currentColor" stroke="none"/><circle cx="14" cy="8.5" r="1.2" fill="currentColor" stroke="none"/><circle cx="15.5" cy="13" r="1.2" fill="currentColor" stroke="none"/></svg>
|
||||
<span>外观</span>
|
||||
</button>
|
||||
<div class="settings-nav-group">模型与 Agent</div>
|
||||
<button type="button" class="settings-nav-item" data-page="models">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="7" y="7" width="10" height="10" rx="2"/><path d="M12 3v4M12 17v4M3 12h4M17 12h4"/></svg>
|
||||
<span>模型设置</span>
|
||||
</button>
|
||||
<button type="button" class="settings-nav-item" data-page="agents">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="5" y="8" width="14" height="10" rx="3"/><path d="M12 8V4M12 4h3"/><circle cx="10" cy="13" r="1.2" fill="currentColor" stroke="none"/><circle cx="14" cy="13" r="1.2" fill="currentColor" stroke="none"/></svg>
|
||||
<span>Agent 配置</span>
|
||||
</button>
|
||||
<button type="button" class="settings-nav-item" data-page="tools">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a4 4 0 0 0-5.4 5.4L4 17v3h3l5.3-5.3a4 4 0 0 0 5.4-5.4l-2.6 2.6-2.4-2.4 2.6-2.6z"/></svg>
|
||||
<span>工具与权限</span>
|
||||
</button>
|
||||
<div class="settings-nav-group">能力与集成</div>
|
||||
<button type="button" class="settings-nav-item" data-page="skills">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M12 3l2.1 5.6L20 10l-5.9 1.4L12 17l-2.1-5.6L4 10l5.9-1.4z"/></svg>
|
||||
<span>Skills</span>
|
||||
</button>
|
||||
<button type="button" class="settings-nav-item" data-page="gui">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="3" y="5" width="18" height="12" rx="2"/><path d="M9 21h6M12 17v4"/></svg>
|
||||
<span>GUI 自动化</span>
|
||||
</button>
|
||||
<button type="button" class="settings-nav-item" data-page="search">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c2.5 2.6 3.8 5.7 3.8 9S14.5 18.4 12 21c-2.5-2.6-3.8-5.7-3.8-9S9.5 5.6 12 3z"/></svg>
|
||||
<span>网络搜索</span>
|
||||
</button>
|
||||
<button type="button" class="settings-nav-item" data-page="mail">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 7l9 6 9-6"/></svg>
|
||||
<span>邮件</span>
|
||||
</button>
|
||||
<div class="settings-nav-group">数据与运行</div>
|
||||
<button type="button" class="settings-nav-item" data-page="storage">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="6" rx="8" ry="3"/><path d="M4 6v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3"/></svg>
|
||||
<span>存储与数据库</span>
|
||||
</button>
|
||||
<button type="button" class="settings-nav-item" data-page="runtime">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 14a8 8 0 1 1 16 0"/><path d="M12 14l4-4"/><path d="M4 18h16"/></svg>
|
||||
<span>运行时参数</span>
|
||||
</button>
|
||||
</nav>
|
||||
<div class="settings-nav-foot">
|
||||
<span class="settings-status" id="settingsStatus"></span>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="settings-main">
|
||||
<!-- 常规 -->
|
||||
<div class="settings-page" data-page="general">
|
||||
<h2>常规</h2>
|
||||
<div class="settings-card">
|
||||
<div class="settings-row">
|
||||
<div class="row-text"><h4>界面语言</h4><p>选择应用 UI 的显示语言,刷新后生效。</p></div>
|
||||
<div class="row-control"><select id="langSelect" class="lang-select"></select></div>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="row-text"><h4>用户昵称</h4><p>用于欢迎语与左栏用户区的显示名称。</p></div>
|
||||
<div class="row-control"><input type="text" data-env="USER_NICKNAME" placeholder="例如:minor" /></div>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="row-text"><h4>Python 解释器路径</h4><p>终端 / 脚本执行使用的自定义 Python 可执行文件,留空自动探测。</p></div>
|
||||
<div class="row-control row-control--stack">
|
||||
<div class="bg-input-row">
|
||||
<input type="text" data-env="USER_PYTHON_PATH" placeholder="例如:C:\myenv\Scripts\python.exe" />
|
||||
<button type="button" class="bg-picker-btn" id="pickPythonBtn" title="选择 Python 可执行文件">⋮</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-card">
|
||||
<div class="settings-row">
|
||||
<div class="row-text"><h4>发送文件大小上限(MB)</h4><p>单个附件允许发送的最大体积。</p></div>
|
||||
<div class="row-control"><input type="number" data-env="SEND_FILE_SIZE_LIMIT" min="1" step="1" /></div>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="row-text"><h4>定时任务冲突检测时段(分钟)</h4><p>检测定时任务时间冲突的时间段长度,应用后热更新调度器。</p></div>
|
||||
<div class="row-control row-control--stack">
|
||||
<div class="bg-input-row">
|
||||
<input type="number" data-env="CRON_TIME_PERIOD_MINUTES" min="1" step="1" />
|
||||
<button type="button" class="btn btn-primary" id="cronPeriodApplyBtn">应用</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-page-actions">
|
||||
<button type="button" class="btn btn-primary settings-save-btn" data-save="env">保存常规设置</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 外观 -->
|
||||
<div class="settings-page" data-page="appearance" hidden>
|
||||
<h2>外观</h2>
|
||||
<div class="settings-card">
|
||||
<div class="settings-row settings-row--block">
|
||||
<div class="row-text"><h4>主题预设</h4><p>即时生效,无需保存。</p></div>
|
||||
<div class="theme-grid" id="themeGrid"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-card">
|
||||
<div class="settings-row settings-row--block">
|
||||
<div class="row-text"><h4>头像</h4><p>选择本地图片作为头像(支持 jpg/png/webp),也可直接点击左栏头像更换,留空恢复默认。</p></div>
|
||||
<div class="bg-input-row">
|
||||
<input type="text" id="avatarInput" placeholder="例如:C:\avatar.png 或 https://..." />
|
||||
<button type="button" class="bg-picker-btn" id="avatarPickerBtn" title="选择图片">⋮</button>
|
||||
<input type="file" id="avatarFileInput" accept="image/*" hidden />
|
||||
</div>
|
||||
<div class="row-inline-actions">
|
||||
<button type="button" class="btn btn-primary" id="avatarApplyBtn">应用</button>
|
||||
<button type="button" class="btn btn-secondary" id="avatarResetBtn">恢复默认</button>
|
||||
</div>
|
||||
<span class="bg-preview-status" id="avatarPreviewStatus"></span>
|
||||
</div>
|
||||
<div class="settings-row settings-row--block">
|
||||
<div class="row-text"><h4>自定义背景</h4><p>输入本地图片路径或 URL(支持 jpg/png/webp),留空恢复默认。</p></div>
|
||||
<div class="bg-input-row">
|
||||
<input type="text" id="bgImageInput" placeholder="例如:C:\wallpaper.jpg 或 https://..." />
|
||||
<button type="button" class="bg-picker-btn" id="bgImagePickerBtn" title="选择图片">⋮</button>
|
||||
<input type="file" id="bgImageFileInput" accept="image/*" hidden />
|
||||
</div>
|
||||
<div class="row-inline-actions">
|
||||
<button type="button" class="btn btn-primary" id="bgImageApplyBtn">应用</button>
|
||||
<button type="button" class="btn btn-secondary" id="bgImageResetBtn">恢复默认</button>
|
||||
</div>
|
||||
<span class="bg-preview-status" id="bgPreviewStatus"></span>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="row-text"><h4>背景模糊度</h4><p id="bgBlurVal">0px</p></div>
|
||||
<div class="row-control"><input type="range" id="bgBlurSlider" min="0" max="10" step="1" value="0" class="compact-slider" /></div>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="row-text"><h4>背景暗角</h4><p id="bgVignetteVal">0%</p></div>
|
||||
<div class="row-control"><input type="range" id="bgVignetteSlider" min="0" max="1" step="0.05" value="0" class="compact-slider" /></div>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="row-text"><h4>字体</h4><p>应用全局字体族。</p></div>
|
||||
<div class="row-control">
|
||||
<select id="fontFamilySelect" class="lang-select">
|
||||
<option value="system">系统默认</option>
|
||||
<option value="sans">无衬线</option>
|
||||
<option value="serif">衬线</option>
|
||||
<option value="mono">等宽</option>
|
||||
<option value="zcool">站酷快乐体</option>
|
||||
<option value="mashan">马山正楷</option>
|
||||
<option value="longcang">龙藏手写</option>
|
||||
<option value="zcool-qy">站酷庆科黄油体</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="row-text"><h4>字号</h4><p>应用全局字号。</p></div>
|
||||
<div class="row-control">
|
||||
<select id="fontSizeSelect" class="lang-select">
|
||||
<option value="12px">12px</option>
|
||||
<option value="13px">小 (13px)</option>
|
||||
<option value="14px" selected>中 (14px)</option>
|
||||
<option value="15px">大 (15px)</option>
|
||||
<option value="16px">16px</option>
|
||||
<option value="18px">特大 (18px)</option>
|
||||
<option value="20px">20px</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 模型设置 -->
|
||||
<div class="settings-page" data-page="models" hidden>
|
||||
<h2>模型设置</h2>
|
||||
<div id="modelsZone"></div>
|
||||
</div>
|
||||
<!-- Agent 配置 -->
|
||||
<div class="settings-page" data-page="agents" hidden>
|
||||
<h2>Agent 配置</h2>
|
||||
<div class="settings-page-actions settings-page-actions--top">
|
||||
<button type="button" class="btn btn-primary" id="addAgentBtn">+ 添加 Agent</button>
|
||||
</div>
|
||||
<div class="settings-agents-list" id="agentsList"></div>
|
||||
<button type="button" class="btn btn-primary settings-save-btn" data-save="agents">保存 Agent 配置</button>
|
||||
<div class="settings-page-actions">
|
||||
<button type="button" class="btn btn-primary settings-save-btn" data-save="agents">保存 Agent 配置</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 工具配置面板 -->
|
||||
<div class="settings-panel" data-panel="tool" id="toolPanel" hidden>
|
||||
<!-- 工具与权限 -->
|
||||
<div class="settings-page" data-page="tools" hidden>
|
||||
<h2>工具与权限</h2>
|
||||
<div class="settings-tools-list" id="toolsList"></div>
|
||||
<button type="button" class="btn btn-primary settings-save-btn" data-save="tools">保存工具配置</button>
|
||||
<div class="settings-page-actions">
|
||||
<button type="button" class="btn btn-primary settings-save-btn" data-save="tools">保存工具配置</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 环境变量配置面板 -->
|
||||
<div class="settings-panel" data-panel="env" id="envPanel" hidden>
|
||||
<div class="settings-env-list" id="envList"></div>
|
||||
<button type="button" class="btn btn-primary settings-save-btn" data-save="env">保存环境变量配置</button>
|
||||
</div>
|
||||
<!-- Skills 管理面板 -->
|
||||
<div class="settings-panel" data-panel="skills" id="skillsPanel" hidden>
|
||||
<div class="settings-toolbar">
|
||||
<div class="settings-toolbar-buttons">
|
||||
<button type="button" class="btn btn-primary" id="addSkillBtn">+ 创建 Skill</button>
|
||||
<span class="skill-import-wrap">
|
||||
<button type="button" class="btn btn-primary import-skill-btn" id="importSkillBtn" title="导入 zip 或文件夹(自动解析 skill.md)">导入 Skill</button>
|
||||
</span>
|
||||
</div>
|
||||
<!-- Skills -->
|
||||
<div class="settings-page" data-page="skills" hidden>
|
||||
<h2>Skills</h2>
|
||||
<div class="settings-page-actions settings-page-actions--top">
|
||||
<button type="button" class="btn btn-primary" id="addSkillBtn">+ 创建 Skill</button>
|
||||
<span class="skill-import-wrap">
|
||||
<button type="button" class="btn btn-primary import-skill-btn" id="importSkillBtn" title="导入 zip 或文件夹(自动解析 skill.md)">导入 Skill</button>
|
||||
</span>
|
||||
</div>
|
||||
<input type="file" id="importSkillZipInput" accept=".zip" hidden />
|
||||
<input type="file" id="importSkillFolderInput" webkitdirectory multiple hidden />
|
||||
@@ -466,92 +629,204 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 其他设置面板 -->
|
||||
<div class="settings-panel" data-panel="other" id="otherPanel" hidden>
|
||||
<div class="settings-section">
|
||||
<h4 class="settings-section-title">GUI 操作区域</h4>
|
||||
<p class="settings-hint">选择 Agent 进行 GUI 自动化操作(点击、截图、拖拽等)的目标显示器。</p>
|
||||
<div class="gui-monitors-grid" id="guiMonitorsGrid"></div>
|
||||
<!-- GUI 自动化 -->
|
||||
<div class="settings-page" data-page="gui" hidden>
|
||||
<h2>GUI 自动化</h2>
|
||||
<p class="settings-hint">选择 Agent 进行 GUI 自动化操作(点击、截图、拖拽等)的目标显示器。</p>
|
||||
<div class="gui-monitors-grid" id="guiMonitorsGrid"></div>
|
||||
<div class="settings-page-actions">
|
||||
<button type="button" class="btn btn-primary settings-save-btn" data-save="gui">保存 GUI 设置</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary settings-save-btn" data-save="gui">保存 GUI 设置</button>
|
||||
</div>
|
||||
<!-- 主题/语言面板 -->
|
||||
<div class="settings-panel" data-panel="theme" id="themePanel" hidden>
|
||||
<div class="settings-section">
|
||||
<h4 class="settings-section-title">主题预设</h4>
|
||||
<div class="theme-grid" id="themeGrid"></div>
|
||||
</div>
|
||||
<div class="settings-section">
|
||||
<h4 class="settings-section-title" style="margin-bottom:0.3rem;">自定义背景</h4>
|
||||
<p class="settings-hint">输入本地图片路径或 URL(支持 jpg/png/webp),留空恢复默认。</p>
|
||||
<div class="setting-field" style="margin-bottom:0.4rem;">
|
||||
<div class="bg-input-row">
|
||||
<input type="text" id="bgImageInput" placeholder="例如:C:\wallpaper.jpg 或 https://..." />
|
||||
<button type="button" class="bg-picker-btn" id="bgImagePickerBtn" title="选择图片">⋮</button>
|
||||
<input type="file" id="bgImageFileInput" accept="image/*" hidden />
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:0.5rem;">
|
||||
<button type="button" class="btn btn-primary" id="bgImageApplyBtn">应用</button>
|
||||
<button type="button" class="btn btn-secondary" id="bgImageResetBtn">恢复默认</button>
|
||||
</div>
|
||||
<span class="bg-preview-status" id="bgPreviewStatus" style="font-size:11px;color:#6a6a6a;display:block;margin-top:0.4rem;"></span>
|
||||
</div>
|
||||
<div class="settings-section">
|
||||
<h4 class="settings-section-title" style="margin-bottom:0.3rem;">背景效果</h4>
|
||||
<div style="display:flex;gap:1rem;">
|
||||
<div class="setting-field" style="flex:1;">
|
||||
<label style="font-size:0.7rem;display:block;margin-bottom:0.15rem;">模糊度 <span id="bgBlurVal" style="color:var(--accent);">0px</span></label>
|
||||
<input type="range" id="bgBlurSlider" min="0" max="10" step="1" value="0" class="compact-slider">
|
||||
</div>
|
||||
<div class="setting-field" style="flex:1;">
|
||||
<label style="font-size:0.7rem;display:block;margin-bottom:0.15rem;">暗角 <span id="bgVignetteVal" style="color:var(--accent);">0%</span></label>
|
||||
<input type="range" id="bgVignetteSlider" min="0" max="1" step="0.05" value="0" class="compact-slider">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-section">
|
||||
<h4 class="settings-section-title" style="margin-bottom:0.3rem;">字体与字号</h4>
|
||||
<div style="display:flex;gap:0.5rem;align-items:flex-end;">
|
||||
<div style="flex:1;">
|
||||
<label style="font-size:0.7rem;display:block;margin-bottom:0.2rem;">字体</label>
|
||||
<select id="fontFamilySelect" class="lang-select" style="max-width:none;">
|
||||
<option value="system">系统默认</option>
|
||||
<option value="sans">无衬线</option>
|
||||
<option value="serif">衬线</option>
|
||||
<option value="mono">等宽</option>
|
||||
<option value="zcool">站酷快乐体</option>
|
||||
<option value="mashan">马山正楷</option>
|
||||
<option value="longcang">龙藏手写</option>
|
||||
<option value="zcool-qy">站酷庆科黄油体</option>
|
||||
<!-- 网络搜索 -->
|
||||
<div class="settings-page" data-page="search" hidden>
|
||||
<h2>网络搜索</h2>
|
||||
<div class="settings-card">
|
||||
<div class="settings-row">
|
||||
<div class="row-text"><h4>搜索引擎</h4><p>web_search 工具使用的搜索引擎,仅支持以下选项。</p></div>
|
||||
<div class="row-control">
|
||||
<select id="searchEngineSelect">
|
||||
<option value="tavily">Tavily</option>
|
||||
<option value="firecrawl">Firecrawl</option>
|
||||
<option value="volcengine">火山引擎(volcengine)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="flex:1;">
|
||||
<label style="font-size:0.7rem;display:block;margin-bottom:0.2rem;">字号</label>
|
||||
<select id="fontSizeSelect" class="lang-select" style="max-width:none;">
|
||||
<option value="12px">12px</option>
|
||||
<option value="13px">小 (13px)</option>
|
||||
<option value="14px" selected>中 (14px)</option>
|
||||
<option value="15px">大 (15px)</option>
|
||||
<option value="16px">16px</option>
|
||||
<option value="18px">特大 (18px)</option>
|
||||
<option value="20px">20px</option>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="row-text"><h4>搜索 API Key</h4><p>所选搜索引擎的 API Key。</p></div>
|
||||
<div class="row-control"><input type="text" data-env="WEB_SEARCH_API_KEY" placeholder="例如:tvly-..." /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-page-actions">
|
||||
<button type="button" class="btn btn-primary settings-save-btn" data-save="env">保存网络搜索设置</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 邮件 -->
|
||||
<div class="settings-page" data-page="mail" hidden>
|
||||
<h2>邮件</h2>
|
||||
<div class="settings-card">
|
||||
<div class="settings-row">
|
||||
<div class="row-text"><h4>邮箱地址</h4><p>邮件发送账号,域名仅支持 qq.com / 163.com / 126.com / yeah.net。</p></div>
|
||||
<div class="row-control row-control--stack">
|
||||
<div class="env-email-wrap">
|
||||
<input type="text" id="emailPrefixInput" placeholder="邮箱用户名" title="邮箱用户名(@ 之前的部分)" />
|
||||
<select id="emailDomainSelect" title="邮箱域名(仅支持下列域名)">
|
||||
<option value="qq.com">@qq.com</option>
|
||||
<option value="163.com">@163.com</option>
|
||||
<option value="126.com">@126.com</option>
|
||||
<option value="yeah.net">@yeah.net</option>
|
||||
<option value="__custom__">@自定义域名…</option>
|
||||
</select>
|
||||
<input type="text" id="emailCustomDomain" placeholder="自定义域名(如 outlook.com)" hidden />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="row-text"><h4>SMTP 授权码</h4><p>邮箱的 SMTP 授权码(非登录密码)。</p></div>
|
||||
<div class="row-control"><input type="password" data-env="EMAIL_AUTH_CODE" placeholder="SMTP 授权码" /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-page-actions">
|
||||
<button type="button" class="btn btn-primary settings-save-btn" data-save="env">保存邮件设置</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 存储与数据库 -->
|
||||
<div class="settings-page" data-page="storage" hidden>
|
||||
<h2>存储与数据库</h2>
|
||||
<div class="settings-card">
|
||||
<div class="settings-row">
|
||||
<div class="row-text"><h4>存储后端</h4><p>会话与定时任务的存储方式:JSON(本地文件)、MySQL(数据库)或 SQLite(本地数据库文件)。</p></div>
|
||||
<div class="row-control">
|
||||
<select id="storageBackendSelect">
|
||||
<option value="json">JSON(本地文件)</option>
|
||||
<option value="mysql">MySQL(数据库)</option>
|
||||
<option value="sqlite">SQLite(本地数据库文件)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-section" style="margin-top:1.2rem;">
|
||||
<h4 class="settings-section-title">界面语言</h4>
|
||||
<div class="lang-select-wrap">
|
||||
<select id="langSelect" class="lang-select"></select>
|
||||
<!-- 存储数据库配置(类型随存储后端联动,后端 ≠ JSON 时显示) -->
|
||||
<div class="settings-card" id="storageDbSection" hidden>
|
||||
<div class="settings-row">
|
||||
<div class="row-text"><h4 id="storageDbTypeTitle">MySQL 连接配置</h4><p>存储后端的数据库连接信息;数据库类型由上方"存储后端"决定。</p></div>
|
||||
</div>
|
||||
<p class="theme-note">主题即时生效,语言需刷新页面后生效。</p>
|
||||
<div id="storageDbMysqlFields" hidden>
|
||||
<div class="settings-row">
|
||||
<div class="row-text"><h4>MySQL 主机</h4><p>数据库主机地址(留空回退旧 MYSQL_HOST)。</p></div>
|
||||
<div class="row-control"><input type="text" data-env="STORAGE_DB_HOST" placeholder="localhost" /></div>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="row-text"><h4>MySQL 端口</h4><p>数据库端口,默认 3306。</p></div>
|
||||
<div class="row-control"><input type="number" data-env="STORAGE_DB_PORT" min="1" step="1" placeholder="3306" /></div>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="row-text"><h4>MySQL 用户名</h4><p>数据库登录用户名。</p></div>
|
||||
<div class="row-control"><input type="text" data-env="STORAGE_DB_USER" placeholder="root" /></div>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="row-text"><h4>MySQL 密码</h4><p>数据库登录密码。</p></div>
|
||||
<div class="row-control"><input type="password" data-env="STORAGE_DB_PASSWORD" /></div>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="row-text"><h4>MySQL 数据库名</h4><p>存放会话与定时任务数据的数据库。</p></div>
|
||||
<div class="row-control"><input type="text" data-env="STORAGE_DB_DATABASE" placeholder="agent_history" /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-row" id="storageDbSqliteFields" hidden>
|
||||
<div class="row-text"><h4>SQLite 文件路径</h4><p>存储库数据库文件;留空默认 <code>history/sqlite/storage.sqlite</code>。</p></div>
|
||||
<div class="row-control"><input type="text" data-env="STORAGE_DB_PATH" placeholder="留空 = history/sqlite/storage.sqlite" /></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 工具数据库配置(与存储后端完全隔离) -->
|
||||
<div class="settings-card" id="toolDbSection">
|
||||
<div class="settings-row">
|
||||
<div class="row-text"><h4>工具数据库类型</h4><p>数据库操作工具(text2sql / excel2sql)使用的数据库;与存储后端配置完全隔离,互不影响。</p></div>
|
||||
<div class="row-control">
|
||||
<select id="toolDbTypeSelect">
|
||||
<option value="mysql">MySQL</option>
|
||||
<option value="sqlite">SQLite</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div id="toolDbMysqlFields" hidden>
|
||||
<div class="settings-row">
|
||||
<div class="row-text"><h4>MySQL 主机</h4><p>数据库主机地址。</p></div>
|
||||
<div class="row-control"><input type="text" data-env="TOOL_DB_HOST" placeholder="localhost" /></div>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="row-text"><h4>MySQL 端口</h4><p>数据库端口,默认 3306。</p></div>
|
||||
<div class="row-control"><input type="number" data-env="TOOL_DB_PORT" min="1" step="1" placeholder="3306" /></div>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="row-text"><h4>MySQL 用户名</h4><p>数据库登录用户名。</p></div>
|
||||
<div class="row-control"><input type="text" data-env="TOOL_DB_USER" placeholder="root" /></div>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="row-text"><h4>MySQL 密码</h4><p>数据库登录密码。</p></div>
|
||||
<div class="row-control"><input type="password" data-env="TOOL_DB_PASSWORD" /></div>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="row-text"><h4>MySQL 默认数据库</h4><p>工具连接默认使用的数据库(text2sql 仍按 database 参数指定目标库)。</p></div>
|
||||
<div class="row-control"><input type="text" data-env="TOOL_DB_DATABASE" /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-row" id="toolDbSqliteFields" hidden>
|
||||
<div class="row-text"><h4>SQLite 路径(文件或目录)</h4><p>目录:每个库名对应一个 <code><目录>/<库名>.sqlite</code> 文件;<code>.sqlite</code> 文件:所有操作使用该文件。留空默认 <code>history/sqlite/tool</code>。</p></div>
|
||||
<div class="row-control"><input type="text" data-env="TOOL_DB_PATH" placeholder="留空 = history/sqlite/tool(目录)" /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-page-actions">
|
||||
<button type="button" class="btn btn-primary settings-save-btn" data-save="env">保存存储设置</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-footer">
|
||||
<span class="settings-status" id="settingsStatus"></span>
|
||||
<button type="button" class="btn btn-secondary" id="settingsCancelBtn">取消</button>
|
||||
<!-- 运行时参数 -->
|
||||
<div class="settings-page" data-page="runtime" hidden>
|
||||
<h2>运行时参数</h2>
|
||||
<div class="settings-card">
|
||||
<div class="settings-row">
|
||||
<div class="row-text"><h4>LLM 上下文窗口</h4><p>上下文窗口大小(token),压缩阈值按窗口比例计算。</p></div>
|
||||
<div class="row-control"><input type="number" data-env="LLM_CONTEXT_WINDOW" min="1" step="1" /></div>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="row-text"><h4>上下文压缩阈值</h4><p>0~1,token 用量超过 窗口×该值 时触发压缩。</p></div>
|
||||
<div class="row-control"><input type="number" data-env="COMPRESS_RATE" min="0" max="1" step="0.05" /></div>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="row-text"><h4>截图缩放边长(px)</h4><p>工具截图 / 图片缩放边长,用于控制 token 消耗。</p></div>
|
||||
<div class="row-control"><input type="number" data-env="IMG_SIZE" min="1" step="1" /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-card">
|
||||
<div class="settings-row">
|
||||
<div class="row-text"><h4>RAG 分块大小(字符)</h4><p>文档检索的分块长度。</p></div>
|
||||
<div class="row-control"><input type="number" data-env="RAG_CHUNK_SIZE" min="1" step="1" /></div>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="row-text"><h4>RAG 分块重叠(字符)</h4><p>相邻分块的重叠长度。</p></div>
|
||||
<div class="row-control"><input type="number" data-env="RAG_CHUNK_OVERLAP" min="0" step="1" /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-card">
|
||||
<div class="settings-row">
|
||||
<div class="row-text"><h4>GUI 定位图像宽度(px)</h4><p>视觉定位模型输入图像宽度。</p></div>
|
||||
<div class="row-control"><input type="number" data-env="GROUNDING_WIDTH" min="1" step="1" /></div>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="row-text"><h4>GUI 定位图像高度(px)</h4><p>视觉定位模型输入图像高度。</p></div>
|
||||
<div class="row-control"><input type="number" data-env="GROUNDING_HEIGHT" min="1" step="1" /></div>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<div class="row-text"><h4>重复工具调用警告阈值</h4><p>同一工具连续重复调用超过该次数时输出警告。</p></div>
|
||||
<div class="row-control"><input type="number" data-env="LOOP_DETECT_REPEATED_TOOL_WARN" min="1" step="1" /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-page-actions">
|
||||
<button type="button" class="btn btn-primary settings-save-btn" data-save="env">保存运行时参数</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-main-foot">
|
||||
<button type="button" class="btn btn-secondary" id="settingsCancelBtn">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -606,6 +881,8 @@
|
||||
_showJsError('Promise: ' + (e.reason?.message || String(e.reason)));
|
||||
});
|
||||
</script>
|
||||
<script type="module" src="/app.js?v=6"></script>
|
||||
<!-- 入口模块:版本参数只加在 boot.js 上。app.js 必须保持无查询串加载,
|
||||
否则与 js/ 模块内 import('../app.js') 形成两个模块实例(双份监听,toggle 互相抵消) -->
|
||||
<script type="module" src="/js/boot.js?v=1"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,83 +1,22 @@
|
||||
// action-bar.js -- 操作图标按钮栏:TodoList / 人机交互 / 文档变更
|
||||
// 替代原来的 floating overlay 模式,改用图标按钮 toggle 面板
|
||||
// action-bar.js -- 操作按钮栏(兼容层)
|
||||
//
|
||||
// 原实现:composer 内静态 todo/pending 按钮 + 徽章,互斥 toggle 浮窗。
|
||||
// 现已迁移:todo/人机交互改为输入区上方通知条(notify-strip.js),
|
||||
// 按钮由通知条左侧按钮点击后动态生成于输入区,浮窗已移除。
|
||||
// 本文件保留导出签名(initActionBar / deactivateAllActionPanels / setActionBadge)
|
||||
// 为空实现,避免既有 import 报错;后续可安全移除对它的引用。
|
||||
|
||||
import { $ } from './utils.js';
|
||||
|
||||
/** 当前激活的面板类型 */
|
||||
let _activePanel = null;
|
||||
/** 连击保护:上次切换时间戳(ms) */
|
||||
let _lastToggleTime = 0;
|
||||
const TOGGLE_COOLDOWN = 300;
|
||||
|
||||
/**
|
||||
* 初始化操作按钮栏事件
|
||||
* @param {object} callbacks - { onToggleTodo, onTogglePending, onToggleDoc }
|
||||
*/
|
||||
/** 原按钮已不存在于静态 DOM,无需绑定事件。回调保留签名以兼容调用方。 */
|
||||
export function initActionBar(callbacks = {}) {
|
||||
const todoBtn = $('actionTodoBtn');
|
||||
const pendingBtn = $('actionPendingBtn');
|
||||
const docBtn = $('actionDocBtn');
|
||||
|
||||
if (todoBtn) {
|
||||
todoBtn.addEventListener('click', () => {
|
||||
_togglePanel('todo', todoBtn, callbacks.onToggleTodo);
|
||||
});
|
||||
}
|
||||
if (pendingBtn) {
|
||||
pendingBtn.addEventListener('click', () => {
|
||||
_togglePanel('pending', pendingBtn, callbacks.onTogglePending);
|
||||
});
|
||||
}
|
||||
if (docBtn) {
|
||||
docBtn.addEventListener('click', () => {
|
||||
_togglePanel('doc', docBtn, callbacks.onToggleDoc);
|
||||
});
|
||||
}
|
||||
// no-op
|
||||
}
|
||||
|
||||
function _togglePanel(name, btn, callback) {
|
||||
const now = Date.now();
|
||||
if (now - _lastToggleTime < TOGGLE_COOLDOWN) return;
|
||||
_lastToggleTime = now;
|
||||
|
||||
if (_activePanel === name) {
|
||||
_deactivateAll();
|
||||
if (callback) callback(false);
|
||||
return;
|
||||
}
|
||||
_deactivateAll();
|
||||
btn.classList.add('is-active');
|
||||
_activePanel = name;
|
||||
if (callback) callback(true);
|
||||
}
|
||||
|
||||
/** 关闭所有面板并取消按钮激活态 */
|
||||
/** 原互斥面板概念已废弃(通知条各自独立显隐)。 */
|
||||
export function deactivateAllActionPanels() {
|
||||
_deactivateAll();
|
||||
// no-op
|
||||
}
|
||||
|
||||
function _deactivateAll() {
|
||||
['actionTodoBtn', 'actionPendingBtn', 'actionDocBtn'].forEach((id) => {
|
||||
const btn = $(id);
|
||||
if (btn) btn.classList.remove('is-active');
|
||||
});
|
||||
_activePanel = null;
|
||||
}
|
||||
|
||||
/** 设置某个按钮的角标(红点) */
|
||||
/** 徽章机制已由通知条自身的计数 chip 取代。 */
|
||||
export function setActionBadge(name, show) {
|
||||
const id = name === 'todo' ? 'actionTodoBtn'
|
||||
: name === 'pending' ? 'actionPendingBtn'
|
||||
: 'actionDocBtn';
|
||||
const btn = $(id);
|
||||
if (!btn) return;
|
||||
const existing = btn.querySelector('.badge-dot');
|
||||
if (show && !existing) {
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'badge-dot';
|
||||
btn.style.position = 'relative';
|
||||
btn.appendChild(dot);
|
||||
} else if (!show && existing) {
|
||||
existing.remove();
|
||||
}
|
||||
// no-op
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// boot.js -- 唯一入口模块(index.html 的 <script> 指向本文件并携带缓存版本号)。
|
||||
//
|
||||
// 背景:app.js 会被 js/ 下多个模块以 import('../app.js') 动态引用(取导出函数)。
|
||||
// ES 模块以完整 URL(含查询串)为身份标识——若入口直接加载 /app.js?v=N,
|
||||
// 动态 import 解析到的 /app.js(无查询串)会被视为另一个模块实例,导致
|
||||
// app.js 顶层副作用执行两遍:事件监听注册两份,toggle 类按钮一次点击触发
|
||||
// "开+关"互相抵消(左栏/终端按钮表现为点击无反应)。
|
||||
//
|
||||
// 因此把版本参数隔离在"不会被任何其他模块 import"的 boot.js 上,
|
||||
// app.js 全站统一以无查询串的相对路径解析,保证单实例。
|
||||
import '../app.js';
|
||||
@@ -1,6 +1,6 @@
|
||||
// chat-render.js -- 消息渲染 / 流式输出 / 音频气泡(新版:无气泡 + 媒体行 + agent-run-block)
|
||||
|
||||
import { $, escapeHtml, getTextFromContent, getMediaParts, isAudioOnlyAssistantMessage, clamp, formatAudioDuration, getAudioBubbleWidth, codeBlockNode, inferUploadKind, dedupePendingFiles, showToast } from './utils.js';
|
||||
import { $, escapeHtml, getTextFromContent, getMediaParts, isAudioOnlyAssistantMessage, clamp, formatAudioDuration, getAudioBubbleWidth, codeBlockNode, inferUploadKind, dedupePendingFiles, showToast, getFileIcon } from './utils.js';
|
||||
import { state } from './state.js';
|
||||
import {
|
||||
showImagePreview,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
attachHorizontalScroll,
|
||||
showFilePreviewDialog,
|
||||
} from './toolcalls.js';
|
||||
import { renderTurnNav } from './turn-nav.js';
|
||||
|
||||
let rollbackChatFn = null;
|
||||
export function setRollbackChatFn(fn) { rollbackChatFn = fn; }
|
||||
@@ -19,9 +20,36 @@ export function setRenderPersistentToolCalls(fn) { renderPersistentToolCallsFn =
|
||||
const chatMessages = $("chatMessages");
|
||||
const chatPlaceholder = $("chatPlaceholder");
|
||||
|
||||
function scrollChatToBottom() {
|
||||
if (!chatMessages) return;
|
||||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||
/* ===== 双容器渲染支持(chat / cron 各自独立消息容器) =====
|
||||
* 所有渲染函数默认作用于 chat 容器(#chatMessages),
|
||||
* 通过 opts.container 可指定目标容器(如 cron 模式的 #cronMessages)。
|
||||
* 占位符与容器配对:container._placeholderEl 或 dataset.placeholderId。 */
|
||||
|
||||
function _defaultContainer() { return chatMessages; }
|
||||
|
||||
/** 获取容器配对的占位符元素(无则返回 null)。 */
|
||||
function _placeholderFor(container) {
|
||||
if (!container) return null;
|
||||
if (container._placeholderEl) return container._placeholderEl;
|
||||
const pid = container.dataset ? container.dataset.placeholderId : "";
|
||||
if (pid) {
|
||||
const el = $(pid);
|
||||
if (el) { container._placeholderEl = el; return el; }
|
||||
}
|
||||
// chat 容器默认配对 chatPlaceholder
|
||||
if (container === chatMessages) return chatPlaceholder;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 注册容器 ↔ 占位符配对(初始化时调用)。 */
|
||||
export function registerMessageContainer(container, placeholderEl) {
|
||||
if (container && placeholderEl) container._placeholderEl = placeholderEl;
|
||||
}
|
||||
|
||||
function scrollChatToBottom(container) {
|
||||
const el = container || chatMessages;
|
||||
if (!el) return;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
function renderAttachmentChips() {
|
||||
@@ -166,7 +194,7 @@ function renderAttachmentChips() {
|
||||
otherFiles.forEach(({ file, idx }) => {
|
||||
const chip = document.createElement("span");
|
||||
chip.className = "chip";
|
||||
chip.innerHTML = `<img src="/image/文档按钮.svg" alt="" class="chip-icon" /> ${escapeHtml(file.name)} <button type="button" aria-label="移除">\u00D7</button>`;
|
||||
chip.innerHTML = `<img src="${getFileIcon(file.name)}" alt="" class="chip-icon" /> ${escapeHtml(file.name)} <button type="button" aria-label="移除">\u00D7</button>`;
|
||||
chip.querySelector("button").addEventListener("click", () => {
|
||||
state.pendingFiles = state.pendingFiles.filter((_, j) => j !== idx);
|
||||
renderAttachmentChips();
|
||||
@@ -179,7 +207,7 @@ function renderAttachmentChips() {
|
||||
refFiles.forEach(({ file, idx }) => {
|
||||
const chip = document.createElement("span");
|
||||
chip.className = "ref-chip";
|
||||
chip.innerHTML = `<img src="/image/文档按钮.svg" alt="" class="ref-chip-icon" /> <span class="ref-chip-label">${escapeHtml(file.refName || file.name)}</span> <button type="button" class="ref-chip-close" aria-label="移除">\u00D7</button>`;
|
||||
chip.innerHTML = `<img src="${getFileIcon(file.refName || file.name)}" alt="" class="ref-chip-icon" /> <span class="ref-chip-label">${escapeHtml(file.refName || file.name)}</span> <button type="button" class="ref-chip-close" aria-label="移除">\u00D7</button>`;
|
||||
chip.querySelector("button").addEventListener("click", () => {
|
||||
state.pendingFiles = state.pendingFiles.filter((_, j) => j !== idx);
|
||||
if (file.refPath) {
|
||||
@@ -341,10 +369,12 @@ async function pseudoStreamAssistant(bubble, full) {
|
||||
bubble.textContent = "";
|
||||
|
||||
const n = t.length;
|
||||
const charsPerSec = 85;
|
||||
const totalMs = Math.min(12000, Math.max(400, (n / charsPerSec) * 1000));
|
||||
const charsPerSec = 350;
|
||||
const totalMs = Math.min(4000, Math.max(300, (n / charsPerSec) * 1000));
|
||||
const t0 = performance.now();
|
||||
let lastSnapshot = "";
|
||||
// 流式期间固定滚动目标为 bubble 所属的消息容器(双容器安全)
|
||||
const scrollTarget = bubble.closest(".chat-messages") || chatMessages;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
function frame(now) {
|
||||
@@ -362,7 +392,7 @@ async function pseudoStreamAssistant(bubble, full) {
|
||||
const stillOpenFence = (block.match(/```/g) || []).length % 2 === 1;
|
||||
bubble.appendChild(renderMarkdownBlock(block, isLast || stillOpenFence));
|
||||
});
|
||||
scrollChatToBottom();
|
||||
scrollChatToBottom(scrollTarget);
|
||||
}
|
||||
|
||||
if (p < 1) {
|
||||
@@ -370,7 +400,7 @@ async function pseudoStreamAssistant(bubble, full) {
|
||||
} else {
|
||||
bubble.textContent = "";
|
||||
bubble.appendChild(renderMarkdownToFragment(t));
|
||||
scrollChatToBottom();
|
||||
scrollChatToBottom(scrollTarget);
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
@@ -715,7 +745,7 @@ function renderUserFileRefCard(filePath) {
|
||||
|
||||
const icon = document.createElement("img");
|
||||
icon.className = "user-file-card-icon";
|
||||
icon.src = "/image/\u6587\u6863\u6309\u94AE.svg"; // 文档按钮.svg
|
||||
icon.src = getFileIcon(safeName); // 按扩展名分类图标(文本/Excel/Word/PDF/压缩/音频)
|
||||
icon.alt = "\u6587\u4EF6";
|
||||
|
||||
const info = document.createElement("div");
|
||||
@@ -1033,9 +1063,11 @@ function renderContentPart(part, options = {}) {
|
||||
return div;
|
||||
}
|
||||
|
||||
function renderMessage(msg, allowAutoplay = false) {
|
||||
function renderMessage(msg, allowAutoplay = false, opts = {}) {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "msg " + (msg.role === "user" ? "user" : "assistant") + (isAudioOnlyAssistantMessage(msg) ? " msg--audio-only" : "");
|
||||
// 标记消息所属回合(回合导航条按此分组 / 跳转)
|
||||
wrap.dataset.turnId = (msg.meta && msg.meta.turn_id) || "";
|
||||
|
||||
const baseOptions = {
|
||||
messageMeta: msg.meta || null,
|
||||
@@ -1043,16 +1075,19 @@ function renderMessage(msg, allowAutoplay = false) {
|
||||
allowAutoplay: allowAutoplay,
|
||||
};
|
||||
|
||||
// minimalActions:cron 执行记录等只读场景,仅保留复制按钮
|
||||
const actionOpts = { minimal: !!opts.minimalActions };
|
||||
|
||||
if (msg.role === "user") {
|
||||
// 用户消息:内容 + 下方操作按钮(复制 / 撤回)
|
||||
const contentEl = renderUserContent(msg.content, baseOptions);
|
||||
wrap.appendChild(contentEl);
|
||||
wrap.appendChild(renderMessageActions("user", msg, wrap));
|
||||
wrap.appendChild(renderMessageActions("user", msg, wrap, actionOpts));
|
||||
} else {
|
||||
// 助手消息:内容 + 下方操作按钮(复制 / 重试)
|
||||
const contentEl = renderAssistantContent(msg, allowAutoplay);
|
||||
wrap.appendChild(contentEl);
|
||||
wrap.appendChild(renderMessageActions("assistant", msg, wrap));
|
||||
wrap.appendChild(renderMessageActions("assistant", msg, wrap, actionOpts));
|
||||
if (msg.meta && msg.meta.pending_action) {
|
||||
// pending_action 不再内嵌在气泡中,由 pending-overlay 浮窗展示
|
||||
}
|
||||
@@ -1062,7 +1097,7 @@ function renderMessage(msg, allowAutoplay = false) {
|
||||
}
|
||||
|
||||
/* ===== 消息下方操作按钮行(无边框图标按钮) ===== */
|
||||
function renderMessageActions(role, msg, wrap) {
|
||||
function renderMessageActions(role, msg, wrap, opts = {}) {
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "msg-actions msg-actions--" + role;
|
||||
|
||||
@@ -1092,6 +1127,9 @@ function renderMessageActions(role, msg, wrap) {
|
||||
});
|
||||
actions.appendChild(copyBtn);
|
||||
|
||||
// 只读场景(如 cron 执行记录):仅保留复制按钮
|
||||
if (opts.minimal) return actions;
|
||||
|
||||
if (role === "user") {
|
||||
// 撤回:回退到此消息
|
||||
const rollbackBtn = document.createElement("button");
|
||||
@@ -1106,8 +1144,9 @@ function renderMessageActions(role, msg, wrap) {
|
||||
rollbackBtn.appendChild(rbImg);
|
||||
rollbackBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
if (!chatMessages) return;
|
||||
const children = Array.from(chatMessages.children);
|
||||
const parent = wrap.parentElement;
|
||||
if (!parent) return;
|
||||
const children = Array.from(parent.children);
|
||||
const idx = children.indexOf(wrap);
|
||||
if (idx < 0) return;
|
||||
if (rollbackChatFn) rollbackChatFn(idx);
|
||||
@@ -1116,8 +1155,9 @@ function renderMessageActions(role, msg, wrap) {
|
||||
} else {
|
||||
// 点赞 / 点踩(本地持久化,刷新/切换会话后保留)
|
||||
const msgIdx = (() => {
|
||||
if (!chatMessages) return -1;
|
||||
return Array.from(chatMessages.children).indexOf(wrap);
|
||||
const parent = wrap.parentElement;
|
||||
if (!parent) return -1;
|
||||
return Array.from(parent.children).indexOf(wrap);
|
||||
})();
|
||||
const fbKey = _feedbackKey(msg, msgIdx);
|
||||
const fbMap0 = _loadFeedbackMap();
|
||||
@@ -1301,8 +1341,9 @@ async function handleRetry(wrap) {
|
||||
showToast("未找到对应的用户消息");
|
||||
return;
|
||||
}
|
||||
if (!chatMessages) return;
|
||||
const children = Array.from(chatMessages.children);
|
||||
const parent = wrap.parentElement;
|
||||
if (!parent) return;
|
||||
const children = Array.from(parent.children);
|
||||
const userIdx = children.indexOf(prev);
|
||||
if (userIdx < 0) return;
|
||||
|
||||
@@ -1362,14 +1403,16 @@ function removeQuoteMenu() {
|
||||
|
||||
/** 初始化聊天区引用:右键选中文字显示「引用」菜单 + 选中文字拖拽到输入区 */
|
||||
function initChatQuoteMenu() {
|
||||
if (!chatMessages) return;
|
||||
// 双容器支持:chat 与 cron 消息容器均可引用文字
|
||||
const containers = Array.from(document.querySelectorAll(".chat-messages"));
|
||||
if (!containers.length) return;
|
||||
|
||||
// 左键选中文字后右键 → 显示"引用"菜单
|
||||
chatMessages.addEventListener("contextmenu", (e) => {
|
||||
const onContextMenu = (hostEl) => (e) => {
|
||||
const sel = window.getSelection();
|
||||
const text = sel ? sel.toString().trim() : "";
|
||||
if (!text) return; // 无选中内容时保留默认右键菜单
|
||||
if (!chatMessages.contains(sel.anchorNode)) return;
|
||||
if (!hostEl.contains(sel.anchorNode)) return;
|
||||
e.preventDefault();
|
||||
removeQuoteMenu();
|
||||
const menu = document.createElement("div");
|
||||
@@ -1397,10 +1440,10 @@ function initChatQuoteMenu() {
|
||||
};
|
||||
_quoteMenuCloseBound = close;
|
||||
setTimeout(() => document.addEventListener("click", close), 0);
|
||||
});
|
||||
};
|
||||
|
||||
// 左键选中文字后拖拽到输入区
|
||||
chatMessages.addEventListener("dragstart", (e) => {
|
||||
const onDragStart = (hostEl) => (e) => {
|
||||
// 图片 / 文件 / 文档引用拖拽走各自的自定义数据,不干扰
|
||||
if (
|
||||
e.dataTransfer.types.includes("application/x-chat-image") ||
|
||||
@@ -1410,44 +1453,137 @@ function initChatQuoteMenu() {
|
||||
const sel = window.getSelection();
|
||||
const text = sel ? sel.toString().trim() : "";
|
||||
if (!text) return;
|
||||
if (!chatMessages.contains(sel.anchorNode)) return;
|
||||
if (!hostEl.contains(sel.anchorNode)) return;
|
||||
e.dataTransfer.setData("application/x-chat-quote", text);
|
||||
e.dataTransfer.setData("text/plain", text);
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
};
|
||||
|
||||
containers.forEach((el) => {
|
||||
el.addEventListener("contextmenu", onContextMenu(el));
|
||||
el.addEventListener("dragstart", onDragStart(el));
|
||||
});
|
||||
}
|
||||
|
||||
function renderMessages(messages, allowAutoplay = false) {
|
||||
if (!chatMessages || !chatPlaceholder) return;
|
||||
/**
|
||||
* 全量渲染消息列表(清空目标容器后重建)。
|
||||
*
|
||||
* @param {Array} messages - 消息数组
|
||||
* @param {boolean|object} opts - 兼容旧签名:true/false 表示 allowAutoplay;
|
||||
* 对象形式 {allowAutoplay, container, minimalActions, keepComposer}
|
||||
* - container: 目标消息容器(默认 chat 容器)
|
||||
* - minimalActions: 只读场景仅保留复制按钮(cron 执行记录用)
|
||||
* - keepComposer: 为 true 时不触发 composer 居中切换(非 chat 视图用)
|
||||
*/
|
||||
function renderMessages(messages, opts = {}) {
|
||||
// 向后兼容:第二参数为布尔时视为 allowAutoplay
|
||||
if (typeof opts === "boolean") opts = { allowAutoplay: opts };
|
||||
const {
|
||||
allowAutoplay = false,
|
||||
container = null,
|
||||
minimalActions = false,
|
||||
keepComposer = false,
|
||||
} = opts;
|
||||
|
||||
const target = container || chatMessages;
|
||||
const placeholder = _placeholderFor(target);
|
||||
if (!target) return;
|
||||
|
||||
const list = messages || [];
|
||||
// 清理所有 typingEl 的计时器,防止内存泄漏
|
||||
chatMessages.querySelectorAll(".tool-call-list-wrap").forEach((w) => {
|
||||
target.querySelectorAll(".tool-call-list-wrap").forEach((w) => {
|
||||
if (typeof w._stopTimer === "function") w._stopTimer();
|
||||
});
|
||||
chatMessages.innerHTML = "";
|
||||
target.innerHTML = "";
|
||||
|
||||
state.lastRenderedMessages.length = 0;
|
||||
// 容器级渲染快照(增量渲染与恢复用)+ 全局快照(回合导航 / 撤回用,跟随最近渲染视图)
|
||||
const snapshot = list.length ? JSON.parse(JSON.stringify(list)) : [];
|
||||
target._renderedMessages = snapshot;
|
||||
target._renderedCount = list.length;
|
||||
state.lastRenderedMessages.length = 0;
|
||||
state.lastRenderedMessages.push(...snapshot);
|
||||
|
||||
const has = list.length > 0;
|
||||
chatPlaceholder.hidden = has;
|
||||
chatMessages.hidden = !has;
|
||||
if (placeholder) placeholder.hidden = has;
|
||||
target.hidden = !has;
|
||||
if (!has) {
|
||||
// 确保无消息时隐藏:内联 display:none 优先级高于 CSS 类设定,不依赖 [hidden] 规则
|
||||
chatMessages.style.display = "none";
|
||||
// 设置为居中状态
|
||||
import('../app.js').then((m) => m.setComposerCentered(true));
|
||||
target.style.display = "none";
|
||||
// 设置为居中状态(仅 chat 视图影响 composer 布局)
|
||||
if (!keepComposer) import('../app.js').then((m) => m.setComposerCentered(true));
|
||||
renderTurnNav();
|
||||
return;
|
||||
}
|
||||
// 有消息时清除内联 display,让 CSS flex 布局生效
|
||||
chatMessages.style.display = "";
|
||||
list.forEach((m) => chatMessages.appendChild(renderMessage(m, allowAutoplay)));
|
||||
scrollChatToBottom();
|
||||
target.style.display = "";
|
||||
list.forEach((m) => target.appendChild(renderMessage(m, allowAutoplay, { minimalActions })));
|
||||
scrollChatToBottom(target);
|
||||
// 取消居中状态
|
||||
import('../app.js').then((m) => m.setComposerCentered(false));
|
||||
if (!keepComposer) import('../app.js').then((m) => m.setComposerCentered(false));
|
||||
renderTurnNav();
|
||||
}
|
||||
|
||||
function appendThinkingIndicator() {
|
||||
/**
|
||||
* 增量渲染:仅追加新增消息(运行期使用,保证渲染速度与连续性)。
|
||||
* 通过 container._renderedCount 判断新增数量;若数据与已渲染不一致则回退全量渲染。
|
||||
*
|
||||
* @param {Array} messages - 完整消息数组(含已渲染部分)
|
||||
* @param {object} opts - {container, minimalActions, allowAutoplay}
|
||||
*/
|
||||
function renderMessagesIncremental(messages, opts = {}) {
|
||||
const { container = null, minimalActions = false, allowAutoplay = false } = opts;
|
||||
const target = container || chatMessages;
|
||||
if (!target) return;
|
||||
const list = messages || [];
|
||||
const prevCount = target._renderedCount || 0;
|
||||
|
||||
// 容器为空或数据缩短(撤回/清空场景)→ 全量渲染兜底
|
||||
if (!prevCount || list.length < prevCount) {
|
||||
renderMessages(list, opts);
|
||||
return;
|
||||
}
|
||||
// 已渲染区间的一致性校验:比较末条已渲染消息的 turn_id/role/content 摘要
|
||||
const prevSnapshot = target._renderedMessages || [];
|
||||
if (prevCount > 0) {
|
||||
const a = prevSnapshot[prevCount - 1];
|
||||
const b = list[prevCount - 1];
|
||||
const key = (m) => (m ? `${m.role}|${(m.meta && m.meta.turn_id) || ""}|${typeof m.content === "string" ? m.content.slice(0, 64) : JSON.stringify(m.content || "").slice(0, 64)}` : "");
|
||||
if (key(a) !== key(b)) {
|
||||
renderMessages(list, opts);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (list.length === prevCount) return; // 无新增
|
||||
|
||||
const placeholder = _placeholderFor(target);
|
||||
if (placeholder) placeholder.hidden = true;
|
||||
target.hidden = false;
|
||||
target.style.display = "";
|
||||
|
||||
const fresh = list.slice(prevCount);
|
||||
fresh.forEach((m) => target.appendChild(renderMessage(m, allowAutoplay, { minimalActions })));
|
||||
target._renderedMessages = JSON.parse(JSON.stringify(list));
|
||||
target._renderedCount = list.length;
|
||||
state.lastRenderedMessages.length = 0;
|
||||
state.lastRenderedMessages.push(...target._renderedMessages);
|
||||
scrollChatToBottom(target);
|
||||
renderTurnNav();
|
||||
}
|
||||
|
||||
/** 释放非活动容器的 DOM(非运行态切出时调用,降低内存占用)。 */
|
||||
function releaseMessageContainer(container) {
|
||||
if (!container) return;
|
||||
container.querySelectorAll(".tool-call-list-wrap").forEach((w) => {
|
||||
if (typeof w._stopTimer === "function") w._stopTimer();
|
||||
});
|
||||
container.innerHTML = "";
|
||||
container._renderedMessages = null;
|
||||
container._renderedCount = 0;
|
||||
}
|
||||
|
||||
function appendThinkingIndicator(container) {
|
||||
const target = container || chatMessages;
|
||||
if (!target) return null;
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "msg assistant msg-typing";
|
||||
wrap.setAttribute("aria-live", "polite");
|
||||
@@ -1463,8 +1599,12 @@ function appendThinkingIndicator() {
|
||||
}
|
||||
bubble.appendChild(dots);
|
||||
wrap.appendChild(bubble);
|
||||
chatMessages.appendChild(wrap);
|
||||
scrollChatToBottom();
|
||||
const placeholder = _placeholderFor(target);
|
||||
if (placeholder) placeholder.hidden = true;
|
||||
target.hidden = false;
|
||||
target.style.display = "";
|
||||
target.appendChild(wrap);
|
||||
scrollChatToBottom(target);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
@@ -1474,16 +1614,24 @@ function showPendingActionInThinking(typingEl, msg) {
|
||||
|
||||
const snapshot = JSON.parse(JSON.stringify(msg));
|
||||
state.lastRenderedMessages.push(snapshot);
|
||||
scrollChatToBottom();
|
||||
scrollChatToBottom(typingEl.closest(".chat-messages") || chatMessages);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function streamAssistantMessage(msg) {
|
||||
async function streamAssistantMessage(msg, container) {
|
||||
const target = container || chatMessages;
|
||||
if (!target) return;
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "msg assistant" + (isAudioOnlyAssistantMessage(msg) ? " msg--audio-only" : "");
|
||||
chatMessages.appendChild(wrap);
|
||||
chatPlaceholder.hidden = true;
|
||||
chatMessages.hidden = false;
|
||||
// 标记消息所属回合(回合导航条按此分组 / 跳转)
|
||||
wrap.dataset.turnId = (msg.meta && msg.meta.turn_id) || "";
|
||||
target.appendChild(wrap);
|
||||
const placeholder = _placeholderFor(target);
|
||||
if (placeholder) placeholder.hidden = true;
|
||||
target.hidden = false;
|
||||
target.style.display = "";
|
||||
// 流式开始时即刷新回合导航条,让新回合横条尽早出现
|
||||
renderTurnNav();
|
||||
|
||||
const meta = msg.meta || {};
|
||||
const toolCalls = Array.isArray(meta.tool_calls) ? meta.tool_calls : [];
|
||||
@@ -1544,16 +1692,20 @@ async function streamAssistantMessage(msg) {
|
||||
// 操作按钮行(复制 / 重试)
|
||||
wrap.appendChild(renderMessageActions("assistant", msg, wrap));
|
||||
|
||||
scrollChatToBottom();
|
||||
scrollChatToBottom(target);
|
||||
// 流式回复完成后重建回合导航条(该回合横条此时才出现)
|
||||
renderTurnNav();
|
||||
}
|
||||
|
||||
/* ===== 语音转文字中占位(用户语音输入时) ===== */
|
||||
function renderVoiceTranscribingPlaceholder() {
|
||||
if (!chatMessages) return null;
|
||||
function renderVoiceTranscribingPlaceholder(container) {
|
||||
const target = container || chatMessages;
|
||||
if (!target) return null;
|
||||
// 确保聊天区域可见(即使之前没有消息)
|
||||
if (chatPlaceholder) chatPlaceholder.hidden = true;
|
||||
chatMessages.hidden = false;
|
||||
chatMessages.style.display = "";
|
||||
const placeholder = _placeholderFor(target);
|
||||
if (placeholder) placeholder.hidden = true;
|
||||
target.hidden = false;
|
||||
target.style.display = "";
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "msg user";
|
||||
const bubble = document.createElement("div");
|
||||
@@ -1563,8 +1715,8 @@ function renderVoiceTranscribingPlaceholder() {
|
||||
indicator.textContent = "语音转文字中";
|
||||
bubble.appendChild(indicator);
|
||||
wrap.appendChild(bubble);
|
||||
chatMessages.appendChild(wrap);
|
||||
scrollChatToBottom();
|
||||
target.appendChild(wrap);
|
||||
scrollChatToBottom(target);
|
||||
// 取消居中状态
|
||||
import('../app.js').then((m) => m.setComposerCentered(false)).catch(() => {});
|
||||
return wrap;
|
||||
@@ -1579,7 +1731,7 @@ export {
|
||||
renderUserMediaRow, renderUserImageThumb,
|
||||
renderUserContent, renderAssistantContent,
|
||||
renderFinalAssets, extractFinalAssets,
|
||||
renderMessage, renderMessages,
|
||||
renderMessage, renderMessages, renderMessagesIncremental, releaseMessageContainer,
|
||||
appendThinkingIndicator, showPendingActionInThinking,
|
||||
streamAssistantMessage,
|
||||
renderVoiceTranscribingPlaceholder,
|
||||
|
||||
@@ -3,20 +3,23 @@
|
||||
// 职责:
|
||||
// 1. 侧栏任务列表渲染(含状态徽章 + 悬停操作)
|
||||
// 2. 任务新建/编辑/删除/启停(弹窗表单)
|
||||
// 3. 聊天区复用 chat-render 渲染执行记录 + 顶部任务信息条
|
||||
// 3. 执行记录渲染到 cron 专属消息容器(#cronMessages)+ 顶部任务信息条
|
||||
// 4. SSE 实时流(/api/cron/{id}/live/stream)→ 复用 injectLiveToolCalls
|
||||
// 5. 追问发送(POST /api/cron/{id}/run {prompt})→ 子进程执行
|
||||
// 5. 追问发送(POST /api/cron/{id}/run {prompt})→ 子进程执行(多轮历史由后端 turn 文件累积)
|
||||
//
|
||||
// 与 chat/edit 模式的关系:
|
||||
// - 共享 #chatMessages DOM 与 renderMessages,切换离开时由 leaveCronMode 恢复聊天视图。
|
||||
// 与 chat/edit 模式的关系(双容器架构):
|
||||
// - cron 使用独立的 #cronMessages 容器,不再与 chat 共享 #chatMessages。
|
||||
// - 模式切换只切显隐:运行中的一侧容器保持挂载(流式渲染不中断),
|
||||
// 非运行态切出时释放 DOM、切入时全量重渲染。
|
||||
// - 不触碰 state.sessionId(chat 会话标识),cron 任务用 state.activeCronTaskId 标识。
|
||||
|
||||
import { $, showToast, escapeHtml } from './utils.js';
|
||||
import { showConfirm, showAlert } from './dialog.js';
|
||||
import { api } from './api.js';
|
||||
import { state } from './state.js';
|
||||
import { renderMessages, appendThinkingIndicator, scrollChatToBottom } from './chat-render.js';
|
||||
import { renderMessages, appendThinkingIndicator, scrollChatToBottom, releaseMessageContainer, registerMessageContainer } from './chat-render.js';
|
||||
import { injectLiveToolCalls } from './toolcalls.js';
|
||||
import { switchStripSession, clearAllStrips, updateTodoStrip, restoreTodoFromMessages } from './notify-strip.js';
|
||||
|
||||
// ===== 状态文案 / 颜色映射 =====
|
||||
const STATUS_TEXT = {
|
||||
@@ -51,12 +54,13 @@ let cronNewBtnEl = null;
|
||||
let chatPanelEl = null;
|
||||
let chatMessagesEl = null;
|
||||
let chatPlaceholderEl = null;
|
||||
let cronMessagesEl = null; // cron 专属消息容器(双容器架构)
|
||||
let textInputEl = null;
|
||||
let sendBtnEl = null;
|
||||
|
||||
// cron 专属 DOM(动态创建/管理)
|
||||
let cronBannerEl = null; // 顶部任务信息条
|
||||
let cronPlaceholderEl = null; // 空状态占位
|
||||
let cronPlaceholderEl = null; // 空状态占位(未选择任务时)
|
||||
let cronEditOverlayEl = null; // 编辑弹窗
|
||||
|
||||
// 运行态追踪
|
||||
@@ -87,9 +91,13 @@ export function initCronMode() {
|
||||
chatPanelEl = $("chatPanel");
|
||||
chatMessagesEl = $("chatMessages");
|
||||
chatPlaceholderEl = $("chatPlaceholder");
|
||||
cronMessagesEl = $("cronMessages");
|
||||
textInputEl = $("textInput");
|
||||
sendBtnEl = $("sendBtn");
|
||||
|
||||
// 注册 cron 消息容器 ↔ 占位符配对(供 chat-render 容器参数化函数使用)
|
||||
if (cronMessagesEl) registerMessageContainer(cronMessagesEl, $("cronPlaceholder"));
|
||||
|
||||
// 新建按钮
|
||||
cronNewBtnEl?.addEventListener("click", () => openCronEditor(null));
|
||||
|
||||
@@ -115,29 +123,102 @@ export function initCronMode() {
|
||||
}
|
||||
|
||||
// ===== 模式进入 / 离开(由 edit-mode.js 钩子调用) =====
|
||||
//
|
||||
// 双容器架构:chat 与 cron 各有独立消息容器,模式切换只切显隐。
|
||||
// - 运行中的一侧容器保持挂载(隐藏但 DOM 保留),SSE/流式渲染不中断;
|
||||
// - 非运行态切出时释放 DOM(降低内存),切入时全量重渲染。
|
||||
export async function enterCronMode() {
|
||||
_cronActive = true;
|
||||
ensureCronPlaceholders();
|
||||
// 拉取时间段长度供冲突检测使用
|
||||
refreshPeriodMinutes();
|
||||
// 隐藏 chat 视图容器(不销毁 DOM:chat 运行中时流式渲染继续写入)
|
||||
if (chatPlaceholderEl) chatPlaceholderEl.hidden = true;
|
||||
if (chatMessagesEl) { chatMessagesEl.hidden = true; chatMessagesEl.style.display = "none"; }
|
||||
// chat 空闲时释放其 DOM(降低内存);切回 chat 由 restoreChatView 全量重建
|
||||
if (!state.sending && chatMessagesEl) releaseMessageContainer(chatMessagesEl);
|
||||
|
||||
await renderCronTasks();
|
||||
// 恢复上次选中的任务,否则展示空状态
|
||||
|
||||
const activeTask = state.activeCronTaskId
|
||||
? state.cronTasks.find((t) => t.task_id === state.activeCronTaskId)
|
||||
: null;
|
||||
if (activeTask && activeTask.is_running && state.cronRunning && _typingEl) {
|
||||
// cron 运行中切入:容器内已有历史 + 活跃 typingEl,直接显示,不全量重渲染
|
||||
showCronChatArea();
|
||||
await refreshCronBanner();
|
||||
switchStripSession("cron:" + state.activeCronTaskId);
|
||||
return;
|
||||
}
|
||||
// 非运行态:全量重渲染(恢复上次选中的任务,否则展示空状态)
|
||||
if (state.activeCronTaskId) {
|
||||
await selectCronTask(state.activeCronTaskId, { silent: true });
|
||||
} else {
|
||||
showCronEmptyState();
|
||||
clearAllStrips();
|
||||
}
|
||||
}
|
||||
|
||||
export async function leaveCronMode() {
|
||||
_cronActive = false;
|
||||
stopCronLive();
|
||||
hideCronPlaceholders();
|
||||
// 恢复聊天会话视图(cron 模式复用了 #chatMessages)
|
||||
await restoreChatView();
|
||||
// 隐藏 cron 专属元素(banner / 空状态占位)
|
||||
if (cronBannerEl) cronBannerEl.hidden = true;
|
||||
if (cronPlaceholderEl) cronPlaceholderEl.hidden = true;
|
||||
const cronStaticPlaceholder = $("cronPlaceholder");
|
||||
if (cronStaticPlaceholder) cronStaticPlaceholder.hidden = true;
|
||||
|
||||
if (state.cronRunning) {
|
||||
// cron 运行中切出:仅隐藏容器,SSE 与 typingEl 继续工作
|
||||
if (cronMessagesEl) { cronMessagesEl.hidden = true; cronMessagesEl.style.display = "none"; }
|
||||
} else {
|
||||
// 非运行态:释放 cron 容器 DOM(下次进入重新拉取全量渲染)
|
||||
stopCronLive();
|
||||
if (cronMessagesEl) releaseMessageContainer(cronMessagesEl);
|
||||
if (cronMessagesEl) { cronMessagesEl.hidden = true; cronMessagesEl.style.display = "none"; }
|
||||
}
|
||||
|
||||
// 恢复 chat 视图 + 通知条切回 chat 会话
|
||||
switchStripSession("chat:" + (state.sessionId || ""));
|
||||
if (state.sending) {
|
||||
// chat 运行中:容器 DOM 完好(流式渲染持续写入),直接显示
|
||||
if (chatMessagesEl) { chatMessagesEl.hidden = false; chatMessagesEl.style.display = ""; }
|
||||
if (chatPlaceholderEl) chatPlaceholderEl.hidden = true;
|
||||
import("../app.js").then((m) => m.setComposerCentered(false)).catch(() => {});
|
||||
} else {
|
||||
// chat 空闲:全量重渲染(容器可能已被释放或内容过期)
|
||||
await restoreChatView();
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 任务列表渲染(按工作区分组,与 chat 会话列表同构) =====
|
||||
|
||||
/** 工作区卡片折叠状态(模块级持久,重渲染保留) */
|
||||
const _collapsedCronWs = new Set();
|
||||
|
||||
/** 把任务列表按工作区归组:[{key, path, name, tasks:[...], plain?}]。
|
||||
未归属/工作区已移除的任务置顶(plain 组),其余按 workspaceList 顺序渲染卡片。 */
|
||||
function buildCronWorkspaceGroups() {
|
||||
const byWs = {};
|
||||
const unassigned = [];
|
||||
for (const t of state.cronTasks) {
|
||||
const ws = t.workspace || "";
|
||||
if (!ws || !(state.workspaceList || []).includes(ws)) {
|
||||
unassigned.push(t);
|
||||
} else {
|
||||
(byWs[ws] = byWs[ws] || []).push(t);
|
||||
}
|
||||
}
|
||||
const groups = [];
|
||||
if (unassigned.length) {
|
||||
groups.push({ key: "__unassigned__", path: "", name: "", tasks: unassigned, plain: true });
|
||||
}
|
||||
for (const ws of state.workspaceList || []) {
|
||||
if (!(byWs[ws] || []).length) continue; // 无任务的工作区不渲染卡片
|
||||
groups.push({ key: ws, path: ws, name: _wsDisplayName(ws), tasks: byWs[ws] });
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
// ===== 任务列表渲染 =====
|
||||
export async function renderCronTasks() {
|
||||
if (!cronListEl) return;
|
||||
try {
|
||||
@@ -155,8 +236,68 @@ export async function renderCronTasks() {
|
||||
cronListEl.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
for (const t of state.cronTasks) {
|
||||
cronListEl.appendChild(renderCronListItem(t));
|
||||
|
||||
for (const group of buildCronWorkspaceGroups()) {
|
||||
const groupLi = document.createElement("li");
|
||||
groupLi.className = "ws-group";
|
||||
|
||||
// 未归属任务:置顶直接显示,无工作区卡片
|
||||
if (group.plain) {
|
||||
const inner = document.createElement("ul");
|
||||
inner.className = "ws-group-sessions ws-group-sessions--plain cron-group-tasks cron-group-tasks--plain";
|
||||
group.tasks.forEach((t) => inner.appendChild(renderCronListItem(t)));
|
||||
groupLi.appendChild(inner);
|
||||
cronListEl.appendChild(groupLi);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 工作区卡片:点击展开/折叠该工作区的任务列表
|
||||
const card = document.createElement("div");
|
||||
card.className = "ws-card";
|
||||
card.title = group.path;
|
||||
|
||||
const explorerBtn = document.createElement("button");
|
||||
explorerBtn.type = "button";
|
||||
explorerBtn.className = "ws-card-explorer";
|
||||
explorerBtn.title = "在资源管理器中打开";
|
||||
explorerBtn.innerHTML = '<img src="/image/文件夹.svg" alt="打开" />';
|
||||
explorerBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
if (window.electronAPI?.openInExplorer) {
|
||||
window.electronAPI.openInExplorer(group.path);
|
||||
} else {
|
||||
showToast("非桌面环境,无法打开资源管理器");
|
||||
}
|
||||
});
|
||||
card.appendChild(explorerBtn);
|
||||
|
||||
const nameSpan = document.createElement("span");
|
||||
nameSpan.className = "ws-card-name";
|
||||
nameSpan.textContent = group.name;
|
||||
nameSpan.title = group.path;
|
||||
card.appendChild(nameSpan);
|
||||
|
||||
// 任务计数徽标
|
||||
const count = document.createElement("span");
|
||||
count.className = "ws-card-count";
|
||||
count.textContent = String(group.tasks.length);
|
||||
card.appendChild(count);
|
||||
|
||||
groupLi.appendChild(card);
|
||||
|
||||
const inner = document.createElement("ul");
|
||||
inner.className = "ws-group-sessions cron-group-tasks";
|
||||
group.tasks.forEach((t) => inner.appendChild(renderCronListItem(t)));
|
||||
if (_collapsedCronWs.has(group.path)) inner.hidden = true;
|
||||
card.addEventListener("click", () => {
|
||||
const nowCollapsed = !_collapsedCronWs.has(group.path);
|
||||
if (nowCollapsed) _collapsedCronWs.add(group.path);
|
||||
else _collapsedCronWs.delete(group.path);
|
||||
inner.hidden = nowCollapsed;
|
||||
});
|
||||
groupLi.appendChild(inner);
|
||||
|
||||
cronListEl.appendChild(groupLi);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -640,19 +781,42 @@ async function handleListAction(action, taskId) {
|
||||
}
|
||||
|
||||
// ===== 选中任务 / 加载消息 =====
|
||||
|
||||
/** 是否允许切换 cron 任务:运行中时锁定(与 chat 的 sending 锁对称)。 */
|
||||
export function canSwitchCronTask() {
|
||||
return !state.cronRunning;
|
||||
}
|
||||
|
||||
export async function selectCronTask(taskId, opts = {}) {
|
||||
if (!taskId) return;
|
||||
// 运行中锁定:禁止切换任务(防止销毁活跃 typingEl / SSE 上下文)
|
||||
if (!opts.silent && !canSwitchCronTask()) {
|
||||
showToast("任务运行中,暂不能切换任务");
|
||||
return;
|
||||
}
|
||||
// 任务正在运行且已展示在 cron 容器中:不重新加载(避免销毁活跃 typingEl)
|
||||
const taskObj = state.cronTasks.find((t) => t.task_id === taskId);
|
||||
const alreadyLive = state.cronRunning
|
||||
&& state.activeCronTaskId === taskId
|
||||
&& cronMessagesEl && !cronMessagesEl.hidden
|
||||
&& !!_typingEl;
|
||||
// 记录旧任务键(切换前保存其通知条状态)
|
||||
const prevActiveTaskId = state.activeCronTaskId;
|
||||
state.activeCronTaskId = taskId;
|
||||
// 通知条会话级切换:每个 cron 任务独立保留自己的 todo / 人机交互状态
|
||||
// (显式传旧任务键,避免状态被存到新任务键下)
|
||||
switchStripSession("cron:" + taskId, "cron:" + prevActiveTaskId);
|
||||
// 更新列表选中态
|
||||
cronListEl?.querySelectorAll(".cron-item").forEach((el) => {
|
||||
el.classList.toggle("active", el.dataset.cronTaskId === taskId);
|
||||
});
|
||||
await loadCronMessages(taskId);
|
||||
if (!alreadyLive) {
|
||||
await loadCronMessages(taskId);
|
||||
}
|
||||
await refreshCronBanner();
|
||||
// 若任务正在运行,接入 SSE 实时流
|
||||
const task = state.cronTasks.find((t) => t.task_id === taskId);
|
||||
const running = task ? !!task.is_running : false;
|
||||
if (running) {
|
||||
// 若任务正在运行且尚未接入实时流,接入 SSE
|
||||
const running = taskObj ? !!taskObj.is_running : false;
|
||||
if (running && !state.cronRunning) {
|
||||
startCronRunningSession(taskId);
|
||||
}
|
||||
}
|
||||
@@ -664,18 +828,24 @@ async function loadCronMessages(taskId) {
|
||||
showCronChatArea();
|
||||
if (msgs.length === 0) {
|
||||
// 任务尚无执行记录:展示提示而非触发 chat 欢迎屏
|
||||
if (chatMessagesEl) {
|
||||
chatMessagesEl.hidden = false;
|
||||
chatMessagesEl.style.display = "";
|
||||
chatMessagesEl.innerHTML = "";
|
||||
if (cronMessagesEl) {
|
||||
cronMessagesEl.hidden = false;
|
||||
cronMessagesEl.style.display = "";
|
||||
cronMessagesEl.innerHTML = "";
|
||||
cronMessagesEl._renderedMessages = null;
|
||||
cronMessagesEl._renderedCount = 0;
|
||||
const note = document.createElement("div");
|
||||
note.className = "msg assistant";
|
||||
note.innerHTML = `<div class="msg-bubble"><div class="cron-empty-record">该任务暂无执行记录。点击顶部"立即运行"可手动触发一次。</div></div>`;
|
||||
chatMessagesEl.appendChild(note);
|
||||
cronMessagesEl.appendChild(note);
|
||||
}
|
||||
if (chatPlaceholderEl) chatPlaceholderEl.hidden = true;
|
||||
const cronStaticPlaceholder = $("cronPlaceholder");
|
||||
if (cronStaticPlaceholder) cronStaticPlaceholder.hidden = true;
|
||||
} else {
|
||||
renderMessages(msgs);
|
||||
// 渲染到 cron 专属容器;只读场景仅保留复制按钮;不影响 composer 居中
|
||||
renderMessages(msgs, { container: cronMessagesEl, minimalActions: true, keepComposer: true });
|
||||
// 恢复该任务的会话级全局 todo 状态
|
||||
restoreTodoFromMessages(msgs);
|
||||
}
|
||||
// 若有 last_error 且非运行中,追加错误提示
|
||||
if (data.last_error && data.last_status === "failed") {
|
||||
@@ -688,12 +858,12 @@ async function loadCronMessages(taskId) {
|
||||
}
|
||||
|
||||
function appendCronErrorNote(error) {
|
||||
if (!chatMessagesEl) return;
|
||||
if (!cronMessagesEl) return;
|
||||
const note = document.createElement("div");
|
||||
note.className = "msg assistant";
|
||||
note.innerHTML = `<div class="msg-bubble"><div class="cron-error-note">⚠ 上次执行失败: ${escapeHtml(String(error).slice(0, 300))}</div></div>`;
|
||||
chatMessagesEl.appendChild(note);
|
||||
scrollChatToBottom();
|
||||
cronMessagesEl.appendChild(note);
|
||||
scrollChatToBottom(cronMessagesEl);
|
||||
}
|
||||
|
||||
// ===== 顶部任务信息条 =====
|
||||
@@ -777,8 +947,8 @@ export async function runCronTaskNow(taskId, prompt) {
|
||||
function startCronRunningSession(taskId) {
|
||||
state.cronRunning = true;
|
||||
updateSendBtnForCron();
|
||||
// 追加思考气泡作为 live 工具调用挂载点
|
||||
_typingEl = appendThinkingIndicator();
|
||||
// 追加思考气泡作为 live 工具调用挂载点(挂载到 cron 专属容器)
|
||||
_typingEl = appendThinkingIndicator(cronMessagesEl);
|
||||
_collectedReflections = [];
|
||||
_lastReflectionCount = 0;
|
||||
_turnStartedAt = null;
|
||||
@@ -831,10 +1001,10 @@ function startCronLiveStream(taskId) {
|
||||
_eventSource.addEventListener("ping", () => { /* 心跳保活 */ });
|
||||
_eventSource.onerror = () => {
|
||||
if (_eventSource) { _eventSource.close(); _eventSource = null; }
|
||||
// 非运行态不重连;运行态延迟重连以接续 live
|
||||
if (state.cronRunning && _cronActive) {
|
||||
// 运行中即重连(不依赖 _cronActive:切到 chat 模式后 cron 仍在跑也需续接 live)
|
||||
if (state.cronRunning) {
|
||||
_reconnectTimer = setTimeout(() => {
|
||||
if (state.cronRunning && _cronActive) startCronLiveStream(taskId);
|
||||
if (state.cronRunning) startCronLiveStream(taskId);
|
||||
}, 1500);
|
||||
}
|
||||
};
|
||||
@@ -850,12 +1020,21 @@ function handleLiveSnapshot(liveData) {
|
||||
if (liveData.started_at && !_turnStartedAt) {
|
||||
_turnStartedAt = Number(liveData.started_at);
|
||||
}
|
||||
// 收集思考内容
|
||||
// Todo 通知条:cron 任务与 chat 会话一样支持 todo 展示(会话级存储)
|
||||
if (Array.isArray(liveData.tool_calls)) {
|
||||
updateTodoStrip(liveData.tool_calls);
|
||||
}
|
||||
// 收集思考/反思内容(backend 条目带 thought/reflection 双字段,二者可同时存在)
|
||||
if (liveData.reflections && liveData.reflections.length > _lastReflectionCount) {
|
||||
const newOnes = liveData.reflections.slice(_lastReflectionCount);
|
||||
const pos = liveData.tool_calls ? liveData.tool_calls.length : -1;
|
||||
newOnes.forEach((r) => {
|
||||
if (r && r.content) {
|
||||
_collectedReflections.push({ content: r.content, between_calls: liveData.tool_calls ? liveData.tool_calls.length : -1 });
|
||||
if (!r) return;
|
||||
if (r.thought) _collectedReflections.push({ content: r.thought, between_calls: pos, kind: "thought" });
|
||||
if (r.reflection) _collectedReflections.push({ content: r.reflection, between_calls: pos, kind: "reflection" });
|
||||
// 旧格式兼容:仅有 content 时按思考处理
|
||||
if (!r.thought && !r.reflection && r.content) {
|
||||
_collectedReflections.push({ content: r.content, between_calls: pos, kind: "thought" });
|
||||
}
|
||||
});
|
||||
_lastReflectionCount = liveData.reflections.length;
|
||||
@@ -870,7 +1049,8 @@ function handleLiveSnapshot(liveData) {
|
||||
function startStatusPolling(taskId) {
|
||||
stopStatusPolling();
|
||||
const poll = async () => {
|
||||
if (!state.cronRunning || !_cronActive) return;
|
||||
// 运行中即继续(不依赖 _cronActive:切到 chat 模式后仍需收敛 finished 状态)
|
||||
if (!state.cronRunning) return;
|
||||
try {
|
||||
const data = await api(`/api/cron/${encodeURIComponent(taskId)}`);
|
||||
const t = data.task;
|
||||
@@ -882,7 +1062,7 @@ function startStatusPolling(taskId) {
|
||||
// 仍在运行:刷新 banner
|
||||
renderCronBanner(t);
|
||||
} catch { /* ignore */ }
|
||||
if (state.cronRunning && _cronActive) {
|
||||
if (state.cronRunning) {
|
||||
_statusPollTimer = setTimeout(poll, 2500);
|
||||
}
|
||||
};
|
||||
@@ -978,16 +1158,15 @@ function ensureCronEmptyPlaceholder() {
|
||||
chatPanelEl.appendChild(cronPlaceholderEl);
|
||||
}
|
||||
|
||||
function hideCronPlaceholders() {
|
||||
if (cronBannerEl) cronBannerEl.hidden = true;
|
||||
if (cronPlaceholderEl) cronPlaceholderEl.hidden = true;
|
||||
}
|
||||
|
||||
function showCronEmptyState() {
|
||||
if (!chatPanelEl) return;
|
||||
ensureCronPlaceholders();
|
||||
// 隐藏 chat 视图与 cron 消息容器,显示 cron 空状态占位
|
||||
if (chatPlaceholderEl) chatPlaceholderEl.hidden = true;
|
||||
if (chatMessagesEl) { chatMessagesEl.hidden = true; chatMessagesEl.style.display = "none"; }
|
||||
if (cronMessagesEl) { cronMessagesEl.hidden = true; cronMessagesEl.style.display = "none"; }
|
||||
const cronStaticPlaceholder = $("cronPlaceholder");
|
||||
if (cronStaticPlaceholder) cronStaticPlaceholder.hidden = true;
|
||||
if (cronPlaceholderEl) cronPlaceholderEl.hidden = false;
|
||||
hideCronBanner();
|
||||
// cron 空状态保持输入框在底部,避免触发 chat 欢迎屏(welcomeView)
|
||||
@@ -997,18 +1176,24 @@ function showCronEmptyState() {
|
||||
function showCronChatArea() {
|
||||
ensureCronPlaceholders();
|
||||
if (cronPlaceholderEl) cronPlaceholderEl.hidden = true;
|
||||
if (chatMessagesEl) { chatMessagesEl.hidden = false; chatMessagesEl.style.display = ""; }
|
||||
const cronStaticPlaceholder = $("cronPlaceholder");
|
||||
if (cronStaticPlaceholder) cronStaticPlaceholder.hidden = true;
|
||||
if (cronMessagesEl) { cronMessagesEl.hidden = false; cronMessagesEl.style.display = ""; }
|
||||
// 隐藏 chat 视图(DOM 保留,chat 运行中时流式写入不受影响)
|
||||
if (chatPlaceholderEl) chatPlaceholderEl.hidden = true;
|
||||
if (chatMessagesEl) { chatMessagesEl.hidden = true; chatMessagesEl.style.display = "none"; }
|
||||
import("../app.js").then((m) => m.setComposerCentered(false)).catch(() => {});
|
||||
}
|
||||
|
||||
// 离开 cron 模式时恢复聊天会话视图
|
||||
// 离开 cron 模式且 chat 空闲时:全量重渲染恢复聊天会话视图
|
||||
async function restoreChatView() {
|
||||
if (!chatPanelEl) return;
|
||||
// 隐藏 cron 专属元素
|
||||
if (cronBannerEl) cronBannerEl.hidden = true;
|
||||
if (cronPlaceholderEl) cronPlaceholderEl.hidden = true;
|
||||
// 重新加载当前聊天会话消息
|
||||
const cronStaticPlaceholder = $("cronPlaceholder");
|
||||
if (cronStaticPlaceholder) cronStaticPlaceholder.hidden = true;
|
||||
// 重新加载当前聊天会话消息(全量重渲染到 chat 容器)
|
||||
try {
|
||||
if (state.sessionId) {
|
||||
const data = await api(`/api/sessions/${encodeURIComponent(state.sessionId)}/messages`);
|
||||
@@ -1164,6 +1349,45 @@ function initOncePicker(runAt) {
|
||||
_onceTimePicker?.setMinute(0);
|
||||
}
|
||||
|
||||
/** 路径末段作为工作区显示名 */
|
||||
function _wsDisplayName(path) {
|
||||
if (!path) return "默认工作区";
|
||||
const parts = String(path).split(/[\\/]/).filter(Boolean);
|
||||
return parts.length ? parts[parts.length - 1] : path;
|
||||
}
|
||||
|
||||
/**
|
||||
* 填充 cron 编辑器的工作区下拉。
|
||||
* @param {string} selectedPath - 需要选中的工作区路径(不在列表中时动态补一项)
|
||||
*/
|
||||
async function populateWorkspaceSelect(selectedPath) {
|
||||
const sel = $("cronEditWorkspace");
|
||||
if (!sel) return;
|
||||
let list = Array.isArray(state.workspaceList) ? [...state.workspaceList] : [];
|
||||
let defaultWs = "";
|
||||
try {
|
||||
const data = await api("/api/workspace");
|
||||
if (data && Array.isArray(data.list)) list = data.list;
|
||||
defaultWs = (data && data.default) || "";
|
||||
} catch (_) { /* 拉取失败回退本地缓存 */ }
|
||||
// 选中项若不在列表中(如已被移除),补充进选项避免丢失
|
||||
if (selectedPath && !list.includes(selectedPath)) list = [selectedPath, ...list];
|
||||
sel.innerHTML = "";
|
||||
const mkOpt = (value, label) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = value;
|
||||
opt.textContent = label;
|
||||
opt.title = value || "使用环境变量默认工作区";
|
||||
return opt;
|
||||
};
|
||||
sel.appendChild(mkOpt("", `默认工作区${defaultWs ? `(${_wsDisplayName(defaultWs)})` : ""}`));
|
||||
list.forEach((p) => {
|
||||
if (!p) return;
|
||||
sel.appendChild(mkOpt(p, _wsDisplayName(p)));
|
||||
});
|
||||
sel.value = selectedPath || "";
|
||||
}
|
||||
|
||||
export function openCronEditor(taskId) {
|
||||
const overlay = $("cronEditOverlay");
|
||||
if (!overlay) return;
|
||||
@@ -1185,6 +1409,10 @@ export function openCronEditor(taskId) {
|
||||
// 重置表单
|
||||
const setVal = (id, v) => { const el = $(id); if (el) el.value = v ?? ""; };
|
||||
|
||||
// 工作区下拉:异步填充(不阻塞弹窗打开)
|
||||
const taskForWs = taskId ? state.cronTasks.find((t) => t.task_id === taskId) : null;
|
||||
populateWorkspaceSelect(taskForWs ? (taskForWs.workspace || "") : (state.workspacePath || ""));
|
||||
|
||||
if (taskId) {
|
||||
// 编辑模式:加载现有任务数据
|
||||
const task = state.cronTasks.find((t) => t.task_id === taskId);
|
||||
@@ -1275,8 +1503,9 @@ async function saveCronTask() {
|
||||
|
||||
const timeout_seconds = parseInt(getVal("cronEditTimeout"), 10) || 300;
|
||||
const enabled = $("cronEnabledSwitch")?.dataset.on === "true";
|
||||
const workspace = $("cronEditWorkspace") ? $("cronEditWorkspace").value : "";
|
||||
|
||||
const payload = { name, prompt, trigger, timeout_seconds, enabled };
|
||||
const payload = { name, prompt, trigger, timeout_seconds, enabled, workspace };
|
||||
|
||||
// 提交前检测时段冲突:候选任务时间点前后一个时间段内是否已有其他任务
|
||||
try {
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
// doc-mod-panel.js -- 文档修改面板:展示会话中所有修改/新增/删除的文件
|
||||
// 每个文件右侧有 ✓(保留/接受)和 ✕(撤销/拒绝)按钮
|
||||
|
||||
import { $, escapeHtml, showToast } from './utils.js';
|
||||
|
||||
function _updateBadge(show) {
|
||||
try {
|
||||
import('./action-bar.js').then(m => m.setActionBadge('doc', show));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** 文档修改列表,格式: { filePath: { original, current, type: 'new'|'modified'|'deleted' } } */
|
||||
let _modifications = {};
|
||||
|
||||
/**
|
||||
* 更新文档修改数据
|
||||
* @param {object} mods - 修改对象
|
||||
*/
|
||||
export function setDocModifications(mods) {
|
||||
_modifications = mods || {};
|
||||
_updateBadge(Object.keys(_modifications).length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染文档修改面板
|
||||
*/
|
||||
export function renderDocModPanel() {
|
||||
const list = $('docModList');
|
||||
const actions = $('docModActions');
|
||||
if (!list || !actions) return;
|
||||
|
||||
const entries = Object.entries(_modifications);
|
||||
if (entries.length === 0) {
|
||||
list.innerHTML = '<div style="padding:1rem;color:#94a3b8;font-size:0.82rem;text-align:center;">暂无文档变更</div>';
|
||||
actions.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
actions.hidden = false;
|
||||
list.innerHTML = '';
|
||||
|
||||
entries.forEach(async ([filePath, mod]) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = `doc-mod-item doc-mod-item--${mod.type}`;
|
||||
item.dataset.file = filePath;
|
||||
|
||||
const infoDiv = document.createElement('div');
|
||||
infoDiv.className = 'doc-mod-item-info';
|
||||
|
||||
const typeLabel = document.createElement('span');
|
||||
typeLabel.className = 'doc-mod-item-tag';
|
||||
typeLabel.textContent = mod.type === 'new' ? '新增' : mod.type === 'modified' ? '修改' : '删除';
|
||||
if (mod.snapshotIdx) typeLabel.title = '第 ' + mod.snapshotIdx + ' 步变更';
|
||||
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'doc-mod-item-name';
|
||||
const parts = filePath.replace(/[\\/]+$/, '').split(/[\\/]/);
|
||||
nameSpan.textContent = parts[parts.length - 1] || filePath;
|
||||
nameSpan.title = filePath;
|
||||
|
||||
infoDiv.appendChild(typeLabel);
|
||||
infoDiv.appendChild(nameSpan);
|
||||
|
||||
const actionsDiv = document.createElement('div');
|
||||
actionsDiv.className = 'doc-mod-item-actions';
|
||||
|
||||
const acceptBtn = document.createElement('button');
|
||||
acceptBtn.className = 'doc-mod-accept';
|
||||
acceptBtn.innerHTML = '✓';
|
||||
acceptBtn.title = '保留修改';
|
||||
acceptBtn.addEventListener('click', () => acceptModification(filePath));
|
||||
|
||||
const rejectBtn = document.createElement('button');
|
||||
rejectBtn.className = 'doc-mod-reject';
|
||||
rejectBtn.innerHTML = '✕';
|
||||
rejectBtn.title = '撤销修改';
|
||||
rejectBtn.addEventListener('click', () => rejectModification(filePath));
|
||||
|
||||
actionsDiv.appendChild(acceptBtn);
|
||||
actionsDiv.appendChild(rejectBtn);
|
||||
|
||||
item.appendChild(infoDiv);
|
||||
item.appendChild(actionsDiv);
|
||||
|
||||
// 如果有旧内容,计算并显示行级 diff
|
||||
// - modified: 新旧内容行级 diff(新增/修改行黄色,删除行红色)
|
||||
// - deleted: 整个旧内容按删除行(红色)展示
|
||||
if ((mod.type === 'modified' || mod.type === 'deleted') && mod.oldContent != null) {
|
||||
const diffDiv = document.createElement('div');
|
||||
diffDiv.className = 'doc-mod-item-diff';
|
||||
diffDiv.hidden = true;
|
||||
item.appendChild(diffDiv);
|
||||
|
||||
item.style.cursor = 'pointer';
|
||||
item.addEventListener('click', async (ev) => {
|
||||
if (ev.target.closest('.doc-mod-accept, .doc-mod-reject')) return;
|
||||
if (diffDiv.hidden) {
|
||||
try {
|
||||
const { computeLineDiff } = await import('./edit-mode.js');
|
||||
let diffs;
|
||||
if (mod.type === 'deleted') {
|
||||
// 删除文件:文件已不存在,整个旧内容按删除行(红色)展示
|
||||
const oldLines = String(mod.oldContent || '').split('\n');
|
||||
diffs = oldLines.length > 0
|
||||
? [{ type: 'remove', startLine: 1, endLine: oldLines.length, lines: oldLines }]
|
||||
: [];
|
||||
} else {
|
||||
// 修改文件:读取当前内容并与旧内容做行级 diff
|
||||
const rootPath = (await import('./state.js')).state._docRootPath;
|
||||
const { readTextFile } = await import('./electron-api.js');
|
||||
const currentContent = await readTextFile(filePath, rootPath);
|
||||
diffs = computeLineDiff(mod.oldContent, currentContent) || [];
|
||||
}
|
||||
diffDiv.innerHTML = diffs.map(d => {
|
||||
const cls = d.type === 'add' ? 'diff-add' : 'diff-remove';
|
||||
const info = d.type === 'add'
|
||||
? `+${d.startLine}${d.endLine !== d.startLine ? '-' + d.endLine : ''}`
|
||||
: `-${d.startLine}${d.endLine !== d.startLine ? '-' + d.endLine : ''}`;
|
||||
return `<div class="diff-line ${cls}"><span class="diff-ln">${info}</span><span class="diff-text">${escapeHtml(d.lines.join('\n'))}</span></div>`;
|
||||
}).join('') || '<div class="diff-line">无差异</div>';
|
||||
diffDiv.hidden = false;
|
||||
} catch { diffDiv.innerHTML = '<div class="diff-line">无法加载差异</div>'; diffDiv.hidden = false; }
|
||||
} else {
|
||||
diffDiv.hidden = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
list.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
/** 接受单个文件修改 */
|
||||
async function acceptModification(filePath) {
|
||||
try {
|
||||
const { acceptFileModification } = await import('./edit-mode.js');
|
||||
await acceptFileModification(filePath);
|
||||
// 从本地列表中移除
|
||||
delete _modifications[filePath];
|
||||
setDocModifications(_modifications);
|
||||
renderDocModPanel();
|
||||
const { renderDocTree } = await import('./edit-mode.js');
|
||||
if (renderDocTree) renderDocTree();
|
||||
showToast('已接受: ' + (filePath.replace(/[\\/]/g, '/').split('/').pop()));
|
||||
} catch (e) {
|
||||
showToast('接受修改失败: ' + (e.message || e));
|
||||
}
|
||||
}
|
||||
|
||||
/** 撤销单个文件修改 */
|
||||
async function rejectModification(filePath) {
|
||||
try {
|
||||
const { rejectFileModification } = await import('./edit-mode.js');
|
||||
await rejectFileModification(filePath);
|
||||
delete _modifications[filePath];
|
||||
setDocModifications(_modifications);
|
||||
renderDocModPanel();
|
||||
const { renderDocTree } = await import('./edit-mode.js');
|
||||
if (renderDocTree) renderDocTree();
|
||||
showToast('已撤销: ' + (filePath.replace(/[\\/]/g, '/').split('/').pop()));
|
||||
} catch (e) {
|
||||
showToast('撤销修改失败: ' + (e.message || e));
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开/关闭文档修改面板 */
|
||||
export function toggleDocModPanel(show) {
|
||||
const panel = $('docModPanel');
|
||||
if (!panel) return;
|
||||
if (show) {
|
||||
setDocModifications(_modifications);
|
||||
renderDocModPanel();
|
||||
}
|
||||
panel.hidden = !show;
|
||||
}
|
||||
|
||||
/** 关闭文档面板并同步按钮状态 */
|
||||
function _closeDocPanel() {
|
||||
const panel = $('docModPanel');
|
||||
if (panel) panel.hidden = true;
|
||||
const btn = $('actionDocBtn');
|
||||
if (btn) btn.classList.remove('is-active');
|
||||
import('./action-bar.js').then(m => m.deactivateAllActionPanels()).catch(() => {});
|
||||
}
|
||||
|
||||
/** 绑定面板事件 */
|
||||
export function bindDocModEvents() {
|
||||
const closeBtn = $('docModClose');
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', () => {
|
||||
_closeDocPanel();
|
||||
});
|
||||
}
|
||||
|
||||
const acceptAllBtn = $('docModAcceptAll');
|
||||
if (acceptAllBtn) {
|
||||
acceptAllBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
const filePaths = Object.keys(_modifications);
|
||||
const { acceptFileModification } = await import('./edit-mode.js');
|
||||
for (const fp of filePaths) { await acceptFileModification(fp); }
|
||||
// 清空本地状态
|
||||
Object.keys(_modifications).forEach(k => delete _modifications[k]);
|
||||
setDocModifications(_modifications);
|
||||
renderDocModPanel();
|
||||
const { renderDocTree } = await import('./edit-mode.js');
|
||||
if (renderDocTree) renderDocTree();
|
||||
showToast('已全部接受 ' + filePaths.length + ' 个文件变更');
|
||||
_closeDocPanel();
|
||||
} catch (e) {
|
||||
showToast(e.message || String(e));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const rejectAllBtn = $('docModRejectAll');
|
||||
if (rejectAllBtn) {
|
||||
rejectAllBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
const filePaths = Object.keys(_modifications);
|
||||
const { rejectFileModification } = await import('./edit-mode.js');
|
||||
for (const fp of filePaths) { await rejectFileModification(fp); }
|
||||
Object.keys(_modifications).forEach(k => delete _modifications[k]);
|
||||
setDocModifications(_modifications);
|
||||
renderDocModPanel();
|
||||
const { renderDocTree } = await import('./edit-mode.js');
|
||||
if (renderDocTree) renderDocTree();
|
||||
showToast('已全部撤销 ' + filePaths.length + ' 个文件变更');
|
||||
_closeDocPanel();
|
||||
} catch (e) {
|
||||
showToast(e.message || String(e));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -162,14 +162,13 @@ function renderLevel(container, node, level, parentPath = '') {
|
||||
div.className = "doc-tree-file";
|
||||
div.style.setProperty("--level", level);
|
||||
div.setAttribute("data-path", f.path);
|
||||
div.draggable = f.status !== "deleted" && f.status !== "externally_deleted";
|
||||
// git 状态着色:untracked→绿(新增) / modified→橙 / deleted→橙+删除线
|
||||
const gState = state.gitStatus[f.path];
|
||||
div.draggable = gState !== "deleted";
|
||||
|
||||
if (f.status === "modified") div.classList.add("doc-tree-file--modified");
|
||||
if (f.status === "new") div.classList.add("doc-tree-file--new");
|
||||
if (f.status === "deleted") div.classList.add("doc-tree-file--deleted");
|
||||
if (f.status === "externally_added") div.classList.add("doc-tree-file--externally-added");
|
||||
if (f.status === "externally_modified") div.classList.add("doc-tree-file--externally-modified");
|
||||
if (f.status === "externally_deleted") div.classList.add("doc-tree-file--externally-deleted");
|
||||
if (gState === "untracked") div.classList.add("doc-tree-file--new");
|
||||
else if (gState === "modified") div.classList.add("doc-tree-file--modified");
|
||||
else if (gState === "deleted") div.classList.add("doc-tree-file--deleted");
|
||||
if (state.activeDocFile === f.path) div.classList.add("doc-tree-file--active");
|
||||
|
||||
const ft = getFileType(f.baseName);
|
||||
@@ -177,7 +176,7 @@ function renderLevel(container, node, level, parentPath = '') {
|
||||
|
||||
// ---- 拖拽:拖动文件 ----
|
||||
div.addEventListener("dragstart", (e) => {
|
||||
if (f.status === "deleted") { e.preventDefault(); return; }
|
||||
if (state.gitStatus[f.path] === "deleted") { e.preventDefault(); return; }
|
||||
e.dataTransfer.setData("application/doc-path", f.path);
|
||||
e.dataTransfer.setData("text/plain", f.path);
|
||||
e.dataTransfer.effectAllowed = "copyMove";
|
||||
@@ -204,7 +203,7 @@ function renderLevel(container, node, level, parentPath = '') {
|
||||
});
|
||||
|
||||
div.addEventListener("click", () => {
|
||||
if (f.status === "deleted") return;
|
||||
if (state.gitStatus[f.path] === "deleted") return;
|
||||
// 外部删除的文件仍可打开查看(会显示 banner)
|
||||
openDocFile(f);
|
||||
});
|
||||
@@ -441,6 +440,7 @@ async function markFileDeleted(filePath) {
|
||||
closeDocFile(filePath);
|
||||
// 不在此处删磁盘文件——撤销删除可恢复
|
||||
renderDocTree();
|
||||
window.dispatchEvent(new CustomEvent("git-status-changed"));
|
||||
if (renderDocTabsFn) renderDocTabsFn();
|
||||
}
|
||||
|
||||
@@ -542,6 +542,7 @@ function pasteFileAtRoot() {
|
||||
docContents[newPath] = content;
|
||||
if (_clipboard.type === "cut") { markFileDeleted(src.path); _clipboard = null; }
|
||||
renderDocTree();
|
||||
window.dispatchEvent(new CustomEvent("git-status-changed"));
|
||||
showToast("已粘贴");
|
||||
}
|
||||
|
||||
@@ -613,6 +614,7 @@ function duplicateFile(filePath) {
|
||||
state.docFiles.push({ path: newPath, name: newPath.split(/[/\\]/).pop(), status: "new" });
|
||||
docContents[newPath] = content;
|
||||
renderDocTree();
|
||||
window.dispatchEvent(new CustomEvent("git-status-changed"));
|
||||
showToast("已创建副本");
|
||||
}
|
||||
|
||||
@@ -650,6 +652,7 @@ async function createNewFileInFolder(folderName) {
|
||||
state.docFiles.push({ path, name, status: "new" });
|
||||
docContents[path] = "";
|
||||
renderDocTree();
|
||||
window.dispatchEvent(new CustomEvent("git-status-changed"));
|
||||
openDocFile({ path, name, status: "new", baseName: name });
|
||||
}
|
||||
|
||||
@@ -667,6 +670,7 @@ async function createNewFolderInFolder(folderName) {
|
||||
state._docTreeFolders[name] = true;
|
||||
state._docTreeFolders[folderName] = true;
|
||||
renderDocTree();
|
||||
window.dispatchEvent(new CustomEvent("git-status-changed"));
|
||||
showToast("文件夹已创建");
|
||||
}
|
||||
|
||||
@@ -689,6 +693,7 @@ async function deleteFolder(folderName) {
|
||||
}
|
||||
if (state._docTreeFolders) delete state._docTreeFolders[folderName];
|
||||
renderDocTree();
|
||||
window.dispatchEvent(new CustomEvent("git-status-changed"));
|
||||
if (renderDocTabsFn) renderDocTabsFn();
|
||||
}
|
||||
|
||||
@@ -702,31 +707,6 @@ export function setDocContent(filePath, content) {
|
||||
}
|
||||
export function getDocContent(filePath) { return docContents[filePath] || ""; }
|
||||
|
||||
// ============ 快照回退 ============
|
||||
let docSnapshots = [];
|
||||
export function saveDocSnapshot() {
|
||||
docSnapshots.push({
|
||||
contents: { ...docContents },
|
||||
files: JSON.parse(JSON.stringify(state.docFiles || [])),
|
||||
});
|
||||
if (docSnapshots.length > 100) docSnapshots.shift();
|
||||
}
|
||||
export function undoDocSnapshot() {
|
||||
if (docSnapshots.length === 0) return false;
|
||||
const snap = docSnapshots.pop();
|
||||
docContents = snap.contents;
|
||||
state.docFiles = snap.files;
|
||||
const ta = $("editEditorTextarea");
|
||||
if (ta && state.activeDocFile) {
|
||||
ta.value = docContents[state.activeDocFile] || "";
|
||||
if (updateLineNumbersFn) updateLineNumbersFn(ta.value);
|
||||
}
|
||||
renderDocTree();
|
||||
if (renderDocTabsFn) renderDocTabsFn();
|
||||
return true;
|
||||
}
|
||||
export function getDocSnapshots() { return docSnapshots; }
|
||||
|
||||
// ============ 新建根级文件/文件夹 ============
|
||||
export async function createNewRootFile() {
|
||||
const name = await showPrompt("输入文件名:", "untitled.txt");
|
||||
@@ -737,6 +717,7 @@ export async function createNewRootFile() {
|
||||
state.docFiles.push({ path, name, status: "new" });
|
||||
docContents[path] = "";
|
||||
renderDocTree();
|
||||
window.dispatchEvent(new CustomEvent("git-status-changed"));
|
||||
openDocFile({ path, name, status: "new", baseName: name });
|
||||
}
|
||||
|
||||
@@ -751,6 +732,7 @@ export async function createNewRootFolder() {
|
||||
if (!state._docTreeFolders) state._docTreeFolders = {};
|
||||
state._docTreeFolders[name] = true;
|
||||
renderDocTree();
|
||||
window.dispatchEvent(new CustomEvent("git-status-changed"));
|
||||
showToast("文件夹已创建");
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// 保留: 文件树渲染、文档修改追踪、外部变更处理、快捷键、拖拽引用
|
||||
// 移除: Monaco 编辑器、标签页、Agent 面板
|
||||
|
||||
import { $, escapeHtml, showToast, dedupePendingFiles } from './utils.js';
|
||||
import { $, escapeHtml, showToast, dedupePendingFiles, filterOutUnsupported, isUnsupportedFile } from './utils.js';
|
||||
import { api } from './api.js';
|
||||
import { state } from './state.js';
|
||||
import {
|
||||
@@ -14,12 +14,9 @@ import {
|
||||
} from './toolcalls.js';
|
||||
import { renderDocTree, setDocTreeDeps } from './doc-tree.js';
|
||||
import { openFilePreview, closeFilePreview } from './file-preview.js';
|
||||
import { setDocModifications } from './doc-mod-panel.js';
|
||||
|
||||
// ===== DOM 引用 =====
|
||||
const modeEditBtn = $("modeEditBtn");
|
||||
const modeChatBtn = $("modeChatBtn");
|
||||
const modeCronBtn = $("modeCronBtn");
|
||||
const modeCronToggleBtn = $("modeCronToggleBtn");
|
||||
const chatPanel = $("chatPanel");
|
||||
const sessionListWrap = $("sessionListWrap");
|
||||
const docTreeWrap = $("docTreeWrap");
|
||||
@@ -27,9 +24,7 @@ const cronListWrap = $("cronListWrap");
|
||||
const docTree = $("docTree");
|
||||
const sidebarHint = document.querySelector(".sidebar-hint");
|
||||
|
||||
// ===== 文档修改状态 =====
|
||||
let docModifications = {};
|
||||
let currentTaskModifications = [];
|
||||
// ===== 外部文件变更 =====
|
||||
let _externalChanges = {};
|
||||
let _fsChangeTimer = null;
|
||||
let _fsApplying = false;
|
||||
@@ -86,52 +81,88 @@ function isBinaryFile(name) {
|
||||
// ===== 模式切换 =====
|
||||
let _modeSwitchCooldown = false;
|
||||
|
||||
/** 同步顶栏定时任务按钮的按下状态(cron 模式按下高亮,其余复位) */
|
||||
function syncModeToggleBtn() {
|
||||
if (!modeCronToggleBtn) return;
|
||||
const isCron = state.mode === "cron";
|
||||
modeCronToggleBtn.classList.toggle("is-active", isCron);
|
||||
modeCronToggleBtn.setAttribute("aria-pressed", isCron ? "true" : "false");
|
||||
modeCronToggleBtn.title = isCron ? "返回对话" : "定时任务";
|
||||
}
|
||||
|
||||
export function switchToMode(mode) {
|
||||
// 防连点:短时间内禁止重复切换
|
||||
if (_modeSwitchCooldown) return;
|
||||
_modeSwitchCooldown = true;
|
||||
setTimeout(() => { _modeSwitchCooldown = false; }, 300);
|
||||
|
||||
// 注:edit/chat/cron 模式均只影响左侧栏(文件树/会话列表/定时任务),
|
||||
// 右侧聊天区保持不变,因此 Agent 运行中也可以自由切换,无需等待
|
||||
// Edit 已迁入右栏:模式切换只影响左侧栏(会话列表/定时任务)与右栏显隐,
|
||||
// 聊天区保持不变,Agent 运行中也可自由切换
|
||||
state.mode = mode;
|
||||
syncModeToggleBtn();
|
||||
if (mode === "edit") {
|
||||
state.editMode = true;
|
||||
modeChatBtn?.classList.remove("active");
|
||||
modeEditBtn?.classList.add("active");
|
||||
modeCronBtn?.classList.remove("active");
|
||||
// 编辑模式只影响左侧栏:显示文件树,隐藏会话列表与定时任务列表;聊天区域保持不变
|
||||
sessionListWrap?.classList.add("mode-hidden");
|
||||
// 左栏始终显示会话列表;文件树在右栏
|
||||
sessionListWrap?.classList.remove("mode-hidden");
|
||||
cronListWrap?.classList.add("mode-hidden");
|
||||
if (sidebarHint) sidebarHint.classList.add("mode-hidden");
|
||||
docTreeWrap?.classList.remove("mode-hidden");
|
||||
if (sidebarHint) sidebarHint.classList.remove("mode-hidden");
|
||||
setRightPanel(true);
|
||||
_onLeaveCron();
|
||||
} else if (mode === "cron") {
|
||||
state.editMode = false;
|
||||
modeChatBtn?.classList.remove("active");
|
||||
modeEditBtn?.classList.remove("active");
|
||||
modeCronBtn?.classList.add("active");
|
||||
chatPanel?.classList.remove("mode-hidden");
|
||||
sessionListWrap?.classList.add("mode-hidden");
|
||||
docTreeWrap?.classList.add("mode-hidden");
|
||||
if (sidebarHint) sidebarHint.classList.add("mode-hidden");
|
||||
cronListWrap?.classList.remove("mode-hidden");
|
||||
setRightPanel(false);
|
||||
closeFilePreview();
|
||||
_onEnterCron();
|
||||
} else {
|
||||
// chat 模式
|
||||
state.editMode = false;
|
||||
modeChatBtn?.classList.add("active");
|
||||
modeEditBtn?.classList.remove("active");
|
||||
modeCronBtn?.classList.remove("active");
|
||||
chatPanel?.classList.remove("mode-hidden");
|
||||
sessionListWrap?.classList.remove("mode-hidden");
|
||||
if (sidebarHint) sidebarHint.classList.remove("mode-hidden");
|
||||
docTreeWrap?.classList.add("mode-hidden");
|
||||
cronListWrap?.classList.add("mode-hidden");
|
||||
setRightPanel(false);
|
||||
closeFilePreview();
|
||||
_onLeaveCron();
|
||||
}
|
||||
// 同步回合导航条显隐(cron 模式复用聊天容器,需隐藏导航条)
|
||||
import('./turn-nav.js').then((m) => m.syncTurnNavVisibility()).catch(() => {});
|
||||
}
|
||||
|
||||
// ===== git 状态(文件树着色:新增绿 / 修改橙 / 删除橙+删除线) =====
|
||||
let _gitStatusTimer = null;
|
||||
|
||||
export function refreshGitStatus() {
|
||||
if (!state._docRootPath) return;
|
||||
if (_gitStatusTimer) clearTimeout(_gitStatusTimer);
|
||||
_gitStatusTimer = setTimeout(async () => {
|
||||
try {
|
||||
const rootPath = state._docRootPath;
|
||||
const data = await api(`/api/workspace/git-status?path=${encodeURIComponent(rootPath)}`);
|
||||
const map = {};
|
||||
for (const s of data.status || []) map[s.path] = s.state;
|
||||
state.gitStatus = map;
|
||||
renderDocTree();
|
||||
} catch { /* git 不可用/非仓库时静默,保持无着色 */ }
|
||||
}, 150);
|
||||
}
|
||||
|
||||
// ===== 右栏(Edit 文件树)控制 =====
|
||||
export function setRightPanel(open) {
|
||||
const appRoot = document.getElementById("app");
|
||||
if (!appRoot) return;
|
||||
appRoot.classList.toggle("right-panel-open", !!open);
|
||||
const rp = document.getElementById("rightPanel");
|
||||
if (rp) rp.setAttribute("aria-hidden", open ? "false" : "true");
|
||||
// 打开时刷新 git 状态着色
|
||||
if (open) {
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent("git-status-changed"));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Cron 模式进入/离开钩子(由 app.js 注入,避免循环依赖) =====
|
||||
@@ -208,8 +239,6 @@ export async function openFolder(folderPath) {
|
||||
}
|
||||
state.docFiles = [];
|
||||
state.docOpenTabs = [];
|
||||
docModifications = {};
|
||||
currentTaskModifications = [];
|
||||
_externalChanges = {};
|
||||
|
||||
for (const f of uniqueFiles) {
|
||||
@@ -218,6 +247,8 @@ export async function openFolder(folderPath) {
|
||||
|
||||
renderDocTree();
|
||||
renderDocTabs && renderDocTabs();
|
||||
// 打开文件夹后刷新 git 状态着色
|
||||
refreshGitStatus();
|
||||
|
||||
if (state.docFiles.length > 0) {
|
||||
state.activeDocFile = state.docFiles[0].path;
|
||||
@@ -237,20 +268,11 @@ export async function openFolder(folderPath) {
|
||||
}
|
||||
|
||||
export function showOpenButtons() {
|
||||
const snapDir = _snapDir();
|
||||
if (snapDir) {
|
||||
try { deleteFile(snapDir, ''); } catch {}
|
||||
}
|
||||
state._docRootPath = null;
|
||||
state._docRootName = null;
|
||||
state.docFiles = [];
|
||||
state.docOpenTabs = [];
|
||||
docModifications = {};
|
||||
currentTaskModifications = [];
|
||||
_externalChanges = {};
|
||||
_snapshots = [];
|
||||
_turnCounter = 0;
|
||||
_acceptedBaselines = {};
|
||||
renderDocTree();
|
||||
renderDocTabs && renderDocTabs();
|
||||
}
|
||||
@@ -264,7 +286,6 @@ export function renderDocTabs() {
|
||||
export async function loadDocContent(filePath) {
|
||||
if (!state._docRootPath) return;
|
||||
const fileName = filePath.split(/[/\\]/).pop() || filePath;
|
||||
const status = docModifications[filePath]?.type || "";
|
||||
|
||||
try {
|
||||
if (isPreviewableImage(fileName)) {
|
||||
@@ -278,7 +299,7 @@ export async function loadDocContent(filePath) {
|
||||
const res = await readTextFile(filePath, state._docRootPath);
|
||||
if (res && res.status === 'ok') {
|
||||
openFilePreview(filePath, fileName, res.content || '',
|
||||
{ status, onDownload: () => downloadFile(filePath, fileName) });
|
||||
{ onDownload: () => downloadFile(filePath, fileName) });
|
||||
}
|
||||
} else if (isPreviewableTable(fileName)) {
|
||||
// XLSX/XLS:后端提取为 TSV 文本,走与 CSV/TSV 一致的按列着色预览
|
||||
@@ -288,10 +309,10 @@ export async function loadDocContent(filePath) {
|
||||
try {
|
||||
const tsv = await api('/api/table/text?path=' + encodeURIComponent(absPath));
|
||||
openFilePreview(absPath, fileName, (tsv && tsv.content) || '',
|
||||
{ status, onDownload: () => downloadFile(absPath, fileName) });
|
||||
{ onDownload: () => downloadFile(absPath, fileName) });
|
||||
} catch (e) {
|
||||
openFilePreview(absPath, fileName, '(加载失败: ' + (e.message || e) + ')',
|
||||
{ status, onDownload: () => downloadFile(absPath, fileName) });
|
||||
{ onDownload: () => downloadFile(absPath, fileName) });
|
||||
}
|
||||
} else if (isBinaryFile(fileName)) {
|
||||
showToast('二进制文件不支持在线查看', 'warning');
|
||||
@@ -299,15 +320,15 @@ export async function loadDocContent(filePath) {
|
||||
const res = await readTextFile(filePath, state._docRootPath);
|
||||
if (res && res.status === 'ok') {
|
||||
openFilePreview(filePath, fileName, res.content || '',
|
||||
{ status, onDownload: () => downloadFile(filePath, fileName) });
|
||||
{ onDownload: () => downloadFile(filePath, fileName) });
|
||||
} else {
|
||||
openFilePreview(filePath, fileName, '(无法读取文件)',
|
||||
{ status, onDownload: () => downloadFile(filePath, fileName) });
|
||||
{ onDownload: () => downloadFile(filePath, fileName) });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
openFilePreview(filePath, fileName, '(加载失败)',
|
||||
{ status, onDownload: () => downloadFile(filePath, fileName) });
|
||||
{ onDownload: () => downloadFile(filePath, fileName) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,19 +340,18 @@ export async function openFileAsText(filePath) {
|
||||
showToast('二进制文件不支持在线查看', 'warning');
|
||||
return;
|
||||
}
|
||||
const status = docModifications[filePath]?.type || "";
|
||||
try {
|
||||
const res = await readTextFile(filePath, state._docRootPath);
|
||||
if (res && res.status === 'ok') {
|
||||
openFilePreview(filePath, fileName, res.content || '',
|
||||
{ status, onDownload: () => downloadFile(filePath, fileName), asText: true });
|
||||
{ onDownload: () => downloadFile(filePath, fileName), asText: true });
|
||||
} else {
|
||||
openFilePreview(filePath, fileName, '(无法读取文件)',
|
||||
{ status, onDownload: () => downloadFile(filePath, fileName) });
|
||||
{ onDownload: () => downloadFile(filePath, fileName) });
|
||||
}
|
||||
} catch {
|
||||
openFilePreview(filePath, fileName, '(加载失败)',
|
||||
{ status, onDownload: () => downloadFile(filePath, fileName) });
|
||||
{ onDownload: () => downloadFile(filePath, fileName) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,329 +393,6 @@ async function downloadFile(filePath, fileName) {
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// ===== 快照系统(类 Git,持久化到磁盘)=====
|
||||
// 目录结构: {snapshots_base}/{workspace_key}/
|
||||
// ├── _accepted/ # 接受基线
|
||||
// ├── turn_1/ # 第一轮快照
|
||||
// └── turn_2/ # 第二轮快照
|
||||
// snapshots_base 由后端 /api/workspace 返回,默认为 workspace/.minor_snapshots
|
||||
let _snapshots = []; // [{ turn: N, rootPath, timestamp, files: Map<path, {content, size, isBinary}> }]
|
||||
let _turnCounter = 0;
|
||||
let _acceptedBaselines = {}; // { path: { content, snapshotIdx } }
|
||||
|
||||
function _workspaceKey() {
|
||||
if (!state._docRootPath) return null;
|
||||
return state._docRootPath.replace(/[/\\]$/, '')
|
||||
.replace(/^[A-Za-z]:/, '')
|
||||
.replace(/[/\\:]/g, '_')
|
||||
.replace(/^_+/, '');
|
||||
}
|
||||
|
||||
function _snapDir() {
|
||||
if (!state._docRootPath) return null;
|
||||
const wsKey = _workspaceKey();
|
||||
if (!wsKey) return null;
|
||||
const base = window.__minorSnapBase
|
||||
|| (state._docRootPath.replace(/[/\\]$/, '') + '/.minor_snapshots');
|
||||
return base + '/' + wsKey;
|
||||
}
|
||||
|
||||
function _turnDir(turn) { return _snapDir() + '/turn_' + turn; }
|
||||
function _acceptDir() { return _snapDir() + '/_accepted'; }
|
||||
|
||||
/** 由外部设置快照基础目录(在 initWorkspace 中调用) */
|
||||
export function setSnapBaseDir(baseDir) {
|
||||
window.__minorSnapBase = baseDir.replace(/\\/g, '/').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
/** 删除快照目录中的文件 */
|
||||
async function _deleteSnapFile(relPath, srcDirRel) {
|
||||
const rootPath = state._docRootPath;
|
||||
const srcRel = srcDirRel.replace(rootPath.replace(/[/\\]$/, ''), '').replace(/^[/\\]+/, '') + '/' + relPath.replace(/\\/g, '/');
|
||||
try { await deleteFile(srcRel, rootPath); } catch {}
|
||||
}
|
||||
|
||||
/** 复制文件(文本或二进制)到目标路径,目标路径相对于 workspace */
|
||||
async function _copyFileTo(relPath, targetDirRel) {
|
||||
const rootPath = state._docRootPath;
|
||||
const tgtRel = targetDirRel.replace(rootPath.replace(/[/\\]$/, ''), '').replace(/^[/\\]+/, '') + '/' + relPath.replace(/\\/g, '/');
|
||||
try {
|
||||
// Try text first
|
||||
const text = await readTextFile(relPath, rootPath);
|
||||
await writeTextFile(tgtRel, text, rootPath);
|
||||
return { content: text, isBinary: false };
|
||||
} catch {
|
||||
// Binary
|
||||
try {
|
||||
const bin = await readBinaryFile(relPath, rootPath);
|
||||
if (bin && bin.base64) {
|
||||
// writeFromBuffer takes (parentDir, fileName, base64)
|
||||
const parentDir = tgtRel.substring(0, tgtRel.lastIndexOf('/'));
|
||||
const fileName = relPath.replace(/\\/g, '/').split('/').pop();
|
||||
await writeFromBuffer(parentDir, fileName, bin.base64);
|
||||
return { content: bin, isBinary: true };
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 恢复文件(从源目录复制到 workspace)*/
|
||||
async function _restoreFile(relPath, srcDirRel) {
|
||||
const rootPath = state._docRootPath;
|
||||
const srcRel = srcDirRel.replace(rootPath.replace(/[/\\]$/, ''), '').replace(/^[/\\]+/, '') + '/' + relPath.replace(/\\/g, '/');
|
||||
try {
|
||||
// Try text
|
||||
const text = await readTextFile(srcRel, rootPath);
|
||||
await writeTextFile(relPath, text, rootPath);
|
||||
return true;
|
||||
} catch {
|
||||
// Try binary
|
||||
try {
|
||||
const bin = await readBinaryFile(srcRel, rootPath);
|
||||
if (bin && bin.base64) {
|
||||
const parentDir = relPath.replace(/\\/g, '/').split('/').slice(0, -1).join('/') || '.';
|
||||
const fileName = relPath.replace(/\\/g, '/').split('/').pop();
|
||||
await writeFromBuffer(parentDir, fileName, bin.base64);
|
||||
return true;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 删除某文件在所有快照中的备份(接受或撤销时清理) */
|
||||
async function _purgeFileFromSnapshots(relPath) {
|
||||
for (const s of _snapshots) {
|
||||
s.files.delete(relPath);
|
||||
await _deleteSnapFile(relPath, _turnDir(s.turn));
|
||||
}
|
||||
// Also delete from accepted baselines
|
||||
await _deleteSnapFile(relPath, _acceptDir());
|
||||
}
|
||||
|
||||
/** 删除某个 turn 目录 */
|
||||
async function _deleteTurnDir(turn) {
|
||||
const dir = _turnDir(turn);
|
||||
try { await deleteFile(dir, ''); } catch {}
|
||||
}
|
||||
|
||||
/** 保存任务前快照(含完整文件内容,持久化到磁盘) */
|
||||
export async function snapshotFilesForTask() {
|
||||
if (!state._docRootPath) return;
|
||||
const rootPath = state._docRootPath;
|
||||
const files = new Map();
|
||||
const turn = ++_turnCounter;
|
||||
const tDir = _turnDir(turn);
|
||||
|
||||
for (const f of state.docFiles) {
|
||||
const result = await _copyFileTo(f.path, tDir);
|
||||
if (result) {
|
||||
files.set(f.path, { content: result.content, size: f.size, isBinary: result.isBinary });
|
||||
} else {
|
||||
files.set(f.path, { content: null, size: f.size, isBinary: false });
|
||||
}
|
||||
}
|
||||
|
||||
_snapshots.push({
|
||||
turn,
|
||||
rootPath,
|
||||
timestamp: Date.now(),
|
||||
files,
|
||||
});
|
||||
|
||||
// 首次快照时初始化接受基线(保存到磁盘)
|
||||
if (_snapshots.length === 1) {
|
||||
for (const [fp, info] of files) {
|
||||
await _copyFileTo(fp, _acceptDir());
|
||||
_acceptedBaselines[fp] = { content: info.content, snapshotIdx: 0, isBinary: info.isBinary };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 任务结束后对比快照,将Agent产生的变更记录到docModifications */
|
||||
export function detectTaskFileChanges() {
|
||||
if (_snapshots.length === 0 || !state._docRootPath) return;
|
||||
|
||||
const preSnap = _snapshots[_snapshots.length - 1];
|
||||
const preMap = preSnap.files;
|
||||
const curMap = new Map(state.docFiles.map(f => [f.path, f]));
|
||||
let changed = false;
|
||||
|
||||
for (const [fp, f] of curMap) {
|
||||
const pre = preMap.get(fp);
|
||||
if (!pre) {
|
||||
// 新文件
|
||||
docModifications[fp] = { original: "", current: f.path, type: "new", snapshotIdx: preSnap.turn };
|
||||
if (!currentTaskModifications.includes(fp)) currentTaskModifications.push(fp);
|
||||
changed = true;
|
||||
} else if (pre.size !== f.size) {
|
||||
// 内容被修改
|
||||
docModifications[fp] = { original: pre.content || f.path, current: f.path, type: "modified", snapshotIdx: preSnap.turn, oldContent: pre.content };
|
||||
if (!currentTaskModifications.includes(fp)) currentTaskModifications.push(fp);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
for (const [fp] of preMap) {
|
||||
if (!curMap.has(fp)) {
|
||||
// 被删除
|
||||
const preInfo = preMap.get(fp);
|
||||
docModifications[fp] = { original: preInfo.content || fp, current: "", type: "deleted", snapshotIdx: preSnap.turn, oldContent: preInfo.content };
|
||||
if (!currentTaskModifications.includes(fp)) currentTaskModifications.push(fp);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
setDocModifications(docModifications);
|
||||
renderDocTree();
|
||||
try { import('./doc-mod-panel.js').then(m => { m.setDocModifications(docModifications); m.renderDocModPanel(); }); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
/** 计算两个文本内容的行级 diff(返回简化差异列表) */
|
||||
export function computeLineDiff(oldText, newText) {
|
||||
if (oldText == null || newText == null) return null;
|
||||
const oldLines = (oldText || '').split('\n');
|
||||
const newLines = (newText || '').split('\n');
|
||||
return _lineDiff(oldLines, newLines);
|
||||
}
|
||||
|
||||
function _lineDiff(oldLines, newLines) {
|
||||
// 回溯 LCS
|
||||
const m = oldLines.length, n = newLines.length;
|
||||
// 对短文本使用简单 diff
|
||||
if (m === 0 && n === 0) return [];
|
||||
if (m === 0) return [{ type: 'add', startLine: 1, endLine: n, lines: newLines }];
|
||||
if (n === 0) return [{ type: 'remove', startLine: 1, endLine: m, lines: oldLines }];
|
||||
|
||||
// LCS 矩阵
|
||||
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
||||
for (let i = 1; i <= m; i++) {
|
||||
for (let j = 1; j <= n; j++) {
|
||||
if (oldLines[i - 1] === newLines[j - 1]) {
|
||||
dp[i][j] = dp[i - 1][j - 1] + 1;
|
||||
} else {
|
||||
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 回溯
|
||||
const diffs = [];
|
||||
let i = m, j = n;
|
||||
const tempOps = [];
|
||||
|
||||
while (i > 0 || j > 0) {
|
||||
if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) {
|
||||
tempOps.unshift({ type: 'keep', oldLine: i, newLine: j });
|
||||
i--; j--;
|
||||
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
|
||||
tempOps.unshift({ type: 'add', newLine: j, line: newLines[j - 1] });
|
||||
j--;
|
||||
} else {
|
||||
tempOps.unshift({ type: 'remove', oldLine: i, line: oldLines[i - 1] });
|
||||
i--;
|
||||
}
|
||||
}
|
||||
|
||||
// 合并相邻的同类型差异
|
||||
for (const op of tempOps) {
|
||||
if (op.type === 'keep') continue;
|
||||
const last = diffs[diffs.length - 1];
|
||||
if (last && last.type === op.type) {
|
||||
if (op.type === 'add') {
|
||||
last.endLine = op.newLine;
|
||||
last.lines.push(op.line);
|
||||
} else {
|
||||
last.endLine = op.oldLine;
|
||||
last.lines.push(op.line);
|
||||
}
|
||||
} else {
|
||||
if (op.type === 'add') {
|
||||
diffs.push({ type: 'add', startLine: op.newLine, endLine: op.newLine, lines: [op.line] });
|
||||
} else {
|
||||
diffs.push({ type: 'remove', startLine: op.oldLine, endLine: op.oldLine, lines: [op.line] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return diffs;
|
||||
}
|
||||
|
||||
/** 接受单个文件的修改(更新接受基线,持久化,清理快照)*/
|
||||
export async function acceptFileModification(filePath) {
|
||||
const rootPath = state._docRootPath;
|
||||
if (!rootPath) return;
|
||||
|
||||
const mod = docModifications[filePath];
|
||||
if (!mod) return;
|
||||
|
||||
if (mod.type === 'deleted') {
|
||||
// 接受删除 → 从修改列表和接受基线中移除
|
||||
delete docModifications[filePath];
|
||||
delete _acceptedBaselines[filePath];
|
||||
await _deleteSnapFile(filePath, _acceptDir());
|
||||
} else {
|
||||
// 接受新增/修改 → 更新磁盘上的接受基线
|
||||
const result = await _copyFileTo(filePath, _acceptDir());
|
||||
_acceptedBaselines[filePath] = {
|
||||
content: result ? result.content : null,
|
||||
snapshotIdx: _snapshots.length,
|
||||
isBinary: result ? result.isBinary : false
|
||||
};
|
||||
delete docModifications[filePath];
|
||||
}
|
||||
|
||||
// 清理该文件在所有快照中的备份
|
||||
await _purgeFileFromSnapshots(filePath);
|
||||
|
||||
currentTaskModifications = currentTaskModifications.filter(f => f !== filePath);
|
||||
setDocModifications(docModifications);
|
||||
renderDocTree();
|
||||
}
|
||||
|
||||
/** 撤销单个文件的修改(回到接受基线,从磁盘恢复,支持二进制)*/
|
||||
export async function rejectFileModification(filePath) {
|
||||
const rootPath = state._docRootPath;
|
||||
if (!rootPath) return;
|
||||
|
||||
const mod = docModifications[filePath];
|
||||
if (!mod) return;
|
||||
|
||||
if (mod.type === 'deleted') {
|
||||
// 撤销删除 → 从接受基线恢复文件
|
||||
await _restoreFile(filePath, _acceptDir());
|
||||
} else if (mod.type === 'new') {
|
||||
// 撤销新增 → 删除文件
|
||||
try { await deleteFile(filePath, rootPath); } catch {}
|
||||
} else {
|
||||
// 撤销修改 → 从接受基线恢复文件
|
||||
await _restoreFile(filePath, _acceptDir());
|
||||
}
|
||||
|
||||
// 清理该文件在所有快照中的备份
|
||||
await _purgeFileFromSnapshots(filePath);
|
||||
|
||||
delete docModifications[filePath];
|
||||
currentTaskModifications = currentTaskModifications.filter(f => f !== filePath);
|
||||
setDocModifications(docModifications);
|
||||
renderDocTree();
|
||||
|
||||
// 刷新文件列表
|
||||
setTimeout(() => openFolder(rootPath), 500);
|
||||
}
|
||||
|
||||
// 向后兼容导出
|
||||
export function getDocModifications() { return docModifications; }
|
||||
export function getCurrentTaskModifications() { return currentTaskModifications; }
|
||||
export async function acceptAllModifications() {
|
||||
for (const fp of Object.keys(docModifications)) await acceptFileModification(fp);
|
||||
}
|
||||
export async function rejectAllModifications() {
|
||||
for (const fp of Object.keys(docModifications)) await rejectFileModification(fp);
|
||||
}
|
||||
|
||||
// ===== 外部文件变更(实时自动刷新,类似 VS Code) =====
|
||||
function handleFsChange(events) {
|
||||
if (_fsChangeTimer) clearTimeout(_fsChangeTimer);
|
||||
@@ -743,13 +440,6 @@ async function applyFsChanges(events) {
|
||||
if (changed) {
|
||||
state.docFiles = currentFiles;
|
||||
renderDocTree();
|
||||
// 同步外部变更到文档修改面板
|
||||
const extMods = {};
|
||||
for (const [fp, ec] of Object.entries(_externalChanges)) {
|
||||
extMods[fp] = { original: fp, current: fp, type: ec.type === 'externally_added' ? 'new' : ec.type === 'externally_modified' ? 'modified' : 'deleted' };
|
||||
}
|
||||
setDocModifications({ ...docModifications, ...extMods });
|
||||
try { import('./doc-mod-panel.js').then(m => m.renderDocModPanel()); } catch {}
|
||||
}
|
||||
} finally {
|
||||
_fsApplying = false;
|
||||
@@ -759,8 +449,6 @@ async function applyFsChanges(events) {
|
||||
export function clearExternalChange(filePath) {
|
||||
delete _externalChanges[filePath];
|
||||
renderDocTree();
|
||||
setDocModifications(docModifications);
|
||||
try { import('./doc-mod-panel.js').then(m => m.renderDocModPanel()); } catch {}
|
||||
}
|
||||
|
||||
// ===== 拖拽引用 =====
|
||||
@@ -843,7 +531,7 @@ function setupInputDropHandler() {
|
||||
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
|
||||
e.stopPropagation();
|
||||
const items = Array.from(e.dataTransfer.items);
|
||||
// 先收集所有 entry,判断是否包含文件夹
|
||||
// 先收集所有 entry,判断是否包含文件夹(视频暂不支持,直接过滤)
|
||||
const allEntries = [];
|
||||
for (const item of items) {
|
||||
if (item.kind !== 'file') continue;
|
||||
@@ -851,11 +539,13 @@ function setupInputDropHandler() {
|
||||
const file = item.getAsFile();
|
||||
allEntries.push({ entry, file });
|
||||
}
|
||||
const hasDirectory = allEntries.some(({ entry }) => entry && entry.isDirectory);
|
||||
const acceptedFiles = filterOutUnsupported(allEntries.map(({ file }) => file).filter(Boolean));
|
||||
const filteredEntries = allEntries.filter(({ file }) => !file || acceptedFiles.includes(file));
|
||||
const hasDirectory = filteredEntries.some(({ entry }) => entry && entry.isDirectory);
|
||||
|
||||
if (hasDirectory) {
|
||||
// 有文件夹时:只处理文件夹项,跳过文件项(避免展开文件夹内容)
|
||||
for (const { entry, file } of allEntries) {
|
||||
for (const { entry, file } of filteredEntries) {
|
||||
if (entry && entry.isDirectory) {
|
||||
const folderPath = (file && file.path) ? file.path : '';
|
||||
const folderName = entry.name || (file ? file.name : '文件夹');
|
||||
@@ -866,7 +556,7 @@ function setupInputDropHandler() {
|
||||
// 无文件夹:图片作为普通 File 上传(HumanMessage image_url),非图片作为 @file: 引用
|
||||
// Electron 拖入的 File 带 path(绝对路径)且已包含二进制内容;无 path 时回退为普通上传
|
||||
let needFallback = false;
|
||||
for (const { file } of allEntries) {
|
||||
for (const { file } of filteredEntries) {
|
||||
if (!file) continue;
|
||||
if (_isImageName(file.name)) {
|
||||
// 图片:直接作为普通文件上传(File 已含二进制内容)
|
||||
@@ -885,7 +575,7 @@ function setupInputDropHandler() {
|
||||
}
|
||||
}
|
||||
|
||||
if (allEntries.length > 0) {
|
||||
if (filteredEntries.length > 0) {
|
||||
import('./chat-render.js').then(m => { if (m.renderAttachmentChips) m.renderAttachmentChips(); });
|
||||
}
|
||||
return;
|
||||
@@ -894,7 +584,7 @@ function setupInputDropHandler() {
|
||||
// 4. 回退:dataTransfer.files(无 items 时)— 图片作为普通 File 上传,非图片作为 @file: 引用
|
||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||
e.stopPropagation();
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
const files = filterOutUnsupported(Array.from(e.dataTransfer.files));
|
||||
let needFallback = false;
|
||||
for (const file of files) {
|
||||
if (_isImageName(file.name)) {
|
||||
@@ -954,6 +644,12 @@ export async function addDocRef(filePath, lineStart, lineEnd) {
|
||||
if (!filePath) return;
|
||||
const fileName = filePath.split(/[/\\]/).pop() || filePath;
|
||||
|
||||
// 不支持的类型(视频 / 旧版 .doc / .rtf):拒绝引用
|
||||
if (isUnsupportedFile(fileName)) {
|
||||
showToast(`暂不支持 ${fileName}(视频 / 旧版 .doc / .rtf)`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 图片且无行号引用 → 作为普通图片上传
|
||||
if (_isImageName(fileName) && !lineStart) {
|
||||
// 防重复:若已作为普通文件或引用存在则跳过
|
||||
@@ -1041,6 +737,12 @@ export async function addExternalDocRef(absolutePath, fileName) {
|
||||
if (!absolutePath) return;
|
||||
const name = fileName || absolutePath.split(/[/\\]/).pop() || absolutePath;
|
||||
|
||||
// 不支持的类型(视频 / 旧版 .doc / .rtf):拒绝引用
|
||||
if (isUnsupportedFile(name)) {
|
||||
showToast(`暂不支持 ${name}(视频 / 旧版 .doc / .rtf)`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 图片 → 读取二进制作为普通图片上传
|
||||
if (_isImageName(name)) {
|
||||
const exists = state.pendingFiles.some((f) =>
|
||||
@@ -1098,18 +800,7 @@ export function clearRefChips() {
|
||||
|
||||
// ===== 清除状态 =====
|
||||
export function clearEditState() {
|
||||
// 清理该工作空间对应的快照子目录
|
||||
const snapDir = _snapDir();
|
||||
if (snapDir) {
|
||||
try { deleteFile(snapDir, ''); } catch {}
|
||||
}
|
||||
docModifications = {};
|
||||
currentTaskModifications = [];
|
||||
_externalChanges = {};
|
||||
_snapshots = [];
|
||||
_turnCounter = 0;
|
||||
_acceptedBaselines = {};
|
||||
setDocModifications({});
|
||||
closeFilePreview();
|
||||
clearRefChips();
|
||||
state.docFiles = [];
|
||||
@@ -1142,13 +833,13 @@ export async function sendEditChat() {
|
||||
|
||||
// ===== 初始化 =====
|
||||
export function initEditMode() {
|
||||
// 模式切换按钮
|
||||
modeEditBtn?.addEventListener("click", () => {
|
||||
if (!state.editMode) switchToMode("edit");
|
||||
});
|
||||
modeChatBtn?.addEventListener("click", () => {
|
||||
// 从 edit 或 cron 模式均可切回 chat
|
||||
if (state.mode !== "chat") switchToMode("chat");
|
||||
// git 状态刷新事件(回撤/右栏打开/文件操作后触发)
|
||||
window.addEventListener("git-status-changed", () => refreshGitStatus());
|
||||
|
||||
// 顶栏"定时任务"按钮:cron ↔ chat 切换(Edit 模式点击同样进入 cron)
|
||||
modeCronToggleBtn?.addEventListener("click", () => {
|
||||
if (state.mode !== "cron") switchToMode("cron");
|
||||
else switchToMode("chat");
|
||||
});
|
||||
|
||||
// 打开文件夹 - 移除按钮,改用工作空间选择器触发
|
||||
@@ -1182,9 +873,4 @@ export function initEditMode() {
|
||||
openFileAsText,
|
||||
externalChanges: () => _externalChanges,
|
||||
});
|
||||
|
||||
// 初始化后同步到 doc-mod-panel
|
||||
try {
|
||||
import('./doc-mod-panel.js').then(m => m.bindDocModEvents());
|
||||
} catch {}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ export const LANGS = {
|
||||
// 通用
|
||||
app_title: "多模态Agent",
|
||||
sidebar_sessions: "会话",
|
||||
sidebar_hint: "鼠标悬停可重命名或删除会话。",
|
||||
sidebar_hint_edit: "在此管理文档文件,支持拖拽和引用。",
|
||||
new_session: "新建会话",
|
||||
edit_mode: "Edit",
|
||||
@@ -90,7 +89,6 @@ export const LANGS = {
|
||||
name: "English",
|
||||
app_title: "Multimodal Agent",
|
||||
sidebar_sessions: "Sessions",
|
||||
sidebar_hint: "Hover to rename or delete sessions.",
|
||||
sidebar_hint_edit: "Manage document files here. Drag & drop supported.",
|
||||
new_session: "New Session",
|
||||
edit_mode: "Edit",
|
||||
@@ -206,7 +204,6 @@ export function applyLanguage() {
|
||||
"#openFolderBtn": "edit_open_folder",
|
||||
"#newFileBtn": "edit_new_file",
|
||||
"#newFolderBtn": "edit_new_folder",
|
||||
".sidebar-hint": "sidebar_hint",
|
||||
"#editEmptyState p:first-child": "edit_empty_title",
|
||||
".edit-empty-hint": "edit_empty_hint",
|
||||
};
|
||||
@@ -222,13 +219,6 @@ export function applyLanguage() {
|
||||
}
|
||||
});
|
||||
|
||||
// 设置 tabs
|
||||
document.querySelectorAll(".settings-tab").forEach((tab) => {
|
||||
const dt = tab.getAttribute("data-tab");
|
||||
const keys = { agent: "tab_agent", tool: "tab_tool", env: "tab_env", skills: "tab_skills", other: "tab_gui", theme: "tab_theme" };
|
||||
if (keys[dt]) tab.textContent = t(keys[dt]);
|
||||
});
|
||||
|
||||
// 设置页标题
|
||||
document.title = t("app_title");
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { state, STORAGE_DOF, STORAGE_VIG, STORAGE_HEADER_COLLAPSED } from './sta
|
||||
|
||||
const appRoot = $("app");
|
||||
const mainPanel = $("mainPanel");
|
||||
const expandRail = $("expandRail");
|
||||
const menuBtn = $("menuBtn");
|
||||
const drawerBackdrop = $("drawerBackdrop");
|
||||
const bgImageLayer = $("bgImageLayer");
|
||||
@@ -23,7 +22,6 @@ function setSidebarCollapsed(collapsed) {
|
||||
state.sidebarCollapsed = collapsed;
|
||||
if (!appRoot) return;
|
||||
appRoot.classList.toggle("sidebar-collapsed", collapsed);
|
||||
if (expandRail) expandRail.setAttribute("aria-hidden", collapsed ? "false" : "true");
|
||||
}
|
||||
|
||||
function setDrawerOpen(open) {
|
||||
|
||||
@@ -0,0 +1,740 @@
|
||||
// notify-strip.js -- 通知条系统(替代 todo / pending 浮窗)
|
||||
//
|
||||
// 职责:
|
||||
// 1. 输入区上方长条通知条:todo 步骤摘要条 + 人机交互/审查条
|
||||
// 2. 左侧按钮点击 → 该条隐藏并在输入区生成对应按钮(画板按钮右侧,从左到右追加)
|
||||
// 3. 点击输入区按钮 → 按钮消失、对应通知条重新出现
|
||||
// 4. 点击条内容 → 展开/收起(pending 展开为卡片,多卡片左右切换;展开态隐藏摘要行)
|
||||
// 5. 会话级存储:chat session 与 cron task 各自保留 todo/pending 状态
|
||||
//
|
||||
// 数据源:
|
||||
// - todo: live snapshot tool_calls 中 name === "todo_list" 的调用(与原 todo.js 同逻辑)
|
||||
// - pending: live snapshot human_requests 数组(human_interaction / tool_call)
|
||||
//
|
||||
// 提交: 复用 pending.js handleHumanAction(阻塞式,后端唤醒工具线程)
|
||||
//
|
||||
// 性能: 渲染为补丁式——快照驱动/交互时只就地更新变化部分(文本/chip/图标),
|
||||
// 展开区仅在内容键变化时重建;不做整条重建,避免运行期卡顿。
|
||||
|
||||
import { $, escapeHtml, showToast } from './utils.js';
|
||||
import { state } from './state.js';
|
||||
import { handleHumanAction } from './pending.js';
|
||||
|
||||
// ===== 模块状态 =====
|
||||
|
||||
const stripState = {
|
||||
todo: { steps: [], doneSteps: [], dismissed: false, expanded: false },
|
||||
pending: { items: [], index: 0, dismissed: false, expanded: false },
|
||||
};
|
||||
|
||||
/** 会话级快照:sessionKey → 深拷贝的 stripState */
|
||||
const _sessionStore = new Map();
|
||||
|
||||
/** 输入区动态按钮(按生成顺序从左到右):[{kind, btn}] */
|
||||
const _composerBtns = [];
|
||||
|
||||
/** 当前通知条所属会话键(chat: sessionId / cron: activeCronTaskId) */
|
||||
function _currentSessionKey() {
|
||||
if (state.mode === "cron") return "cron:" + (state.activeCronTaskId || "");
|
||||
return "chat:" + (state.sessionId || "");
|
||||
}
|
||||
|
||||
// ===== 会话级存储 =====
|
||||
|
||||
/** 切换会话前保存当前通知条状态 */
|
||||
export function saveStripSession(key) {
|
||||
const k = key || _currentSessionKey();
|
||||
if (!k || k.endsWith(":")) return;
|
||||
_sessionStore.set(k, JSON.parse(JSON.stringify(stripState)));
|
||||
// 防止无限膨胀:只保留最近 40 个会话
|
||||
if (_sessionStore.size > 40) {
|
||||
const firstKey = _sessionStore.keys().next().value;
|
||||
_sessionStore.delete(firstKey);
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换到目标会话:保存当前 → 恢复目标 → 重渲染。
|
||||
* @param {string} newKey - 目标会话键
|
||||
* @param {string} [saveKey] - 当前状态应保存到的会话键;
|
||||
* 缺省时用 _currentSessionKey()(适用于尚未切换 ID 的场景,如模式切换);
|
||||
* 调用方已改 ID 时必须显式传入旧键,避免旧状态被存到新键下。
|
||||
*/
|
||||
export function switchStripSession(newKey, saveKey) {
|
||||
saveStripSession(saveKey);
|
||||
const target = newKey || _currentSessionKey();
|
||||
const saved = _sessionStore.get(target);
|
||||
if (saved) {
|
||||
stripState.todo = saved.todo || { steps: [], doneSteps: [], dismissed: false, expanded: false };
|
||||
stripState.pending = saved.pending || { items: [], index: 0, dismissed: false, expanded: false };
|
||||
} else {
|
||||
stripState.todo = { steps: [], doneSteps: [], dismissed: false, expanded: false };
|
||||
stripState.pending = { items: [], index: 0, dismissed: false, expanded: false };
|
||||
}
|
||||
_clearComposerBtns();
|
||||
renderStrip();
|
||||
}
|
||||
|
||||
// ===== Todo 数据派生(与原 todo.js updateTodoOverlayFromRecords 同逻辑) =====
|
||||
|
||||
/** 从 live tool_calls 派生 todo 数据并更新通知条。 */
|
||||
export function updateTodoStrip(toolCalls) {
|
||||
if (!toolCalls || !toolCalls.length) return;
|
||||
let steps = null;
|
||||
const doneSteps = new Set();
|
||||
for (const call of toolCalls) {
|
||||
if (!call || call.name !== "todo_list") continue;
|
||||
const args = (call.args || {});
|
||||
if (Array.isArray(args.steps) && args.steps.length) {
|
||||
steps = args.steps.map((s) => String(s));
|
||||
doneSteps.clear();
|
||||
}
|
||||
if (!steps) continue;
|
||||
const n = parseInt(args.done_step, 10);
|
||||
if (Number.isFinite(n) && n >= 1) {
|
||||
for (let i = 0; i < Math.min(n, steps.length); i++) doneSteps.add(i);
|
||||
}
|
||||
}
|
||||
if (!steps) return;
|
||||
const prev = stripState.todo;
|
||||
const changed = JSON.stringify(prev.steps) !== JSON.stringify(steps)
|
||||
|| JSON.stringify(prev.doneSteps) !== JSON.stringify([...doneSteps].sort((a, b) => a - b));
|
||||
stripState.todo.steps = steps;
|
||||
stripState.todo.doneSteps = [...doneSteps].sort((a, b) => a - b);
|
||||
// 新 todo 出现 → 重置 dismissed(用户需要重新感知)
|
||||
if (changed) stripState.todo.dismissed = false;
|
||||
if (changed) renderStrip(); // 仅变化时渲染(补丁式,开销极小)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从历史消息回溯该会话的全局 todo 状态(会话级存储)。
|
||||
* 扫描消息中 assistant 消息的 meta.tool_calls,取最后一个带 steps 的
|
||||
* todo_list 调用重建步骤,后续 done_step 调用累积完成进度。
|
||||
* 用于切换/加载会话时恢复通知条,使 todo 状态跨轮次、跨会话保持。
|
||||
*/
|
||||
export function restoreTodoFromMessages(messages) {
|
||||
if (!Array.isArray(messages)) return;
|
||||
let steps = null;
|
||||
const doneSteps = new Set();
|
||||
for (const msg of messages) {
|
||||
const tcs = (msg && msg.meta && msg.meta.tool_calls) || [];
|
||||
for (const call of tcs) {
|
||||
if (!call || call.name !== "todo_list") continue;
|
||||
const args = (call.args || {});
|
||||
if (Array.isArray(args.steps) && args.steps.length) {
|
||||
steps = args.steps.map((s) => String(s));
|
||||
doneSteps.clear();
|
||||
}
|
||||
if (!steps) continue;
|
||||
const n = parseInt(args.done_step, 10);
|
||||
if (Number.isFinite(n) && n >= 1) {
|
||||
for (let i = 0; i < Math.min(n, steps.length); i++) doneSteps.add(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!steps) return;
|
||||
stripState.todo.steps = steps;
|
||||
stripState.todo.doneSteps = [...doneSteps].sort((a, b) => a - b);
|
||||
stripState.todo.dismissed = false;
|
||||
saveStripSession(); // 持久化到会话快照(跨会话切换恢复)
|
||||
renderStrip();
|
||||
}
|
||||
|
||||
// ===== Pending 数据更新 =====
|
||||
|
||||
/** 用 live snapshot 的 human_requests 更新 pending 通知条。 */
|
||||
export function updatePendingStrip(humanRequests) {
|
||||
const items = Array.isArray(humanRequests) ? humanRequests : [];
|
||||
const prevIds = stripState.pending.items.map((p) => p.id).join(",");
|
||||
const nextIds = items.map((p) => p.id).join(",");
|
||||
const changed = prevIds !== nextIds || items.length !== stripState.pending.items.length;
|
||||
stripState.pending.items = items;
|
||||
if (stripState.pending.index >= items.length) {
|
||||
stripState.pending.index = Math.max(0, items.length - 1);
|
||||
}
|
||||
// 新请求出现 → 重置 dismissed
|
||||
if (changed && items.length) stripState.pending.dismissed = false;
|
||||
if (!items.length) {
|
||||
stripState.pending.expanded = false;
|
||||
stripState.pending.index = 0;
|
||||
}
|
||||
if (changed) renderStrip(); // 仅变化时渲染
|
||||
}
|
||||
|
||||
// ===== DOM 构建 =====
|
||||
|
||||
function _stripRoot() {
|
||||
return $("notifyStrip");
|
||||
}
|
||||
|
||||
function _itemsRoot() {
|
||||
return $("notifyStripItems");
|
||||
}
|
||||
|
||||
/** 通知条是否有任何可见内容 */
|
||||
function _hasContent() {
|
||||
const hasTodo = stripState.todo.steps.length > 0 && !stripState.todo.dismissed;
|
||||
const hasPending = stripState.pending.items.length > 0 && !stripState.pending.dismissed;
|
||||
return hasTodo || hasPending;
|
||||
}
|
||||
|
||||
/** 主渲染入口(补丁式):diff 条集合 → 复用/增删 → 就地更新内容。 */
|
||||
export function renderStrip() {
|
||||
const root = _stripRoot();
|
||||
const items = _itemsRoot();
|
||||
if (!root || !items) return;
|
||||
|
||||
// 期望的可见条集合(出现顺序自下而上:todo 在下,pending 在上)
|
||||
const desired = [];
|
||||
if (stripState.pending.items.length > 0 && !stripState.pending.dismissed) desired.push({ kind: "pending" });
|
||||
if (stripState.todo.steps.length > 0 && !stripState.todo.dismissed) desired.push({ kind: "todo" });
|
||||
|
||||
if (!desired.length) {
|
||||
// 空态:完全消失(零占位,composer 紧贴聊天区)
|
||||
if (!root.hidden) {
|
||||
root.classList.remove("notify-strip--visible");
|
||||
root.hidden = true;
|
||||
}
|
||||
items.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const wasHidden = root.hidden;
|
||||
root.hidden = false;
|
||||
if (wasHidden) {
|
||||
// 下一帧加可见类,触发进入动画
|
||||
requestAnimationFrame(() => root.classList.add("notify-strip--visible"));
|
||||
} else {
|
||||
root.classList.add("notify-strip--visible");
|
||||
}
|
||||
|
||||
// diff:按顺序同步子元素(最多 2 项)
|
||||
const oldKids = Array.from(items.children);
|
||||
const newKids = desired.map((d) => {
|
||||
const existing = oldKids.find((el) => el.dataset.stripKind === d.kind);
|
||||
if (existing) return existing; // 复用,避免重建
|
||||
return d.kind === "todo" ? _buildTodoItem() : _buildPendingItem();
|
||||
});
|
||||
oldKids.forEach((el) => { if (!newKids.includes(el)) el.remove(); });
|
||||
newKids.forEach((el, i) => {
|
||||
if (items.children[i] !== el) items.insertBefore(el, items.children[i] || null);
|
||||
});
|
||||
|
||||
// 就地更新内容(只改变化的文本/chip/图标/展开区)
|
||||
newKids.forEach((el) => {
|
||||
if (el.dataset.stripKind === "todo") _updateTodoItemEl(el);
|
||||
else _updatePendingItemEl(el);
|
||||
});
|
||||
}
|
||||
|
||||
/** Todo 通知条基础行(按钮 + 内容;展开区由 _updateTodoItemEl 维护) */
|
||||
function _buildTodoItem() {
|
||||
const item = document.createElement("div");
|
||||
item.className = "strip-item strip-item--todo";
|
||||
item.dataset.stripKind = "todo";
|
||||
|
||||
const row = document.createElement("div");
|
||||
row.className = "strip-item-row";
|
||||
|
||||
// 左侧按钮:点击隐藏本条 → 输入区生成按钮
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "strip-item-btn";
|
||||
btn.title = "隐藏 Todo 条(收入输入区)";
|
||||
btn.innerHTML = '<img src="/image/todolist.svg" alt="Todo" />';
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
_dismissStripItem("todo");
|
||||
});
|
||||
row.appendChild(btn);
|
||||
|
||||
// 内容区(点击展开/收起)
|
||||
const content = document.createElement("div");
|
||||
content.className = "strip-item-content";
|
||||
content.addEventListener("click", () => {
|
||||
stripState.todo.expanded = !stripState.todo.expanded;
|
||||
renderStrip();
|
||||
});
|
||||
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "strip-todo-icon";
|
||||
content.appendChild(icon);
|
||||
|
||||
const text = document.createElement("span");
|
||||
text.className = "strip-item-text";
|
||||
content.appendChild(text);
|
||||
|
||||
const chip = document.createElement("span");
|
||||
chip.className = "strip-item-count";
|
||||
content.appendChild(chip);
|
||||
|
||||
row.appendChild(content);
|
||||
item.appendChild(row);
|
||||
return item;
|
||||
}
|
||||
|
||||
/** Todo 展开区(头部含收起按钮 + 完整步骤列表) */
|
||||
function _buildTodoExpandBody() {
|
||||
const { steps, doneSteps } = stripState.todo;
|
||||
const body = document.createElement("div");
|
||||
body.className = "strip-expand-area";
|
||||
|
||||
const head = document.createElement("div");
|
||||
head.className = "strip-expand-head";
|
||||
const label = document.createElement("span");
|
||||
label.className = "strip-expand-label";
|
||||
label.textContent = `Todo List · ${doneSteps.length}/${steps.length}`;
|
||||
head.appendChild(label);
|
||||
head.appendChild(_buildCollapseBtn("todo"));
|
||||
body.appendChild(head);
|
||||
|
||||
const list = document.createElement("ul");
|
||||
list.className = "strip-todo-list";
|
||||
steps.forEach((s, i) => {
|
||||
const li = document.createElement("li");
|
||||
li.className = "strip-todo-step" + (doneSteps.includes(i) ? " strip-todo-step--done" : "");
|
||||
const dot = document.createElement("span");
|
||||
dot.className = "strip-todo-icon" + (doneSteps.includes(i) ? " strip-todo-icon--done" : "");
|
||||
li.appendChild(dot);
|
||||
const labelEl = document.createElement("span");
|
||||
labelEl.className = "strip-todo-label";
|
||||
labelEl.textContent = s;
|
||||
li.appendChild(labelEl);
|
||||
list.appendChild(li);
|
||||
});
|
||||
body.appendChild(list);
|
||||
return body;
|
||||
}
|
||||
|
||||
/** 就地更新 Todo 条:图标/文本/chip 只改文本节点;展开区按内容键重建。 */
|
||||
function _updateTodoItemEl(item) {
|
||||
const { steps, doneSteps, expanded } = stripState.todo;
|
||||
const nextIdx = steps.findIndex((_, i) => !doneSteps.includes(i));
|
||||
const showIdx = nextIdx >= 0 ? nextIdx : steps.length - 1;
|
||||
const allDone = nextIdx < 0;
|
||||
|
||||
item.classList.toggle("strip-item--expanded", expanded);
|
||||
// 行内文本节点就地更新(无重建)
|
||||
const icon = item.querySelector(".strip-todo-icon");
|
||||
if (icon) icon.classList.toggle("strip-todo-icon--done", allDone);
|
||||
const text = item.querySelector(".strip-item-text");
|
||||
if (text) text.textContent = steps[showIdx] || "";
|
||||
const chip = item.querySelector(".strip-item-count");
|
||||
if (chip) chip.textContent = `${doneSteps.length}/${steps.length}`;
|
||||
|
||||
// 展开区维护:仅在内容键变化时重建,否则保留 DOM(避免打字/滚动丢状态)
|
||||
const existingBody = item.querySelector(".strip-expand-area");
|
||||
const key = JSON.stringify(steps) + "|" + doneSteps.join(",");
|
||||
if (expanded) {
|
||||
if (!existingBody || item._stepsKey !== key) {
|
||||
if (existingBody) existingBody.remove();
|
||||
item.appendChild(_buildTodoExpandBody());
|
||||
item._stepsKey = key;
|
||||
}
|
||||
} else if (existingBody) {
|
||||
existingBody.remove();
|
||||
item._stepsKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Pending 通知条基础行(按钮 + 内容;展开区由 _updatePendingItemEl 维护) */
|
||||
function _buildPendingItem() {
|
||||
const item = document.createElement("div");
|
||||
item.className = "strip-item strip-item--pending";
|
||||
item.dataset.stripKind = "pending";
|
||||
|
||||
const row = document.createElement("div");
|
||||
row.className = "strip-item-row";
|
||||
|
||||
// 左侧按钮:点击隐藏本条 → 输入区生成按钮
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "strip-item-btn";
|
||||
btn.title = "隐藏待确认条(收入输入区)";
|
||||
btn.innerHTML = '<img src="/image/人机交互.svg" alt="待确认" />';
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
_dismissStripItem("pending");
|
||||
});
|
||||
row.appendChild(btn);
|
||||
|
||||
// 内容区(点击展开/收起)
|
||||
const content = document.createElement("div");
|
||||
content.className = "strip-item-content";
|
||||
content.addEventListener("click", () => {
|
||||
stripState.pending.expanded = !stripState.pending.expanded;
|
||||
renderStrip();
|
||||
});
|
||||
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "strip-pending-icon";
|
||||
content.appendChild(icon);
|
||||
|
||||
const text = document.createElement("span");
|
||||
text.className = "strip-item-text";
|
||||
content.appendChild(text);
|
||||
|
||||
const chip = document.createElement("span");
|
||||
chip.className = "strip-item-count";
|
||||
content.appendChild(chip);
|
||||
|
||||
row.appendChild(content);
|
||||
item.appendChild(row);
|
||||
return item;
|
||||
}
|
||||
|
||||
/** 就地更新 Pending 条:标题/chip 只改文本;展开区按卡片键重建。 */
|
||||
function _updatePendingItemEl(item) {
|
||||
const { items, index, expanded } = stripState.pending;
|
||||
const current = items[index];
|
||||
|
||||
item.classList.toggle("strip-item--expanded", expanded);
|
||||
const text = item.querySelector(".strip-item-text");
|
||||
if (text) text.textContent = current ? (current.title || "需要确认") : "需要确认";
|
||||
const chip = item.querySelector(".strip-item-count");
|
||||
if (chip) chip.textContent = items.length > 1 ? `×${items.length}` : "1";
|
||||
|
||||
const existingBody = item.querySelector(".strip-expand-area");
|
||||
if (expanded && current) {
|
||||
const key = (current.id || "") + "|" + index;
|
||||
if (!existingBody || item._cardKey !== key) {
|
||||
if (existingBody) existingBody.remove();
|
||||
const body = document.createElement("div");
|
||||
body.className = "strip-expand-area";
|
||||
body.appendChild(_buildPendingCard(current, index));
|
||||
if (items.length > 1) body.appendChild(_buildPendingNav());
|
||||
item.appendChild(body);
|
||||
item._cardKey = key;
|
||||
}
|
||||
} else if (existingBody) {
|
||||
existingBody.remove();
|
||||
item._cardKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 收起按钮(展开区右上角) */
|
||||
function _buildCollapseBtn(kind) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "strip-collapse-btn";
|
||||
btn.textContent = "收起";
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
if (kind === "todo") stripState.todo.expanded = false;
|
||||
else stripState.pending.expanded = false;
|
||||
renderStrip();
|
||||
});
|
||||
return btn;
|
||||
}
|
||||
|
||||
/** 左右导航条(多 pending 项时) */
|
||||
function _buildPendingNav() {
|
||||
const { items, index } = stripState.pending;
|
||||
const nav = document.createElement("div");
|
||||
nav.className = "strip-card-nav";
|
||||
|
||||
const prev = document.createElement("button");
|
||||
prev.type = "button";
|
||||
prev.className = "strip-nav-arrow";
|
||||
prev.textContent = "‹";
|
||||
prev.disabled = index <= 0;
|
||||
prev.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
if (stripState.pending.index > 0) {
|
||||
stripState.pending.index--;
|
||||
renderStrip();
|
||||
}
|
||||
});
|
||||
|
||||
const indicator = document.createElement("span");
|
||||
indicator.className = "strip-nav-indicator";
|
||||
indicator.textContent = `${index + 1}/${items.length}`;
|
||||
|
||||
const next = document.createElement("button");
|
||||
next.type = "button";
|
||||
next.className = "strip-nav-arrow";
|
||||
next.textContent = "›";
|
||||
next.disabled = index >= items.length - 1;
|
||||
next.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
if (stripState.pending.index < stripState.pending.items.length - 1) {
|
||||
stripState.pending.index++;
|
||||
renderStrip();
|
||||
}
|
||||
});
|
||||
|
||||
nav.appendChild(prev);
|
||||
nav.appendChild(indicator);
|
||||
nav.appendChild(next);
|
||||
return nav;
|
||||
}
|
||||
|
||||
// ===== Pending 卡片构建 =====
|
||||
|
||||
function _buildPendingCard(pending, idx) {
|
||||
const isToolCall = pending.type === "tool_call";
|
||||
const isLast = idx >= stripState.pending.items.length - 1;
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className = "strip-card";
|
||||
card.dataset.pendingId = pending.id || "";
|
||||
|
||||
if (isToolCall) {
|
||||
_buildToolCallCard(card, pending);
|
||||
} else {
|
||||
_buildHumanCard(card, pending, isLast);
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
/** 审查卡片(tool_call):参数摘要 + 同意/拒绝 */
|
||||
function _buildToolCallCard(card, pending) {
|
||||
const head = document.createElement("div");
|
||||
head.className = "strip-card-head";
|
||||
const title = document.createElement("div");
|
||||
title.className = "strip-card-title";
|
||||
title.textContent = pending.title || `待确认执行工具:${pending.tool_name || ""}`;
|
||||
head.appendChild(title);
|
||||
head.appendChild(_buildCollapseBtn("pending"));
|
||||
card.appendChild(head);
|
||||
|
||||
// 越界审批提示
|
||||
if (pending.policy_note) {
|
||||
const note = document.createElement("div");
|
||||
note.className = "strip-card-note";
|
||||
note.textContent = "⚠ 越界操作待审批:" + pending.policy_note;
|
||||
card.appendChild(note);
|
||||
}
|
||||
|
||||
// 参数摘要(单行关键参数)
|
||||
const argsEl = document.createElement("div");
|
||||
argsEl.className = "strip-card-args";
|
||||
argsEl.textContent = _argsSummary(pending.args, !!pending.policy_note);
|
||||
card.appendChild(argsEl);
|
||||
|
||||
const footer = document.createElement("div");
|
||||
footer.className = "strip-card-footer";
|
||||
|
||||
const rejectBtn = document.createElement("button");
|
||||
rejectBtn.type = "button";
|
||||
rejectBtn.className = "strip-card-btn strip-card-btn--reject";
|
||||
rejectBtn.textContent = "拒绝";
|
||||
rejectBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
_submitPending(pending, "reject", "");
|
||||
});
|
||||
|
||||
const approveBtn = document.createElement("button");
|
||||
approveBtn.type = "button";
|
||||
approveBtn.className = "strip-card-btn strip-card-btn--approve";
|
||||
approveBtn.textContent = "同意";
|
||||
approveBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
_submitPending(pending, "approve", "");
|
||||
});
|
||||
|
||||
footer.appendChild(rejectBtn);
|
||||
footer.appendChild(approveBtn);
|
||||
card.appendChild(footer);
|
||||
}
|
||||
|
||||
/** 人机交互卡片:标题 + 问题 + 选项 + 补充输入框(输入内容直接作为工具返回值) */
|
||||
function _buildHumanCard(card, pending, isLast) {
|
||||
const head = document.createElement("div");
|
||||
head.className = "strip-card-head";
|
||||
const title = document.createElement("div");
|
||||
title.className = "strip-card-title";
|
||||
title.textContent = pending.title || "需要确认";
|
||||
head.appendChild(title);
|
||||
head.appendChild(_buildCollapseBtn("pending"));
|
||||
card.appendChild(head);
|
||||
|
||||
if (pending.prompt) {
|
||||
const content = document.createElement("div");
|
||||
content.className = "strip-card-content";
|
||||
content.textContent = _unescapeNewlines(pending.prompt);
|
||||
card.appendChild(content);
|
||||
}
|
||||
|
||||
// 选项胶囊(可空——无选项时仅问题 + 补充输入框)
|
||||
let selectedPill = null;
|
||||
const options = Array.isArray(pending.options) ? pending.options : [];
|
||||
if (options.length) {
|
||||
const pillsWrap = document.createElement("div");
|
||||
pillsWrap.className = "strip-card-options";
|
||||
options.forEach((opt) => {
|
||||
const pill = document.createElement("button");
|
||||
pill.type = "button";
|
||||
pill.className = "strip-option-pill";
|
||||
pill.textContent = String(opt);
|
||||
pill.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
// 仅切换类名,不触发重渲染(消除卡顿)
|
||||
pillsWrap.querySelectorAll(".strip-option-pill").forEach((p) => p.classList.remove("is-selected"));
|
||||
pill.classList.add("is-selected");
|
||||
selectedPill = String(opt);
|
||||
});
|
||||
pillsWrap.appendChild(pill);
|
||||
});
|
||||
card.appendChild(pillsWrap);
|
||||
}
|
||||
|
||||
// 补充输入框:内容直接作为工具返回值
|
||||
const input = document.createElement("textarea");
|
||||
input.className = "strip-card-input";
|
||||
input.rows = 2;
|
||||
input.placeholder = options.length ? "补充信息(或直接输入选项)…" : "请输入…";
|
||||
card.appendChild(input);
|
||||
|
||||
const footer = document.createElement("div");
|
||||
footer.className = "strip-card-footer";
|
||||
|
||||
const submitBtn = document.createElement("button");
|
||||
submitBtn.type = "button";
|
||||
submitBtn.className = "strip-card-btn strip-card-btn--continue";
|
||||
submitBtn.textContent = isLast ? "确认" : "继续";
|
||||
submitBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
// 优先补充输入框内容(直达工具返回值),否则取选中选项
|
||||
const val = input.value.trim() || selectedPill || "";
|
||||
if (!val) {
|
||||
showToast("请先输入内容或选择选项");
|
||||
return;
|
||||
}
|
||||
_submitPending(pending, "edit", val, submitBtn);
|
||||
});
|
||||
|
||||
footer.appendChild(submitBtn);
|
||||
card.appendChild(footer);
|
||||
}
|
||||
|
||||
/** 提交决策并自动前进到下一张卡片 */
|
||||
async function _submitPending(pending, decision, instruction, btnEl) {
|
||||
if (!pending || !pending.id) return;
|
||||
// 提交视觉反馈:按钮短暂禁用
|
||||
if (btnEl) {
|
||||
btnEl.disabled = true;
|
||||
btnEl.classList.add("is-submitting");
|
||||
}
|
||||
try {
|
||||
await handleHumanAction(pending.id, decision, instruction, null, _activeSessionIdForPending());
|
||||
} catch (e) {
|
||||
showToast("提交失败: " + (e.message || e));
|
||||
if (btnEl) { btnEl.disabled = false; btnEl.classList.remove("is-submitting"); }
|
||||
return;
|
||||
}
|
||||
// 本地立即移除该项(后端快照随后也会同步);前进到下一张
|
||||
const items = stripState.pending.items;
|
||||
const idx = items.findIndex((p) => p.id === pending.id);
|
||||
if (idx >= 0) items.splice(idx, 1);
|
||||
if (stripState.pending.index >= items.length) {
|
||||
stripState.pending.index = Math.max(0, items.length - 1);
|
||||
}
|
||||
if (!items.length) {
|
||||
stripState.pending.expanded = false;
|
||||
stripState.pending.index = 0;
|
||||
}
|
||||
renderStrip();
|
||||
}
|
||||
|
||||
/** pending 提交用的 session_id:chat 模式为当前会话;cron 模式为任务 ID */
|
||||
function _activeSessionIdForPending() {
|
||||
if (state.mode === "cron" && state.activeCronTaskId) return state.activeCronTaskId;
|
||||
return state.sessionId;
|
||||
}
|
||||
|
||||
// ===== 按钮 toggle:strip ↔ composer =====
|
||||
|
||||
/** 隐藏某类通知条,并在输入区生成对应按钮 */
|
||||
function _dismissStripItem(kind) {
|
||||
if (kind === "todo") stripState.todo.dismissed = true;
|
||||
else stripState.pending.dismissed = true;
|
||||
_addComposerBtn(kind);
|
||||
renderStrip();
|
||||
}
|
||||
|
||||
/** 在输入区(画板按钮右侧)动态生成按钮;按加入顺序从左到右 */
|
||||
function _addComposerBtn(kind) {
|
||||
if (_composerBtns.find((b) => b.kind === kind)) return; // 已存在
|
||||
const host = $("composerInputBottomLeft") || document.querySelector(".composer-input-bottom-left");
|
||||
if (!host) return;
|
||||
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "action-icon-btn strip-composer-btn";
|
||||
btn.title = kind === "todo" ? "Todo List" : "人机交互";
|
||||
btn.innerHTML = kind === "todo"
|
||||
? '<img src="/image/todolist.svg" alt="Todo" />'
|
||||
: '<img src="/image/人机交互.svg" alt="交互" />';
|
||||
btn.addEventListener("click", () => {
|
||||
// 点击输入区按钮 → 按钮消失、对应通知条重新出现
|
||||
_removeComposerBtn(kind);
|
||||
if (kind === "todo") stripState.todo.dismissed = false;
|
||||
else stripState.pending.dismissed = false;
|
||||
renderStrip();
|
||||
});
|
||||
|
||||
host.appendChild(btn);
|
||||
// 进入动画
|
||||
requestAnimationFrame(() => btn.classList.add("is-visible"));
|
||||
_composerBtns.push({ kind, btn });
|
||||
}
|
||||
|
||||
function _removeComposerBtn(kind) {
|
||||
const idx = _composerBtns.findIndex((b) => b.kind === kind);
|
||||
if (idx < 0) return;
|
||||
const { btn } = _composerBtns[idx];
|
||||
btn.classList.remove("is-visible");
|
||||
btn.classList.add("is-leaving");
|
||||
setTimeout(() => btn.remove(), 160);
|
||||
_composerBtns.splice(idx, 1);
|
||||
}
|
||||
|
||||
function _clearComposerBtns() {
|
||||
[..._composerBtns].forEach(({ kind }) => _removeComposerBtn(kind));
|
||||
}
|
||||
|
||||
// ===== 工具函数 =====
|
||||
|
||||
function _unescapeNewlines(s) {
|
||||
return String(s ?? "").replace(/\\n/g, "\n");
|
||||
}
|
||||
|
||||
/** 参数摘要:优先显示关键参数(file_path/command 等),越界审批只显示一条 */
|
||||
function _argsSummary(args, oneLine) {
|
||||
if (!args || typeof args !== "object") return "";
|
||||
// 后端可能包一层 {name, args}
|
||||
let inner = args;
|
||||
if (args.args && typeof args.args === "object" && args.name) inner = args.args;
|
||||
const PRIORITY = ["file_path", "path", "command", "cmd", "url", "dir", "folder", "query", "name", "action"];
|
||||
for (const key of PRIORITY) {
|
||||
if (inner[key] !== undefined && inner[key] !== null && String(inner[key]).trim()) {
|
||||
const v = String(inner[key]);
|
||||
return `${key}: ${v.length > 120 ? v.slice(0, 120) + "…" : v}`;
|
||||
}
|
||||
}
|
||||
// 无关键参数:拼接前几个
|
||||
const entries = Object.entries(inner).filter(([, v]) => v !== undefined && v !== null);
|
||||
if (!entries.length) return "";
|
||||
const shown = oneLine ? entries.slice(0, 1) : entries.slice(0, 3);
|
||||
return shown.map(([k, v]) => `${k}: ${String(v).slice(0, 60)}`).join(" ");
|
||||
}
|
||||
|
||||
/** 仅清空 todo 通知条(不影响 pending——两类 UI 互相独立) */
|
||||
export function clearTodoStrip() {
|
||||
stripState.todo = { steps: [], doneSteps: [], dismissed: false, expanded: false };
|
||||
renderStrip();
|
||||
}
|
||||
|
||||
/** 仅清空 pending 通知条(不影响 todo) */
|
||||
export function clearPendingStrip() {
|
||||
stripState.pending = { items: [], index: 0, dismissed: false, expanded: false };
|
||||
renderStrip();
|
||||
}
|
||||
|
||||
/** 清除全部通知条状态(新会话/清空会话时) */
|
||||
export function clearAllStrips() {
|
||||
stripState.todo = { steps: [], doneSteps: [], dismissed: false, expanded: false };
|
||||
stripState.pending = { items: [], index: 0, dismissed: false, expanded: false };
|
||||
_clearComposerBtns();
|
||||
renderStrip();
|
||||
}
|
||||
@@ -1,499 +1,36 @@
|
||||
// pending-overlay.js -- 待确认浮窗(HumanInteraction + ToolCall 确认)
|
||||
// 直接接收后端 API 返回的 pending_actions 数组,按 agent_name 分组展示。
|
||||
// 与 Todo 浮窗并排显示。
|
||||
// pending-overlay.js -- 人机交互 / 工具审查(通知条版)
|
||||
//
|
||||
// 原浮窗实现已迁移至 notify-strip.js:
|
||||
// 等待中的 human_requests 以输入区上方通知条展示(标题 + 计数 chip),
|
||||
// 点击展开为卡片(人机交互:内容/选项/输入 + 继续/确认;审查:同意/拒绝),
|
||||
// 多项时左右箭头切换。本文件保留导出签名以兼容既有调用点。
|
||||
|
||||
import { $, escapeHtml, showToast } from './utils.js';
|
||||
import { state } from './state.js';
|
||||
import { handleHumanAction } from './pending.js';
|
||||
import {
|
||||
updatePendingStrip,
|
||||
clearPendingStrip,
|
||||
saveStripSession,
|
||||
} from './notify-strip.js';
|
||||
|
||||
function _updateBadge(show) {
|
||||
try {
|
||||
import('./action-bar.js').then(m => m.setActionBadge('pending', show));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将字符串中的 \\n 转为实际换行符(修复 LLM 输出双转义问题)
|
||||
*/
|
||||
function unescapeNewlines(s) {
|
||||
if (!s) return s;
|
||||
return String(s).replace(/\\n/g, '\n').replace(/\\t/g, '\t');
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 pending 浮窗:按 agent_name 分组,每个 agent 一个分组区。
|
||||
* @param {Array} pendingActions - 后端返回的未解决 pending_action 列表
|
||||
* @returns {HTMLElement|null}
|
||||
*/
|
||||
function buildPendingOverlay(pendingActions) {
|
||||
clearPendingOverlay();
|
||||
if (!pendingActions || !pendingActions.length) return null;
|
||||
|
||||
// 按 agent_name 分组(保留出现顺序)
|
||||
const groups = {};
|
||||
const groupOrder = [];
|
||||
pendingActions.forEach((p) => {
|
||||
const name = p.agent_name || "主Agent";
|
||||
if (!groups[name]) { groups[name] = []; groupOrder.push(name); }
|
||||
groups[name].push(p);
|
||||
});
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "pending-overlay";
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "pending-overlay-header";
|
||||
header.textContent = "\u270D\uFE0F 需要确认";
|
||||
header.addEventListener("click", () => {
|
||||
overlay.classList.toggle("pending-overlay--collapsed");
|
||||
});
|
||||
overlay.appendChild(header);
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "pending-overlay-body";
|
||||
|
||||
let hasSelectionBatch = false;
|
||||
|
||||
groupOrder.forEach((name) => {
|
||||
const items = groups[name];
|
||||
const isSub = items.some((p) => p.is_sub_agent);
|
||||
|
||||
const group = document.createElement("div");
|
||||
group.className = "pending-overlay-agent-group";
|
||||
|
||||
const groupHeader = document.createElement("div");
|
||||
groupHeader.className = "pending-overlay-agent-header";
|
||||
groupHeader.textContent = name;
|
||||
group.appendChild(groupHeader);
|
||||
|
||||
items.forEach((pending) => {
|
||||
if (pending.type === "human_interaction" && (pending.interaction_type || "information") === "selection") {
|
||||
hasSelectionBatch = true;
|
||||
}
|
||||
group.appendChild(buildPendingCard(pending, isSub));
|
||||
});
|
||||
|
||||
body.appendChild(group);
|
||||
});
|
||||
|
||||
overlay.appendChild(body);
|
||||
|
||||
// 全局提交按钮(仅当存在 selection 类型时显示,用于批量确认)
|
||||
if (hasSelectionBatch) {
|
||||
const submitRow = document.createElement("div");
|
||||
submitRow.className = "pending-overlay-submit-row";
|
||||
const submitBtn = document.createElement("button");
|
||||
submitBtn.type = "button";
|
||||
submitBtn.className = "pending-overlay-submit-btn";
|
||||
submitBtn.textContent = "确认全部选择";
|
||||
submitBtn.addEventListener("click", () => submitAllPending(overlay, pendingActions));
|
||||
submitRow.appendChild(submitBtn);
|
||||
overlay.appendChild(submitRow);
|
||||
}
|
||||
|
||||
state._currentPendingOverlay = overlay;
|
||||
return overlay;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 tool_call 的 args 规整为紧凑参数摘要。
|
||||
* 后端包装为 {name, args:{...}},解包后:
|
||||
* - 权限审查(oneLine=true)时只展示一条关键参数(如 file_path / command);
|
||||
* - 普通确认时逐行展示 key: value(长值截断),避免整段 JSON 重复显示。
|
||||
* @param {*} args - pending.args
|
||||
* @param {boolean} oneLine - 是否只取关键参数一条(权限审查)
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildArgsSummary(args, oneLine) {
|
||||
let obj = args;
|
||||
if (typeof obj === "string") {
|
||||
try { obj = JSON.parse(obj); } catch { /* 非 JSON 原样返回 */ }
|
||||
}
|
||||
if (obj === null || obj === undefined) return "";
|
||||
if (typeof obj !== "object") return String(obj);
|
||||
|
||||
// 解包 {name, args} 包装(tool_call_utils 构造)
|
||||
let inner = obj;
|
||||
if (typeof obj.name === "string" && obj.args && typeof obj.args === "object" && !Array.isArray(obj.args)) {
|
||||
inner = obj.args;
|
||||
}
|
||||
|
||||
const fmt = (v) => {
|
||||
if (v === null || v === undefined) return "";
|
||||
const s = typeof v === "string" ? v : JSON.stringify(v);
|
||||
return s.length > 160 ? s.slice(0, 157) + "..." : s;
|
||||
};
|
||||
|
||||
const entries = Object.entries(inner || {}).filter(([, v]) => v !== null && v !== undefined && v !== "");
|
||||
if (entries.length === 0) return "";
|
||||
|
||||
// 关键参数优先(权限审查时只显示这一条)
|
||||
const PRIORITY = ["file_path", "path", "command", "cmd", "url", "dir", "folder", "query", "name", "action"];
|
||||
const keyOf = (k) => {
|
||||
const i = PRIORITY.indexOf(k);
|
||||
return i === -1 ? 99 : i;
|
||||
};
|
||||
entries.sort((a, b) => keyOf(a[0]) - keyOf(b[0]));
|
||||
|
||||
if (oneLine) {
|
||||
const [k, v] = entries[0];
|
||||
return `${k}: ${fmt(v)}`;
|
||||
}
|
||||
return entries.map(([k, v]) => `${k}: ${fmt(v)}`).join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建决策按钮
|
||||
* @param {string} label
|
||||
* @param {string} decision - approve | reject | skip | edit
|
||||
* @param {string} cls - 按钮附加类名
|
||||
* @param {Function} onClick
|
||||
*/
|
||||
function makeDecisionBtn(label, decision, cls, onClick) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = `pending-overlay-btn pending-overlay-btn--${decision} ${cls || ""}`.trim();
|
||||
btn.textContent = label;
|
||||
btn.addEventListener("click", onClick);
|
||||
return btn;
|
||||
}
|
||||
|
||||
/**
|
||||
* 为单个 pending_action 构建卡片
|
||||
* @param {Object} pending - pending_action 数据
|
||||
* @param {boolean} isSubAgent - 是否属于子 Agent
|
||||
*/
|
||||
function buildPendingCard(pending, isSubAgent) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "pending-overlay-card";
|
||||
card.setAttribute("data-pending-id", pending.id);
|
||||
|
||||
const pendingType = pending.type || "human_interaction";
|
||||
const interactionType = pending.interaction_type || "information";
|
||||
|
||||
// 标题栏(不折叠,纯展示)
|
||||
const cardHeader = document.createElement("div");
|
||||
cardHeader.className = "pending-overlay-card-header";
|
||||
const icon = pendingType === "tool_call" ? "\u{1F527}" : "\u{1F4DD}";
|
||||
const titleText = pending.title || (pendingType === "tool_call" ? `待确认执行工具:${pending.tool_name || ""}` : "需要确认");
|
||||
cardHeader.innerHTML = `<span class="pending-overlay-card-icon">${icon}</span><span class="pending-overlay-card-title">${escapeHtml(titleText)}</span>`;
|
||||
card.appendChild(cardHeader);
|
||||
|
||||
// 内容区
|
||||
const cardBody = document.createElement("div");
|
||||
cardBody.className = "pending-overlay-card-body";
|
||||
|
||||
if (pending.prompt) {
|
||||
const promptEl = document.createElement("div");
|
||||
promptEl.className = "pending-overlay-prompt";
|
||||
promptEl.textContent = unescapeNewlines(pending.prompt);
|
||||
cardBody.appendChild(promptEl);
|
||||
}
|
||||
|
||||
// ===== tool_call 类型:展示参数摘要(只读)+ 决策按钮 =====
|
||||
if (pendingType === "tool_call") {
|
||||
// 工作空间越界审批提示(访问策略注入的 policy_note)
|
||||
if (pending.policy_note) {
|
||||
const noteEl = document.createElement("div");
|
||||
noteEl.className = "pending-overlay-note";
|
||||
noteEl.textContent = "⚠️ 越界操作待审批:" + unescapeNewlines(pending.policy_note);
|
||||
cardBody.appendChild(noteEl);
|
||||
}
|
||||
// 参数摘要:权限审查只展示一条关键参数;普通确认展示紧凑 key: value,
|
||||
// 不再整段 JSON 重复(标题已含工具名)
|
||||
const argsSummary = buildArgsSummary(pending.args, !!pending.policy_note);
|
||||
if (argsSummary) {
|
||||
const argsEl = document.createElement("div");
|
||||
argsEl.className = "pending-overlay-tool-args";
|
||||
argsEl.textContent = argsSummary;
|
||||
cardBody.appendChild(argsEl);
|
||||
}
|
||||
// 指令输入(可选,用于 approve 时附带说明;不预填 JSON)
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.className = "pending-overlay-textarea";
|
||||
textarea.placeholder = "可选:补充指令...";
|
||||
textarea.rows = 2;
|
||||
textarea.setAttribute("data-field", "instruction");
|
||||
cardBody.appendChild(textarea);
|
||||
|
||||
// 按钮行:主Agent = 同意/拒绝/跳过;子Agent = 确认/跳过
|
||||
const btnRow = document.createElement("div");
|
||||
btnRow.className = "pending-overlay-btn-row";
|
||||
const onDecide = (decision) => () => {
|
||||
const val = textarea.value.trim();
|
||||
submitSingle(pending.id, decision, val, card);
|
||||
};
|
||||
btnRow.appendChild(makeDecisionBtn(isSubAgent ? "确认" : "同意", "approve", "", onDecide("approve")));
|
||||
if (!isSubAgent) {
|
||||
btnRow.appendChild(makeDecisionBtn("拒绝", "reject", "", onDecide("reject")));
|
||||
}
|
||||
btnRow.appendChild(makeDecisionBtn("跳过", "skip", "", onDecide("skip")));
|
||||
cardBody.appendChild(btnRow);
|
||||
|
||||
card.appendChild(cardBody);
|
||||
return card;
|
||||
}
|
||||
|
||||
// ===== human_interaction 类型 =====
|
||||
if (interactionType === "information") {
|
||||
// 文本输入
|
||||
const inputRow = document.createElement("div");
|
||||
inputRow.className = "pending-overlay-input-row";
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.className = "pending-overlay-textarea";
|
||||
textarea.placeholder = "在这里补充信息...";
|
||||
textarea.value = pending.instruction || "";
|
||||
textarea.rows = 3;
|
||||
textarea.setAttribute("data-field", "instruction");
|
||||
inputRow.appendChild(textarea);
|
||||
|
||||
const sendBtn = document.createElement("button");
|
||||
sendBtn.type = "button";
|
||||
sendBtn.className = "pending-overlay-send-btn";
|
||||
sendBtn.textContent = "确认";
|
||||
sendBtn.addEventListener("click", () => {
|
||||
const val = textarea.value.trim();
|
||||
if (!val) {
|
||||
// 空输入时给出明确反馈,避免误以为按钮无响应
|
||||
showToast("请先输入内容再确认");
|
||||
textarea.focus();
|
||||
return;
|
||||
}
|
||||
submitSingle(pending.id, "approve", val, card);
|
||||
});
|
||||
inputRow.appendChild(sendBtn);
|
||||
|
||||
cardBody.appendChild(inputRow);
|
||||
} else if (interactionType === "selection") {
|
||||
if (pending.questions && Array.isArray(pending.questions) && pending.questions.length > 0) {
|
||||
pending.questions.forEach((q) => {
|
||||
const qBlock = document.createElement("div");
|
||||
qBlock.className = "pending-overlay-question";
|
||||
|
||||
const qHeader = document.createElement("div");
|
||||
qHeader.className = "pending-overlay-question-header";
|
||||
qHeader.innerHTML = `<span class="pending-overlay-q-icon">\u2753</span><span>${escapeHtml(q.question || "")}</span>`;
|
||||
qHeader.addEventListener("click", () => {
|
||||
qBlock.classList.toggle("pending-overlay-question--collapsed");
|
||||
});
|
||||
qBlock.appendChild(qHeader);
|
||||
|
||||
const qBody = document.createElement("div");
|
||||
qBody.className = "pending-overlay-question-body";
|
||||
|
||||
if (q.options && Array.isArray(q.options)) {
|
||||
const pillsRow = document.createElement("div");
|
||||
pillsRow.className = "pending-overlay-option-pills";
|
||||
|
||||
q.options.forEach((opt) => {
|
||||
const pill = document.createElement("button");
|
||||
pill.type = "button";
|
||||
pill.className = "pending-overlay-option-pill";
|
||||
pill.textContent = String(opt);
|
||||
pill.addEventListener("click", () => {
|
||||
const prevSelected = pillsRow.querySelector(".pending-overlay-option-pill--selected");
|
||||
if (prevSelected === pill) {
|
||||
pill.classList.remove("pending-overlay-option-pill--selected");
|
||||
return;
|
||||
}
|
||||
if (prevSelected) prevSelected.classList.remove("pending-overlay-option-pill--selected");
|
||||
pill.classList.add("pending-overlay-option-pill--selected");
|
||||
});
|
||||
pillsRow.appendChild(pill);
|
||||
});
|
||||
qBody.appendChild(pillsRow);
|
||||
}
|
||||
|
||||
// "其他" 输入
|
||||
const otherRow = document.createElement("div");
|
||||
otherRow.className = "pending-overlay-other-row";
|
||||
const otherInput = document.createElement("input");
|
||||
otherInput.type = "text";
|
||||
otherInput.className = "pending-overlay-other-input";
|
||||
otherInput.placeholder = "其他自定义...";
|
||||
otherRow.appendChild(otherInput);
|
||||
qBody.appendChild(otherRow);
|
||||
|
||||
qBlock.appendChild(qBody);
|
||||
cardBody.appendChild(qBlock);
|
||||
});
|
||||
} else {
|
||||
// 简单选项列表
|
||||
const pillsRow = document.createElement("div");
|
||||
pillsRow.className = "pending-overlay-option-pills";
|
||||
(pending.options || []).forEach((opt) => {
|
||||
const pill = document.createElement("button");
|
||||
pill.type = "button";
|
||||
pill.className = "pending-overlay-option-pill";
|
||||
pill.textContent = String(opt);
|
||||
pill.addEventListener("click", () => {
|
||||
const prev = pillsRow.querySelector(".pending-overlay-option-pill--selected");
|
||||
if (prev === pill) { pill.classList.remove("pending-overlay-option-pill--selected"); return; }
|
||||
if (prev) prev.classList.remove("pending-overlay-option-pill--selected");
|
||||
pill.classList.add("pending-overlay-option-pill--selected");
|
||||
});
|
||||
pillsRow.appendChild(pill);
|
||||
});
|
||||
cardBody.appendChild(pillsRow);
|
||||
}
|
||||
}
|
||||
|
||||
// 人机交互卡片统一提供"拒绝"按钮:与手动暂停一致,拒绝后暂停本轮运行
|
||||
if (pendingType === "human_interaction") {
|
||||
const rejectRow = document.createElement("div");
|
||||
rejectRow.className = "pending-overlay-btn-row";
|
||||
rejectRow.appendChild(makeDecisionBtn("拒绝", "reject", "", () => {
|
||||
submitSingle(pending.id, "reject", "", card);
|
||||
}));
|
||||
cardBody.appendChild(rejectRow);
|
||||
}
|
||||
|
||||
card.appendChild(cardBody);
|
||||
return card;
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集所有 selection 类型 pending 的答案并提交
|
||||
*/
|
||||
function submitAllPending(overlay, pendingActions) {
|
||||
const results = {};
|
||||
|
||||
pendingActions.forEach((pending) => {
|
||||
if (pending.type === "tool_call") return;
|
||||
const card = overlay.querySelector(`[data-pending-id="${pending.id}"]`);
|
||||
if (!card) return;
|
||||
|
||||
const interactionType = pending.interaction_type || "information";
|
||||
if (interactionType === "information") {
|
||||
const textarea = card.querySelector(".pending-overlay-textarea");
|
||||
results[pending.id] = textarea ? textarea.value.trim() : "";
|
||||
} else if (interactionType === "selection") {
|
||||
if (pending.questions && Array.isArray(pending.questions)) {
|
||||
const qResponses = {};
|
||||
const questionBlocks = card.querySelectorAll(".pending-overlay-question");
|
||||
pending.questions.forEach((q, qIdx) => {
|
||||
const qBlock = questionBlocks[qIdx];
|
||||
if (!qBlock) return;
|
||||
const otherInput = qBlock.querySelector(".pending-overlay-other-input");
|
||||
let answer = otherInput ? otherInput.value.trim() : "";
|
||||
if (!answer) {
|
||||
const selectedPill = qBlock.querySelector(".pending-overlay-option-pill--selected");
|
||||
if (selectedPill) answer = selectedPill.textContent || "";
|
||||
}
|
||||
qResponses[q.question || ""] = answer;
|
||||
});
|
||||
results[pending.id] = JSON.stringify(qResponses);
|
||||
} else {
|
||||
const selectedPill = card.querySelector(".pending-overlay-option-pill--selected");
|
||||
results[pending.id] = selectedPill ? selectedPill.textContent || "" : "";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const submitNext = (idx) => {
|
||||
if (idx >= pendingActions.length) return;
|
||||
const pending = pendingActions[idx];
|
||||
if (pending.type === "tool_call") { submitNext(idx + 1); return; }
|
||||
const card = overlay.querySelector(`[data-pending-id="${pending.id}"]`);
|
||||
const answer = results[pending.id] || "";
|
||||
const instruction = typeof answer === "string" ? answer : JSON.stringify(answer);
|
||||
handleHumanAction(pending.id, "edit", instruction, card).then(() => {
|
||||
if (card) card.classList.add("pending-overlay-card--resolved");
|
||||
setTimeout(() => submitNext(idx + 1), 100);
|
||||
}).catch((e) => {
|
||||
showToast(e.message || String(e));
|
||||
});
|
||||
};
|
||||
|
||||
submitNext(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 单独提交一个 pending_action
|
||||
*/
|
||||
function submitSingle(approvalId, decision, instruction, card) {
|
||||
handleHumanAction(approvalId, decision, instruction, card).then(() => {
|
||||
if (card) card.classList.add("pending-overlay-card--resolved");
|
||||
}).catch((e) => {
|
||||
showToast(e.message || String(e));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新浮窗:直接接收 pending_actions 数组
|
||||
* @param {Array|null} pendingActions - API 响应的 pending_actions 数组
|
||||
*/
|
||||
/** live snapshot 驱动入口:human_requests 数组出现即渲染,应答后随快照消失。 */
|
||||
function updatePendingOverlay(pendingActions) {
|
||||
_updateBadge(!!(pendingActions && pendingActions.length));
|
||||
if (!pendingActions || !pendingActions.length) {
|
||||
clearPendingOverlay();
|
||||
return;
|
||||
}
|
||||
const overlay = buildPendingOverlay(pendingActions);
|
||||
if (!overlay) return;
|
||||
|
||||
const container = getOverlayContainer();
|
||||
if (!container) return;
|
||||
const existing = container.querySelector(".pending-overlay");
|
||||
if (existing && existing !== overlay) existing.remove();
|
||||
if (!overlay.parentNode) container.appendChild(overlay);
|
||||
updateOverlayLayout();
|
||||
updatePendingStrip(pendingActions);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除 pending 浮窗
|
||||
*/
|
||||
/** 清除当前会话的 pending 通知条(仅 pending,不影响 todo;先持久化到会话快照)。 */
|
||||
function clearPendingOverlay() {
|
||||
_updateBadge(false);
|
||||
state._pendingHumanAction = false;
|
||||
if (state._currentPendingOverlay) {
|
||||
state._currentPendingOverlay.hidden = true;
|
||||
}
|
||||
document.querySelectorAll(".pending-overlay").forEach((el) => el.hidden = true);
|
||||
updateOverlayLayout();
|
||||
saveStripSession();
|
||||
clearPendingStrip();
|
||||
}
|
||||
|
||||
function togglePendingOverlay() {
|
||||
if (state._currentPendingOverlay) {
|
||||
state._currentPendingOverlay.hidden = !state._currentPendingOverlay.hidden;
|
||||
} else {
|
||||
const el = document.querySelector(".pending-overlay");
|
||||
if (el) {
|
||||
el.hidden = !el.hidden;
|
||||
state._currentPendingOverlay = el;
|
||||
}
|
||||
}
|
||||
}
|
||||
/** 兼容旧接口:通知条模式下无独立浮窗可切换。 */
|
||||
function togglePendingOverlay() {}
|
||||
|
||||
/**
|
||||
* 获取 overlay 挂载容器(与 todo overlay 相同层级)
|
||||
*/
|
||||
function getOverlayContainer() {
|
||||
const chatMessages = $("chatMessages");
|
||||
const container = chatMessages ? (chatMessages.closest(".chat-area") || chatMessages.parentElement) : null;
|
||||
if (!container) {
|
||||
return document.body;
|
||||
}
|
||||
return container;
|
||||
}
|
||||
/** 兼容旧接口:通知条模式下无双浮窗布局需要调整。 */
|
||||
function updateOverlayLayout() {}
|
||||
|
||||
/**
|
||||
* 更新 overlay 布局(与 todo 浮窗并排)
|
||||
*/
|
||||
function updateOverlayLayout() {
|
||||
const todoOverlay = document.querySelector(".todo-list-overlay");
|
||||
const pendingOverlay = document.querySelector(".pending-overlay");
|
||||
const todoVisible = todoOverlay && !todoOverlay.hidden;
|
||||
const pendingVisible = pendingOverlay && !pendingOverlay.hidden;
|
||||
|
||||
if (todoVisible && pendingVisible) {
|
||||
todoOverlay.classList.add("todo-list-overlay--dual");
|
||||
pendingOverlay.classList.add("pending-overlay--dual");
|
||||
} else {
|
||||
if (todoOverlay) todoOverlay.classList.remove("todo-list-overlay--dual");
|
||||
if (pendingOverlay) pendingOverlay.classList.remove("pending-overlay--dual");
|
||||
}
|
||||
/** 兼容旧接口:通知条由 notify-strip 构建。 */
|
||||
function buildPendingOverlay() {
|
||||
return null;
|
||||
}
|
||||
|
||||
export {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// pending.js -- 人工请求决策提交(卡片渲染在 pending-overlay.js,数据源为 live snapshot)
|
||||
|
||||
import { api } from './api.js';
|
||||
import { state } from './state.js';
|
||||
|
||||
// 防重入:同一 approval_id 同时只允许一个提交请求(连点保护)
|
||||
const _inFlight = new Set();
|
||||
@@ -16,13 +17,16 @@ const _inFlight = new Set();
|
||||
*
|
||||
* 注意:Agent 运行期间 state.sending 恒为 true(本轮仍在后台执行),
|
||||
* 因此这里绝不能以 state.sending 作为拦截条件,否则浮窗按钮全部失效。
|
||||
*
|
||||
* @param {string} [sessionId] - 目标会话 ID。cron 模式传 task_id;
|
||||
* 缺省时回退 state.sessionId(chat)。
|
||||
*/
|
||||
async function handleHumanAction(requestId, decision, instruction, card) {
|
||||
async function handleHumanAction(requestId, decision, instruction, card, sessionId) {
|
||||
if (!requestId || _inFlight.has(requestId)) return;
|
||||
_inFlight.add(requestId);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("session_id", state.sessionId);
|
||||
fd.append("session_id", sessionId || state.sessionId);
|
||||
fd.append("approval_id", requestId);
|
||||
fd.append("decision", decision);
|
||||
fd.append("instruction", instruction || "");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// rollback.js -- 统一回撤机制
|
||||
// Chat 模式回撤:清空 todolist / 人机交互,回滚历史,恢复输入区,清理本地 JSON 记录,docSnapshots 回退
|
||||
// 回撤消息:后端删除后续 turn 并恢复会话所属工作区的 git 状态与文件,
|
||||
// 前端刷新会话/消息/文件树,并把回撤点消息放回输入区。
|
||||
|
||||
import { $, showToast } from './utils.js';
|
||||
import { showConfirm } from './dialog.js';
|
||||
@@ -12,9 +13,6 @@ let renderMessagesFn = null;
|
||||
let renderSessionsFn = null;
|
||||
let renderAttachmentChipsFn = null;
|
||||
let clearTodoOverlayFn = null;
|
||||
let undoDocSnapshotFn = null;
|
||||
let getDocSnapshotsFn = null;
|
||||
let saveDocSnapshotFn = null;
|
||||
let setDocContentFn = null;
|
||||
let getDocContentFn = null;
|
||||
let renderDocTreeFn = null;
|
||||
@@ -27,9 +25,6 @@ export function setRollbackDeps(deps) {
|
||||
renderSessionsFn = deps.renderSessions;
|
||||
renderAttachmentChipsFn = deps.renderAttachmentChips;
|
||||
clearTodoOverlayFn = deps.clearTodoOverlay;
|
||||
undoDocSnapshotFn = deps.undoDocSnapshot;
|
||||
getDocSnapshotsFn = deps.getDocSnapshots;
|
||||
saveDocSnapshotFn = deps.saveDocSnapshot;
|
||||
setDocContentFn = deps.setDocContent;
|
||||
getDocContentFn = deps.getDocContent;
|
||||
renderDocTreeFn = deps.renderDocTree;
|
||||
@@ -60,13 +55,14 @@ export async function rollbackChat(rollbackIndex, options = {}) {
|
||||
|
||||
if (!options.skipConfirm) {
|
||||
const confirmed = await showConfirm(
|
||||
"确认回退到此消息?\n\n将清除此消息之后的所有对话历史、TodoList、人机交互内容、工具调用记录。"
|
||||
"确认回退到此消息?\n\n将清除此消息之后的所有对话历史、TodoList、人机交互内容、工具调用记录;\n" +
|
||||
"若该会话关联工作区,文件系统与 git 状态也会恢复到该消息时的状态(之后的文件改动将被丢弃)。"
|
||||
);
|
||||
if (!confirmed) return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 调用后端回撤 API
|
||||
// 1. 调用后端回撤 API(含 git 状态与文件恢复)
|
||||
const fd = new FormData();
|
||||
fd.append("session_id", state.sessionId);
|
||||
fd.append("rollback_index", rollbackIndex);
|
||||
@@ -76,13 +72,14 @@ export async function rollbackChat(rollbackIndex, options = {}) {
|
||||
state.sessionId = data.sessionId;
|
||||
state.sessions.length = 0;
|
||||
state.sessions.push(...(data.sessions || []));
|
||||
if (data.session_workspaces) state.sessionWorkspaces = data.session_workspaces;
|
||||
|
||||
// 3. 清除 TodoList 和 人机交互 浮窗
|
||||
if (clearTodoOverlayFn) clearTodoOverlayFn();
|
||||
clearPendingOverlay();
|
||||
|
||||
// 4. 回撤文件修改(通过 docSnapshots)
|
||||
await rollbackDocSnapshots();
|
||||
// 4. 文件已由后端 git 恢复:刷新文件树与打开的文件预览
|
||||
await refreshDocTreeAfterRollback();
|
||||
|
||||
// 5. 刷新视图
|
||||
if (renderSessionsFn) renderSessionsFn();
|
||||
@@ -94,7 +91,7 @@ export async function rollbackChat(rollbackIndex, options = {}) {
|
||||
restoreUserInput(rollbackMsg);
|
||||
}
|
||||
|
||||
showToast("已回退到该消息");
|
||||
showToast("已回退到该消息(文件与 git 状态已恢复)");
|
||||
return true;
|
||||
} catch (e) {
|
||||
showToast("回退失败: " + (e.message || "未知错误"));
|
||||
@@ -103,23 +100,20 @@ export async function rollbackChat(rollbackIndex, options = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 回退 docSnapshots,恢复到文件修改前的状态
|
||||
* 回撤后刷新文件树与已打开文件(git 状态与文件内容由后端恢复)
|
||||
*/
|
||||
async function rollbackDocSnapshots() {
|
||||
if (!undoDocSnapshotFn || !getDocSnapshotsFn) return;
|
||||
// 如果不在 edit 模式或没有打开文件夹,跳过
|
||||
if (!state.editMode || !state._docRootPath) return;
|
||||
|
||||
const snapshotsCount = (getDocSnapshotsFn() || []).length;
|
||||
if (snapshotsCount === 0) return;
|
||||
|
||||
// 撤销所有待处理的快照
|
||||
let undone = 0;
|
||||
const maxUndo = snapshotsCount;
|
||||
while (undone < maxUndo && (getDocSnapshotsFn() || []).length > 0) {
|
||||
undoDocSnapshotFn();
|
||||
undone++;
|
||||
}
|
||||
async function refreshDocTreeAfterRollback() {
|
||||
const rootPath = state._docRootPath;
|
||||
try {
|
||||
// 通知 doc-tree 重新拉取 git 状态并渲染
|
||||
window.dispatchEvent(new CustomEvent("git-status-changed"));
|
||||
if (renderDocTreeFn) renderDocTreeFn();
|
||||
if (renderDocTabsFn) renderDocTabsFn();
|
||||
// 重新加载当前打开的文件内容
|
||||
if (rootPath && loadDocContentFn && state.activeDocFile) {
|
||||
await loadDocContentFn(state.activeDocFile);
|
||||
}
|
||||
} catch { /* 文件树刷新失败不影响回撤主流程 */ }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,6 +10,7 @@ import { renderVoiceTranscribingPlaceholder } from './chat-render.js';
|
||||
|
||||
let renderSessionsFn = null;
|
||||
let renderMessagesFn = null;
|
||||
let renderMessagesIncrementalFn = null;
|
||||
let renderAttachmentChipsFn = null;
|
||||
let appendThinkingIndicatorFn = null;
|
||||
let injectLiveToolCallsFn = null;
|
||||
@@ -78,6 +79,7 @@ function _playPcmChunk(ctx, b64Data, sampleRate, startTime) {
|
||||
export function setSendDeps(deps) {
|
||||
renderSessionsFn = deps.renderSessions;
|
||||
renderMessagesFn = deps.renderMessages;
|
||||
renderMessagesIncrementalFn = deps.renderMessagesIncremental || null;
|
||||
renderAttachmentChipsFn = deps.renderAttachmentChips;
|
||||
appendThinkingIndicatorFn = deps.appendThinkingIndicator;
|
||||
injectLiveToolCallsFn = deps.injectLiveToolCalls;
|
||||
@@ -86,6 +88,16 @@ export function setSendDeps(deps) {
|
||||
onSendStartFn = deps.onSendStart;
|
||||
}
|
||||
|
||||
/** 运行期渲染:优先增量(只 append 新增消息,保证速度与连续性),
|
||||
* 无增量渲染器时回退全量。非运行态的查看/切换仍走全量重渲染。 */
|
||||
function _renderRuntime(messages) {
|
||||
if (renderMessagesIncrementalFn) {
|
||||
renderMessagesIncrementalFn(messages);
|
||||
} else if (renderMessagesFn) {
|
||||
renderMessagesFn(messages);
|
||||
}
|
||||
}
|
||||
|
||||
const textInput = $("textInput");
|
||||
const fileInput = $("fileInput");
|
||||
const sendBtn = $("sendBtn");
|
||||
@@ -191,6 +203,15 @@ async function sendChat() {
|
||||
const savedQuotes = (state.pendingRefs || []).filter((r) => r && r.type === "quote");
|
||||
const savedFileRefs = (state.pendingRefs || []).filter((r) => r && r.type !== "quote");
|
||||
if (!effectiveText && state.pendingFiles.length === 0) return;
|
||||
// 强制选择工作区:无工作区不允许开始对话(会话按工作区组织)
|
||||
if (!state.workspacePath) {
|
||||
showToast("请先选择工作区再开始对话(点击左侧栏「新增工作区」或顶栏文件夹图标)", "warning");
|
||||
try {
|
||||
const { switchToMode } = await import('./edit-mode.js');
|
||||
switchToMode("chat");
|
||||
} catch { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
// 首次发送时,输入框从居中移至底部
|
||||
if (onSendStartFn) onSendStartFn();
|
||||
_lastSendTime = Date.now();
|
||||
@@ -205,9 +226,6 @@ async function sendChat() {
|
||||
const sl = $("sessionList");
|
||||
if (sl) sl.classList.add("session-locked");
|
||||
|
||||
// 任务前文件快照(不阻塞 UI 更新)
|
||||
try { const m = await import('./edit-mode.js'); m.snapshotFilesForTask(); } catch {}
|
||||
|
||||
const savedTextRaw = textInput ? textInput.value : "";
|
||||
const savedFiles = state.pendingFiles.slice();
|
||||
// 分离语音输入(麦克风录音)和普通文件(含上传的音频文件),排除文件夹占位
|
||||
@@ -310,22 +328,28 @@ async function sendChat() {
|
||||
if (userMsg && userMsg.role === "user") {
|
||||
const current = (state.lastRenderedMessages || []).slice();
|
||||
current.push(userMsg);
|
||||
renderMessagesFn(current);
|
||||
_renderRuntime(current);
|
||||
}
|
||||
} else if (renderMessagesFn) {
|
||||
// 包括:文本模式、或语音输出模式但有乐观渲染/语音输入占位需要替换
|
||||
renderMessagesFn(startData.messages || []);
|
||||
// 运行期增量渲染:乐观渲染已包含历史 + 用户消息时只 append 差异
|
||||
_renderRuntime(startData.messages || []);
|
||||
}
|
||||
revokeBlobUrlStack(blobUrls);
|
||||
// 移除旧思考气泡(停止计时器后移除),避免双份
|
||||
document.querySelectorAll('.msg-typing').forEach(el => _cleanupTypingEl(el));
|
||||
// 仅清理 chat 容器内的 typing 气泡;cron 容器有独立 typingEl,双模式并行时不可误删
|
||||
const _chatContainer = $("chatMessages");
|
||||
if (_chatContainer) {
|
||||
_chatContainer.querySelectorAll('.msg-typing').forEach(el => _cleanupTypingEl(el));
|
||||
}
|
||||
typingEl = appendThinkingIndicatorFn ? appendThinkingIndicatorFn() : null;
|
||||
|
||||
// Reset tool call expansion state
|
||||
state.liveToolCallExpanded = false;
|
||||
state.thinkExpanded = false;
|
||||
|
||||
if (clearTodoOverlayFn) clearTodoOverlayFn();
|
||||
// 新轮次开始:仅清空上一轮遗留的人机交互/审查请求(其注册表随轮次结束失效),
|
||||
// 不清空 todo——todo 是会话级全局状态,跨轮次保持(新 todo_list 到达时自动替换)
|
||||
clearPendingOverlay();
|
||||
|
||||
// 启动实时工具调用与反思轮询(snapshot 同时驱动人工请求浮窗与 Todo 浮窗)
|
||||
@@ -373,14 +397,14 @@ async function sendChat() {
|
||||
|
||||
if (last && last.role === "assistant") {
|
||||
const head = msgs.slice(0, -1);
|
||||
if (renderMessagesFn) renderMessagesFn(head);
|
||||
_renderRuntime(head);
|
||||
// 同步 lastRenderedMessages 为完整消息列表,确保后续打断时 assistant 消息不丢失
|
||||
state.lastRenderedMessages.length = 0;
|
||||
state.lastRenderedMessages.push(...JSON.parse(JSON.stringify(msgs)));
|
||||
revokeBlobUrlStack(blobUrls);
|
||||
if (streamAssistantMessageFn) streamAssistantMessageFn(last);
|
||||
} else {
|
||||
if (renderMessagesFn) renderMessagesFn(msgs);
|
||||
_renderRuntime(msgs);
|
||||
revokeBlobUrlStack(blobUrls);
|
||||
}
|
||||
|
||||
@@ -453,11 +477,12 @@ async function sendChat() {
|
||||
typingEl = null;
|
||||
if (last && last.role === "assistant") {
|
||||
const head = messages.slice(0, -1);
|
||||
if (renderMessagesFn) renderMessagesFn(head);
|
||||
// 增量渲染:head 与已渲染内容一致时零重建,仅由 streamAssistantMessage 追加末条
|
||||
_renderRuntime(head);
|
||||
revokeBlobUrlStack(blobUrls);
|
||||
if (streamAssistantMessageFn) await streamAssistantMessageFn(last);
|
||||
} else {
|
||||
if (renderMessagesFn) renderMessagesFn(messages);
|
||||
_renderRuntime(messages);
|
||||
revokeBlobUrlStack(blobUrls);
|
||||
}
|
||||
}
|
||||
@@ -523,10 +548,7 @@ async function sendChat() {
|
||||
setSendBtnPauseMode(false);
|
||||
const fBtn = sendBtn || $("sendBtn");
|
||||
if (fBtn) fBtn.disabled = false;
|
||||
// 延迟检测文件变更(等待文件监听器处理完)
|
||||
setTimeout(() => {
|
||||
try { import('./edit-mode.js').then(m => m.detectTaskFileChanges()); } catch {}
|
||||
}, 1500);
|
||||
// 文件变更由 git 状态着色(/api/workspace/git-status)驱动,此处无需检测
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// sessions.js -- 会话管理(支持重命名、按项删除)
|
||||
|
||||
import { $ } from './utils.js';
|
||||
import { $, showToast } from './utils.js';
|
||||
import { showConfirm, showPrompt } from './dialog.js';
|
||||
import { api } from './api.js';
|
||||
import { state } from './state.js';
|
||||
@@ -22,6 +22,9 @@ export function setSessionDeps(deps) {
|
||||
const sessionList = $("sessionList");
|
||||
const fileInput = $("fileInput");
|
||||
|
||||
// 已折叠的工作区路径集合(点击工作区卡片展开/折叠其会话列表)
|
||||
const _collapsedWs = new Set();
|
||||
|
||||
// ---------- 会话显示名称(localStorage) ----------
|
||||
const SESSION_NAMES_KEY = "minor_session_names";
|
||||
|
||||
@@ -50,60 +53,220 @@ function setSessionDisplayName(id, name) {
|
||||
saveSessionNames(names);
|
||||
}
|
||||
|
||||
// ---------- 工作区分组 ----------
|
||||
function wsBasename(path) {
|
||||
const parts = String(path || "").replace(/[\\/]+$/, "").split(/[\\/]/);
|
||||
return parts[parts.length - 1] || path || "默认工作空间";
|
||||
}
|
||||
|
||||
/** 把会话列表按工作区归组:[{key, path, name, sessions:[...]}]。
|
||||
只渲染有会话的工作区卡片(无会话时不显示空卡片);未归属/已移除工作区的会话
|
||||
直接置顶显示(无"默认工作区"卡片)。 */
|
||||
function buildWorkspaceGroups() {
|
||||
const byWs = {};
|
||||
const unassigned = [];
|
||||
for (const id of state.sessions) {
|
||||
const ws = state.sessionWorkspaces[id] || "";
|
||||
if (!ws || !(state.workspaceList || []).includes(ws)) {
|
||||
unassigned.push(id); // 未归属或工作区已移除 → 置顶
|
||||
} else {
|
||||
(byWs[ws] = byWs[ws] || []).push(id);
|
||||
}
|
||||
}
|
||||
const groups = [];
|
||||
if (unassigned.length) {
|
||||
groups.push({ key: "__unassigned__", path: "", name: "", sessions: unassigned, plain: true });
|
||||
}
|
||||
for (const ws of state.workspaceList || []) {
|
||||
if (!(byWs[ws] || []).length) continue; // 空会话的工作区不渲染卡片
|
||||
groups.push({ key: ws, path: ws, name: wsBasename(ws), sessions: byWs[ws] });
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** 顶栏工作区 chip 跟随当前会话:切换/新建/删除会话后,显示其所属工作区;
|
||||
并同步"选择工作区开始对话"引导态(无工作区或无会话时显示输入区加号)。 */
|
||||
function syncWorkspaceChip() {
|
||||
const ws = (state.sessionWorkspaces || {})[state.sessionId];
|
||||
if (ws) {
|
||||
// 被禁止的目录(如桌面)不作为工作区:视为未归属会话,清空工作区 chip
|
||||
const norm = (p) => String(p || "").replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
||||
if ((state.forbiddenWorkspaces || []).map(norm).includes(norm(ws))) {
|
||||
state.workspacePath = "";
|
||||
} else {
|
||||
state.workspacePath = ws;
|
||||
}
|
||||
}
|
||||
import('./workspace.js').then((m) => m.updateTopBarWorkspace()).catch(() => {});
|
||||
// 右栏文件树跟随会话工作区:右栏打开且根目录不是该工作区时,重新打开
|
||||
if (ws && state.workspacePath) {
|
||||
const appRoot = document.getElementById("app");
|
||||
const rightPanelOpen = appRoot && appRoot.classList.contains("right-panel-open");
|
||||
if (rightPanelOpen && state._docRootPath !== ws) {
|
||||
import('./edit-mode.js').then((m) => m.openFolder(ws)).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 渲染单个会话项(工作区分组内使用) */
|
||||
function renderSessionItem(id) {
|
||||
const displayName = getSessionDisplayName(id);
|
||||
const isActive = id === state.sessionId;
|
||||
|
||||
const li = document.createElement("li");
|
||||
li.className = "session-item" + (isActive ? " active" : "");
|
||||
li.setAttribute("role", "option");
|
||||
li.setAttribute("aria-selected", isActive ? "true" : "false");
|
||||
|
||||
// 会话图标(文档按钮样式,随主题着色)
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "session-item-icon";
|
||||
li.appendChild(icon);
|
||||
|
||||
const nameSpan = document.createElement("span");
|
||||
nameSpan.className = "session-item-name";
|
||||
nameSpan.textContent = displayName;
|
||||
nameSpan.title = `ID: ${id}`;
|
||||
nameSpan.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
selectSession(id);
|
||||
});
|
||||
|
||||
const actionsDiv = document.createElement("span");
|
||||
actionsDiv.className = "session-item-actions";
|
||||
|
||||
const editBtn = document.createElement("button");
|
||||
editBtn.className = "session-item-action-btn";
|
||||
editBtn.title = "重命名";
|
||||
editBtn.innerHTML = '<img src="/image/编辑.svg" alt="编辑" width="16" height="16" />';
|
||||
editBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
renameSession(id);
|
||||
});
|
||||
|
||||
const delBtn = document.createElement("button");
|
||||
delBtn.className = "session-item-action-btn danger";
|
||||
delBtn.title = "删除";
|
||||
delBtn.innerHTML = '<img src="/image/删除.svg" alt="删除" width="16" height="16" />';
|
||||
delBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
deleteSessionItem(id);
|
||||
});
|
||||
|
||||
actionsDiv.appendChild(editBtn);
|
||||
actionsDiv.appendChild(delBtn);
|
||||
|
||||
li.appendChild(nameSpan);
|
||||
li.appendChild(actionsDiv);
|
||||
return li;
|
||||
}
|
||||
|
||||
// ---------- 渲染 ----------
|
||||
export function renderSessions() {
|
||||
if (!sessionList) return;
|
||||
sessionList.innerHTML = "";
|
||||
state.sessions.forEach((id) => {
|
||||
const displayName = getSessionDisplayName(id);
|
||||
const isActive = id === state.sessionId;
|
||||
const groups = buildWorkspaceGroups();
|
||||
|
||||
const li = document.createElement("li");
|
||||
li.className = "session-item" + (isActive ? " active" : "");
|
||||
li.setAttribute("role", "option");
|
||||
li.setAttribute("aria-selected", isActive ? "true" : "false");
|
||||
for (const group of groups) {
|
||||
const groupLi = document.createElement("li");
|
||||
groupLi.className = "ws-group";
|
||||
|
||||
// 未归属会话:置顶直接显示,无工作区卡片
|
||||
if (group.plain) {
|
||||
const inner = document.createElement("ul");
|
||||
inner.className = "ws-group-sessions ws-group-sessions--plain";
|
||||
group.sessions.forEach((id) => inner.appendChild(renderSessionItem(id)));
|
||||
groupLi.appendChild(inner);
|
||||
sessionList.appendChild(groupLi);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 工作区卡片:点击展开/折叠该工作区的会话列表;操作按钮 hover 时出现
|
||||
const card = document.createElement("div");
|
||||
card.className = "ws-card";
|
||||
card.title = group.path;
|
||||
|
||||
// 资源管理器按钮:恒显于名称最左侧(不在 hover 隐藏的 actions 内)
|
||||
const explorerBtn = document.createElement("button");
|
||||
explorerBtn.type = "button";
|
||||
explorerBtn.className = "ws-card-explorer";
|
||||
explorerBtn.title = "在资源管理器中打开";
|
||||
explorerBtn.innerHTML = '<img src="/image/文件夹.svg" alt="打开" />';
|
||||
explorerBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
if (window.electronAPI?.openInExplorer) {
|
||||
window.electronAPI.openInExplorer(group.path);
|
||||
} else {
|
||||
showToast("非桌面环境,无法打开资源管理器");
|
||||
}
|
||||
});
|
||||
card.appendChild(explorerBtn);
|
||||
|
||||
// 名称区域(点击切换会话)
|
||||
const nameSpan = document.createElement("span");
|
||||
nameSpan.className = "session-item-name";
|
||||
nameSpan.textContent = displayName;
|
||||
nameSpan.title = `ID: ${id}`;
|
||||
nameSpan.addEventListener("click", (e) => {
|
||||
nameSpan.className = "ws-card-name";
|
||||
nameSpan.textContent = group.name;
|
||||
nameSpan.title = group.path;
|
||||
card.appendChild(nameSpan);
|
||||
|
||||
const actions = document.createElement("span");
|
||||
actions.className = "ws-card-actions";
|
||||
|
||||
// 新增会话(归属该工作区)
|
||||
const newBtn = document.createElement("button");
|
||||
newBtn.type = "button";
|
||||
newBtn.className = "ws-card-action";
|
||||
newBtn.title = "新增会话";
|
||||
newBtn.innerHTML = '<img src="/image/新增会话.svg" alt="新增会话" />';
|
||||
newBtn.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
selectSession(id);
|
||||
await newSession(group.path);
|
||||
});
|
||||
actions.appendChild(newBtn);
|
||||
|
||||
// 操作图标区域(hover 显示)
|
||||
const actionsDiv = document.createElement("span");
|
||||
actionsDiv.className = "session-item-actions";
|
||||
|
||||
// 编辑图标
|
||||
const editBtn = document.createElement("button");
|
||||
editBtn.className = "session-item-action-btn";
|
||||
editBtn.title = "重命名";
|
||||
editBtn.innerHTML = '<img src="/image/编辑.svg" alt="编辑" width="16" height="16" />';
|
||||
editBtn.addEventListener("click", (e) => {
|
||||
// 从列表移除(删除按钮;确认弹窗与级联删除会话见 workspace.js removeWorkspace)
|
||||
const delWsBtn = document.createElement("button");
|
||||
delWsBtn.type = "button";
|
||||
delWsBtn.className = "ws-card-action";
|
||||
delWsBtn.title = "从列表移除";
|
||||
delWsBtn.innerHTML = '<img src="/image/删除.svg" alt="移除" />';
|
||||
delWsBtn.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
renameSession(id);
|
||||
const m = await import('./workspace.js');
|
||||
await m.removeWorkspace(group.path);
|
||||
});
|
||||
actions.appendChild(delWsBtn);
|
||||
|
||||
// 删除图标
|
||||
const delBtn = document.createElement("button");
|
||||
delBtn.className = "session-item-action-btn danger";
|
||||
delBtn.title = "删除";
|
||||
delBtn.innerHTML = '<img src="/image/删除.svg" alt="删除" width="16" height="16" />';
|
||||
delBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
deleteSessionItem(id);
|
||||
});
|
||||
card.appendChild(actions);
|
||||
groupLi.appendChild(card);
|
||||
|
||||
actionsDiv.appendChild(editBtn);
|
||||
actionsDiv.appendChild(delBtn);
|
||||
// 组内会话(缩进对齐卡片名称);无会话时给出提示
|
||||
const inner = document.createElement("ul");
|
||||
inner.className = "ws-group-sessions";
|
||||
if (group.sessions.length === 0) {
|
||||
const empty = document.createElement("li");
|
||||
empty.className = "ws-group-empty";
|
||||
empty.textContent = "暂无会话";
|
||||
inner.appendChild(empty);
|
||||
} else {
|
||||
group.sessions.forEach((id) => inner.appendChild(renderSessionItem(id)));
|
||||
// 卡片点击:展开/折叠会话列表
|
||||
if (_collapsedWs.has(group.path)) {
|
||||
inner.hidden = true;
|
||||
}
|
||||
card.addEventListener("click", () => {
|
||||
const nowCollapsed = !_collapsedWs.has(group.path);
|
||||
if (nowCollapsed) {
|
||||
_collapsedWs.add(group.path);
|
||||
} else {
|
||||
_collapsedWs.delete(group.path);
|
||||
}
|
||||
inner.hidden = nowCollapsed;
|
||||
});
|
||||
}
|
||||
groupLi.appendChild(inner);
|
||||
|
||||
li.appendChild(nameSpan);
|
||||
li.appendChild(actionsDiv);
|
||||
sessionList.appendChild(li);
|
||||
});
|
||||
sessionList.appendChild(groupLi);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 重命名 ----------
|
||||
@@ -130,6 +293,7 @@ async function deleteSessionItem(id) {
|
||||
// 如果删除的是当前会话,切换到服务端返回的新会话
|
||||
if (id === state.sessionId) {
|
||||
state.sessionId = data.sessionId;
|
||||
applySessionThinkingLevel(data);
|
||||
if (renderMessagesFn) renderMessagesFn(data.messages || []);
|
||||
// 清理当前会话的 UI 状态
|
||||
state.pendingFiles.length = 0;
|
||||
@@ -140,6 +304,8 @@ async function deleteSessionItem(id) {
|
||||
}
|
||||
state.sessions.length = 0;
|
||||
state.sessions.push(...(data.sessions || []).filter((sid) => sid !== deletingSessionId));
|
||||
if (data.session_workspaces) state.sessionWorkspaces = data.session_workspaces;
|
||||
syncWorkspaceChip();
|
||||
|
||||
renderSessions();
|
||||
|
||||
@@ -155,15 +321,36 @@ async function deleteSessionItem(id) {
|
||||
if (_ar4 && _ar4.classList.contains("is-mobile")) _ar4.classList.remove("drawer-open");
|
||||
}
|
||||
|
||||
// ---------- 会话级思考档位同步 ----------
|
||||
// 各会话独立保存 THINKING_LEVEL(session_meta.json),切换/新建/删除会话时
|
||||
// 用服务端返回的 thinking_level 同步滑条 UI。
|
||||
function applySessionThinkingLevel(data) {
|
||||
if (!data || !data.thinking_level) return;
|
||||
if (state.thinkingLevel === data.thinking_level) return;
|
||||
state.thinkingLevel = data.thinking_level;
|
||||
import('./think-level.js').then((m) => m.syncThinkLevelUI()).catch(() => {});
|
||||
}
|
||||
|
||||
// ---------- 启动 ----------
|
||||
// 工作区列表增删/切换后重渲染分组
|
||||
window.addEventListener("workspaces-changed", () => renderSessions());
|
||||
|
||||
export async function bootstrap() {
|
||||
const data = await api("/api/bootstrap");
|
||||
state.sessionId = data.sessionId;
|
||||
state.sessions.length = 0;
|
||||
state.sessions.push(...(data.sessions || []));
|
||||
state.sessionWorkspaces = data.session_workspaces || {};
|
||||
state.currentTokens = data.tokens || 0;
|
||||
applySessionThinkingLevel(data);
|
||||
syncWorkspaceChip();
|
||||
if (renderSessionsFnRef) renderSessionsFnRef();
|
||||
if (renderMessagesFn) renderMessagesFn(data.messages);
|
||||
// 恢复会话级全局 todo 状态(启动时展示该会话已有的 todo 计划)
|
||||
try {
|
||||
const { restoreTodoFromMessages } = await import('./notify-strip.js');
|
||||
restoreTodoFromMessages(data.messages || []);
|
||||
} catch { /* ignore */ }
|
||||
// 设置居中状态
|
||||
const msgs = data.messages || [];
|
||||
import('../app.js').then((m) => m.setComposerCentered(msgs.length === 0));
|
||||
@@ -181,13 +368,24 @@ async function selectSession(id) {
|
||||
* 供「分支」等创建新会话后直接跳转使用。
|
||||
*/
|
||||
export async function applySessionData(data) {
|
||||
if (!data || !data.sessionId) return;
|
||||
state.sessionId = data.sessionId;
|
||||
if (!data) return;
|
||||
// 记录旧会话键(切换前保存其通知条状态,避免被存到新键下)
|
||||
const oldSessionId = state.sessionId;
|
||||
// sessionId 为空表示无任何会话(工作区级联删除后):渲染空引导态
|
||||
state.sessionId = data.sessionId || "";
|
||||
state.sessions.length = 0;
|
||||
state.sessions.push(...(data.sessions || []));
|
||||
if (data.session_workspaces) state.sessionWorkspaces = data.session_workspaces;
|
||||
state.currentTokens = data.tokens || 0;
|
||||
if (clearTodoOverlayFn) clearTodoOverlayFn();
|
||||
clearPendingOverlay();
|
||||
applySessionThinkingLevel(data);
|
||||
syncWorkspaceChip();
|
||||
// 通知条会话级切换:保存旧会话状态 → 恢复目标会话的 todo/pending
|
||||
try {
|
||||
const { switchStripSession, restoreTodoFromMessages } = await import('./notify-strip.js');
|
||||
switchStripSession("chat:" + (state.sessionId || ""), "chat:" + oldSessionId);
|
||||
// 从历史消息回溯会话级全局 todo 状态(跨轮次保持,重新加载后仍可见)
|
||||
restoreTodoFromMessages(data.messages || []);
|
||||
} catch { /* ignore */ }
|
||||
if (renderSessionsFnRef) renderSessionsFnRef();
|
||||
if (renderMessagesFn) renderMessagesFn(data.messages || []);
|
||||
refreshTokens();
|
||||
@@ -200,7 +398,7 @@ export async function applySessionData(data) {
|
||||
|
||||
let _creatingSession = false;
|
||||
|
||||
async function newSession() {
|
||||
async function newSession(workspace) {
|
||||
// 防止并发调用
|
||||
if (_creatingSession) return;
|
||||
if (state.sending) return; // Agent 运行中禁止新建会话
|
||||
@@ -227,14 +425,24 @@ async function newSession() {
|
||||
state.sessionId = "";
|
||||
const cm = document.getElementById("chatMessages");
|
||||
const cp = document.getElementById("chatPlaceholder");
|
||||
if (cm) { cm.innerHTML = ""; cm.hidden = true; cm.style.display = "none"; }
|
||||
if (cm) {
|
||||
cm.innerHTML = ""; cm.hidden = true; cm.style.display = "none";
|
||||
// 同步增量渲染跟踪状态,避免硬清空后计数不一致
|
||||
cm._renderedMessages = null;
|
||||
cm._renderedCount = 0;
|
||||
}
|
||||
if (cp) { cp.hidden = false; }
|
||||
|
||||
const data = await api("/api/sessions/new", { method: "POST" });
|
||||
const fd = new FormData();
|
||||
if (workspace) fd.append("workspace", workspace);
|
||||
const data = await api("/api/sessions/new", { method: "POST", body: fd });
|
||||
state.sessionId = data.sessionId;
|
||||
state.sessions.length = 0;
|
||||
state.sessions.push(...(data.sessions || []));
|
||||
if (data.session_workspaces) state.sessionWorkspaces = data.session_workspaces;
|
||||
state.currentTokens = 0;
|
||||
applySessionThinkingLevel(data);
|
||||
syncWorkspaceChip();
|
||||
state.pendingFiles.length = 0;
|
||||
if (fileInput) fileInput.value = "";
|
||||
if (clearTodoOverlayFn) clearTodoOverlayFn();
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// settings.js -- 设置面板:Agent / Tool / Env / GUI / Models / Theme
|
||||
// settings.js -- 设置面板(左侧分组导航 + 右侧内容区):
|
||||
// 常规 / 外观 / 模型 / Agent / 工具 / Skills / GUI / 网络搜索 / 邮件 / 存储 / 运行时参数
|
||||
|
||||
import { $, escapeHtml, makeId, showToast, withClickGuard } from './utils.js';
|
||||
import { $, escapeHtml, makeId, showToast } from './utils.js';
|
||||
import { showConfirm } from './dialog.js';
|
||||
import { api } from './api.js';
|
||||
import { state } from './state.js';
|
||||
import { THEMES, getCurrentTheme, applyTheme, getBgImage, setBgImage, getBgBlur, setBgBlur, getBgVignette, setBgVignette, FONT_PRESETS, FONT_SIZE_PRESETS, getFontSetting, setFontSetting } from './themes.js';
|
||||
import { THEMES, getCurrentTheme, applyTheme, getBgImage, setBgImage, getBgBlur, setBgBlur, getBgVignette, setBgVignette, FONT_PRESETS, FONT_SIZE_PRESETS, getFontSetting, setFontSetting, getAvatar, setAvatar } from './themes.js';
|
||||
import { LANGS, setLanguage } from './i18n.js';
|
||||
|
||||
let renderSkillsFn = null;
|
||||
@@ -16,10 +17,13 @@ const settingsCancelBtn = $("settingsCancelBtn");
|
||||
const settingsStatus = $("settingsStatus");
|
||||
const agentsListEl = $("agentsList");
|
||||
const toolsListEl = $("toolsList");
|
||||
const envListEl = $("envList");
|
||||
const modelsZoneEl = $("modelsZone");
|
||||
const guiMonitorsGrid = $("guiMonitorsGrid");
|
||||
const addAgentBtn = $("addAgentBtn");
|
||||
|
||||
// 邮箱地址组合控件(仅支持固定域名,其余走自定义输入)
|
||||
const EMAIL_DOMAINS = ["qq.com", "163.com", "126.com", "yeah.net"];
|
||||
|
||||
export function setStatus(msg, type) {
|
||||
if (!settingsStatus) return;
|
||||
settingsStatus.textContent = msg;
|
||||
@@ -32,6 +36,7 @@ export function setStatus(msg, type) {
|
||||
function openSettings() {
|
||||
if (!settingsOverlay) return;
|
||||
settingsOverlay.hidden = false;
|
||||
renderThemePanel();
|
||||
loadAllConfigs().catch((e) => setStatus("加载配置失败: " + e.message, "error"));
|
||||
}
|
||||
|
||||
@@ -43,21 +48,19 @@ function closeSettings() {
|
||||
import('../app.js').then((m) => { if (m.refreshModelSelect) m.refreshModelSelect(); }).catch(() => {});
|
||||
}
|
||||
|
||||
// Tab 切换
|
||||
document.querySelectorAll(".settings-tab").forEach((tab) => {
|
||||
tab.addEventListener("click", () => {
|
||||
const target = tab.getAttribute("data-tab");
|
||||
document.querySelectorAll(".settings-tab").forEach((t) => t.classList.remove("active"));
|
||||
tab.classList.add("active");
|
||||
document.querySelectorAll(".settings-panel").forEach((p) => p.hidden = p.getAttribute("data-panel") !== target);
|
||||
if (target === "agent") renderAgentConfigs();
|
||||
else if (target === "tool") renderToolConfigs();
|
||||
else if (target === "env") renderEnvConfig();
|
||||
else if (target === "skills" && renderSkillsFn) renderSkillsFn();
|
||||
else if (target === "other") renderGuiConfig();
|
||||
else if (target === "theme") renderThemePanel();
|
||||
// ===== 左侧导航切换 =====
|
||||
function bindSettingsNav() {
|
||||
document.querySelectorAll(".settings-nav-item").forEach((item) => {
|
||||
item.addEventListener("click", () => {
|
||||
const page = item.getAttribute("data-page");
|
||||
document.querySelectorAll(".settings-nav-item").forEach((n) => n.classList.toggle("active", n === item));
|
||||
document.querySelectorAll(".settings-page").forEach((p) => { p.hidden = p.getAttribute("data-page") !== page; });
|
||||
const main = document.querySelector(".settings-main");
|
||||
if (main) main.scrollTop = 0;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
bindSettingsNav();
|
||||
|
||||
async function loadAllConfigs() {
|
||||
const [agentRes, toolRes, envRes, guiRes] = await Promise.all([
|
||||
@@ -87,11 +90,14 @@ async function loadAllConfigs() {
|
||||
state.registeredToolNames.push(...(toolRes.registered_tool_names || []));
|
||||
window._toolParameters = toolRes.tool_parameters || {};
|
||||
window._forcedPermissions = toolRes.forced_permissions || {};
|
||||
// 所有页面同时渲染(nav 切换仅控制显隐)
|
||||
renderAgentConfigs();
|
||||
renderToolConfigs();
|
||||
renderModelsConfig();
|
||||
renderEnvConfig();
|
||||
if (renderSkillsFn) renderSkillsFn();
|
||||
renderGuiConfig();
|
||||
renderThemePanel();
|
||||
}
|
||||
|
||||
// ===== Agent 配置渲染 =====
|
||||
@@ -613,152 +619,35 @@ function renderGuiConfig() {
|
||||
guiMonitorsGrid.appendChild(modelSection);
|
||||
}
|
||||
|
||||
// ===== Env 配置渲染 =====
|
||||
// 环境变量解释(鼠标悬停变量名时显示)
|
||||
const ENV_DESC = {
|
||||
WORKING_DIR: "项目工作目录,Agent 产生的文件默认存放位置。",
|
||||
WORKSPACE_DIR: "工作空间目录(相对 WORKING_DIR),留空则使用 WORKING_DIR。",
|
||||
USER_PYTHON_PATH: "自定义 Python 解释器路径,供终端/脚本执行使用。",
|
||||
STREAMING_TTS_URL: "语音合成(TTS)服务地址。",
|
||||
ASR_BASE_URL: "语音识别(ASR)服务地址。",
|
||||
RAG_BASE_URL: "RAG 检索服务地址。",
|
||||
IMAGE_GEN_URL: "图片生成服务地址。",
|
||||
WEB_SEARCH_API_KEY: "网络搜索 API Key。",
|
||||
WEB_SEARCH_ENGINE: "网络搜索引擎(如 tavily)。",
|
||||
LLM_CONTEXT_WINDOW: "LLM 上下文窗口大小(token),压缩阈值按窗口比例计算。",
|
||||
COMPRESS_RATE: "上下文压缩阈值比例(0~1),token 用量超过 窗口×该值 时触发压缩。",
|
||||
IMG_SIZE: "工具截图/图片缩放边长(像素),用于控制 token 消耗。",
|
||||
RAG_CHUNK_SIZE: "RAG 文档分块大小(字符)。",
|
||||
RAG_CHUNK_OVERLAP: "RAG 分块重叠大小(字符)。",
|
||||
GROUNDING_WIDTH: "GUI 定位图像宽度(像素)。",
|
||||
GROUNDING_HEIGHT: "GUI 定位图像高度(像素)。",
|
||||
LOOP_DETECT_REPEATED_TOOL_WARN: "同一工具连续重复调用超过该次数时输出警告。",
|
||||
SEND_FILE_SIZE_LIMIT: "发送文件大小上限(MB)。",
|
||||
STORAGE_BACKEND: "存储后端:json(本地文件)或 mysql(数据库)。",
|
||||
MYSQL_HOST: "MySQL 主机地址。",
|
||||
MYSQL_PORT: "MySQL 端口。",
|
||||
MYSQL_USER: "MySQL 用户名。",
|
||||
MYSQL_PASSWORD: "MySQL 密码。",
|
||||
MYSQL_DATABASE: "MySQL 数据库名。",
|
||||
EMAIL_ADDRESS: "邮箱地址(邮件发送账号)。",
|
||||
EMAIL_AUTH_CODE: "邮箱 SMTP 授权码。",
|
||||
CRON_TIME_PERIOD_MINUTES: "定时任务冲突检测的时间段长度(分钟)。",
|
||||
LLM_BASE_URL: "LLM 服务地址。",
|
||||
LLM_API_KEY: "LLM API Key。",
|
||||
LLM_MODEL: "LLM 模型名。",
|
||||
LLM_TIMEOUT: "LLM 请求超时(秒)。",
|
||||
};
|
||||
// ===== Env 配置渲染(静态行 + 缓存双向同步) =====
|
||||
// 环境变量输入框静态写在 index.html 各页面(data-env 属性),
|
||||
// 这里负责缓存 -> 控件 的同步与控件 -> 缓存 的绑定(仅绑定一次)。
|
||||
function renderModelsConfig() {
|
||||
if (!modelsZoneEl) return;
|
||||
modelsZoneEl.innerHTML = "";
|
||||
|
||||
// 构建 MySQL 连接配置子区块(仅在 STORAGE_BACKEND=mysql 时渲染)
|
||||
function buildMysqlConfigSection() {
|
||||
const section = document.createElement("div");
|
||||
section.className = "env-mysql-section";
|
||||
|
||||
const title = document.createElement("div");
|
||||
title.className = "env-mysql-section-title";
|
||||
title.textContent = "MySQL 连接配置";
|
||||
section.appendChild(title);
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "env-mysql-grid";
|
||||
|
||||
const fields = [
|
||||
{ key: "MYSQL_HOST", label: "主机 (host)", type: "text", title: ENV_DESC.MYSQL_HOST },
|
||||
{ key: "MYSQL_PORT", label: "端口 (port)", type: "number", title: ENV_DESC.MYSQL_PORT },
|
||||
{ key: "MYSQL_USER", label: "用户名 (user)", type: "text", title: ENV_DESC.MYSQL_USER },
|
||||
{ key: "MYSQL_PASSWORD", label: "密码 (password)", type: "password", title: ENV_DESC.MYSQL_PASSWORD },
|
||||
{ key: "MYSQL_DATABASE", label: "数据库 (database)", type: "text", title: ENV_DESC.MYSQL_DATABASE },
|
||||
];
|
||||
|
||||
fields.forEach((f) => {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "env-mysql-field";
|
||||
const lbl = document.createElement("label");
|
||||
lbl.textContent = f.label;
|
||||
if (f.title) lbl.title = f.title;
|
||||
lbl.setAttribute("for", `envMysql_${f.key}`);
|
||||
const inp = document.createElement("input");
|
||||
inp.id = `envMysql_${f.key}`;
|
||||
inp.type = f.type;
|
||||
if (f.type === "number") inp.step = "1";
|
||||
inp.value = state.cachedEnvConfig[f.key] ?? "";
|
||||
inp.addEventListener("input", () => { state.cachedEnvConfig[f.key] = inp.value; });
|
||||
wrap.appendChild(lbl);
|
||||
wrap.appendChild(inp);
|
||||
grid.appendChild(wrap);
|
||||
});
|
||||
|
||||
section.appendChild(grid);
|
||||
return section;
|
||||
}
|
||||
|
||||
/**
|
||||
* 给环境变量行右侧添加 "?" 帮助按钮(点击显示 ENV_DESC 说明的 popover,含连击保护)。
|
||||
* @param {HTMLElement} row - .env-config-item 行容器
|
||||
* @param {string} key - 环境变量名
|
||||
*/
|
||||
let _envHelpDocListenerBound = false;
|
||||
function attachEnvHelp(row, key) {
|
||||
const desc = ENV_DESC[key] || "该环境变量的用途暂无说明";
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "env-help-btn";
|
||||
btn.textContent = "?";
|
||||
btn.title = "查看说明";
|
||||
const pop = document.createElement("div");
|
||||
pop.className = "env-help-popover";
|
||||
pop.textContent = desc;
|
||||
pop.hidden = true;
|
||||
btn.addEventListener("click", withClickGuard((e) => {
|
||||
e.stopPropagation();
|
||||
document.querySelectorAll(".env-help-popover").forEach((p) => { if (p !== pop) p.hidden = true; });
|
||||
pop.hidden = !pop.hidden;
|
||||
}));
|
||||
row.appendChild(btn);
|
||||
row.appendChild(pop);
|
||||
|
||||
if (!_envHelpDocListenerBound) {
|
||||
_envHelpDocListenerBound = true;
|
||||
document.addEventListener("click", (e) => {
|
||||
if (e.target.closest && e.target.closest(".env-help-btn, .env-help-popover")) return;
|
||||
document.querySelectorAll(".env-help-popover").forEach((p) => { p.hidden = true; });
|
||||
});
|
||||
}
|
||||
return btn;
|
||||
}
|
||||
|
||||
function renderEnvConfig() {
|
||||
if (!envListEl) return;
|
||||
envListEl.innerHTML = "";
|
||||
|
||||
// 模型管理区
|
||||
const modelsSection = document.createElement("div");
|
||||
modelsSection.style.cssText = "margin-bottom:1.5rem;";
|
||||
|
||||
const modelsHeader = document.createElement("div");
|
||||
modelsHeader.className = "settings-section-title";
|
||||
const modelsTitle = document.createElement("h4");
|
||||
modelsTitle.textContent = "LLM \u6A21\u578B\u5217\u8868";
|
||||
// 模型管理工具栏(添加按钮)
|
||||
const toolbar = document.createElement("div");
|
||||
toolbar.className = "settings-toolbar";
|
||||
const addModelBtn = document.createElement("button");
|
||||
addModelBtn.type = "button";
|
||||
addModelBtn.className = "btn btn-primary";
|
||||
addModelBtn.textContent = "+ \u6DFB\u52A0\u6A21\u578B";
|
||||
addModelBtn.textContent = "+ 添加模型";
|
||||
addModelBtn.addEventListener("click", () => openModelModal(-1));
|
||||
modelsHeader.appendChild(modelsTitle);
|
||||
modelsHeader.appendChild(addModelBtn);
|
||||
modelsSection.appendChild(modelsHeader);
|
||||
toolbar.appendChild(addModelBtn);
|
||||
modelsZoneEl.appendChild(toolbar);
|
||||
|
||||
if (state.cachedModels.length === 0) {
|
||||
const emptyHint = document.createElement("p");
|
||||
emptyHint.style.cssText = "color:#94a3b8;font-size:0.8rem;";
|
||||
emptyHint.textContent = "\u6682\u65E0\u6A21\u578B\uFF0C\u8BF7\u6DFB\u52A0\u81F3\u5C11\u4E00\u4E2A LLM \u6A21\u578B\u3002";
|
||||
modelsSection.appendChild(emptyHint);
|
||||
emptyHint.textContent = "暂无模型,请添加至少一个 LLM 模型。";
|
||||
modelsZoneEl.appendChild(emptyHint);
|
||||
} else {
|
||||
const table = document.createElement("div");
|
||||
table.className = "model-table";
|
||||
const thead = document.createElement("div");
|
||||
thead.className = "model-table-head";
|
||||
thead.innerHTML = '<span class="col-name">\u540D\u79F0</span><span class="col-model">Model</span><span class="col-url">Base URL</span><span class="col-actions" style="justify-content:flex-end;color:inherit;font-size:inherit;">\u64CD\u4F5C</span>';
|
||||
thead.innerHTML = '<span class="col-name">名称</span><span class="col-model">Model</span><span class="col-url">Base URL</span><span class="col-actions" style="justify-content:flex-end;color:inherit;font-size:inherit;">操作</span>';
|
||||
table.appendChild(thead);
|
||||
state.cachedModels.forEach((m, mi) => {
|
||||
const row = document.createElement("div");
|
||||
@@ -770,19 +659,19 @@ function renderEnvConfig() {
|
||||
actions.className = "col-actions";
|
||||
const editBtn = document.createElement("button");
|
||||
editBtn.type = "button";
|
||||
editBtn.textContent = "\u7F16\u8F91";
|
||||
editBtn.textContent = "编辑";
|
||||
editBtn.addEventListener("click", () => openModelModal(mi));
|
||||
const delBtn = document.createElement("button");
|
||||
delBtn.type = "button";
|
||||
delBtn.className = "danger";
|
||||
delBtn.textContent = "\u5220\u9664";
|
||||
delBtn.textContent = "删除";
|
||||
delBtn.addEventListener("click", async () => {
|
||||
if (!await showConfirm(`\u786E\u5B9A\u5220\u9664\u6A21\u578B "${m.name}"\uFF1F`)) return;
|
||||
if (!await showConfirm(`确定删除模型 "${m.name}"?`)) return;
|
||||
state.cachedModels.splice(mi, 1);
|
||||
state.cachedAgentConfigs.forEach((a) => { if (a.llm_model_id === m.id) a.llm_model_id = ""; });
|
||||
api("/api/config/models", { method: "POST", body: JSON.stringify({ models: state.cachedModels }) }).catch(() => {});
|
||||
api("/api/config/reload", { method: "POST", body: JSON.stringify({}) }).catch(() => {});
|
||||
renderEnvConfig();
|
||||
renderModelsConfig();
|
||||
renderAgentConfigs();
|
||||
});
|
||||
actions.appendChild(editBtn);
|
||||
@@ -790,9 +679,8 @@ function renderEnvConfig() {
|
||||
row.appendChild(actions);
|
||||
table.appendChild(row);
|
||||
});
|
||||
modelsSection.appendChild(table);
|
||||
modelsZoneEl.appendChild(table);
|
||||
}
|
||||
envListEl.appendChild(modelsSection);
|
||||
|
||||
// 模型编辑弹窗
|
||||
const modalOverlay = document.createElement("div");
|
||||
@@ -800,7 +688,7 @@ function renderEnvConfig() {
|
||||
modalOverlay.addEventListener("click", (e) => { if (e.target === modalOverlay) closeModelModal(); });
|
||||
const modalBox = document.createElement("div");
|
||||
modalBox.className = "model-modal-box";
|
||||
modalBox.innerHTML = '<h3>\u6A21\u578B\u914D\u7F6E</h3>';
|
||||
modalBox.innerHTML = '<h3>模型配置</h3>';
|
||||
const modalBody = document.createElement("div");
|
||||
modalBody.className = "model-modal-body";
|
||||
const modalFields = {};
|
||||
@@ -808,7 +696,7 @@ function renderEnvConfig() {
|
||||
const div = document.createElement("div");
|
||||
div.className = "modal-field";
|
||||
const lbl = document.createElement("label");
|
||||
lbl.textContent = { name: "\u663E\u793A\u540D\u79F0", model: "\u6A21\u578B\u540D (model)", api_key: "API Key", base_url: "Base URL", timeout: "Timeout (\u79D2)" }[key];
|
||||
lbl.textContent = { name: "显示名称", model: "模型名 (model)", api_key: "API Key", base_url: "Base URL", timeout: "Timeout (秒)" }[key];
|
||||
const inp = document.createElement("input");
|
||||
inp.type = key === "timeout" ? "number" : key === "api_key" ? "password" : "text";
|
||||
if (key === "timeout") inp.step = "any";
|
||||
@@ -817,7 +705,7 @@ function renderEnvConfig() {
|
||||
modalFields[key] = inp;
|
||||
modalBody.appendChild(div);
|
||||
});
|
||||
// 深度思考改由 composer 的思考档位(low/high/xhigh/max/ultra)全局控制,此处不再提供开关
|
||||
// 深度思考改由 composer 的思考档位(low/high/xhigh/max/ultra,会话级)控制,此处不再提供开关
|
||||
modalBody.appendChild(document.createComment("thinking-toggle-removed"));
|
||||
modalBox.appendChild(modalBody);
|
||||
const modalActions = document.createElement("div");
|
||||
@@ -825,12 +713,12 @@ function renderEnvConfig() {
|
||||
const cancelBtn = document.createElement("button");
|
||||
cancelBtn.type = "button";
|
||||
cancelBtn.className = "btn btn-secondary";
|
||||
cancelBtn.textContent = "\u53D6\u6D88";
|
||||
cancelBtn.textContent = "取消";
|
||||
cancelBtn.addEventListener("click", closeModelModal);
|
||||
const saveBtn = document.createElement("button");
|
||||
saveBtn.type = "button";
|
||||
saveBtn.className = "btn btn-primary";
|
||||
saveBtn.textContent = "\u4FDD\u5B58";
|
||||
saveBtn.textContent = "保存";
|
||||
saveBtn.addEventListener("click", async () => {
|
||||
const data = {
|
||||
id: state.editingModelIdx >= 0 && state.cachedModels[state.editingModelIdx] ? state.cachedModels[state.editingModelIdx].id : makeId(),
|
||||
@@ -841,7 +729,7 @@ function renderEnvConfig() {
|
||||
timeout: parseFloat(modalFields.timeout.value) || 60,
|
||||
max_retries: 0,
|
||||
};
|
||||
if (!data.name || !data.model) { showToast("\u540D\u79F0\u548C\u6A21\u578B\u540D\u4E0D\u80FD\u4E3A\u7A7A"); return; }
|
||||
if (!data.name || !data.model) { showToast("名称和模型名不能为空"); return; }
|
||||
if (state.editingModelIdx >= 0) {
|
||||
state.cachedModels[state.editingModelIdx] = data;
|
||||
} else {
|
||||
@@ -850,161 +738,201 @@ function renderEnvConfig() {
|
||||
await api("/api/config/models", { method: "POST", body: JSON.stringify({ models: state.cachedModels }) });
|
||||
await api("/api/config/reload", { method: "POST", body: JSON.stringify({}) });
|
||||
closeModelModal();
|
||||
renderEnvConfig();
|
||||
renderModelsConfig();
|
||||
renderAgentConfigs();
|
||||
import('../app.js').then((m) => { if (m.refreshModelSelect) m.refreshModelSelect(); }).catch(() => {});
|
||||
setStatus("\u6A21\u578B\u5DF2\u4FDD\u5B58", "success");
|
||||
setStatus("模型已保存", "success");
|
||||
});
|
||||
modalActions.appendChild(cancelBtn);
|
||||
modalActions.appendChild(saveBtn);
|
||||
modalBox.appendChild(modalActions);
|
||||
modalOverlay.appendChild(modalBox);
|
||||
envListEl.appendChild(modalOverlay);
|
||||
modelsZoneEl.appendChild(modalOverlay);
|
||||
|
||||
window._modelModalOverlay = modalOverlay;
|
||||
window._modelModalFields = modalFields;
|
||||
}
|
||||
|
||||
const divider = document.createElement("div");
|
||||
divider.style.cssText = "border-top:1px solid rgba(148,163,184,0.25);margin:1rem 0;";
|
||||
envListEl.appendChild(divider);
|
||||
let _envControlsBound = false;
|
||||
|
||||
const otherTitle = document.createElement("h4");
|
||||
otherTitle.textContent = "\u5176\u4ED6\u73AF\u5883\u53D8\u91CF";
|
||||
otherTitle.style.cssText = "margin:0 0 0.65rem;font-size:0.92rem;font-weight:700;color:#1e293b;";
|
||||
envListEl.appendChild(otherTitle);
|
||||
function bindEnvControls() {
|
||||
if (_envControlsBound) return;
|
||||
_envControlsBound = true;
|
||||
|
||||
const DIR_PICKER_KEYS = new Set(["WORKING_DIR", "WORKSPACE_DIR"]);
|
||||
const FILE_PICKER_KEYS = new Set(["USER_PYTHON_PATH"]);
|
||||
|
||||
const entries = Object.entries(state.cachedEnvConfig);
|
||||
entries.forEach(([key, val]) => {
|
||||
// MYSQL_* 由存储后端区块统一渲染(仅选 mysql 时显示)
|
||||
if (key.startsWith("MYSQL_")) return;
|
||||
|
||||
const row = document.createElement("div");
|
||||
row.className = "env-config-item";
|
||||
const lbl = document.createElement("label");
|
||||
lbl.textContent = key;
|
||||
if (ENV_DESC[key]) lbl.title = ENV_DESC[key];
|
||||
|
||||
// 定时任务存储后端:用下拉选择而非纯文本框
|
||||
if (key === "STORAGE_BACKEND") {
|
||||
lbl.textContent = "Storage Backend";
|
||||
if (ENV_DESC.STORAGE_BACKEND) lbl.title = ENV_DESC.STORAGE_BACKEND;
|
||||
const sel = document.createElement("select");
|
||||
const optJson = document.createElement("option");
|
||||
optJson.value = "json"; optJson.textContent = "JSON(本地文件)";
|
||||
const optMysql = document.createElement("option");
|
||||
optMysql.value = "mysql"; optMysql.textContent = "MySQL(数据库)";
|
||||
sel.appendChild(optJson);
|
||||
sel.appendChild(optMysql);
|
||||
sel.value = (val === "mysql") ? "mysql" : "json";
|
||||
sel.addEventListener("change", () => {
|
||||
state.cachedEnvConfig[key] = sel.value;
|
||||
// 重新渲染以显隐 MySQL 连接配置区块
|
||||
renderEnvConfig();
|
||||
});
|
||||
row.appendChild(lbl);
|
||||
row.appendChild(sel);
|
||||
attachEnvHelp(row, key);
|
||||
// 选 mysql 时紧随其后渲染连接配置区块
|
||||
if (sel.value === "mysql") {
|
||||
const mysqlSection = buildMysqlConfigSection();
|
||||
row.appendChild(mysqlSection);
|
||||
}
|
||||
envListEl.appendChild(row);
|
||||
return;
|
||||
}
|
||||
|
||||
// 定时任务时间段长度:数字输入 + 「应用」按钮(热更新调度器 + 持久化)
|
||||
if (key === "CRON_TIME_PERIOD_MINUTES") {
|
||||
lbl.textContent = "Cron Period (minutes)";
|
||||
if (ENV_DESC.CRON_TIME_PERIOD_MINUTES) lbl.title = ENV_DESC.CRON_TIME_PERIOD_MINUTES;
|
||||
const wrap = document.createElement("div");
|
||||
wrap.style.cssText = "display:flex;gap:0.5rem;align-items:center;flex:1;";
|
||||
const numInp = document.createElement("input");
|
||||
numInp.type = "number";
|
||||
numInp.step = "1";
|
||||
numInp.min = "1";
|
||||
numInp.style.cssText = "flex:1;";
|
||||
numInp.value = val ?? 30;
|
||||
numInp.addEventListener("input", () => { state.cachedEnvConfig[key] = numInp.value; });
|
||||
const applyBtn = document.createElement("button");
|
||||
applyBtn.type = "button";
|
||||
applyBtn.className = "btn btn-primary";
|
||||
applyBtn.style.cssText = "white-space:nowrap;";
|
||||
applyBtn.textContent = "应用";
|
||||
applyBtn.addEventListener("click", async () => {
|
||||
const v = parseFloat(numInp.value);
|
||||
if (!(v > 0)) { showToast("时间段长度必须为正数"); return; }
|
||||
try {
|
||||
const res = await api("/api/cron/period", { method: "POST", body: JSON.stringify({ period_minutes: v }) });
|
||||
state.cachedEnvConfig[key] = String(res.period_minutes);
|
||||
numInp.value = res.period_minutes;
|
||||
showToast(`时间段长度已设为 ${res.period_minutes} 分钟`);
|
||||
} catch (e) {
|
||||
showToast("应用失败: " + (e.message || e));
|
||||
}
|
||||
});
|
||||
wrap.appendChild(numInp);
|
||||
wrap.appendChild(applyBtn);
|
||||
row.appendChild(lbl);
|
||||
row.appendChild(wrap);
|
||||
attachEnvHelp(row, key);
|
||||
envListEl.appendChild(row);
|
||||
return;
|
||||
}
|
||||
|
||||
const inp = document.createElement("input");
|
||||
inp.type = "text";
|
||||
inp.value = val ?? "";
|
||||
// data-env 常规输入框:输入即写入缓存
|
||||
document.querySelectorAll("[data-env]").forEach((inp) => {
|
||||
const key = inp.getAttribute("data-env");
|
||||
inp.addEventListener("input", () => { state.cachedEnvConfig[key] = inp.value; });
|
||||
row.appendChild(lbl);
|
||||
row.appendChild(inp);
|
||||
|
||||
if (DIR_PICKER_KEYS.has(key)) {
|
||||
const dirBtn = document.createElement("button");
|
||||
dirBtn.type = "button";
|
||||
dirBtn.className = "btn btn-ghost dir-pick-btn";
|
||||
dirBtn.textContent = "\u9009\u62E9";
|
||||
dirBtn.title = "\u9009\u62E9\u6587\u4EF6\u5939";
|
||||
dirBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
const res = await fetch("/api/pick-folder");
|
||||
const data = await res.json();
|
||||
if (data.path) {
|
||||
inp.value = data.path;
|
||||
state.cachedEnvConfig[key] = data.path;
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
row.appendChild(dirBtn);
|
||||
}
|
||||
|
||||
if (FILE_PICKER_KEYS.has(key)) {
|
||||
const fileBtn = document.createElement("button");
|
||||
fileBtn.type = "button";
|
||||
fileBtn.className = "btn btn-ghost dir-pick-btn";
|
||||
fileBtn.textContent = "\u9009\u62E9";
|
||||
fileBtn.title = "\u9009\u62E9 Python \u53EF\u6267\u884C\u6587\u4EF6";
|
||||
fileBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
if (window.electronAPI && window.electronAPI.pickPython) {
|
||||
const res = await window.electronAPI.pickPython();
|
||||
if (res.path) {
|
||||
inp.value = res.path;
|
||||
state.cachedEnvConfig[key] = res.path;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
row.appendChild(fileBtn);
|
||||
}
|
||||
|
||||
attachEnvHelp(row, key);
|
||||
|
||||
envListEl.appendChild(row);
|
||||
});
|
||||
|
||||
// 存储后端下拉:同步缓存 + 联动存储数据库类型(mysql/sqlite)+ 显隐配置块
|
||||
const storageSel = $("storageBackendSelect");
|
||||
if (storageSel) {
|
||||
storageSel.addEventListener("change", () => {
|
||||
state.cachedEnvConfig.STORAGE_BACKEND = storageSel.value;
|
||||
if (storageSel.value === "mysql" || storageSel.value === "sqlite") {
|
||||
state.cachedEnvConfig.STORAGE_DB_TYPE = storageSel.value;
|
||||
}
|
||||
renderEnvConfig();
|
||||
});
|
||||
}
|
||||
|
||||
// 工具数据库类型下拉:同步缓存 + 显隐字段组
|
||||
const toolDbSel = $("toolDbTypeSelect");
|
||||
if (toolDbSel) {
|
||||
toolDbSel.addEventListener("change", () => {
|
||||
state.cachedEnvConfig.TOOL_DB_TYPE = toolDbSel.value;
|
||||
renderEnvConfig();
|
||||
});
|
||||
}
|
||||
|
||||
// 搜索引擎下拉(仅支持固定引擎)
|
||||
const engineSel = $("searchEngineSelect");
|
||||
if (engineSel) {
|
||||
engineSel.addEventListener("change", () => { state.cachedEnvConfig.WEB_SEARCH_ENGINE = engineSel.value; });
|
||||
}
|
||||
|
||||
// 邮箱地址:用户名 + 域名下拉(+自定义域名)组合为 EMAIL_ADDRESS
|
||||
const emailPrefix = $("emailPrefixInput");
|
||||
const emailDomain = $("emailDomainSelect");
|
||||
const emailCustom = $("emailCustomDomain");
|
||||
const commitEmail = () => {
|
||||
let dom = emailDomain ? emailDomain.value : "";
|
||||
if (dom === "__custom__") dom = (emailCustom && emailCustom.value.trim()) || EMAIL_DOMAINS[0];
|
||||
state.cachedEnvConfig.EMAIL_ADDRESS = `${(emailPrefix && emailPrefix.value.trim()) || ""}@${dom || EMAIL_DOMAINS[0]}`;
|
||||
};
|
||||
if (emailPrefix) emailPrefix.addEventListener("input", commitEmail);
|
||||
if (emailDomain) emailDomain.addEventListener("change", () => {
|
||||
if (emailCustom) emailCustom.hidden = emailDomain.value !== "__custom__";
|
||||
commitEmail();
|
||||
});
|
||||
if (emailCustom) emailCustom.addEventListener("input", commitEmail);
|
||||
|
||||
// Python 解释器选择按钮(Electron 环境)
|
||||
const pickBtn = $("pickPythonBtn");
|
||||
if (pickBtn) {
|
||||
pickBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
if (window.electronAPI && window.electronAPI.pickPython) {
|
||||
const res = await window.electronAPI.pickPython();
|
||||
if (res.path) {
|
||||
state.cachedEnvConfig.USER_PYTHON_PATH = res.path;
|
||||
renderEnvConfig();
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
}
|
||||
|
||||
// 定时任务时段长度「应用」按钮(热更新调度器 + 持久化)
|
||||
const cronApply = $("cronPeriodApplyBtn");
|
||||
if (cronApply) {
|
||||
cronApply.addEventListener("click", async () => {
|
||||
const inp = document.querySelector("[data-env=CRON_TIME_PERIOD_MINUTES]");
|
||||
const v = parseFloat(inp ? inp.value : "");
|
||||
if (!(v > 0)) { showToast("时间段长度必须为正数"); return; }
|
||||
try {
|
||||
const res = await api("/api/cron/period", { method: "POST", body: JSON.stringify({ period_minutes: v }) });
|
||||
state.cachedEnvConfig.CRON_TIME_PERIOD_MINUTES = String(res.period_minutes);
|
||||
renderEnvConfig();
|
||||
showToast(`时间段长度已设为 ${res.period_minutes} 分钟`);
|
||||
} catch (e) {
|
||||
showToast("应用失败: " + (e.message || e));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存 -> 控件 同步:将 cachedEnvConfig 的值填回各页面的静态输入框/下拉框,
|
||||
* 并处理组合控件(邮箱)与条件显隐(MySQL 区块)。
|
||||
*/
|
||||
function renderEnvConfig() {
|
||||
bindEnvControls();
|
||||
const env = state.cachedEnvConfig;
|
||||
|
||||
document.querySelectorAll("[data-env]").forEach((inp) => {
|
||||
const key = inp.getAttribute("data-env");
|
||||
if (key in env && document.activeElement !== inp) {
|
||||
inp.value = env[key] ?? "";
|
||||
}
|
||||
});
|
||||
|
||||
// 存储后端回显(json/mysql/sqlite 三态)+ 存储数据库配置块显隐
|
||||
const storageSel = $("storageBackendSelect");
|
||||
if (storageSel) {
|
||||
const backend = (env.STORAGE_BACKEND === "mysql" || env.STORAGE_BACKEND === "sqlite")
|
||||
? env.STORAGE_BACKEND : "json";
|
||||
storageSel.value = backend;
|
||||
const storageDbSection = $("storageDbSection");
|
||||
if (storageDbSection) storageDbSection.hidden = backend === "json";
|
||||
// 存储数据库类型:随后端联动(mysql 后端 → mysql,sqlite 后端 → sqlite)
|
||||
const storageDbType = env.STORAGE_DB_TYPE === "sqlite" || backend === "sqlite" ? "sqlite" : "mysql";
|
||||
const storageDbMysql = $("storageDbMysqlFields");
|
||||
const storageDbSqlite = $("storageDbSqliteFields");
|
||||
if (storageDbMysql) storageDbMysql.hidden = storageDbType !== "mysql";
|
||||
if (storageDbSqlite) storageDbSqlite.hidden = storageDbType !== "sqlite";
|
||||
const storageTitle = $("storageDbTypeTitle");
|
||||
if (storageTitle) storageTitle.textContent = storageDbType === "sqlite" ? "SQLite 连接配置" : "MySQL 连接配置";
|
||||
}
|
||||
|
||||
// 工具数据库配置:类型回显 + 字段组显隐
|
||||
const toolDbSel = $("toolDbTypeSelect");
|
||||
if (toolDbSel) {
|
||||
toolDbSel.value = env.TOOL_DB_TYPE === "sqlite" ? "sqlite" : "mysql";
|
||||
}
|
||||
const toolDbMysql = $("toolDbMysqlFields");
|
||||
const toolDbSqlite = $("toolDbSqliteFields");
|
||||
const toolDbType = (toolDbSel && toolDbSel.value) || "mysql";
|
||||
if (toolDbMysql) toolDbMysql.hidden = toolDbType !== "mysql";
|
||||
if (toolDbSqlite) toolDbSqlite.hidden = toolDbType !== "sqlite";
|
||||
|
||||
// 旧 MYSQL_* 值预填显示(仅展示辅助迁移:新键为空时显示旧值;
|
||||
// 编辑时写入新键 TOOL_DB_*/STORAGE_DB_*,运行时零耦合)
|
||||
const legacyDbMap = {
|
||||
STORAGE_DB_HOST: "MYSQL_HOST", STORAGE_DB_PORT: "MYSQL_PORT",
|
||||
STORAGE_DB_USER: "MYSQL_USER", STORAGE_DB_PASSWORD: "MYSQL_PASSWORD",
|
||||
STORAGE_DB_DATABASE: "MYSQL_DATABASE",
|
||||
TOOL_DB_HOST: "MYSQL_HOST", TOOL_DB_PORT: "MYSQL_PORT",
|
||||
TOOL_DB_USER: "MYSQL_USER", TOOL_DB_PASSWORD: "MYSQL_PASSWORD",
|
||||
TOOL_DB_DATABASE: "MYSQL_DATABASE",
|
||||
};
|
||||
Object.entries(legacyDbMap).forEach(([key, legacy]) => {
|
||||
const inp = document.querySelector(`[data-env="${key}"]`);
|
||||
if (inp && !String(env[key] ?? "").trim() && String(env[legacy] ?? "").trim() && document.activeElement !== inp) {
|
||||
inp.value = env[legacy];
|
||||
}
|
||||
});
|
||||
|
||||
const engineSel = $("searchEngineSelect");
|
||||
if (engineSel) {
|
||||
const cur = String(env.WEB_SEARCH_ENGINE || "tavily");
|
||||
if ([...engineSel.options].some((o) => o.value === cur)) {
|
||||
engineSel.value = cur;
|
||||
} else {
|
||||
engineSel.value = "tavily";
|
||||
}
|
||||
}
|
||||
|
||||
const emailPrefix = $("emailPrefixInput");
|
||||
const emailDomain = $("emailDomainSelect");
|
||||
const emailCustom = $("emailCustomDomain");
|
||||
if (emailPrefix && emailDomain) {
|
||||
const value = String(env.EMAIL_ADDRESS || "");
|
||||
const atIdx = value.indexOf("@");
|
||||
const prefix = atIdx >= 0 ? value.slice(0, atIdx) : value;
|
||||
const domain = atIdx >= 0 ? value.slice(atIdx + 1) : "";
|
||||
emailPrefix.value = prefix;
|
||||
if (domain && EMAIL_DOMAINS.includes(domain)) {
|
||||
emailDomain.value = domain;
|
||||
} else if (domain) {
|
||||
emailDomain.value = "__custom__";
|
||||
if (emailCustom) emailCustom.value = domain;
|
||||
} else {
|
||||
emailDomain.value = EMAIL_DOMAINS[0];
|
||||
}
|
||||
if (emailCustom) emailCustom.hidden = emailDomain.value !== "__custom__";
|
||||
}
|
||||
}
|
||||
|
||||
function openModelModal(idx) {
|
||||
@@ -1054,8 +982,11 @@ if (addAgentBtn) {
|
||||
}
|
||||
|
||||
// 设置面板事件绑定
|
||||
// 打开设置:顶栏按钮(历史兼容)与左栏用户区设置按钮
|
||||
const settingsBtn = $("settingsBtn");
|
||||
if (settingsBtn) settingsBtn.addEventListener("click", openSettings);
|
||||
const sidebarSettingsBtn = $("sidebarSettingsBtn");
|
||||
if (sidebarSettingsBtn) sidebarSettingsBtn.addEventListener("click", openSettings);
|
||||
if (settingsCloseBtn) settingsCloseBtn.addEventListener("click", closeSettings);
|
||||
if (settingsCancelBtn) settingsCancelBtn.addEventListener("click", closeSettings);
|
||||
if (settingsOverlay) {
|
||||
@@ -1117,7 +1048,7 @@ document.querySelectorAll(".settings-save-btn").forEach((btn) => {
|
||||
const mod = await import("./sessions.js");
|
||||
if (mod && typeof mod.bootstrap === "function") {
|
||||
await mod.bootstrap();
|
||||
const label = newBackend === "mysql" ? "MySQL(数据库)" : "JSON(本地文件)";
|
||||
const label = newBackend === "mysql" ? "MySQL(数据库)" : newBackend === "sqlite" ? "SQLite(本地数据库文件)" : "JSON(本地文件)";
|
||||
setStatus(`存储后端已切换为 ${label},会话列表已刷新。`, "success");
|
||||
return;
|
||||
}
|
||||
@@ -1274,6 +1205,54 @@ function renderThemePanel() {
|
||||
};
|
||||
}
|
||||
|
||||
// 头像设置
|
||||
const avatarInput = $("avatarInput");
|
||||
const avatarApplyBtn = $("avatarApplyBtn");
|
||||
const avatarResetBtn = $("avatarResetBtn");
|
||||
const avatarStatus = $("avatarPreviewStatus");
|
||||
const avatarPickerBtn = $("avatarPickerBtn");
|
||||
const avatarFileInput = $("avatarFileInput");
|
||||
if (avatarInput) {
|
||||
avatarInput.value = getAvatar();
|
||||
const applyAvatarSetting = () => {
|
||||
const url = avatarInput.value.trim();
|
||||
setAvatar(url);
|
||||
if (avatarStatus) {
|
||||
avatarStatus.textContent = url ? "头像已更新 ✓" : "已恢复默认头像";
|
||||
avatarStatus.style.color = url ? "#22c55e" : "#6a6a6a";
|
||||
}
|
||||
};
|
||||
if (avatarApplyBtn) avatarApplyBtn.onclick = applyAvatarSetting;
|
||||
if (avatarResetBtn) {
|
||||
avatarResetBtn.onclick = () => {
|
||||
avatarInput.value = "";
|
||||
applyAvatarSetting();
|
||||
};
|
||||
}
|
||||
avatarInput.onkeydown = (e) => { if (e.key === 'Enter') applyAvatarSetting(); };
|
||||
|
||||
if (avatarPickerBtn && avatarFileInput) {
|
||||
avatarPickerBtn.onclick = () => avatarFileInput.click();
|
||||
avatarFileInput.onchange = (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
if (window.electronAPI && window.electronAPI.getFilePath) {
|
||||
avatarInput.value = window.electronAPI.getFilePath(file) || "";
|
||||
applyAvatarSetting();
|
||||
} else {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
avatarInput.value = event.target?.result || "";
|
||||
applyAvatarSetting();
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
avatarFileInput.value = "";
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 字体设置
|
||||
const fontSelect = $("fontFamilySelect");
|
||||
const fontSizeSelect = $("fontSizeSelect");
|
||||
@@ -1322,4 +1301,4 @@ async function refreshNickname() {
|
||||
}
|
||||
}
|
||||
|
||||
export { openSettings, closeSettings, loadAllConfigs, renderAgentConfigs, renderToolConfigs, renderGuiConfig, renderEnvConfig, openModelModal, closeModelModal, makeField, getModelLabel, buildAgentCard, buildToolCard, renderThemePanel };
|
||||
export { openSettings, closeSettings, loadAllConfigs, renderAgentConfigs, renderToolConfigs, renderGuiConfig, renderModelsConfig, renderEnvConfig, openModelModal, closeModelModal, makeField, getModelLabel, buildAgentCard, buildToolCard, renderThemePanel };
|
||||
@@ -16,6 +16,10 @@ export const state = {
|
||||
sessionId: "",
|
||||
/** @type {string[]} */
|
||||
sessions: [],
|
||||
/** @type {Object<string,string>} 会话ID → 所属工作区路径(会话列表按工作区分组) */
|
||||
sessionWorkspaces: {},
|
||||
/** @type {Object<string,string>} 文件相对路径 → git 状态(untracked|modified|deleted,文件树着色) */
|
||||
gitStatus: {},
|
||||
/** @type {File[]} */
|
||||
pendingFiles: [],
|
||||
pendingRefs: [], // Edit 模式引用:[{path, name, startLine, endLine, text}]
|
||||
@@ -84,6 +88,7 @@ export const state = {
|
||||
workspacePath: "", // 当前选中的工作空间路径(空=使用默认)
|
||||
workspaceList: [], // 已保存的工作空间路径列表
|
||||
workspaceDefault: "", // 默认工作空间路径
|
||||
forbiddenWorkspaces: [], // 禁止作为工作区的目录(如桌面),后端返回
|
||||
accessMode: "restricted", // 工作空间访问模式:restricted(限制访问) | approval(权限审查) | full(完全访问)
|
||||
|
||||
// Edit 模式
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
// terminal.js -- 内置 PowerShell 终端(原生管道,多标签)
|
||||
// 主进程 spawn powershell.exe,stdin/stdout 经 IPC 桥接;前端自绘终端 UI。
|
||||
// 面板折叠不 kill(仅隐藏);标签 x 单个 kill;右上角 x 确认后 kill 全部。
|
||||
// 颜色全部映射主题 CSS 变量,随主题变化。
|
||||
|
||||
import { $, showToast } from './utils.js';
|
||||
import { showConfirm } from './dialog.js';
|
||||
import { state } from './state.js';
|
||||
|
||||
/** 终端实例:{ id(主进程), title, cwd, pane, outEl, alive } */
|
||||
let terminals = [];
|
||||
let activeId = null;
|
||||
let _dockVisible = false;
|
||||
|
||||
// ---------- ANSI 解析(CSI SGR 颜色 → 主题色 class;其余控制序列忽略) ----------
|
||||
const ANSI_FG = {
|
||||
'0': '', '30': 't-fg-muted', '31': 't-fg-danger', '32': 't-fg-success',
|
||||
'33': 't-fg-warning', '34': 't-fg-accent', '35': 't-fg-purple', '36': 't-fg-cyan',
|
||||
'37': '', '90': 't-fg-muted', '91': 't-fg-danger', '92': 't-fg-success',
|
||||
'93': 't-fg-warning', '94': 't-fg-accent', '95': 't-fg-purple', '96': 't-fg-cyan', '97': '',
|
||||
};
|
||||
const ANSI_BG = {
|
||||
'40': 't-bg-dark', '41': 't-bg-danger', '42': 't-bg-success', '43': 't-bg-warning',
|
||||
'44': 't-bg-accent', '47': '',
|
||||
};
|
||||
|
||||
/** 把含 ANSI 序列的文本转为安全的 HTML 片段(SGR 颜色映射,其他 CSI 忽略) */
|
||||
export function ansiToHtml(text) {
|
||||
let html = '';
|
||||
let i = 0;
|
||||
let curCls = '';
|
||||
const n = text.length;
|
||||
while (i < n) {
|
||||
const ch = text[i];
|
||||
if (ch === '\x1b' && text[i + 1] === '[') {
|
||||
// CSI: \x1b[ ... 最终字符(A-Z 或 @-~)
|
||||
let j = i + 2;
|
||||
while (j < n && !/[A-Za-z@-~]/.test(text[j])) j++;
|
||||
if (j < n) {
|
||||
const params = text.slice(i + 2, j);
|
||||
const final = text[j];
|
||||
if (final === 'm') {
|
||||
// SGR:最后一个参数决定样式
|
||||
const codes = params.split(';').filter(Boolean);
|
||||
const last = codes[codes.length - 1] || '0';
|
||||
if (last === '0') {
|
||||
curCls = '';
|
||||
} else if (ANSI_FG[last] !== undefined) {
|
||||
curCls = ANSI_FG[last];
|
||||
} else if (ANSI_BG[last] !== undefined) {
|
||||
curCls = ANSI_BG[last];
|
||||
} else {
|
||||
// 亮度/其他:忽略
|
||||
}
|
||||
}
|
||||
// 其余 CSI(光标移动等)忽略
|
||||
i = j + 1;
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\r') {
|
||||
i += 1;
|
||||
continue; // \r\n 中的 \r 忽略,单独 \r 不处理(简化)
|
||||
}
|
||||
if (ch === '\n') {
|
||||
html += '</span>\n';
|
||||
curCls = ''; // 换行后颜色状态简化重置(避免跨行 span 混乱)
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
const esc = ch === '&' ? '&' : ch === '<' ? '<' : ch === '>' ? '>' : ch;
|
||||
html += curCls ? `<span class="${curCls}">${esc}</span>` : esc;
|
||||
i += 1;
|
||||
}
|
||||
if (curCls) html += '</span>';
|
||||
return html;
|
||||
}
|
||||
|
||||
// ---------- 终端实例管理 ----------
|
||||
function currentCwd() {
|
||||
const p = state.workspacePath || state.workspaceDefault;
|
||||
return p || '';
|
||||
}
|
||||
|
||||
function wsName() {
|
||||
const p = state.workspacePath || state.workspaceDefault;
|
||||
if (!p) return 'workspace';
|
||||
const parts = p.replace(/[\\/]+$/, '').split(/[\\/]/);
|
||||
return parts[parts.length - 1] || 'workspace';
|
||||
}
|
||||
|
||||
function ensureElectron() {
|
||||
if (!window.electronAPI?.spawnTerminal) {
|
||||
showToast('终端仅桌面版(Electron)可用');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 新建一个 PowerShell 终端标签 */
|
||||
export async function spawnTerminal() {
|
||||
if (!ensureElectron()) return null;
|
||||
try {
|
||||
const cwd = currentCwd();
|
||||
const { id, ok, error } = await window.electronAPI.spawnTerminal(cwd);
|
||||
if (!ok) {
|
||||
showToast('终端启动失败: ' + (error || '未知错误'));
|
||||
return null;
|
||||
}
|
||||
// 渲染标签 + 输出面板
|
||||
const tab = document.createElement('div');
|
||||
tab.className = 'term-tab';
|
||||
tab.dataset.tid = String(id);
|
||||
tab.innerHTML = `<span class="term-tab-title">PowerShell <span class="term-tab-ws">${escapeHtml(wsName())}</span></span>`;
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.type = 'button';
|
||||
closeBtn.className = 'term-tab-close';
|
||||
closeBtn.title = '关闭此终端(终止进程)';
|
||||
closeBtn.textContent = '\u2715';
|
||||
closeBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
killTerminal(id);
|
||||
});
|
||||
tab.appendChild(closeBtn);
|
||||
tab.addEventListener('click', () => activateTerminal(id));
|
||||
// 标签插到 + 号之前,保证 [标签…][+] 在左、[×] 在右的布局
|
||||
const tabsBar = $('terminalTabs');
|
||||
const addBtn = tabsBar.querySelector('.term-tab-add');
|
||||
if (addBtn) tabsBar.insertBefore(tab, addBtn);
|
||||
else tabsBar.appendChild(tab);
|
||||
|
||||
const pane = document.createElement('div');
|
||||
pane.className = 'term-pane';
|
||||
pane.dataset.tid = String(id);
|
||||
const outEl = document.createElement('div');
|
||||
outEl.className = 'term-out';
|
||||
pane.appendChild(outEl);
|
||||
$('terminalBody').appendChild(pane);
|
||||
|
||||
terminals.push({ id, tab, pane, outEl, alive: true });
|
||||
activateTerminal(id);
|
||||
// 首次 spawn 时输出欢迎提示
|
||||
appendOutput(id, `PowerShell 已启动${cwd ? `(工作区: ${cwd})` : ''}\r\n`);
|
||||
return id;
|
||||
} catch (e) {
|
||||
showToast('终端启动失败: ' + (e.message || e));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getTerm(id) {
|
||||
return terminals.find((t) => t.id === id) || null;
|
||||
}
|
||||
|
||||
function activateTerminal(id) {
|
||||
activeId = id;
|
||||
for (const t of terminals) {
|
||||
const isActive = t.id === id;
|
||||
t.tab.classList.toggle('active', isActive);
|
||||
t.pane.classList.toggle('active', isActive);
|
||||
}
|
||||
// 命令行(提示符+输入)挂载到活动面板输出末尾,观感等同真实终端的当前行
|
||||
const t = getTerm(id);
|
||||
if (!_cmdlineEl) buildCmdline(); // 兜底:initTerminal 尚未执行时按需构建
|
||||
const cl = _cmdlineEl;
|
||||
if (t && cl) {
|
||||
t.outEl.appendChild(cl);
|
||||
scrollPaneToBottom(t.pane);
|
||||
}
|
||||
const input = _cmdInput;
|
||||
if (input) {
|
||||
input.disabled = !t?.alive;
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/** 滚动容器是 .term-pane(非 .term-out),输出追加后保持跟随底部 */
|
||||
function scrollPaneToBottom(pane) {
|
||||
if (pane) pane.scrollTop = pane.scrollHeight;
|
||||
}
|
||||
|
||||
function appendOutput(id, chunk) {
|
||||
const t = getTerm(id);
|
||||
if (!t) return;
|
||||
const html = ansiToHtml(chunk);
|
||||
const cl = _cmdlineEl;
|
||||
if (cl && cl.parentNode === t.outEl) {
|
||||
// 命令行保持在输出最后一行:新输出插在它之前
|
||||
cl.insertAdjacentHTML('beforebegin', html);
|
||||
} else {
|
||||
t.outEl.insertAdjacentHTML('beforeend', html);
|
||||
}
|
||||
scrollPaneToBottom(t.pane);
|
||||
}
|
||||
|
||||
/** 单个终端关闭(kill 进程并移除标签) */
|
||||
export function killTerminal(id) {
|
||||
const t = getTerm(id);
|
||||
if (!t) return;
|
||||
t.alive = false;
|
||||
try { window.electronAPI?.killTerminal(id); } catch { /* ignore */ }
|
||||
t.tab.remove();
|
||||
t.pane.remove();
|
||||
terminals = terminals.filter((x) => x.id !== id);
|
||||
if (activeId === id) {
|
||||
activeId = null;
|
||||
if (terminals.length > 0) activateTerminal(terminals[terminals.length - 1].id);
|
||||
}
|
||||
if (terminals.length === 0) {
|
||||
setDockVisible(false); // 全部关闭后隐藏面板
|
||||
}
|
||||
}
|
||||
|
||||
/** 关闭全部终端(kill 所有进程 + 清空面板) */
|
||||
export function killAllTerminals() {
|
||||
try { window.electronAPI?.killAllTerminals(); } catch { /* ignore */ }
|
||||
for (const t of terminals) {
|
||||
t.tab.remove();
|
||||
t.pane.remove();
|
||||
}
|
||||
terminals = [];
|
||||
activeId = null;
|
||||
setDockVisible(false);
|
||||
}
|
||||
|
||||
/** 显示/折叠终端面板(不 kill,仅隐藏) */
|
||||
export function setDockVisible(visible) {
|
||||
_dockVisible = !!visible;
|
||||
const dock = $('terminalDock');
|
||||
if (!dock) return;
|
||||
dock.hidden = !_dockVisible;
|
||||
if (_dockVisible && terminals.length > 0 && activeId == null) {
|
||||
activateTerminal(terminals[terminals.length - 1].id);
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换终端面板显隐;首次点击自动启动一个 PowerShell */
|
||||
export function toggleTerminal() {
|
||||
if (!ensureElectron()) return;
|
||||
if (_dockVisible) {
|
||||
setDockVisible(false);
|
||||
return;
|
||||
}
|
||||
setDockVisible(true);
|
||||
if (terminals.length === 0) {
|
||||
spawnTerminal();
|
||||
} else if (activeId == null && terminals.length > 0) {
|
||||
activateTerminal(terminals[terminals.length - 1].id);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 输入 ----------
|
||||
// 管道模式下 PowerShell 不回显提示符与命令,前端自行回显以还原真实终端观感
|
||||
const _cmdHistory = [];
|
||||
let _histIdx = -1; // -1 = 未在浏览历史
|
||||
// 命令行元素引用:buildCmdline 构建后先处于游离态(未挂到 document),
|
||||
// 不能用 getElementById 查找(查不到游离节点),必须用模块级引用持有
|
||||
let _cmdlineEl = null;
|
||||
let _cmdInput = null;
|
||||
|
||||
function echoCommand(text) {
|
||||
const t = getTerm(activeId);
|
||||
if (!t) return;
|
||||
const html = `<span class="t-fg-success">PS></span> ${escapeHtml(text)}\n`;
|
||||
const cl = _cmdlineEl;
|
||||
if (cl && cl.parentNode === t.outEl) cl.insertAdjacentHTML('beforebegin', html);
|
||||
else t.outEl.insertAdjacentHTML('beforeend', html);
|
||||
scrollPaneToBottom(t.pane);
|
||||
}
|
||||
|
||||
function sendInput() {
|
||||
const input = _cmdInput;
|
||||
const t = getTerm(activeId);
|
||||
if (!t || !t.alive || !input) return;
|
||||
const text = input.value;
|
||||
input.value = '';
|
||||
_histIdx = -1;
|
||||
if (!text) return;
|
||||
if (_cmdHistory[_cmdHistory.length - 1] !== text) _cmdHistory.push(text);
|
||||
if (_cmdHistory.length > 200) _cmdHistory.shift();
|
||||
echoCommand(text);
|
||||
try { window.electronAPI.sendTerminalInput(activeId, text + '\r'); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/** ↑/↓ 浏览命令历史 */
|
||||
function historyNav(dir) {
|
||||
const input = _cmdInput;
|
||||
if (!input || _cmdHistory.length === 0) return;
|
||||
if (_histIdx === -1) _histIdx = _cmdHistory.length;
|
||||
_histIdx += dir;
|
||||
if (_histIdx < 0) _histIdx = 0;
|
||||
if (_histIdx >= _cmdHistory.length) { _histIdx = -1; input.value = ''; return; }
|
||||
input.value = _cmdHistory[_histIdx];
|
||||
input.setSelectionRange(input.value.length, input.value.length);
|
||||
}
|
||||
|
||||
/** 构建命令行元素(提示符 + 输入框);由 activateTerminal 挂到活动面板输出末尾 */
|
||||
function buildCmdline() {
|
||||
if (_cmdlineEl) return;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'terminal-input-row';
|
||||
row.id = 'terminalCmdline';
|
||||
const prompt = document.createElement('span');
|
||||
prompt.className = 'terminal-prompt';
|
||||
prompt.id = 'terminalPrompt';
|
||||
prompt.textContent = 'PS>';
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'terminal-input';
|
||||
input.id = 'terminalInput';
|
||||
input.autocomplete = 'off';
|
||||
input.spellcheck = false;
|
||||
input.placeholder = '输入命令,Enter 执行';
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); sendInput(); }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); historyNav(-1); }
|
||||
else if (e.key === 'ArrowDown') { e.preventDefault(); historyNav(1); }
|
||||
});
|
||||
row.appendChild(prompt);
|
||||
row.appendChild(input);
|
||||
// 游离态持有引用;activateTerminal 首次挂载后才进入 document
|
||||
_cmdlineEl = row;
|
||||
_cmdInput = input;
|
||||
}
|
||||
|
||||
// ---------- 事件绑定 ----------
|
||||
let _bound = false;
|
||||
|
||||
export function initTerminal() {
|
||||
if (_bound) return;
|
||||
_bound = true;
|
||||
|
||||
// 顶栏终端按钮:由 app.js document 委托统一绑定(toggleTerminal),
|
||||
// 此处仅移除占位样式
|
||||
const termBtn = $('terminalBtn');
|
||||
if (termBtn) termBtn.classList.remove('is-placeholder');
|
||||
|
||||
// 命令行(提示符 + 输入)动态构建,挂到活动面板输出末尾,随内容滚动
|
||||
buildCmdline();
|
||||
|
||||
// 点击终端区域任意处直接键入(焦点落到命令行输入框)
|
||||
const termBody = $('terminalBody');
|
||||
if (termBody) {
|
||||
termBody.addEventListener('click', () => {
|
||||
const input = _cmdInput;
|
||||
if (input && !input.disabled) input.focus();
|
||||
});
|
||||
}
|
||||
|
||||
// 新标签 +
|
||||
const addBtn = document.createElement('button');
|
||||
addBtn.type = 'button';
|
||||
addBtn.className = 'term-tab-add';
|
||||
addBtn.title = '新建终端';
|
||||
addBtn.textContent = '+';
|
||||
addBtn.addEventListener('click', () => spawnTerminal());
|
||||
$('terminalTabs').appendChild(addBtn);
|
||||
|
||||
// 右上角关闭全部(确认浮窗)
|
||||
const dockClose = document.createElement('button');
|
||||
dockClose.type = 'button';
|
||||
dockClose.className = 'term-dock-close';
|
||||
dockClose.title = '关闭全部终端(终止所有进程)';
|
||||
dockClose.textContent = '\u2715';
|
||||
dockClose.addEventListener('click', async () => {
|
||||
const ok = await showConfirm('确定关闭全部终端吗?\n将终止所有 PowerShell 进程。');
|
||||
if (ok) killAllTerminals();
|
||||
});
|
||||
$('terminalTabs').appendChild(dockClose);
|
||||
|
||||
// 主进程数据推送
|
||||
if (window.electronAPI?.onTerminalData) {
|
||||
window.electronAPI.onTerminalData(({ id, chunk }) => appendOutput(id, chunk || ''));
|
||||
window.electronAPI.onTerminalExit(({ id, code }) => {
|
||||
const t = getTerm(id);
|
||||
if (!t) return;
|
||||
t.alive = false;
|
||||
t.tab.classList.add('term-tab--dead');
|
||||
appendOutput(id, `\r\n[进程已退出,code=${code == null ? '?' : code}](关闭标签可移除)\r\n`);
|
||||
if (activeId === id && _cmdInput) _cmdInput.disabled = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
@@ -592,6 +592,7 @@ let _settings = {
|
||||
font_size: "",
|
||||
bg_blur: "",
|
||||
bg_vignette: "",
|
||||
avatar: "",
|
||||
};
|
||||
let _loadingTheme = false; // 从服务端加载期间不写回,避免覆盖已有配置
|
||||
let _persistTimer = null;
|
||||
@@ -612,6 +613,7 @@ function _persistThemeConfig() {
|
||||
font_size: _settings.font_size,
|
||||
bg_blur: _settings.bg_blur,
|
||||
bg_vignette: _settings.bg_vignette,
|
||||
avatar: _settings.avatar,
|
||||
}),
|
||||
});
|
||||
} catch { /* ignore */ }
|
||||
@@ -694,6 +696,34 @@ export function getCurrentTheme() {
|
||||
return _settings.theme;
|
||||
}
|
||||
|
||||
// ===== 头像 =====
|
||||
/** 获取当前头像(本地路径 / data URL / http(s) URL,空字符串表示默认字母头像) */
|
||||
export function getAvatar() {
|
||||
return _settings.avatar;
|
||||
}
|
||||
export function setAvatar(val) {
|
||||
_settings.avatar = val || "";
|
||||
applyAvatar(_settings.avatar);
|
||||
_persistThemeConfig();
|
||||
}
|
||||
/** 应用头像到左栏用户区:有头像显示图片(本地路径走 API 代理),无头像显示昵称首字 */
|
||||
export function applyAvatar(url) {
|
||||
const img = document.getElementById("userAvatarImg");
|
||||
const letter = document.getElementById("userAvatarLetter");
|
||||
if (url) {
|
||||
const displayUrl = /^[a-zA-Z]:[\\/]/.test(url) ? localPathToUrl(url) : url;
|
||||
if (img) { img.src = displayUrl; img.hidden = false; }
|
||||
if (letter) letter.hidden = true;
|
||||
} else {
|
||||
if (img) { img.removeAttribute("src"); img.hidden = true; }
|
||||
if (letter) {
|
||||
const name = (document.getElementById("userName")?.textContent || "").trim();
|
||||
letter.textContent = (name.charAt(0) || "U").toUpperCase();
|
||||
letter.hidden = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 应用主题到页面 */
|
||||
export function applyTheme(themeId) {
|
||||
const theme = THEMES[themeId] || THEMES[DEFAULT_THEME];
|
||||
@@ -735,6 +765,7 @@ export function initTheme() {
|
||||
applyFontSetting("size", "");
|
||||
applyBgBlur("");
|
||||
applyBgVignette("");
|
||||
applyAvatar("");
|
||||
loadThemeConfig(); // 完成后在 finally 中解除 _loadingTheme
|
||||
return DEFAULT_THEME;
|
||||
}
|
||||
@@ -806,6 +837,10 @@ export async function loadThemeConfig() {
|
||||
_settings.bg_vignette = res.bg_vignette || "";
|
||||
applyBgVignette(_settings.bg_vignette);
|
||||
}
|
||||
if (res.avatar !== undefined) {
|
||||
_settings.avatar = res.avatar || "";
|
||||
applyAvatar(_settings.avatar);
|
||||
}
|
||||
} catch {
|
||||
// 服务端不可达:保持默认设置
|
||||
} finally {
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
// 档位同时决定运行模式与深度思考:
|
||||
// low=agent+不思考 high=plan+不思考 xhigh=agent+思考 max=plan+思考
|
||||
// ultra 为未来 react→审批图预留占位(暂按 agent+思考 处理)
|
||||
// 持久化在后端 env_config.json 的 THINKING_LEVEL(/api/config/thinking-level),
|
||||
// 由 agent_runtime._build_llm_for_agent 决定 Multimodel_LLM 的 extra_body。
|
||||
// 会话级变量:持久化在各会话 session_meta.json 的 thinking_level
|
||||
// (POST /api/sessions/{id}/thinking-level),由 llm.Multimodel_LLM 每轮
|
||||
// 按当前会话动态解析 extra_body,仅作用于该会话。
|
||||
// 交互通过 document 级事件委托实现(触发器开关 + 档位点击 + 滑条拖动)。
|
||||
|
||||
import { $, showToast, closeInlinePanelsExcept } from './utils.js';
|
||||
@@ -38,15 +39,11 @@ let _committing = false;
|
||||
let _lastCommitAt = 0;
|
||||
let _lastTriggerAt = 0;
|
||||
|
||||
/** 初始化思考档位(bootstrap 后调用):从后端加载并同步滑条 */
|
||||
export async function initThinkLevel() {
|
||||
try {
|
||||
const data = await api("/api/config/env");
|
||||
const lv = data && data.env && data.env.THINKING_LEVEL;
|
||||
if (THINK_LEVELS.includes(lv)) {
|
||||
state.thinkingLevel = lv;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
/** 初始化思考档位(bootstrap 后调用):state.thinkingLevel 已由会话数据同步 */
|
||||
export function initThinkLevel() {
|
||||
if (!THINK_LEVELS.includes(state.thinkingLevel)) {
|
||||
state.thinkingLevel = "low";
|
||||
}
|
||||
renderLabels();
|
||||
syncThinkLevelUI();
|
||||
}
|
||||
@@ -173,7 +170,7 @@ function setLevelFromPointer(clientX) {
|
||||
syncThinkLevelUI();
|
||||
}
|
||||
|
||||
/** 提交档位到后端(连击/重复提交保护) */
|
||||
/** 提交档位到当前会话(会话级变量,存于该会话 session_meta.json) */
|
||||
async function commitLevel(level) {
|
||||
if (_committing) return;
|
||||
const now = Date.now();
|
||||
@@ -181,14 +178,16 @@ async function commitLevel(level) {
|
||||
_lastCommitAt = now;
|
||||
_committing = true;
|
||||
try {
|
||||
const data = await api("/api/config/thinking-level", {
|
||||
const sid = state.sessionId || "";
|
||||
if (!sid) { showToast("当前无会话,无法保存思考档位"); return; }
|
||||
const data = await api(`/api/sessions/${encodeURIComponent(sid)}/thinking-level`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ level }),
|
||||
});
|
||||
if (data && data.level) {
|
||||
state.thinkingLevel = data.level;
|
||||
syncThinkLevelUI();
|
||||
showToast(`思考模式已切换为「${data.level}」`);
|
||||
showToast(`思考模式已切换为「${data.level}」(仅当前会话)`);
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(e.message || String(e));
|
||||
|
||||
@@ -1,138 +1,35 @@
|
||||
// todo.js -- Todo List 悬浮窗
|
||||
// todo.js -- Todo List(通知条版)
|
||||
//
|
||||
// 原浮窗实现已迁移至 notify-strip.js:
|
||||
// todo 数据从 live snapshot 的 tool_calls 派生(todo_list 工具调用),
|
||||
// 以输入区上方通知条形式展示(最新步骤 + 计数 chip,可展开完整列表)。
|
||||
// 本文件保留导出签名以兼容既有依赖注入(setLiveUiDeps / clearTodoOverlay)。
|
||||
|
||||
import { $, escapeHtml } from './utils.js';
|
||||
import { state } from './state.js';
|
||||
import {
|
||||
updateTodoStrip,
|
||||
clearTodoStrip,
|
||||
saveStripSession,
|
||||
} from './notify-strip.js';
|
||||
|
||||
function _updateBadge(show) {
|
||||
try {
|
||||
import('./action-bar.js').then(m => m.setActionBadge('todo', show));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function buildTodoListOverlay(todoData) {
|
||||
if (!todoData || !todoData.steps || !todoData.steps.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "todo-list-overlay";
|
||||
overlay.hidden = true; // 默认隐藏,按钮点击后显示
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "todo-list-header";
|
||||
header.textContent = "\u{1F4CB} Todo List";
|
||||
header.addEventListener("click", () => {
|
||||
overlay.classList.toggle("todo-list-overlay--collapsed");
|
||||
});
|
||||
overlay.appendChild(header);
|
||||
|
||||
const list = document.createElement("ul");
|
||||
list.className = "todo-list-body";
|
||||
todoData.steps.forEach((s, i) => {
|
||||
const item = document.createElement("li");
|
||||
item.className = "todo-list-item";
|
||||
const statusIcon = todoData.done_steps && todoData.done_steps.includes(i)
|
||||
? '<span class="todo-check">\u2713</span>'
|
||||
: '<span class="todo-spinner"></span>';
|
||||
item.innerHTML = `${statusIcon}<span class="todo-label">${escapeHtml(s)}</span>`;
|
||||
list.appendChild(item);
|
||||
});
|
||||
overlay.appendChild(list);
|
||||
|
||||
return overlay;
|
||||
}
|
||||
|
||||
function _getContainer() {
|
||||
const chatMessages = $("chatMessages");
|
||||
return chatMessages ? (chatMessages.closest(".chat-area") || chatMessages.parentElement) : null;
|
||||
}
|
||||
|
||||
function updateTodoOverlay(todoData) {
|
||||
_updateBadge(!!(todoData && todoData.steps && todoData.steps.length));
|
||||
|
||||
// 如果已有 overlay,更新其内容
|
||||
if (state._currentTodoOverlay) {
|
||||
const existing = state._currentTodoOverlay;
|
||||
// 更新列表内容
|
||||
const list = existing.querySelector(".todo-list-body");
|
||||
if (list) {
|
||||
list.innerHTML = "";
|
||||
if (todoData && todoData.steps && todoData.steps.length) {
|
||||
todoData.steps.forEach((s, i) => {
|
||||
const item = document.createElement("li");
|
||||
item.className = "todo-list-item";
|
||||
const statusIcon = todoData.done_steps && todoData.done_steps.includes(i)
|
||||
? '<span class="todo-check">\u2713</span>'
|
||||
: '<span class="todo-spinner"></span>';
|
||||
item.innerHTML = `${statusIcon}<span class="todo-label">${escapeHtml(s)}</span>`;
|
||||
list.appendChild(item);
|
||||
});
|
||||
}
|
||||
}
|
||||
existing.hidden = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!todoData || !todoData.steps || !todoData.steps.length) {
|
||||
_updateBadge(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const overlay = buildTodoListOverlay(todoData);
|
||||
if (!overlay) return;
|
||||
|
||||
const container = _getContainer();
|
||||
if (!container) return;
|
||||
|
||||
// 移除旧 overlay(如果有)
|
||||
const old = container.querySelector(".todo-list-overlay");
|
||||
if (old) old.remove();
|
||||
|
||||
// 新增(初始隐藏,等待按钮点击)
|
||||
overlay.hidden = false;
|
||||
container.appendChild(overlay);
|
||||
state._currentTodoOverlay = overlay;
|
||||
|
||||
// 同步双浮窗布局
|
||||
try { import('./pending-overlay.js').then(m => m.updateOverlayLayout && m.updateOverlayLayout()); } catch {}
|
||||
}
|
||||
|
||||
function clearTodoOverlay() {
|
||||
_updateBadge(false);
|
||||
if (state._currentTodoOverlay) {
|
||||
state._currentTodoOverlay.hidden = true;
|
||||
}
|
||||
// 也隐藏 DOM 中可能存在的其他 todo overlay
|
||||
document.querySelectorAll(".todo-list-overlay").forEach((el) => el.hidden = true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 live 工具调用记录派生 Todo 浮窗数据(展示类工具的通用前端 adapter)。
|
||||
* todo_list 每次调用都会出现在 live snapshot 的 tool_calls 中:
|
||||
* - 携带 steps 的调用 → 重建步骤列表(done 状态重置)
|
||||
* - 携带 done_step 的调用 → 标记 1~n 步为完成
|
||||
* 后端不再维护 todo store、也没有轮询接口。
|
||||
* @param {Array} toolCalls - live snapshot 的 tool_calls 数组
|
||||
*/
|
||||
/** 从 live 工具调用记录派生 Todo 通知条数据。
|
||||
* @param {Array} toolCalls - live snapshot 的 tool_calls 数组 */
|
||||
function updateTodoOverlayFromRecords(toolCalls) {
|
||||
if (!toolCalls || !toolCalls.length) return;
|
||||
let steps = null;
|
||||
const doneSteps = new Set();
|
||||
for (const call of toolCalls) {
|
||||
if (!call || call.name !== "todo_list") continue;
|
||||
const args = (call.args || {});
|
||||
if (Array.isArray(args.steps) && args.steps.length) {
|
||||
steps = args.steps.map((s) => String(s));
|
||||
doneSteps.clear();
|
||||
}
|
||||
if (!steps) continue;
|
||||
const n = parseInt(args.done_step, 10);
|
||||
if (Number.isFinite(n) && n >= 1) {
|
||||
for (let i = 0; i < Math.min(n, steps.length); i++) doneSteps.add(i);
|
||||
}
|
||||
}
|
||||
if (!steps) return;
|
||||
updateTodoOverlay({ steps, done_steps: [...doneSteps].sort((a, b) => a - b) });
|
||||
updateTodoStrip(toolCalls);
|
||||
}
|
||||
|
||||
/** 清空 todo 通知条(仅 todo,不影响人机交互条)。 */
|
||||
function clearTodoOverlay() {
|
||||
saveStripSession();
|
||||
clearTodoStrip();
|
||||
}
|
||||
|
||||
/** 兼容旧接口:通知条模式下无需构建独立 overlay。 */
|
||||
function buildTodoListOverlay() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function updateTodoOverlay() {
|
||||
// 通知条由 updateTodoStrip 驱动,此接口保留为空实现以兼容旧调用点
|
||||
}
|
||||
|
||||
export { buildTodoListOverlay, updateTodoOverlay, updateTodoOverlayFromRecords, clearTodoOverlay };
|
||||
@@ -1,6 +1,6 @@
|
||||
// toolcalls.js -- 工具调用 / think面板 渲染(新版:无框单行 + 状态圆圈 + 图标 + 展开折叠 + 计时)
|
||||
|
||||
import { escapeHtml, showToast } from './utils.js';
|
||||
import { escapeHtml, showToast, getFileIcon } from './utils.js';
|
||||
import { state } from './state.js';
|
||||
import { api } from './api.js';
|
||||
|
||||
@@ -9,6 +9,7 @@ const TOOL_META = {
|
||||
doc_tool: { icon: null, dynamicIcon: true }, // 按 action 选 写/读/删除.svg
|
||||
todo_list: { icon: '/image/todolist.svg' },
|
||||
browser_control: { icon: '/image/控制.svg' },
|
||||
web_page: { icon: '/image/web.svg' },
|
||||
software_control: { icon: '/image/控制.svg' },
|
||||
terminal_execute: { icon: '/image/终端.svg' },
|
||||
email: { icon: '/image/邮件.svg' },
|
||||
@@ -32,7 +33,8 @@ const CRON_ACTION_LABEL = { list: '查看任务', get: '查看任务', create: '
|
||||
const DOC_ACTION_ICON = { read: '/image/读.svg', create: '/image/写.svg', update: '/image/写.svg', delete: '/image/删除.svg' };
|
||||
const DOC_ACTION_LABEL = { read: '查看文档', create: '创建文档', update: '修改文档', delete: '删除文档' };
|
||||
const TEXT2SQL_ACTION_LABEL = { list_tables: '查看库结构', describe: '查看表结构', query: '查询', execute: '增删改' };
|
||||
const BROWSER_ACTION_LABEL = { open: '打开浏览器', close: '关闭浏览器' };
|
||||
const BROWSER_ACTION_LABEL = { open: '打开浏览器', close: '关闭浏览器', list_tabs: '查看标签页', switch_tab: '切换标签页' };
|
||||
const WEB_PAGE_ACTION_LABEL = { goto: '打开网页', snapshot: '页面快照', click: '点击元素', fill: '输入文本', hover: '悬停', select: '选择选项', scroll: '滚动页面', press: '按键', wait: '等待', extract: '提取内容', back: '后退', forward: '前进', reload: '刷新页面', screenshot: '页面截图', eval_js: '执行JS' };
|
||||
const SOFTWARE_ACTION_LABEL = { open: '打开软件', close: '关闭软件', force_close: '强制关闭' };
|
||||
const MAIL_ACTION_LABEL = { send: '发送邮件', read: '读取邮件' };
|
||||
const RAG_ACTION_LABEL = { query: '查询知识库', add_document: '添加文档', clear: '清空来源', list_sources: '列出来源' };
|
||||
@@ -92,7 +94,14 @@ function getToolLabel(call) {
|
||||
}
|
||||
if (name === "browser_control") {
|
||||
const action = args.action || "open";
|
||||
return BROWSER_ACTION_LABEL[action] || "浏览器";
|
||||
return BROWSER_ACTION_LABEL[action] || '浏览器';
|
||||
}
|
||||
if (name === "web_page") {
|
||||
const acts = Array.isArray(args.actions) ? args.actions : [];
|
||||
if (!acts.length) return '网页操作';
|
||||
const labels = acts.slice(0, 3).map(a => WEB_PAGE_ACTION_LABEL[a && a.action] || '网页操作');
|
||||
const suffix = acts.length > 3 ? `等${acts.length}步` : '';
|
||||
return labels.join('+') + suffix;
|
||||
}
|
||||
if (name === "software_control") {
|
||||
const action = args.action || "open";
|
||||
@@ -147,6 +156,12 @@ function getToolArgsText(call) {
|
||||
if (name === "browser_control") {
|
||||
return args.url ? String(args.url).slice(0, 120) : "";
|
||||
}
|
||||
if (name === "web_page") {
|
||||
const acts = Array.isArray(args.actions) ? args.actions : [];
|
||||
const first = acts.find(a => a && (a.url || a.selector || a.text));
|
||||
if (!first) return "";
|
||||
return String(first.url || first.selector || first.text).slice(0, 120);
|
||||
}
|
||||
if (name === "software_control") {
|
||||
return args.software_name ? String(args.software_name) : "";
|
||||
}
|
||||
@@ -444,9 +459,11 @@ function renderUserFileCard(fileUrl, fileName, options = {}) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "user-file-card";
|
||||
|
||||
const safeName = fileName || "附件";
|
||||
|
||||
const icon = document.createElement("img");
|
||||
icon.className = "user-file-card-icon";
|
||||
icon.src = "/image/\u6587\u6863\u6309\u94AE.svg"; // 文档按钮.svg
|
||||
icon.src = getFileIcon(safeName); // 按扩展名分类图标(文本/Excel/Word/PDF/压缩/音频)
|
||||
icon.alt = "\u6587\u4EF6";
|
||||
|
||||
const info = document.createElement("div");
|
||||
@@ -454,7 +471,6 @@ function renderUserFileCard(fileUrl, fileName, options = {}) {
|
||||
|
||||
const nameEl = document.createElement("span");
|
||||
nameEl.className = "user-file-name";
|
||||
const safeName = fileName || "附件";
|
||||
nameEl.textContent = safeName;
|
||||
nameEl.title = safeName;
|
||||
|
||||
@@ -768,6 +784,7 @@ function renderThinkRow(content, options = {}) {
|
||||
const isLive = !!options.live;
|
||||
const defaultExpanded = isLive || !!options.defaultExpanded;
|
||||
const index = options.index || 1;
|
||||
const isReflection = options.type === "reflection";
|
||||
|
||||
const row = document.createElement("div");
|
||||
row.className = "think-row" + (isLive ? " think-row--live" : " think-row--done") + (defaultExpanded ? " think-row--expanded" : "");
|
||||
@@ -778,13 +795,13 @@ function renderThinkRow(content, options = {}) {
|
||||
icon.src = "/image/think.svg";
|
||||
icon.alt = "";
|
||||
icon.style.cursor = "pointer";
|
||||
icon.title = "点击展开/折叠思考";
|
||||
icon.title = isReflection ? "点击展开/折叠反思" : "点击展开/折叠思考";
|
||||
row.appendChild(icon);
|
||||
|
||||
// 标签
|
||||
// 标签(反思与思考分开编号/命名,二者可同时存在)
|
||||
const label = document.createElement("span");
|
||||
label.className = "think-row-label";
|
||||
label.textContent = "Thought #" + index;
|
||||
label.className = "think-row-label" + (isReflection ? " think-row-label--reflection" : "");
|
||||
label.textContent = isReflection ? "Reflection #" + index : "Thought #" + index;
|
||||
row.appendChild(label);
|
||||
|
||||
// 详情(思考内容)
|
||||
@@ -846,43 +863,49 @@ function renderPersistentToolCalls(toolCalls, options = {}) {
|
||||
const body = document.createElement("div");
|
||||
body.className = "tc-list-toggle-body";
|
||||
|
||||
// 将 reflections 渲染为思考块,按位置穿插在工具调用之间
|
||||
// 将 reflections 渲染为思考/反思块,按位置穿插在工具调用之间
|
||||
// (每个条目携带 kind:thought=深度思考 / reflection=反思,二者可同时存在)
|
||||
const reflectionMap = new Map();
|
||||
reflections.forEach((r) => {
|
||||
if (!r || !r.content) return;
|
||||
const idx = (r.between_calls != null) ? r.between_calls : -1;
|
||||
if (!reflectionMap.has(idx)) reflectionMap.set(idx, []);
|
||||
reflectionMap.get(idx).push(r.content);
|
||||
reflectionMap.get(idx).push({ content: r.content, kind: r.kind === "reflection" ? "reflection" : "thought" });
|
||||
});
|
||||
|
||||
let thinkIndex = 0;
|
||||
// live 模式下思考内容已通过 reflections 提供(带 between_calls 定位),
|
||||
// 此时 call.thinking 是同一份内容的冗余副本,不再重复渲染;
|
||||
// 历史模式(无 reflections)仍使用 call.thinking 作为唯一来源。
|
||||
// live 模式下思考/反思内容已通过 reflections 提供(带 between_calls 定位),
|
||||
// 此时 call.thinking / call.reflection 是同一份内容的冗余副本,不再重复渲染;
|
||||
// 历史模式(无 reflections)仍使用 call.thinking / call.reflection 作为唯一来源。
|
||||
const hasReflections = reflections.length > 0;
|
||||
|
||||
toolCalls.forEach((call, ci) => {
|
||||
// 该工具调用前的思考块(between_calls 对应此位置)
|
||||
// 该工具调用前的思考/反思块(between_calls 对应此位置)
|
||||
const preBlocks = reflectionMap.get(ci) || [];
|
||||
preBlocks.forEach((txt) => {
|
||||
preBlocks.forEach((item) => {
|
||||
thinkIndex++;
|
||||
body.appendChild(renderThinkRow(txt, { live: isLive, index: thinkIndex }));
|
||||
body.appendChild(renderThinkRow(item.content, { live: isLive, index: thinkIndex, type: item.kind }));
|
||||
});
|
||||
// 思考记录(附属于该工具调用)—— 仅在没有 reflections 时渲染,避免重复
|
||||
if (call.thinking && !hasReflections) {
|
||||
thinkIndex++;
|
||||
body.appendChild(renderThinkRow(call.thinking, { live: isLive, index: thinkIndex }));
|
||||
body.appendChild(renderThinkRow(call.thinking, { live: isLive, index: thinkIndex, type: "thought" }));
|
||||
}
|
||||
// 反思记录(附属于该工具调用)—— 与思考独立存储,可同时存在
|
||||
if (call.reflection && !hasReflections) {
|
||||
thinkIndex++;
|
||||
body.appendChild(renderThinkRow(call.reflection, { live: isLive, index: thinkIndex, type: "reflection" }));
|
||||
}
|
||||
const { row, detail } = renderToolCallRow(call, { defaultExpanded: false, disableExpand: !!options.disableExpand });
|
||||
body.appendChild(row);
|
||||
if (detail) body.appendChild(detail);
|
||||
});
|
||||
|
||||
// 末尾的未映射思考块
|
||||
// 末尾的未映射思考/反思块
|
||||
const tailBlocks = reflectionMap.get(-1) || [];
|
||||
tailBlocks.forEach((txt) => {
|
||||
tailBlocks.forEach((item) => {
|
||||
thinkIndex++;
|
||||
body.appendChild(renderThinkRow(txt, { live: isLive, index: thinkIndex }));
|
||||
body.appendChild(renderThinkRow(item.content, { live: isLive, index: thinkIndex, type: item.kind }));
|
||||
});
|
||||
|
||||
wrap.appendChild(body);
|
||||
@@ -1004,8 +1027,8 @@ function injectThinkStream(typingEl, thinkContent, options = {}) {
|
||||
|
||||
const full = String(thinkContent || "");
|
||||
const n = full.length;
|
||||
const charsPerSec = 120;
|
||||
const totalMs = Math.min(6000, Math.max(300, (n / charsPerSec) * 1000));
|
||||
const charsPerSec = 300;
|
||||
const totalMs = Math.min(3000, Math.max(250, (n / charsPerSec) * 1000));
|
||||
const t0 = performance.now();
|
||||
let last = "";
|
||||
|
||||
@@ -1105,6 +1128,24 @@ function startPollLiveToolCalls(sessionId, typingEl, injectFn) {
|
||||
let eventSource = null;
|
||||
let reconnectTimer = null;
|
||||
|
||||
// rAF 合并:同一帧内多个快照只注入最新一次。
|
||||
// 运行中快照高频到达(每次工具记录/结果/反思变更都推送),
|
||||
// 直接逐条重建工具列表会导致卡顿;合并后渲染频率上限 = 帧率。
|
||||
let _pendingInject = null;
|
||||
let _injectRafId = 0;
|
||||
const scheduleInject = (liveCalls) => {
|
||||
_pendingInject = { typingEl, injectFn, liveCalls, turnStartedAt, reflections: collectedReflections };
|
||||
if (_injectRafId) return;
|
||||
_injectRafId = requestAnimationFrame(() => {
|
||||
_injectRafId = 0;
|
||||
const p = _pendingInject;
|
||||
_pendingInject = null;
|
||||
if (p && p.typingEl && p.injectFn && p.liveCalls && p.liveCalls.length) {
|
||||
p.injectFn(p.typingEl, p.liveCalls, p.turnStartedAt, p.reflections);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleSnapshot = (liveData) => {
|
||||
if (!active || !liveData) return;
|
||||
if (liveData.started_at && !turnStartedAt) {
|
||||
@@ -1118,17 +1159,22 @@ function startPollLiveToolCalls(sessionId, typingEl, injectFn) {
|
||||
if (liveUiDeps.updateTodoOverlayFromRecords && Array.isArray(liveData.tool_calls)) {
|
||||
liveUiDeps.updateTodoOverlayFromRecords(liveData.tool_calls);
|
||||
}
|
||||
// 收集思考内容
|
||||
// 收集思考/反思内容(backend 条目带 thought/reflection 双字段,二者可同时存在)
|
||||
if (liveData.reflections && liveData.reflections.length > lastReflectionCount) {
|
||||
const newOnes = liveData.reflections.slice(lastReflectionCount);
|
||||
const pos = liveData.tool_calls ? liveData.tool_calls.length : -1;
|
||||
newOnes.forEach((r) => {
|
||||
if (r && r.content) {
|
||||
collectedReflections.push({ content: r.content, between_calls: liveData.tool_calls ? liveData.tool_calls.length : -1 });
|
||||
if (!r) return;
|
||||
if (r.thought) collectedReflections.push({ content: r.thought, between_calls: pos, kind: "thought" });
|
||||
if (r.reflection) collectedReflections.push({ content: r.reflection, between_calls: pos, kind: "reflection" });
|
||||
// 旧格式兼容:仅有 content 时按思考处理
|
||||
if (!r.thought && !r.reflection && r.content) {
|
||||
collectedReflections.push({ content: r.content, between_calls: pos, kind: "thought" });
|
||||
}
|
||||
});
|
||||
lastReflectionCount = liveData.reflections.length;
|
||||
}
|
||||
// 处理工具调用
|
||||
// 处理工具调用(rAF 合并注入,高频快照不逐条重建)
|
||||
if (typingEl && injectFn && liveData.tool_calls && liveData.tool_calls.length > 0) {
|
||||
for (const tc of liveData.tool_calls) {
|
||||
if (tc.name === "__summarizer__" || tc.name === "summarizer") {
|
||||
@@ -1141,7 +1187,7 @@ function startPollLiveToolCalls(sessionId, typingEl, injectFn) {
|
||||
}
|
||||
}
|
||||
}
|
||||
injectFn(typingEl, liveData.tool_calls, turnStartedAt, collectedReflections);
|
||||
scheduleInject(liveData.tool_calls);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1181,6 +1227,8 @@ function startPollLiveToolCalls(sessionId, typingEl, injectFn) {
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
if (_injectRafId) { cancelAnimationFrame(_injectRafId); _injectRafId = 0; }
|
||||
_pendingInject = null;
|
||||
if (eventSource) { eventSource.close(); eventSource = null; }
|
||||
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
||||
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// turn-nav.js -- 聊天回合导航条:每个用户输入一条横条,点击跳转到对应回合,当前回合高亮
|
||||
// 悬停横条时左侧浮窗显示该用户输入内容的前 20 字。
|
||||
|
||||
import { $ } from './utils.js';
|
||||
import { state } from './state.js';
|
||||
|
||||
const chatMessages = $("chatMessages");
|
||||
|
||||
let _navEl = null; // 导航容器(.turn-nav)
|
||||
let _tipEl = null; // 悬停浮窗(.turn-nav-tip)
|
||||
let _turns = []; // [{ el, bar, snippet }]
|
||||
let _bound = false;
|
||||
|
||||
/** 提取用户消息的可读文本(不含 @file/@folder 引用),最多 20 字 */
|
||||
function extractUserSnippet(el) {
|
||||
const txtEl = el.querySelector(".msg-text");
|
||||
let s = (txtEl ? txtEl.textContent : "").trim();
|
||||
if (!s) s = (el.textContent || "").trim();
|
||||
return s.slice(0, 20) || "(无文本)";
|
||||
}
|
||||
|
||||
/** 从 DOM 消息元素构建回合分组:每个用户输入(.msg.user)为一条导航条,
|
||||
* 其后的助手消息归入同一回合(仅用于定位,不新建条)。typing 指示器不参与。 */
|
||||
function buildTurns() {
|
||||
if (!chatMessages) return [];
|
||||
const turns = [];
|
||||
for (const el of chatMessages.children) {
|
||||
if (!el.classList || !el.classList.contains("msg") || el.classList.contains("msg-typing")) continue;
|
||||
if (el.classList.contains("user")) {
|
||||
turns.push({ el, bar: null, snippet: extractUserSnippet(el) });
|
||||
}
|
||||
}
|
||||
return turns;
|
||||
}
|
||||
|
||||
/** 在悬停横条左侧显示用户输入摘要浮窗 */
|
||||
function showTip(bar, text) {
|
||||
if (!_tipEl) return;
|
||||
_tipEl.textContent = text;
|
||||
_tipEl.hidden = false;
|
||||
const navRect = _navEl.getBoundingClientRect();
|
||||
const barRect = bar.getBoundingClientRect();
|
||||
const top = barRect.top - navRect.top + barRect.height / 2;
|
||||
const tipHeight = _tipEl.offsetHeight || 24;
|
||||
// 上下边界内夹紧,避免被 .chat-shell 的 overflow:hidden 裁剪
|
||||
_tipEl.style.top = Math.min(Math.max(top, tipHeight / 2 + 4), navRect.height - tipHeight / 2 - 4) + "px";
|
||||
}
|
||||
|
||||
function hideTip() {
|
||||
if (_tipEl) _tipEl.hidden = true;
|
||||
}
|
||||
|
||||
function bindScroll() {
|
||||
if (_bound || !chatMessages) return;
|
||||
_bound = true;
|
||||
chatMessages.addEventListener("scroll", hideTip);
|
||||
}
|
||||
|
||||
/** 重建回合导航条(消息列表渲染 / 流式回复结束后调用) */
|
||||
export function renderTurnNav() {
|
||||
if (!chatMessages) return;
|
||||
if (state.mode === "cron") { hideTurnNav(); return; }
|
||||
const turns = buildTurns();
|
||||
_turns = turns;
|
||||
|
||||
if (!_navEl) {
|
||||
_navEl = document.createElement("div");
|
||||
_navEl.className = "turn-nav";
|
||||
_navEl.setAttribute("role", "navigation");
|
||||
_navEl.setAttribute("aria-label", "回合导航");
|
||||
const shell = chatMessages.closest(".chat-shell") || chatMessages.parentElement;
|
||||
if (shell) shell.appendChild(_navEl);
|
||||
|
||||
_tipEl = document.createElement("div");
|
||||
_tipEl.className = "turn-nav-tip";
|
||||
_tipEl.hidden = true;
|
||||
_navEl.appendChild(_tipEl);
|
||||
}
|
||||
if (turns.length === 0) {
|
||||
_navEl.hidden = true;
|
||||
hideTip();
|
||||
return;
|
||||
}
|
||||
_navEl.hidden = false;
|
||||
_navEl.innerHTML = "";
|
||||
_navEl.appendChild(_tipEl); // 浮窗保持在横条之后渲染(位于最上层)
|
||||
|
||||
turns.forEach((t, i) => {
|
||||
const bar = document.createElement("button");
|
||||
bar.type = "button";
|
||||
bar.className = "turn-nav-bar";
|
||||
bar.title = `回合 ${i + 1}:${t.snippet}`;
|
||||
bar.addEventListener("click", () => {
|
||||
if (!t.el || !chatMessages) return;
|
||||
const cRect = chatMessages.getBoundingClientRect();
|
||||
const eRect = t.el.getBoundingClientRect();
|
||||
chatMessages.scrollTop += eRect.top - cRect.top;
|
||||
});
|
||||
// 悬停 / 聚焦时左侧浮窗显示用户输入前 20 字
|
||||
bar.addEventListener("mouseenter", () => showTip(bar, t.snippet));
|
||||
bar.addEventListener("mouseleave", hideTip);
|
||||
bar.addEventListener("focus", () => showTip(bar, t.snippet));
|
||||
bar.addEventListener("blur", hideTip);
|
||||
t.bar = bar;
|
||||
_navEl.appendChild(bar);
|
||||
});
|
||||
|
||||
bindScroll();
|
||||
hideTip();
|
||||
}
|
||||
|
||||
function hideTurnNav() {
|
||||
if (_navEl) _navEl.hidden = true;
|
||||
hideTip();
|
||||
}
|
||||
|
||||
/** 模式切换后同步显隐(cron 模式复用聊天容器,需隐藏导航条) */
|
||||
export function syncTurnNavVisibility() {
|
||||
if (state.mode === "cron") hideTurnNav();
|
||||
else renderTurnNav();
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// user-area.js -- 左栏底部用户区:头像 / 用户名 / 邮箱 + 设置按钮
|
||||
// 头像持久化在 theme_config.json(themes.js 管理),本地路径经 /api/bg-image 代理显示。
|
||||
|
||||
import { $ } from './utils.js';
|
||||
import { api } from './api.js';
|
||||
import { getAvatar, applyAvatar, setAvatar } from './themes.js';
|
||||
|
||||
/** 读取用户信息并渲染用户区(昵称 / 邮箱 / 头像),绑定设置按钮与头像更换 */
|
||||
export async function initUserArea() {
|
||||
const nameEl = $("userName");
|
||||
const emailEl = $("userEmail");
|
||||
try {
|
||||
const data = await api("/api/config/env");
|
||||
const env = data.env || {};
|
||||
const nickname = env.USER_NICKNAME || data.USER_NICKNAME || "";
|
||||
const email = env.EMAIL_ADDRESS || "";
|
||||
if (nameEl) nameEl.textContent = nickname || "未命名用户";
|
||||
if (emailEl) {
|
||||
emailEl.textContent = email || "";
|
||||
emailEl.hidden = !email;
|
||||
}
|
||||
} catch { /* 保留默认占位 */ }
|
||||
|
||||
// 无头像时字母头像依赖昵称,渲染后再应用一次
|
||||
applyAvatar(getAvatar());
|
||||
|
||||
// 左栏设置按钮由 settings.js 模块加载时统一绑定(打开系统配置面板)
|
||||
|
||||
// 左栏头像点击:选择本地图片替换(高亮反馈由 CSS hover/active 提供)
|
||||
const avatarBtn = $("userAvatarBtn");
|
||||
if (avatarBtn) {
|
||||
avatarBtn.addEventListener("click", () => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "image/*";
|
||||
input.onchange = () => {
|
||||
const file = input.files && input.files[0];
|
||||
if (!file) return;
|
||||
if (window.electronAPI && window.electronAPI.getFilePath) {
|
||||
// Electron 环境:直接使用文件绝对路径(代理接口读取)
|
||||
const filePath = window.electronAPI.getFilePath(file);
|
||||
if (filePath) { setAvatar(filePath); return; }
|
||||
}
|
||||
// Web 环境:读取为 data URL
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => setAvatar(e.target && e.target.result || "");
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -198,6 +198,60 @@ function closeInlinePanelsExcept(keepEl) {
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 附件图标分类(按扩展名映射) =====
|
||||
// 说明:vedio 图标适用于音频文件(视频暂不支持);所有 UTF-8 类文本文件统一定义为 plaintext
|
||||
const FILE_ICON_EXCEL = ["xls", "xlsx", "xlsm", "xltx", "csv"];
|
||||
const FILE_ICON_WORD = ["doc", "docx", "docm", "dot", "dotx", "rtf"];
|
||||
const FILE_ICON_PDF = ["pdf"];
|
||||
const FILE_ICON_ARCHIVE = ["zip", "rar", "7z", "tar", "gz", "tgz", "bz2", "xz", "zst"];
|
||||
const FILE_ICON_AUDIO = ["mp3", "wav", "flac", "aac", "ogg", "oga", "m4a", "wma", "opus", "amr", "ape", "mid", "midi"];
|
||||
const FILE_ICON_TEXT = [
|
||||
"txt", "md", "markdown", "json", "js", "mjs", "cjs", "ts", "tsx", "jsx", "vue", "svelte",
|
||||
"py", "pyw", "html", "htm", "css", "scss", "less", "sass", "xml", "yaml", "yml", "toml",
|
||||
"ini", "cfg", "conf", "log", "env", "sh", "bat", "cmd", "ps1", "sql", "r", "go", "rs", "rb",
|
||||
"php", "swift", "kt", "scala", "cpp", "cc", "c", "h", "hpp", "java", "m", "svg",
|
||||
];
|
||||
|
||||
/** 按文件名扩展名返回附件分类图标路径;未知类型(含视频)统一回退文本图标 */
|
||||
function getFileIcon(name) {
|
||||
const dot = String(name || "").lastIndexOf(".");
|
||||
const ext = (dot >= 0 ? String(name).slice(dot + 1) : "").toLowerCase();
|
||||
if (FILE_ICON_EXCEL.includes(ext)) return "/image/Excel.svg";
|
||||
if (FILE_ICON_WORD.includes(ext)) return "/image/word.svg";
|
||||
if (FILE_ICON_PDF.includes(ext)) return "/image/pdf.svg";
|
||||
if (FILE_ICON_ARCHIVE.includes(ext)) return "/image/zip.svg";
|
||||
if (FILE_ICON_AUDIO.includes(ext)) return "/image/vedio.svg";
|
||||
return "/image/文本文件.svg";
|
||||
}
|
||||
|
||||
// ===== 不支持上传/解读的文件类型 =====
|
||||
// 视频:暂不支持;旧版 .doc / .rtf:无解析器,agent 无法解读
|
||||
const UNSUPPORTED_FILE_EXTENSIONS = [
|
||||
// 视频
|
||||
"mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "m4v", "mpg", "mpeg",
|
||||
"ts", "m2ts", "mts", "3gp", "3g2", "rm", "rmvb", "vob",
|
||||
// 旧版 Word / RTF
|
||||
"doc", "rtf",
|
||||
];
|
||||
|
||||
/** 判断文件是否为不支持的类型(视频 / 旧版 .doc / .rtf) */
|
||||
function isUnsupportedFile(name) {
|
||||
const dot = String(name || "").lastIndexOf(".");
|
||||
const ext = (dot >= 0 ? String(name).slice(dot + 1) : "").toLowerCase();
|
||||
return UNSUPPORTED_FILE_EXTENSIONS.includes(ext);
|
||||
}
|
||||
|
||||
/** 过滤不支持的文件(视频 / 旧版 .doc / .rtf):返回过滤后的文件数组;有被拒文件时浮窗提示 */
|
||||
function filterOutUnsupported(files) {
|
||||
const list = Array.isArray(files) ? files : [];
|
||||
const accepted = list.filter((f) => !isUnsupportedFile(f && (f.name || "")));
|
||||
const rejected = list.length - accepted.length;
|
||||
if (rejected > 0) {
|
||||
showToast(`暂不支持 ${rejected} 个文件(视频 / 旧版 .doc / .rtf),已忽略`);
|
||||
}
|
||||
return accepted;
|
||||
}
|
||||
|
||||
export {
|
||||
$,
|
||||
escapeHtml,
|
||||
@@ -210,4 +264,7 @@ export {
|
||||
dedupePendingFiles,
|
||||
withClickGuard,
|
||||
closeInlinePanelsExcept,
|
||||
getFileIcon,
|
||||
isUnsupportedFile,
|
||||
filterOutUnsupported,
|
||||
};
|
||||
@@ -2,6 +2,7 @@
|
||||
// 工作空间列表统一从后端 workspace_config.json 读写,不再使用 localStorage
|
||||
|
||||
import { $, showToast } from './utils.js';
|
||||
import { showConfirm } from './dialog.js';
|
||||
import { api } from './api.js';
|
||||
import { state } from './state.js';
|
||||
|
||||
@@ -16,15 +17,44 @@ export async function initWorkspace() {
|
||||
state.workspacePath = data.current || "";
|
||||
state.workspaceDefault = data.default || "";
|
||||
state.workspaceList = data.list || [];
|
||||
// 设置快照存储基础目录
|
||||
if (data.snapshots_base) {
|
||||
try {
|
||||
const { setSnapBaseDir } = await import('./edit-mode.js');
|
||||
setSnapBaseDir(data.snapshots_base);
|
||||
} catch {}
|
||||
}
|
||||
state.forbiddenWorkspaces = data.forbidden || [];
|
||||
} catch { /* ignore */ }
|
||||
// 顶栏工作区 chip 跟随当前会话:会话归属工作区优先(含启动时的会话)
|
||||
const sessWs = (state.sessionWorkspaces || {})[state.sessionId];
|
||||
if (sessWs) state.workspacePath = sessWs;
|
||||
updateWsBtnState();
|
||||
updateTopBarWorkspace();
|
||||
// 启动时 bootstrap 先于本函数渲染会话列表,彼时 workspaceList 尚为空,
|
||||
// 列表会退化为无卡片平铺;加载完成后通知重渲染以按工作区分组。
|
||||
window.dispatchEvent(new CustomEvent("workspaces-changed"));
|
||||
}
|
||||
|
||||
/** 更新顶栏工作区 chip(选中工作区后显示:文件夹图标 + 名称) */
|
||||
export function updateTopBarWorkspace() {
|
||||
const chip = $("mainHeaderWs");
|
||||
if (!chip) return;
|
||||
const nameEl = $("mainHeaderWsName");
|
||||
if (!state.workspacePath) {
|
||||
chip.hidden = true;
|
||||
syncComposerStartState();
|
||||
return;
|
||||
}
|
||||
const parts = state.workspacePath.replace(/[\\/]+$/, "").split(/[\\/]/);
|
||||
if (nameEl) nameEl.textContent = parts[parts.length - 1] || state.workspacePath;
|
||||
chip.title = state.workspacePath;
|
||||
chip.hidden = false;
|
||||
syncComposerStartState();
|
||||
}
|
||||
|
||||
/** 同步"选择工作区开始对话"引导态:
|
||||
无工作区或无任何会话时,输入框内出现可悬停的虚线遮罩(点击选择工作区)。 */
|
||||
export function syncComposerStartState() {
|
||||
const composer = document.querySelector(".composer");
|
||||
const overlay = $("composerEmptyOverlay");
|
||||
if (!composer) return;
|
||||
const showPlus = !state.workspacePath || state.sessions.length === 0;
|
||||
composer.classList.toggle("composer--no-workspace", showPlus);
|
||||
if (overlay) overlay.hidden = !showPlus;
|
||||
}
|
||||
|
||||
/** 更新按钮激活状态 */
|
||||
@@ -62,6 +92,7 @@ async function openDropdown() {
|
||||
state.workspacePath = data.current || "";
|
||||
state.workspaceDefault = data.default || "";
|
||||
state.workspaceList = data.list || [];
|
||||
state.forbiddenWorkspaces = data.forbidden || [];
|
||||
} catch { /* ignore */ }
|
||||
// 欢迎区(居中态)向下展开,否则向上展开
|
||||
const composer = document.querySelector(".composer");
|
||||
@@ -172,8 +203,19 @@ function renderDropdown() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 选择工作空间 */
|
||||
async function selectWorkspace(path) {
|
||||
/** 该工作区下无会话时自动新增一个(仅在"添加工作区"完成后调用) */
|
||||
async function ensureSessionForWorkspace(path) {
|
||||
if (!path) return;
|
||||
try {
|
||||
const has = Object.values(state.sessionWorkspaces || {}).some((ws) => ws === path);
|
||||
if (has) return;
|
||||
const { newSession } = await import('./sessions.js');
|
||||
await newSession(path);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/** 选择工作空间(仅切换工作区/打开文件树,不自动新建会话) */
|
||||
export async function selectWorkspace(path) {
|
||||
try {
|
||||
const data = await api("/api/workspace/select", {
|
||||
method: "POST",
|
||||
@@ -181,7 +223,9 @@ async function selectWorkspace(path) {
|
||||
});
|
||||
state.workspacePath = data.current || "";
|
||||
updateWsBtnState();
|
||||
updateTopBarWorkspace();
|
||||
closeDropdown();
|
||||
window.dispatchEvent(new CustomEvent("workspaces-changed"));
|
||||
// 自动切换到 Edit 模式并打开该文件夹
|
||||
// 选中具体路径时直接用该路径,选中"默认工作空间"时用 default
|
||||
const targetPath = path || state.workspaceDefault;
|
||||
@@ -197,8 +241,8 @@ async function selectWorkspace(path) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 添加工作空间(打开系统文件夹选择器) */
|
||||
async function addWorkspace() {
|
||||
/** 添加工作空间(打开系统文件夹选择器)—— 输入区加号"选择工作区开始对话"也走此流程 */
|
||||
export async function addWorkspace() {
|
||||
if (_addingWorkspace) return;
|
||||
_addingWorkspace = true;
|
||||
try {
|
||||
@@ -230,6 +274,10 @@ async function addWorkspace() {
|
||||
// 自动选中
|
||||
await selectWorkspace(folderPath);
|
||||
|
||||
// 仅"添加工作区"流程在添加完成后新建会话,以便立即在该工作区开始对话;
|
||||
// 普通切换工作区不自动建会话
|
||||
await ensureSessionForWorkspace(folderPath);
|
||||
|
||||
// 重新渲染下拉列表
|
||||
renderDropdown();
|
||||
} finally {
|
||||
@@ -237,8 +285,16 @@ async function addWorkspace() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 从列表移除工作空间 */
|
||||
async function removeWorkspace(path) {
|
||||
/** 从列表移除工作空间(级联删除该工作区下的所有会话) */
|
||||
export async function removeWorkspace(path) {
|
||||
if (!path) return;
|
||||
const parts = path.replace(/[\\/]+$/, "").split(/[\\/]/);
|
||||
const name = parts[parts.length - 1] || path;
|
||||
const memberCount = state.sessions.filter((sid) => (state.sessionWorkspaces || {})[sid] === path).length;
|
||||
const hint = memberCount > 0 ? `该工作区下有 ${memberCount} 个会话,将一并删除。` : "";
|
||||
if (!await showConfirm(`确认移除工作区「${name}」吗?${hint}(不会删除文件夹本身)`)) return;
|
||||
|
||||
// 服务端移除工作区并级联删除其下所有会话,返回最新会话列表与回退会话
|
||||
try {
|
||||
const data = await api("/api/workspace/remove", {
|
||||
method: "POST",
|
||||
@@ -246,30 +302,61 @@ async function removeWorkspace(path) {
|
||||
});
|
||||
state.workspaceList = data.list || [];
|
||||
state.workspacePath = data.current || "";
|
||||
// 当前会话被级联删除:切换到服务端返回的回退会话(无剩余会话则为空引导态)
|
||||
if (state.sessionId && !(data.sessions || []).includes(state.sessionId)) {
|
||||
const { applySessionData } = await import('./sessions.js');
|
||||
await applySessionData(data);
|
||||
return;
|
||||
}
|
||||
// 当前会话仍存在:仅同步会话列表(被删会话消失)并重渲染
|
||||
state.sessions.length = 0;
|
||||
state.sessions.push(...(data.sessions || []));
|
||||
if (data.session_workspaces) state.sessionWorkspaces = data.session_workspaces;
|
||||
} catch (e) {
|
||||
showToast(e.message || String(e));
|
||||
}
|
||||
|
||||
renderDropdown();
|
||||
updateWsBtnState();
|
||||
updateTopBarWorkspace();
|
||||
window.dispatchEvent(new CustomEvent("workspaces-changed"));
|
||||
}
|
||||
|
||||
/** 绑定事件 */
|
||||
export function bindWorkspaceEvents() {
|
||||
const wsBtn = $("wsBtn");
|
||||
const wsAddBtn = $("wsAddBtn");
|
||||
|
||||
if (wsBtn) {
|
||||
wsBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
toggleDropdown();
|
||||
});
|
||||
}
|
||||
|
||||
if (wsAddBtn) {
|
||||
wsAddBtn.addEventListener("click", (e) => {
|
||||
// 侧栏品牌区:新增工作区按钮(系统文件夹选择器)
|
||||
const brandNewWsBtn = $("brandNewWorkspaceBtn");
|
||||
if (brandNewWsBtn) {
|
||||
brandNewWsBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
addWorkspace();
|
||||
});
|
||||
}
|
||||
|
||||
// 顶栏工作区 chip:文件夹图标→资源管理器打开;名称→打开文件树
|
||||
const wsIcon = $("mainHeaderWsIcon");
|
||||
if (wsIcon) {
|
||||
wsIcon.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const target = state.workspacePath || state.workspaceDefault;
|
||||
if (!target) return;
|
||||
if (window.electronAPI?.openInExplorer) {
|
||||
window.electronAPI.openInExplorer(target);
|
||||
} else {
|
||||
showToast("非桌面环境,无法打开资源管理器");
|
||||
}
|
||||
});
|
||||
}
|
||||
const wsNameEl = $("mainHeaderWsName");
|
||||
if (wsNameEl) {
|
||||
wsNameEl.addEventListener("click", async () => {
|
||||
const target = state.workspacePath || state.workspaceDefault;
|
||||
if (!target) return;
|
||||
try {
|
||||
const { switchToMode, openFolder } = await import('./edit-mode.js');
|
||||
switchToMode("edit");
|
||||
await openFolder(target);
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -47,36 +47,76 @@ from agent.core.config_manager import ( # noqa: E402
|
||||
|
||||
_WEB_DIR = Path(__file__).resolve().parent
|
||||
_IMAGE_DIR = _WEB_DIR / "image"
|
||||
_CONFIG_DIR = _WEB_DIR.parent / "agent" / "config" # src/agent/config/
|
||||
# 合并后的唯一配置文件(agent/agents/config.json),分区见 agent/core/config_manager.py
|
||||
_AGENTS_DIR = _WEB_DIR.parent / "agent" / "agents" # src/agent/agents/
|
||||
_CONFIG_PATH = _AGENTS_DIR / "config.json"
|
||||
|
||||
_AUDIO_EXT = frozenset(_AUDIO_EXTENSIONS)
|
||||
|
||||
# ---------- 工作空间配置持久化 ----------
|
||||
|
||||
_WS_CONFIG_PATH = _CONFIG_DIR / "workspace_config.json"
|
||||
_WS_CONFIG_PATH = _CONFIG_PATH
|
||||
|
||||
|
||||
def _read_ws_config() -> dict:
|
||||
"""读取工作空间配置文件。"""
|
||||
"""读取 config.json 的 workspace 分区。"""
|
||||
if _WS_CONFIG_PATH.is_file():
|
||||
try:
|
||||
with open(_WS_CONFIG_PATH, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
merged = json.load(f)
|
||||
cfg = merged.get("workspace") if isinstance(merged, dict) else None
|
||||
if isinstance(cfg, dict):
|
||||
return cfg
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
return {"current": "", "list": [], "access_mode": "restricted"}
|
||||
|
||||
|
||||
def _write_ws_config(data: dict) -> bool:
|
||||
"""写入工作空间配置文件。"""
|
||||
"""写入 config.json 的 workspace 分区(保留其他分区)。"""
|
||||
try:
|
||||
merged = {}
|
||||
if _WS_CONFIG_PATH.is_file():
|
||||
with open(_WS_CONFIG_PATH, "r", encoding="utf-8") as f:
|
||||
loaded = json.load(f)
|
||||
if isinstance(loaded, dict):
|
||||
merged = loaded
|
||||
merged["workspace"] = data
|
||||
with open(_WS_CONFIG_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
json.dump(merged, f, ensure_ascii=False, indent=2)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
# ---------- 禁止作为工作区的目录 ----------
|
||||
# 桌面 Desktop:防止对整个用户目录做 git 快照 / 文件监听 / 文件操作
|
||||
_FORBIDDEN_WORKSPACES_CACHE: list[str] | None = None
|
||||
|
||||
|
||||
def _get_forbidden_workspaces() -> list[str]:
|
||||
"""返回禁止作为工作区的目录(规范化绝对路径列表)。"""
|
||||
global _FORBIDDEN_WORKSPACES_CACHE
|
||||
if _FORBIDDEN_WORKSPACES_CACHE is None:
|
||||
_FORBIDDEN_WORKSPACES_CACHE = []
|
||||
home = os.path.expanduser("~")
|
||||
for candidate in (
|
||||
os.path.join(home, "Desktop"),
|
||||
os.path.join(home, "OneDrive", "Desktop"),
|
||||
):
|
||||
if os.path.isdir(candidate):
|
||||
_FORBIDDEN_WORKSPACES_CACHE.append(os.path.normcase(os.path.abspath(candidate)))
|
||||
return _FORBIDDEN_WORKSPACES_CACHE
|
||||
|
||||
|
||||
def _is_forbidden_workspace(path: str) -> bool:
|
||||
"""判断路径是否被禁止作为工作区(桌面等)。"""
|
||||
if not path:
|
||||
return False
|
||||
norm = os.path.normcase(os.path.abspath(path))
|
||||
return norm in _get_forbidden_workspaces()
|
||||
|
||||
|
||||
# 启动时加载已保存的工作空间配置
|
||||
def _init_workspace():
|
||||
"""从 workspace_config.json 恢复上次选择的工作空间。"""
|
||||
@@ -85,14 +125,14 @@ def _init_workspace():
|
||||
ws_list = cfg.get("list", [])
|
||||
changed = False
|
||||
|
||||
# 清理列表中已不存在的文件夹
|
||||
valid = [p for p in ws_list if os.path.isdir(p)]
|
||||
# 清理列表中已不存在的文件夹 / 被禁止的目录(如桌面)
|
||||
valid = [p for p in ws_list if os.path.isdir(p) and not _is_forbidden_workspace(p)]
|
||||
if len(valid) != len(ws_list):
|
||||
cfg["list"] = valid
|
||||
changed = True
|
||||
|
||||
# 上次选择的工作空间已不存在时,回退到默认工作空间
|
||||
if current and not os.path.isdir(current):
|
||||
# 上次选择的工作空间已不存在或被禁止时,回退到默认工作空间
|
||||
if current and (not os.path.isdir(current) or _is_forbidden_workspace(current)):
|
||||
current = ""
|
||||
cfg["current"] = ""
|
||||
changed = True
|
||||
@@ -381,12 +421,19 @@ def _inject_sub_tool_calls(
|
||||
tc["sub_tool_calls"] = sub_tc
|
||||
|
||||
|
||||
def _session_workspaces_map(ids: list[str]) -> dict[str, str]:
|
||||
"""会话 → 所属工作区 映射(供前端按工作区分组会话列表)。"""
|
||||
return {sid: ui.get_session_workspace(sid) for sid in ids}
|
||||
|
||||
|
||||
def _bootstrap(force_new: bool = False) -> tuple[str, list[str], list[dict[str, Any]]]:
|
||||
"""初始化默认会话:默认新建空会话;force_new=False 时回退到最近会话。
|
||||
"""初始化默认会话:默认回退到最近会话;force_new=True 时始终新建空会话。
|
||||
|
||||
输入:
|
||||
force_new: 为 True 时始终新建空会话(应用每次打开默认新建会话);
|
||||
为 False 时取最近会话(删除会话后的回退逻辑)。
|
||||
force_new: 为 True 时始终新建空会话;
|
||||
为 False 时取最近会话(无任何会话时**不再自动新建**,
|
||||
返回空 sessionId 由前端展示"选择工作区开始对话"引导态),
|
||||
(删除会话后的回退逻辑也使用本函数)。
|
||||
|
||||
输出:
|
||||
(session_id, sessions列表, messages列表)
|
||||
@@ -398,10 +445,18 @@ def _bootstrap(force_new: bool = False) -> tuple[str, list[str], list[dict[str,
|
||||
可增加用户偏好默认会话逻辑。
|
||||
"""
|
||||
ids = ui.list_session_ids_ordered()
|
||||
# 无任何会话且非强制新建:不自动创建,返回空会话(首次启动 / 全部会话已删除)
|
||||
if not ids and not force_new:
|
||||
return "", [], []
|
||||
if force_new or not ids:
|
||||
sid = ui.new_timestamp_session_id()
|
||||
hist = []
|
||||
ids = [sid] + [x for x in ids if x != sid]
|
||||
# 记录新建会话所属工作区(按工作区分组展示的依据);被禁止的目录视为未归属
|
||||
_ws = ui.get_current_workspace()
|
||||
if _is_forbidden_workspace(_ws):
|
||||
_ws = ""
|
||||
ui.set_session_workspace(sid, _ws)
|
||||
else:
|
||||
sid = ids[0]
|
||||
hist = ui.load_ui_messages_for_session(sid)
|
||||
@@ -471,25 +526,30 @@ def api_bootstrap() -> dict[str, Any]:
|
||||
|
||||
系统定位:
|
||||
前端页面加载时调用的第一个 API,用于初始化界面。
|
||||
打开应用不自动新建会话:回退到最近会话继续(仅当完全没有任何会话时才新建)。
|
||||
|
||||
可扩展性:
|
||||
可返回用户配置、主题等元信息。
|
||||
"""
|
||||
sid, ids, hist = _bootstrap(force_new=True)
|
||||
sid, ids, hist = _bootstrap(force_new=False)
|
||||
from agent.history.tool_call_recorder import get_session_tokens
|
||||
return {
|
||||
"sessionId": sid,
|
||||
"sessions": ids,
|
||||
"session_workspaces": _session_workspaces_map(ids),
|
||||
"messages": _serialize_messages(hist, sid),
|
||||
"tokens": get_session_tokens(sid),
|
||||
"thinking_level": _session_thinking_level(sid),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/sessions/new")
|
||||
def api_new_session() -> dict[str, Any]:
|
||||
def api_new_session(workspace: str = Form("")) -> dict[str, Any]:
|
||||
"""创建新会话(基于时间戳),返回会话信息。
|
||||
|
||||
输入: 无。
|
||||
输入:
|
||||
workspace: 可选,指定新会话归属的工作区路径(工作区卡片"新增会话"按钮传入);
|
||||
为空时归属当前选中的工作区。
|
||||
|
||||
输出:
|
||||
{"sessionId": str, "sessions": list[str], "messages": []}
|
||||
@@ -498,9 +558,21 @@ def api_new_session() -> dict[str, Any]:
|
||||
前端点击“新建会话”时调用。
|
||||
"""
|
||||
sid = ui.new_timestamp_session_id()
|
||||
# 记录新会话所属工作区(按工作区分组展示的依据);被禁止的目录(如桌面)视为未归属
|
||||
ws = workspace or ui.get_current_workspace()
|
||||
if _is_forbidden_workspace(ws):
|
||||
ws = ""
|
||||
ui.set_session_workspace(sid, ws)
|
||||
ids = ui.list_session_ids_ordered()
|
||||
ids = [sid] + [x for x in ids if x != sid]
|
||||
return {"sessionId": sid, "sessions": ids, "messages": [], "tokens": 0}
|
||||
return {
|
||||
"sessionId": sid,
|
||||
"sessions": ids,
|
||||
"session_workspaces": _session_workspaces_map(ids),
|
||||
"messages": [],
|
||||
"tokens": 0,
|
||||
"thinking_level": _session_thinking_level(sid),
|
||||
}
|
||||
|
||||
|
||||
def _branch_copy_attachment(abs_src: str, dst_dir: str, new_sid: str) -> str:
|
||||
@@ -622,13 +694,24 @@ def api_session_branch(
|
||||
shutil.rmtree(dst_dir, ignore_errors=True)
|
||||
raise HTTPException(status_code=400, detail="无效的分支回合")
|
||||
|
||||
# 分支会话继承源会话的工作区归属与思考档位
|
||||
ui.set_session_workspace(new_sid, ui.get_session_workspace(session_id))
|
||||
try:
|
||||
from agent.utils.agent_utils import get_session_thinking_level, set_session_thinking_level
|
||||
lv = get_session_thinking_level(session_id)
|
||||
if lv in ("low", "high", "xhigh", "max", "ultra"):
|
||||
set_session_thinking_level(new_sid, lv)
|
||||
except Exception:
|
||||
pass
|
||||
ids = _ensure_session_in_list(new_sid, ui.list_session_ids_ordered())
|
||||
from agent.history.tool_call_recorder import get_session_tokens
|
||||
return {
|
||||
"sessionId": new_sid,
|
||||
"sessions": ids,
|
||||
"session_workspaces": _session_workspaces_map(ids),
|
||||
"messages": _serialize_messages(ui.load_ui_messages_for_session(new_sid), new_sid),
|
||||
"tokens": get_session_tokens(new_sid),
|
||||
"thinking_level": _session_thinking_level(new_sid),
|
||||
}
|
||||
|
||||
|
||||
@@ -653,8 +736,10 @@ def api_session_messages(session_id: str) -> dict[str, Any]:
|
||||
return {
|
||||
"sessionId": session_id,
|
||||
"sessions": ids,
|
||||
"session_workspaces": _session_workspaces_map(ids),
|
||||
"messages": _serialize_messages(hist, session_id),
|
||||
"tokens": get_session_tokens(session_id),
|
||||
"thinking_level": _session_thinking_level(session_id),
|
||||
}
|
||||
|
||||
|
||||
@@ -712,18 +797,35 @@ def api_delete_session(session_id: str = Form(...)) -> dict[str, Any]:
|
||||
ui.delete_session(session_id)
|
||||
turn_runner.drop_turn(session_id)
|
||||
# delete_session 已清理 tool_calling 目录,此处仅需清理 token
|
||||
# (clear 内部有存在性守卫,不会回写重建已删除的会话目录)
|
||||
try:
|
||||
from agent.history.tool_call_recorder import clear_session_tokens
|
||||
clear_session_tokens(session_id)
|
||||
except Exception:
|
||||
pass
|
||||
# 延迟兜底:删除瞬间仍在执行的轮次可能在 rmtree 之后完成最后一次 token 回写
|
||||
# (LLM 调用跨越删除时刻),2s 后再清一次目录确保不残留。
|
||||
# session_id 为时间戳生成不会复用,无条件清理安全;对不存在的目录/行是 no-op。
|
||||
import threading, time as _time
|
||||
|
||||
def _delayed_sweep() -> None:
|
||||
_time.sleep(2.0)
|
||||
try:
|
||||
from agent.history import session_storage
|
||||
session_storage.delete_session(session_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
threading.Thread(target=_delayed_sweep, daemon=True).start()
|
||||
sid, ids, hist = _bootstrap()
|
||||
from agent.history.tool_call_recorder import get_session_tokens
|
||||
return {
|
||||
"sessionId": sid,
|
||||
"sessions": ids,
|
||||
"messages": _serialize_messages(hist, sid),
|
||||
"session_workspaces": _session_workspaces_map(ids),
|
||||
"messages": hist,
|
||||
"tokens": get_session_tokens(sid),
|
||||
"thinking_level": _session_thinking_level(sid),
|
||||
}
|
||||
|
||||
|
||||
@@ -1006,18 +1108,22 @@ def _stream_chat_complete_core(session_id: str, output_type: str = "text", agent
|
||||
# 语音模式:流式 TTS
|
||||
if output_type == "voice" and final_text.strip():
|
||||
request_id = f"tts-{session_id}-{int(_time.time() * 1000)}"
|
||||
tts_url = "http://localhost:8902"
|
||||
from agent.utils.env_utils import get_service_model_config
|
||||
tts_cfg = get_service_model_config("tts")
|
||||
tts_url = tts_cfg["base_url"]
|
||||
|
||||
# 清理文本 + 按句号换行(帮助 VoxCPM 分句)
|
||||
tts_text = _clean_tts_text(final_text)
|
||||
|
||||
import requests as _req
|
||||
tts_headers = {"Content-Type": "application/json"}
|
||||
if tts_cfg["api_key"]:
|
||||
tts_headers["Authorization"] = f"Bearer {tts_cfg['api_key']}"
|
||||
try:
|
||||
from agent.utils.env_utils import STREAMING_TTS_URL
|
||||
tts_url = STREAMING_TTS_URL or tts_url
|
||||
|
||||
# 清理文本 + 按句号换行(帮助 VoxCPM 分句)
|
||||
tts_text = _clean_tts_text(final_text)
|
||||
|
||||
import requests as _req
|
||||
resp = _req.post(
|
||||
f"{tts_url}/stream_tts",
|
||||
json={"text": tts_text, "request_id": request_id},
|
||||
json={"text": tts_text, "request_id": request_id, "model": tts_cfg["model"]},
|
||||
headers=tts_headers,
|
||||
stream=True,
|
||||
timeout=300,
|
||||
)
|
||||
@@ -1088,6 +1194,22 @@ def api_chat_abort(session_id: str = Form(...)) -> dict[str, Any]:
|
||||
resolve_all_for_session(session_id, decision="reject", instruction="已被用户手动暂停")
|
||||
except Exception:
|
||||
pass
|
||||
# 立即终结当前 live turn 并落盘完整轮次 meta(ended_at/duration)。
|
||||
# 此刻 execute_agent 仍在后台线程收尾,尚未调用 save_aborted_turn;
|
||||
# 若不在此处补写,下方 _serialize_messages 里 get_turn_meta 读到的是
|
||||
# 增量持久化的半成品记录(只有 started_at、无 duration),前端回退按
|
||||
# 工具时间戳推算总耗时,而单条计时已移除 → 本轮用时显示为 0。
|
||||
# execute_agent 随后的重复调用是安全的:live 记录已被 end_live_turn
|
||||
# 弹出,save_aborted_turn 对空记录为 no-op,不会覆盖这里的完整 meta。
|
||||
try:
|
||||
from agent.utils.agent_utils import get_last_turn_id
|
||||
from agent.history.tool_call_recorder import save_aborted_turn, end_live_turn
|
||||
live_turn_id = get_last_turn_id(session_id)
|
||||
if live_turn_id:
|
||||
save_aborted_turn(session_id, live_turn_id)
|
||||
end_live_turn(session_id)
|
||||
except Exception:
|
||||
pass
|
||||
# 不 rollback,保留当前轮已完成的工具调用记录,追加暂停消息结束本轮
|
||||
hist = ui.load_ui_messages_for_session(session_id)
|
||||
paused = list(hist or [])
|
||||
@@ -1149,7 +1271,17 @@ def api_chat_rollback_to_message(
|
||||
break
|
||||
delete_turns_after(session_id, keep_turn_id)
|
||||
|
||||
# 3. 同步 turn 文件(写最新的 turn 状态)
|
||||
# 3. 恢复会话所属工作区的 git 状态与文件(回撤到该点之前;
|
||||
# 非仓库/无对应 commit 时跳过,仅回撤消息)
|
||||
try:
|
||||
from agent.utils.git_utils import rollback_workspace_to
|
||||
ws = ui.get_session_workspace(session_id)
|
||||
if ws:
|
||||
rollback_workspace_to(ws, session_id, keep_turn_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 4. 同步 turn 文件(写最新的 turn 状态)
|
||||
|
||||
ids = _ensure_session_in_list(session_id, ui.list_session_ids_ordered())
|
||||
return {
|
||||
@@ -1297,6 +1429,24 @@ def api_save_models(data: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
# ---------- 工作空间 API ----------
|
||||
|
||||
@app.get("/api/workspace/git-status")
|
||||
def api_workspace_git_status(path: str = "") -> dict[str, Any]:
|
||||
"""返回工作区 git 状态(供 Edit 文件树着色:新增绿 / 修改橙 / 删除橙)。
|
||||
|
||||
输入:
|
||||
path: 查询参数,目标工作区路径;为空时使用当前选中的工作区。
|
||||
|
||||
输出:
|
||||
{"repo": bool, "status": [{"path": str, "state": str}]}
|
||||
state: untracked | modified | deleted
|
||||
"""
|
||||
from agent.utils.git_utils import status
|
||||
ws = path or ui.get_current_workspace()
|
||||
if not ws or not os.path.isdir(ws):
|
||||
return {"repo": False, "status": []}
|
||||
return {"repo": True, "status": status(ws)}
|
||||
|
||||
|
||||
@app.get("/api/workspace")
|
||||
def api_get_workspace() -> dict[str, Any]:
|
||||
"""获取工作空间配置。
|
||||
@@ -1318,18 +1468,24 @@ def api_get_workspace() -> dict[str, Any]:
|
||||
current = _current_workspace_dir or cfg.get("current", "")
|
||||
ws_list = cfg.get("list", [])
|
||||
|
||||
# 清理列表中已不存在的文件夹
|
||||
valid = [p for p in ws_list if os.path.isdir(p)]
|
||||
# 清理列表中已不存在的文件夹 / 被禁止的目录(如桌面)
|
||||
valid = [p for p in ws_list if os.path.isdir(p) and not _is_forbidden_workspace(p)]
|
||||
if len(valid) != len(ws_list):
|
||||
cfg["list"] = valid
|
||||
_write_ws_config(cfg)
|
||||
# 当前选中被禁止时回退默认(如历史遗留的桌面工作区)
|
||||
if _is_forbidden_workspace(current):
|
||||
current = ""
|
||||
cfg["current"] = ""
|
||||
_write_ws_config(cfg)
|
||||
|
||||
return {
|
||||
"current": current,
|
||||
"list": valid,
|
||||
"default": get_workspace_dir(),
|
||||
"snapshots_base": os.path.join(SESSIONS_ROOT, ".minor_snapshots").replace("\\", "/"),
|
||||
"access_mode": get_access_mode(),
|
||||
# 前端据此拦截被禁止的工作区(如桌面),禁止选中/绑定
|
||||
"forbidden": [p.replace("\\", "/") for p in _get_forbidden_workspaces()],
|
||||
}
|
||||
|
||||
|
||||
@@ -1359,6 +1515,9 @@ def api_select_workspace(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
from agent.memory.system_prompt import set_current_workspace
|
||||
path = data.get("path", "") if isinstance(data, dict) else ""
|
||||
# 禁止将桌面等目录作为工作区
|
||||
if _is_forbidden_workspace(path):
|
||||
raise HTTPException(status_code=400, detail="禁止将桌面(Desktop)作为工作区,请选择项目文件夹")
|
||||
cfg = _read_ws_config()
|
||||
cfg["current"] = path
|
||||
_write_ws_config(cfg)
|
||||
@@ -1388,6 +1547,9 @@ def api_add_workspace(data: dict[str, Any]) -> dict[str, Any]:
|
||||
path = data.get("path", "") if isinstance(data, dict) else ""
|
||||
if not path or not os.path.isdir(path):
|
||||
raise HTTPException(status_code=400, detail="无效的工作空间路径")
|
||||
# 禁止将桌面等目录作为工作区
|
||||
if _is_forbidden_workspace(path):
|
||||
raise HTTPException(status_code=400, detail="禁止将桌面(Desktop)作为工作区,请选择项目文件夹")
|
||||
cfg = _read_ws_config()
|
||||
ws_list = cfg.get("list", [])
|
||||
if path not in ws_list:
|
||||
@@ -1401,10 +1563,12 @@ def api_add_workspace(data: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
@app.post("/api/workspace/remove")
|
||||
def api_remove_workspace(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""从列表中移除工作空间。
|
||||
"""从列表中移除工作空间,并级联删除其下所有会话(避免列表残留孤儿会话)。
|
||||
|
||||
输入: {"path": str} - 要移除的工作空间路径
|
||||
输出: {"success": bool, "list": list[str], "current": str}
|
||||
输出: {"success": bool, "list": list[str], "current": str,
|
||||
"sessionId": str, "sessions": list[str],
|
||||
"session_workspaces": dict[str, str], "messages": list[dict]}
|
||||
"""
|
||||
from agent.memory.system_prompt import set_current_workspace, _current_workspace_dir
|
||||
path = data.get("path", "") if isinstance(data, dict) else ""
|
||||
@@ -1424,7 +1588,32 @@ def api_remove_workspace(data: dict[str, Any]) -> dict[str, Any]:
|
||||
except Exception:
|
||||
pass
|
||||
_write_ws_config(cfg)
|
||||
return {"success": True, "list": ws_list, "current": cfg.get("current", "")}
|
||||
|
||||
# 级联删除该工作区下的所有会话(目录、工具记录、token 一并清理)
|
||||
if path:
|
||||
try:
|
||||
for sid in list(ui.list_session_ids_ordered()):
|
||||
if ui.get_session_workspace(sid) == path:
|
||||
ui.delete_session(sid)
|
||||
try:
|
||||
from agent.history.tool_call_recorder import clear_session_tokens
|
||||
clear_session_tokens(sid)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 回退会话:当前会话若被级联删除,返回最近可用会话;无任何会话时返回空引导态
|
||||
sid, ids, hist = _bootstrap(force_new=False)
|
||||
return {
|
||||
"success": True,
|
||||
"list": ws_list,
|
||||
"current": cfg.get("current", ""),
|
||||
"sessionId": sid,
|
||||
"sessions": ids,
|
||||
"session_workspaces": _session_workspaces_map(ids),
|
||||
"messages": hist,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/config/env")
|
||||
@@ -1513,7 +1702,10 @@ def api_services_status() -> dict[str, Any]:
|
||||
输出: {"asr": {"ok": bool, "url": str}, "tts": {"ok": bool, "url": str}}
|
||||
"""
|
||||
import requests as _req
|
||||
from agent.utils.env_utils import ASR_BASE_URL, STREAMING_TTS_URL
|
||||
from agent.utils.env_utils import get_service_model_config
|
||||
|
||||
asr_cfg = get_service_model_config("asr")
|
||||
tts_cfg = get_service_model_config("tts")
|
||||
|
||||
def _probe(url: str) -> bool:
|
||||
if not url or not str(url).strip():
|
||||
@@ -1525,8 +1717,8 @@ def api_services_status() -> dict[str, Any]:
|
||||
return False
|
||||
|
||||
return {
|
||||
"asr": {"ok": _probe(ASR_BASE_URL), "url": ASR_BASE_URL or ""},
|
||||
"tts": {"ok": _probe(STREAMING_TTS_URL), "url": STREAMING_TTS_URL or ""},
|
||||
"asr": {"ok": _probe(asr_cfg["base_url"]), "url": asr_cfg["base_url"] or ""},
|
||||
"tts": {"ok": _probe(tts_cfg["base_url"]), "url": tts_cfg["base_url"] or ""},
|
||||
}
|
||||
|
||||
|
||||
@@ -1631,35 +1823,43 @@ def api_save_compress_settings(data: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"success": updated}
|
||||
|
||||
|
||||
# ---------- 深度思考档位 API ----------
|
||||
@app.post("/api/config/thinking-level")
|
||||
def api_set_thinking_level(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""设置深度思考档位(low | high | xhigh | max | ultra),重建 Agent 运行时。
|
||||
# ---------- 深度思考档位 API(会话级) ----------
|
||||
@app.post("/api/sessions/{session_id}/thinking-level")
|
||||
def api_set_session_thinking_level(session_id: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""设置指定会话的思考档位(low | high | xhigh | max | ultra)。
|
||||
|
||||
档位同时决定运行模式与思考开关:
|
||||
low = agent + 不思考;high = plan + 不思考;
|
||||
xhigh = agent + 思考;max = plan + 思考;
|
||||
ultra 为未来 react→审批图预留占位(暂按 agent + 思考)。
|
||||
|
||||
档位存于该会话的 session_meta.json(会话级变量,仅作用于该会话);
|
||||
每轮执行时由 Multimodel_LLM 按当前会话动态解析 extra_body。
|
||||
|
||||
输入: {"level": str}
|
||||
输出: {"success": bool, "level": str}
|
||||
输出: {"success": bool, "session_id": str, "level": str}
|
||||
"""
|
||||
from agent.utils.env_utils import THINKING_LEVELS, _load_config
|
||||
from agent.utils.env_utils import THINKING_LEVELS
|
||||
from agent.utils.agent_utils import set_session_thinking_level
|
||||
level = data.get("level", "") if isinstance(data, dict) else ""
|
||||
if level not in THINKING_LEVELS:
|
||||
raise HTTPException(status_code=400, detail="无效的思考档位(low | high | xhigh | max | ultra)")
|
||||
config = load_env_config()
|
||||
config["THINKING_LEVEL"] = level
|
||||
if not save_env_config(config):
|
||||
raise HTTPException(status_code=500, detail="保存思考档位失败")
|
||||
_load_config()
|
||||
# 重建 Agent 运行时使新的 extra_body 生效
|
||||
if not session_id:
|
||||
raise HTTPException(status_code=400, detail="缺少 session_id")
|
||||
set_session_thinking_level(session_id, level)
|
||||
return {"success": True, "session_id": session_id, "level": level}
|
||||
|
||||
|
||||
def _session_thinking_level(session_id: str) -> str:
|
||||
"""读取会话级思考档位(bootstrap / 切会话响应时返回,供前端同步滑条)。"""
|
||||
try:
|
||||
from agent.agents.agent_runtime import reload_all_agent_runtimes
|
||||
reload_all_agent_runtimes()
|
||||
from agent.utils.agent_utils import get_session_thinking_level
|
||||
lv = get_session_thinking_level(session_id)
|
||||
if lv in ("low", "high", "xhigh", "max", "ultra"):
|
||||
return lv
|
||||
except Exception:
|
||||
pass
|
||||
return {"success": True, "level": level}
|
||||
return "low"
|
||||
|
||||
|
||||
# ---------- 会话 Token 用量 API ----------
|
||||
@@ -2024,13 +2224,15 @@ def favicon() -> FileResponse:
|
||||
|
||||
@app.get("/api/config/theme")
|
||||
def get_theme_config() -> dict:
|
||||
"""读取主题配置。"""
|
||||
"""读取主题配置(config.json 的 theme 分区)。"""
|
||||
import json
|
||||
config_path = _CONFIG_DIR / "theme_config.json"
|
||||
if config_path.is_file():
|
||||
if _CONFIG_PATH.is_file():
|
||||
try:
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
with open(_CONFIG_PATH, "r", encoding="utf-8") as f:
|
||||
merged = json.load(f)
|
||||
theme = merged.get("theme") if isinstance(merged, dict) else None
|
||||
if isinstance(theme, dict):
|
||||
return theme
|
||||
except Exception:
|
||||
pass
|
||||
return {"theme": "cyber", "bg_image": "", "font_family": "msjh", "font_size": "14px", "language": "zh"}
|
||||
@@ -2038,12 +2240,20 @@ def get_theme_config() -> dict:
|
||||
|
||||
@app.post("/api/config/theme")
|
||||
async def save_theme_config(request: Request):
|
||||
"""保存主题配置。"""
|
||||
config_path = _CONFIG_DIR / "theme_config.json"
|
||||
_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
data = await request.json()
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
"""保存主题配置(config.json 的 theme 分区)。"""
|
||||
merged = {}
|
||||
if _CONFIG_PATH.is_file():
|
||||
try:
|
||||
with open(_CONFIG_PATH, "r", encoding="utf-8") as f:
|
||||
loaded = json.load(f)
|
||||
if isinstance(loaded, dict):
|
||||
merged = loaded
|
||||
except Exception:
|
||||
merged = {}
|
||||
merged["theme"] = await request.json()
|
||||
_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(_CONFIG_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump(merged, f, ensure_ascii=False, indent=2)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -2168,10 +2378,15 @@ def _xls_to_tsv(filepath: str, sheet_name: str = "") -> dict[str, Any]:
|
||||
return {"content": "\n".join(lines), "sheet": selected, "sheets": sheets}
|
||||
|
||||
|
||||
# 前端入口/模块文件禁用启发式缓存:始终向浏览器声明"需重新校验",
|
||||
# 避免旧 HTML/JS 被缓存导致双模块实例或陈旧界面(本地服务,重校验开销可忽略)。
|
||||
_NO_CACHE = {"Cache-Control": "no-cache"}
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def index_page() -> FileResponse:
|
||||
"""返回前端主页面 index.html。"""
|
||||
return FileResponse(_WEB_DIR / "index.html")
|
||||
return FileResponse(_WEB_DIR / "index.html", headers=_NO_CACHE)
|
||||
|
||||
|
||||
# ==================== 定时任务(Cron)API ====================
|
||||
@@ -2324,6 +2539,7 @@ async def api_cron_create(request: Request) -> dict[str, Any]:
|
||||
trigger=trig,
|
||||
enabled=bool(body.get("enabled", True)),
|
||||
timeout_seconds=int(body.get("timeout_seconds", 300) or 300),
|
||||
workspace=str(body.get("workspace", "") or ""),
|
||||
created_at=now_iso(),
|
||||
last_status="pending",
|
||||
)
|
||||
@@ -2367,6 +2583,8 @@ async def api_cron_update(task_id: str, request: Request) -> dict[str, Any]:
|
||||
task.prompt = str(body["prompt"] or "")
|
||||
if "timeout_seconds" in body:
|
||||
task.timeout_seconds = int(body["timeout_seconds"] or 300)
|
||||
if "workspace" in body:
|
||||
task.workspace = str(body["workspace"] or "")
|
||||
if "enabled" in body:
|
||||
task.enabled = bool(body["enabled"])
|
||||
if "trigger" in body:
|
||||
@@ -2576,25 +2794,25 @@ async def api_cron_internal_finished(request: Request) -> dict[str, Any]:
|
||||
@app.get("/app.css")
|
||||
def serve_css() -> FileResponse:
|
||||
"""返回样式文件 app.css。"""
|
||||
return FileResponse(_WEB_DIR / "app.css")
|
||||
return FileResponse(_WEB_DIR / "app.css", headers=_NO_CACHE)
|
||||
|
||||
|
||||
@app.get("/app.js")
|
||||
def serve_js() -> FileResponse:
|
||||
"""返回前端脚本 app.js。"""
|
||||
return FileResponse(_WEB_DIR / "app.js")
|
||||
return FileResponse(_WEB_DIR / "app.js", headers=_NO_CACHE)
|
||||
|
||||
|
||||
@app.get("/js/{filename:path}")
|
||||
def serve_js_module(filename: str) -> FileResponse:
|
||||
"""返回前端 JS 模块文件。"""
|
||||
return FileResponse(_WEB_DIR / "js" / filename)
|
||||
return FileResponse(_WEB_DIR / "js" / filename, headers=_NO_CACHE)
|
||||
|
||||
|
||||
@app.get("/css/{filename:path}")
|
||||
def serve_css_module(filename: str) -> FileResponse:
|
||||
"""返回前端 CSS 模块文件。"""
|
||||
return FileResponse(_WEB_DIR / "css" / filename)
|
||||
return FileResponse(_WEB_DIR / "css" / filename, headers=_NO_CACHE)
|
||||
|
||||
|
||||
@app.get("/fonts/{filename}")
|
||||
|
||||
@@ -69,6 +69,42 @@ def new_timestamp_session_id() -> str:
|
||||
n += 1
|
||||
|
||||
|
||||
def get_current_workspace() -> str:
|
||||
"""当前选中的工作空间路径(空串=默认),供会话归属记录使用。"""
|
||||
try:
|
||||
from agent.memory.system_prompt import _current_workspace_dir
|
||||
return _current_workspace_dir or ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def set_session_workspace(session_id: str, workspace: str) -> None:
|
||||
"""记录会话所属工作区(合并写入会话 meta,后端无关)。
|
||||
|
||||
会话按工作区分组展示的依据;创建/分支会话时由 API 层调用。
|
||||
"""
|
||||
if not session_id:
|
||||
return
|
||||
try:
|
||||
from agent.history import session_storage
|
||||
extra = session_storage.load_session_extra(session_id) or {}
|
||||
meta = dict(extra.get("agent_meta") or {})
|
||||
meta["workspace"] = workspace or ""
|
||||
session_storage.save_session_extra(session_id, {"agent_meta": meta})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def get_session_workspace(session_id: str) -> str:
|
||||
"""读取会话所属工作区(空串=默认/未分配)。"""
|
||||
try:
|
||||
from agent.history import session_storage
|
||||
extra = session_storage.load_session_extra(session_id) or {}
|
||||
return str((extra.get("agent_meta") or {}).get("workspace") or "")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def prune_memory_session_ids() -> None:
|
||||
"""移除已持久化的内存 session_id 缓存,避免集合无限增长。
|
||||
|
||||
@@ -457,6 +493,14 @@ def sync_turn_from_chat_history(session_id: str, chat_history: list, latest_user
|
||||
messages_out.append(item)
|
||||
|
||||
save_turn_messages(session_id, turn_id, messages_out)
|
||||
# 轮次结束:提交会话所属工作区的 git 快照(供回撤恢复文件;失败静默不影响主流程)
|
||||
try:
|
||||
from agent.utils.git_utils import commit_turn
|
||||
ws = get_session_workspace(session_id)
|
||||
if ws:
|
||||
commit_turn(ws, session_id, turn_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def rollback_turns_from_history(session_id: str, chat_history: list) -> int:
|
||||
|
||||