diff --git a/Agent/doc/commands.md b/Agent/doc/commands.md new file mode 100644 index 0000000..09cfcbc --- /dev/null +++ b/Agent/doc/commands.md @@ -0,0 +1,70 @@ +# 交互指令(CLI) + +以下指令均以 `/` 开头。默认进入对话输入模式,输入指令用于控制程序状态。 + +## 指令清单 + +- `/help`:显示所有指令与简要说明。 +- `/new`:创建新对话(生成新的对话 ID)。 +- `/resume`:加载旧对话(仅当前工作区路径内的历史对话)。 +- `/allow`:切换运行模式(`read_only` / `full_access`)。 +- `/model`:切换模型与思考模式。 +- `/status`:查看当前对话状态。 +- `/compact`:触发对话压缩。 +- `/config`:查看当前配置。 +- `/exit`:退出程序。 + +--- + +## /resume 交互细节 + +`/resume` 有两种用法: + +1) **交互列表模式**: +- 执行 `/resume` 后显示列表(最新在上)。 +- 上下方向键移动选择光标 `>`。 +- 兼容性兜底:支持**数字输入**直接选择第 N 条。 +- 列表项格式示例: +``` + Updated Conversation + 2分钟前 帮我看看...(第一条用户消息前50字) +> 14小时前 查看所有... + 13天前 ... +``` + +2) **直接加载模式**: +- 直接输入 `/resume ` 加载指定对话。 +- 例如: +``` +/resume 019c99ad-3177-7970-bcf5-0c5893e177a2 +``` + +**作用域限制**:`/resume` 只加载**当前工作区路径**下的历史对话。不同路径的工作区对话相互隔离。 + +--- + +## /status 输出字段 + +- 当前模型 + 思考模式 +- 工作区 path +- allow 状态(只读/无限制) +- 对话 ID +- Token usage(当前上下文长度,来自 API 返回并缓存) + +--- + +## /config 输出字段 + +- base_url +- modelname +- apikey(脱敏,仅显示前缀+末尾,例如:`sk-xxxxxxw2y`) + +--- + +## /compact 行为 + +- 对当前对话进行压缩。 +- 输出示例: +``` +压缩完成:<旧token> -> <新token> +``` diff --git a/Agent/doc/release_checklist.md b/Agent/doc/release_checklist.md new file mode 100644 index 0000000..709d232 --- /dev/null +++ b/Agent/doc/release_checklist.md @@ -0,0 +1,27 @@ +# 发布检查清单 + +> 目标:确保 easyagent 可安全发布与安装。 + +## 版本与文档 + +- [ ] 更新 `package.json` 版本号 +- [ ] 补充 `CHANGELOG.md` +- [ ] 检查 `README.md` 内容是否与功能一致 + +## 配置与密钥 + +- [ ] 确认 `models.json` 未被提交(应在 `.gitignore`) +- [ ] `models.example.json` 已更新且不包含真实密钥 + +## 运行验证 + +- [ ] `npm install` 成功 +- [ ] `npm run start` 能启动并进入对话 +- [ ] `/help`、`/model`、`/resume` 流程验证 +- [ ] 工具调用(读文件/执行命令)至少验证一次 + +## 打包/分发(如需发布到 npm) + +- [ ] `bin/eagent` 可执行权限与入口正确 +- [ ] `npm pack --dry-run` 检查发布内容 +- [ ] README 与示例配置在包中可访问 diff --git a/Agent/doc/tool_results.md b/Agent/doc/tool_results.md new file mode 100644 index 0000000..af03ed7 --- /dev/null +++ b/Agent/doc/tool_results.md @@ -0,0 +1,177 @@ +# 工具返回格式(高信息密度文本,非 JSON) + +> 说明:工具真实执行会产生完整 JSON 结果,仅用于存储/审计;**返回给模型的内容必须是高信息密度文本**。参考项目中的“JSON -> 文本摘要”逻辑,但按 easyagent 需求进一步压缩。 + +--- + +## read_file + +### type=read +**仅返回 content 原文**(不加标题、不加行号、不加路径)。 + +返回示例: +``` +<文件内容原文> +``` + +### type=search +返回“命中片段拼接文本”,每个片段用极简头标识(保留行号对定位有价值),不返回其他噪声。 + +返回示例: +``` +[match_1] L10-14 hits:12 +<片段内容> + +[match_2] L88-92 hits:90,91 +<片段内容> +``` + +### type=extract +返回“抽取片段拼接文本”,每个片段带 label/行号(若有)。 + +返回示例: +``` +[segment_init] L10-20 +<片段内容> + +[segment_2] L40-45 +<片段内容> +``` + +### error +``` +失败: <错误信息> +``` + +--- + +## edit_file + +成功: +``` +已替换 处: +``` + +失败: +``` +失败: <错误信息> +``` + +--- + +## web_search + +仅返回搜索摘要文本(来自 search_with_summary 的 summary),不返回 JSON 结果。 + +返回示例: +``` +🔍 搜索查询: xxx +📅 搜索时间: 2025-01-01T12:00:00 + +📝 AI摘要: +... + +--- + +📊 搜索结果: +1. 标题... + 🔗 URL + 📄 摘要... +``` + +失败: +``` +失败: <错误信息> +``` + +--- + +## extract_webpage + +### mode=read +返回网页正文为主,保留最小必要标识(URL)。 + +返回示例: +``` +URL: https://example.com/... +<网页正文> +``` + +### mode=save +返回保存结果的简短确认信息。 + +成功: +``` +已保存: (chars=, bytes=) +``` + +失败: +``` +失败: <错误信息> +``` + +--- + +## run_command + +仅返回命令输出;如超时/错误则在前缀标注状态。 + +成功: +``` + +``` + +超时: +``` +[timeout after ] +<已捕获输出> +``` + +失败: +``` +[error rc=] <错误信息或输出> +``` + +--- + +## read_mediafile + +成功(文字提示 + 附加媒体 payload): +``` +已附加图片: +``` +或 +``` +已附加视频: +``` + +失败: +``` +失败: <错误信息> +``` + +--- + +## search_workspace + +### mode=file +``` +命中文件(): +1) +2) +``` + +### mode=content +``` + +- L12: +- L30: + + +- L5: +``` + +失败: +``` +失败: <错误信息> +``` diff --git a/Agent/doc/tools.json b/Agent/doc/tools.json new file mode 100644 index 0000000..17ea9b1 --- /dev/null +++ b/Agent/doc/tools.json @@ -0,0 +1,370 @@ +[ + { + "type": "function", + "function": { + "name": "read_file", + "description": "读取/搜索/抽取 UTF-8 文本文件。type=read/search/extract,仅单文件操作。", + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "文件路径:工作区内请用相对路径,工作区外可用绝对路径。" + }, + "type": { + "type": "string", + "enum": ["read", "search", "extract"], + "description": "读取模式:read=按行读取;search=在文件内搜索;extract=按行段抽取。" + }, + "max_chars": { + "type": "integer", + "description": "返回内容最大字符数,超出将截断。" + }, + "start_line": { + "type": "integer", + "description": "[read] 起始行号(1-based)。" + }, + "end_line": { + "type": "integer", + "description": "[read] 结束行号(>= start_line)。" + }, + "query": { + "type": "string", + "description": "[search] 搜索关键词。" + }, + "max_matches": { + "type": "integer", + "description": "[search] 最多返回多少条命中窗口。" + }, + "context_before": { + "type": "integer", + "description": "[search] 命中行向上追加行数。" + }, + "context_after": { + "type": "integer", + "description": "[search] 命中行向下追加行数。" + }, + "case_sensitive": { + "type": "boolean", + "description": "[search] 是否区分大小写。" + }, + "segments": { + "type": "array", + "description": "[extract] 需要抽取的行区间数组。", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string", + "description": "片段标签(可选)。" + }, + "start_line": { + "type": "integer", + "description": "起始行号(>=1)。" + }, + "end_line": { + "type": "integer", + "description": "结束行号(>= start_line)。" + } + }, + "required": ["start_line", "end_line"] + }, + "minItems": 1 + } + }, + "required": ["path", "type"] + } + } + }, + { + "type": "function", + "function": { + "name": "edit_file", + "description": "在文件中执行精确字符串替换;建议先用 read_file 精确复制 old_string。", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "文件路径:工作区内请用相对路径,工作区外可用绝对路径。" + }, + "old_string": { + "type": "string", + "description": "要替换的旧文本(必须与文件内容精确匹配)。" + }, + "new_string": { + "type": "string", + "description": "替换的新文本(必须不同于 old_string)。" + } + }, + "required": ["file_path", "old_string", "new_string"] + } + } + }, + { + "type": "function", + "function": { + "name": "web_search", + "description": "网络搜索(Tavily)。用于外部资料或最新信息检索。", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "搜索关键词(不要包含时间范围)。" + }, + "max_results": { + "type": "integer", + "description": "最大返回结果数。" + }, + "topic": { + "type": "string", + "enum": ["general", "news", "finance"], + "description": "搜索主题类型。" + }, + "time_range": { + "type": "string", + "enum": ["day", "week", "month", "year", "d", "w", "m", "y"], + "description": "相对时间范围(与 days / start_date+end_date 互斥)。" + }, + "days": { + "type": "integer", + "description": "最近 N 天(仅 topic=news 可用)。" + }, + "start_date": { + "type": "string", + "description": "开始日期 YYYY-MM-DD(必须与 end_date 同时提供)。" + }, + "end_date": { + "type": "string", + "description": "结束日期 YYYY-MM-DD(必须与 start_date 同时提供)。" + }, + "country": { + "type": "string", + "description": "国家过滤,仅 topic=general 可用(英文小写国名)。" + } + }, + "required": ["query"] + } + } + }, + { + "type": "function", + "function": { + "name": "extract_webpage", + "description": "网页内容提取(Tavily)。mode=read 直接返回内容,mode=save 保存为文件。", + "parameters": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": ["read", "save"], + "description": "read=直接返回内容;save=写入文件。" + }, + "url": { + "type": "string", + "description": "目标网页 URL。" + }, + "target_path": { + "type": "string", + "description": "[save] 保存路径:工作区内请用相对路径,工作区外可用绝对路径。必须是 .md 文件。mode=save 必填。" + } + }, + "required": ["mode", "url"] + } + } + }, + { + "type": "function", + "function": { + "name": "run_command", + "description": "在当前终端环境执行一次性命令(非交互式)。", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "要执行的命令。" + }, + "timeout": { + "type": "number", + "description": "超时时间(秒),必填。" + }, + "working_dir": { + "type": "string", + "description": "工作目录:工作区内请用相对路径,工作区外可用绝对路径。" + } + }, + "required": ["command", "timeout"] + } + } + }, + { + "type": "function", + "function": { + "name": "search_workspace", + "description": "在本地目录内搜索文件名或文件内容(跨文件)。仅返回摘要。", + "parameters": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": ["file", "content"], + "description": "file=搜索文件名;content=跨文件搜索内容。" + }, + "query": { + "type": "string", + "description": "搜索关键词或正则。" + }, + "root": { + "type": "string", + "description": "搜索起点目录:工作区内请用相对路径,工作区外可用绝对路径。默认 '.'。" + }, + "use_regex": { + "type": "boolean", + "description": "是否使用正则匹配(默认 false)。" + }, + "case_sensitive": { + "type": "boolean", + "description": "是否区分大小写(默认 false)。" + }, + "max_results": { + "type": "integer", + "description": "最大返回结果数(默认 20)。" + }, + "max_matches_per_file": { + "type": "integer", + "description": "[content] 每个文件最多返回多少处匹配(默认 3)。" + }, + "include_glob": { + "type": "array", + "description": "仅包含匹配这些 glob 的文件。", + "items": { "type": "string" } + }, + "exclude_glob": { + "type": "array", + "description": "排除匹配这些 glob 的文件。", + "items": { "type": "string" } + }, + "max_file_size": { + "type": "integer", + "description": "跳过超过该字节数的文件。" + } + }, + "required": ["mode", "query"] + } + } + }, + { + "type": "function", + "function": { + "name": "read_mediafile", + "description": "读取图片或视频文件并附加给模型。非媒体文件将拒绝。", + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "媒体文件路径:工作区内请用相对路径,工作区外可用绝对路径。" + } + }, + "required": ["path"] + } + } + }, + { + "type": "function", + "function": { + "name": "remember", + "description": "记录用户个人记忆。当用户表达了个人偏好、习惯、待办事项、身份信息、人际关系、重要事件等值得记住的信息时调用。\n注意:不要记录无意义信息,例如用户的打招呼、让调低音量等一次性操作。", + "parameters": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["add", "update", "delete"], + "description": "操作类型:add=新增记忆,update=更新已有记忆,delete=删除记忆" + }, + "type": { + "type": "string", + "enum": ["preference", "habit", "event", "todo", "identity", "relationship", "other"], + "description": "记忆类型。preference=偏好喜欢什么不喜欢什么,habit=用户的生活习惯,event=过去需要记住的重要事件,todo=未来需要完成的任务,identity=用户的身份信息,relationship=用户和其他人的关联,other=其他" + }, + "content": { + "type": "string", + "description": "记忆内容。add时必须提供。update时可选(不提供则保留原内容)" + }, + "keywords": { + "type": "array", + "items": { "type": "string" }, + "description": "关键词数组,便于后续搜索匹配" + }, + "id": { + "type": "string", + "description": "记忆ID。update和delete时必填" + } + }, + "required": ["action"] + } + } + }, + { + "type": "function", + "function": { + "name": "recall", + "description": "搜索用户个人记忆。当用户提问可能涉及个人偏好、历史或已知信息时使用。注意:简单操作如调音量、问天气不需要搜索。搜索结果自然融入回答,不要显式说'根据我的记忆'。", + "parameters": { + "type": "object", + "properties": { + "types": { + "type": "array", + "items": { "type": "string" }, + "description": "限定搜索的记忆类型,可选值:preference, habit, event, todo, identity, relationship, other。不提供则搜索所有类型" + }, + "query": { + "type": "string", + "description": "搜索关键词,在记忆内容和关键词中匹配" + }, + "start_date": { + "type": "string", + "description": "起始日期(ISO格式,如2026-01-01),可选" + }, + "end_date": { + "type": "string", + "description": "结束日期(ISO格式,如2026-12-31),可选" + }, + "max_results": { + "type": "integer", + "description": "最大返回数量,默认10" + } + }, + "required": [] + } + } + }, + { + "type": "function", + "function": { + "name": "set_identity", + "description": "设置用户的身份信息和 AI 助手个性化配置。当用户表达以下意图时使用此工具,而不是 remember:\n- 希望 AI 助手叫什么名字\n- 希望被怎么称呼(用户的名字/昵称)\n- 希望 AI 助手用什么语气交流\n这三个配置会立即生效,影响后续所有对话。可以只更新其中一个字段。", + "parameters": { + "type": "object", + "properties": { + "assistant_name": { + "type": "string", + "description": "AI 助手的名字,如'小A'、'Jarvis'。可选。" + }, + "user_name": { + "type": "string", + "description": "用户希望被称呼的名字,如'jojo'、'张总'。可选。" + }, + "assistant_tone": { + "type": "string", + "description": "AI 助手的语气,可选值:友好、专业、幽默、简洁、温暖、正式。可选。" + } + }, + "required": [] + } + } + } +] diff --git a/Agent/prompts/system.txt b/Agent/prompts/system.txt index 842d657..ced516f 100644 --- a/Agent/prompts/system.txt +++ b/Agent/prompts/system.txt @@ -1,21 +1,151 @@ -你是 VoiceInput 的 AI 智能助手,通过语音或文字与用户交互。 -目标:帮助用户完成开发与日常任务,回答简洁准确。 +你是 {assistant_name},运行在用户 Mac 上的个人智能助手。你可以通过语音或文字与用户交流,帮助完成 Mac 日常操作和各类任务。 -【输出格式 — 严禁使用 Markdown】 -显示界面是纯文本渲染,无法解析任何 Markdown 语法。你必须严格遵守以下规则: +【你的身份】 +- 名字:{assistant_name} +- 语气:{assistant_tone} +- 用户希望你称呼他为 {user_name} + +【输出格式 — 禁止 Markdown】 +显示界面是纯文本渲染,无法解析任何 Markdown 语法。你必须严格遵守: - 禁止使用 ** 加粗、* 斜体、` 行内代码、``` 代码块 - 禁止使用 # 标题、- 或 * 无序列表、1. 有序列表 - 禁止使用 [链接](url)、![图片](url)、> 引用、| 表格 -- 使用纯文本表达:用缩进或空行分隔段落,用「」标注关键术语,用编号 1) 2) 3) 代替列表 -- 如需展示代码,直接写出并用「代码:」前缀标明 +- 使用纯文本表达:用缩进或空行分隔段落,用「」标注关键术语,用 1) 2) 3) 编号代替列表 +- 如需展示代码或命令,用「代码:」前缀后直接写出 -【回复风格 — 务必简洁】 -- 优先用最少的字把问题说清楚,不要铺垫、不要寒暄、不要冗余解释 -- 用户问什么就答什么,不主动发散无关内容 -- 如果回答较长,把核心结论放在最前面,再补充细节 +【回复风格】 +- 以用户的语言自然交流,不废话但也不冷冰冰 +- 如果用户问题简单(如调音量、查时间),直接执行并简短告知结果 +- 如果用户问题复杂或涉及个人偏好,先搜索记忆再回答 +- 回答时直接给出结论和建议,不要显式说「根据记忆」「我搜索了记忆」 +- 语音输入可能存在同音字或转录错误,请结合上下文推测用户真实意图。如果实在无法理解,向用户确认而不要猜测 -重要提示:用户的输入来自语音识别,可能存在同音字、转录错误或模糊表达。请结合上下文推测用户真实意图。如果确实无法理解,应向用户确认而非猜测回复。 +【macOS 系统能力 — 你拥有以下核心能力】 +系统控制: +- 音量:osascript -e 'set volume output volume N'(0-100) +- 静音/取消:osascript -e 'set volume output muted true/false' +- 锁屏:/System/Library/CoreServices/Menu Extras/User.menu/Contents/Resources/CGSession -suspend(需确认) +- 深色模式:osascript -e 'tell app "System Events" to tell appearance preferences to set dark mode to true/false' +- 屏保:open -a ScreenSaverEngine +- 通知:osascript -e 'display notification "内容" with title "标题"' +- 调度中心:osascript -e 'tell app "System Events" to key code 103 using control down' + +应用控制: +- 打开应用:open -a "应用名" +- 关闭应用:osascript -e 'tell app "应用名" to quit' +- 获取前台应用:osascript -e 'tell app "System Events" to get name of first process whose frontmost is true' +- 检查应用是否运行:pgrep -x "进程名" + +音乐与媒体: +- 播放/暂停:osascript -e 'tell app "Music" to playpause' +- 下一曲/上一曲:osascript -e 'tell app "Music" to next track/previous track' +- 获取当前歌曲:osascript -e 'tell app "Music" to if running then return name of current track & " - " & artist of current track' + +个人效率: +- 创建提醒:osascript -e 'tell app "Reminders" to make new reminder with properties {name:"内容", due date:(current date) + 秒数}' +- 创建备忘录:osascript -e 'tell app "Notes" to make new note at folder "Notes" with properties {name:"标题", body:"内容"}' +- 朗读文字:say "内容"(中文用 say -v Tingting "内容") +- 运行快捷指令:shortcuts run "名称" + +文件与系统信息: +- 截图:screencapture ~/Desktop/文件名.png(-i 选区,-C 带鼠标) +- 文件搜索:mdfind -name "关键词" +- 磁盘空间:df -h / +- 电池:pmset -g batt +- WiFi:/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport -I +- 剪贴板:pbpaste 读取,pbcopy 写入 +- 打开网页:open "https://..." + +常用快捷键模拟: +- 截取选区:osascript -e 'tell app "System Events" to key code 21 using {command down, shift down}'(Cmd+Shift+4) +- 切换应用:open -a "应用名" + +以上命令通过 run_command 工具执行。不确定后果的命令(锁屏、强制退出、清空废纸篓)应先向用户确认。 + +【身份设置 — set_identity 工具】 + +当用户表达了以下意图时,使用 set_identity 工具(而不是 remember): +- 让你改名叫什么(如「你叫小A吧」)→ assistant_name +- 告诉你 TA 的名字或希望你怎么称呼 TA(如「叫我 jojo」)→ user_name +- 希望调整交流语气(如「语气幽默一点」)→ assistant_tone + +这三个配置会立即生效,影响后续所有对话。可以只更新其中一个字段。 + +set_identity 和 remember 的本质区别: +- set_identity:修改 AI 助手的运行时身份和交互方式,即改即生效 +- remember:记录用户个人信息,供后续对话参考,不影响当前交互行为 +举例: + 「你叫 Jarvis」→ set_identity(改 AI 名字) + 「我是 iOS 开发者」→ remember(记下身份信息) + 「叫我张总」→ set_identity(改用户称呼) + 「我喜欢喝咖啡」→ remember(记下偏好) + +【记忆系统 — 重要,必须严格遵守】 + +你有一个长期记忆系统,可以记住关于用户的信息。记住:你是个人助手,不是记录机器。 + +1) 何时记录记忆(remember 工具): +以下情况应调用 remember 工具 add 操作: +- 用户表达了个人偏好(喜欢/不喜欢/觉得什么好用/讨厌什么) + → type=preference,content=具体偏好,keywords=相关关键词 +- 用户透露了生活习惯(如「我每天早上8点喝咖啡」) + → type=habit +- 用户分享了重要事件(如「上个月我换了新电脑」) + → type=event +- 用户提到了待办事项(如「明天下午3点开会」) + → type=todo,content 必须包含明确的绝对日期(如「2026年5月1日下午3点开会」),禁止使用「明天」「下周三」「月底」等相对时间 + → 当前时间是 {current_time},请据此将相对时间转换为绝对日期 +- 用户告知了个人身份信息(如「我是iOS开发者」) + → type=identity +- 用户提及了人际关系(如「张三是我同事」) + → type=relationship +- 其他值得记住的信息 + → type=other + +判断标准:如果这个信息以后对话可能会用到,就记住。 + +以下情况不要记录: +- 用户的普通问候(你好、早上好) +- 一次性操作(调高音量、打开应用、问天气) +- 纯粹的技术问题(怎么截图、Python怎么用) +- 不包含个人信息的闲聊 +- 任何不属于上述类型中的无意义信息 + +2) 何时搜索记忆(recall 工具): +当用户提问可能涉及个人偏好、历史或已记录信息时才搜索,例如: +- 「我喜欢什么...」「我之前说过...」「帮我查一下我的...」 +- 涉及个人习惯、喜好、身份的询问 +- 涉及待办事项或日程 + +不需要搜索的情况: +- 简单的系统操作(调音量、截图、打开应用) +- 纯知识/技能问题(怎么...、什么是...) +- 查询时间、天气等实时信息 +- 用户问「我是谁」「我叫什么」「你叫什么」等身份问题——直接查看 prompt 中【你的身份】部分,那里有用户希望被称呼的名字和你的名字,无需搜索记忆 + +搜索时指定相关的 type(可多个),让结果更精准。 + +3) 如何使用搜索结果: +将搜索结果自然融入回答,就像你本来就记得一样。 +- 好:「好的,上次你提到喜欢美式咖啡,要帮你记下这家咖啡馆吗?」 +- 不好:「根据我的记忆,你之前说过喜欢美式咖啡...」 + +绝对不要在回答中显式提到「记忆」「搜索」「记录」等词,也不要说「根据我的记忆」。 + +【工具使用原则】 +- 处理用户请求时,优先用 run_command 执行 macOS 命令 +- 需要外部信息时用 web_search +- 如果用户请求需要先了解其偏好/历史,用 recall 搜索记忆 +- 如果用户在对话中透露值得记住的信息,用 remember 记录 +- 文件操作工具在需要时使用,不限于开发场景 +- 重要:工具调用前后的回复顺序必须合理。不要在工具调用前就说出结论或承诺结果。 + 正确做法:工具调用前只做简短过渡(如「好的」),或不回复直接调用工具;等工具返回结果后,再输出完整的最终回复。 + 示例 — 用户说「记住我叫xxx」: + 错误:先输出「我来帮你记住」,调用 remember,最后说「有什么需要随时说」 + 正确:直接调用 remember,最后输出「已经记住你叫xxx了,有什么需要随时说」 + +【系统信息】 - 当前时间:{current_time} - 当前模型:{model_id} - 工作区路径:{path} diff --git a/Agent/src/bridge.js b/Agent/src/bridge.js index a2d923c..2aad809 100644 --- a/Agent/src/bridge.js +++ b/Agent/src/bridge.js @@ -28,6 +28,7 @@ const { buildSystemPrompt } = require('./core/context'); const { streamChat } = require('./model/client'); const { executeTool } = require('./tools/dispatcher'); const { applyUsage, normalizeTokenUsage, normalizeUsagePayload } = require('./utils/token_usage'); +const { createConversation, updateConversation, listConversations, loadConversation } = require('./storage/conversation_store'); const { buildStartLine, buildFinalLine, formatResultLines } = require('./ui/tool_display'); // --- 配置 --- @@ -110,20 +111,50 @@ async function runAgent(input) { } const state = persistentState; + const workspace = persistentWorkspace || process.cwd(); process.stderr.write(`[bridge] runAgent start, msgs=${state.messages.length}, thinkingMode=${state.thinkingMode}\n`); - // 用户消息 + // 对话持久化:优先加载最新对话(除非强制新对话),没有则创建 + // ⚠️ 必须在 push 用户消息之前加载,否则旧对话消息会覆盖新消息 + if (!state.conversation) { + if (!state._forceNew) { + const list = listConversations(workspace); + if (list.length > 0) { + const latestId = list[0].id; + const conv = loadConversation(workspace, latestId); + if (conv) { + state.conversation = conv; + state.messages = conv.messages || []; + state.allowMode = conv.metadata?.allow_mode || state.allowMode; + state.tokenUsage = normalizeTokenUsage(conv.metadata?.token_usage); + process.stderr.write(`[bridge] loaded conversation ${latestId}, msgs=${state.messages.length}, tokens=${state.tokenUsage.total}\n`); + } + } + } + if (!state.conversation) { + state.conversation = createConversation(workspace, { + model_key: state.modelKey, + model_id: state.modelId, + thinking_mode: state.thinkingMode, + allow_mode: state.allowMode, + }); + process.stderr.write(`[bridge] created conversation ${state.conversation.id}\n`); + } + delete state._forceNew; + } + + // 用户消息(必须在加载对话之后 push) state.messages.push({ role: 'user', content: input }); const startTime = Date.now(); let continueLoop = true; let lastAssistantContent = ''; + let accumulatedUsage = { prompt: 0, completion: 0, total: 0 }; while (continueLoop) { let assistantContent = ''; let fullThinkingBuffer = ''; let toolCalls = {}; - let usageTotal = null; emit({ type: 'status', mode: 'thinking' }); @@ -186,11 +217,11 @@ async function runAgent(input) { fullThinkingBuffer += rc; } - // Usage + // Usage(累积) const usage = chunk.usage || choice.usage || delta.usage; const normalized = normalizeUsagePayload(usage); - if (normalized && Number.isFinite(normalized.total_tokens)) { - usageTotal = normalized.total_tokens; + if (normalized) { + accumulatedUsage = applyUsage(accumulatedUsage, normalized); } } } catch (err) { @@ -275,8 +306,34 @@ async function runAgent(input) { continueLoop = false; } + // 去重:删除末尾多余的连续 assistant 消息(API 偶发多次返回) + const msgs = state.messages; + while (msgs.length >= 2 && msgs[msgs.length - 1].role === 'assistant' && msgs[msgs.length - 2].role === 'assistant') { + const cur = msgs[msgs.length - 1].content || ''; + const prev = msgs[msgs.length - 2].content || ''; + if (prev.includes(cur) || cur.length <= prev.length) { + msgs.pop(); + } else { + msgs[msgs.length - 2].content = (prev + '\n' + cur).trim(); + msgs.pop(); + } + } + + // 持久化对话到磁盘(含 token:prompt/completion 累加,total 覆盖为最新请求的上下文长度) + if (state.conversation) { + const prevTokens = state.conversation.metadata?.token_usage; + const mergedUsage = { + prompt: (prevTokens?.prompt || 0) + (accumulatedUsage?.prompt || 0), + completion: (prevTokens?.completion || 0) + (accumulatedUsage?.completion || 0), + total: accumulatedUsage?.total || prevTokens?.total || 0, + }; + state.conversation = updateConversation(workspace, state.conversation, state.messages, { + token_usage: mergedUsage, + }); + } + const elapsed = Date.now() - startTime; - emit({ type: 'done', content: lastAssistantContent, elapsed_ms: elapsed }); + emit({ type: 'done', content: lastAssistantContent, elapsed_ms: elapsed, total_tokens: accumulatedUsage?.total || 0 }); // 不退出,等待下一轮输入继续对话 } @@ -293,6 +350,7 @@ rl.on('line', (line) => { persistentWorkspace = resolveWorkspace(msg.workspace); persistentState = createState(config, persistentWorkspace); persistentState.allowMode = msg.allowMode || 'full_access'; + persistentState._forceNew = !!msg.new_conversation; // 强制新对话标记 if (msg.modelKey && config.model_map.has(msg.modelKey)) { const model = config.model_map.get(msg.modelKey); persistentState.modelKey = model.key; diff --git a/Agent/src/core/context.js b/Agent/src/core/context.js index f6fbf7f..7ce6431 100644 --- a/Agent/src/core/context.js +++ b/Agent/src/core/context.js @@ -2,6 +2,7 @@ const os = require('os'); const path = require('path'); +const fs = require('fs'); const { execSync } = require('child_process'); function getTerminalType() { @@ -28,18 +29,41 @@ function getGitInfo(workspace) { } } +function loadUserProfile() { + const profileDir = path.join(os.homedir(), 'Library', 'Application Support', 'VoiceInput'); + const profilePath = path.join(profileDir, 'user_profile.json'); + try { + if (fs.existsSync(profilePath)) { + const raw = fs.readFileSync(profilePath, 'utf8'); + const data = JSON.parse(raw); + return { + assistantName: data.assistant_name || 'AI助手未设置', + assistantTone: data.assistant_tone || '语气未设置', + userName: data.user_name || '用户未设置', + }; + } + } catch (_) { /* 忽略,使用默认值 */ } + return { + assistantName: 'AI助手未设置', + assistantTone: '语气未设置', + userName: '用户未设置', + }; +} + function buildSystemPrompt(basePrompt, opts) { const now = new Date(); - const tzOffset = now.getTimezoneOffset(); - const localMs = now.getTime() - tzOffset * 60 * 1000; - const localIso = new Date(localMs).toISOString().slice(0, 16); + const localIso = `${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}`; const platform = os.platform(); const systemName = platform === 'darwin' ? 'macos' : platform === 'win32' ? 'windows' : 'linux'; + const profile = loadUserProfile(); let prompt = basePrompt; const allowModeValue = opts.allowMode === 'read_only' ? `${opts.allowMode}\n 已禁用 edit_file、run_command。若用户要求使用,请告知当前无权限需要用户输入 /allow 切换权限。` : opts.allowMode; const replacements = { + assistant_name: profile.assistantName, + assistant_tone: profile.assistantTone, + user_name: profile.userName, current_time: localIso, path: opts.workspace, workspace: opts.workspace, diff --git a/Agent/src/tools/dispatcher.js b/Agent/src/tools/dispatcher.js index 44c86ae..c5c151c 100644 --- a/Agent/src/tools/dispatcher.js +++ b/Agent/src/tools/dispatcher.js @@ -6,6 +6,9 @@ const { runCommandTool } = require('./run_command'); const { webSearchTool, extractWebpageTool } = require('./web_search'); const { searchWorkspaceTool } = require('./search_workspace'); const { readMediafileTool } = require('./read_mediafile'); +const { rememberTool } = require('./remember'); +const { recallTool } = require('./recall'); +const { setIdentityTool } = require('./set_identity'); const path = require('path'); const MAX_RUN_COMMAND_CHARS = 10000; @@ -122,6 +125,48 @@ function formatReadMediafile(result) { return `已附加视频: ${result.path}`; } +function formatRemember(result) { + if (!result.success) return formatFailure(result.error || '记忆操作失败'); + const typeNames = { + preference: '偏好', habit: '习惯', event: '事件', + todo: '待办', identity: '身份', relationship: '关系', other: '其他', + }; + const typeName = typeNames[result.entry?.type] || result.entry?.type || '未知'; + if (result.action === 'add') return `已记录 [${typeName}]: ${result.entry.content}`; + if (result.action === 'update') return `已更新 [${typeName}]: ${result.entry.content}`; + if (result.action === 'delete') return `已删除 [${typeName}]: ${result.entry.content}`; + return '未知操作'; +} + +function formatRecall(result) { + if (!result.success) return formatFailure(result.error || '搜索失败'); + if (result.returned === 0) return '未找到相关记忆'; + const lines = []; + const typeNames = { + preference: '偏好', habit: '习惯', event: '事件', + todo: '待办', identity: '身份', relationship: '关系', other: '其他', + }; + for (const m of result.results) { + const typeName = typeNames[m.type] || m.type; + const date = (m.updated_at || m.created_at || '').slice(0, 10); + lines.push(`[${typeName}] ${m.content} (记录于${date}) id:${m.id}`); + } + return lines.join('\n'); +} + +function formatSetIdentity(result) { + if (!result.success) return formatFailure(result.error || '设置失败'); + const fieldNames = { + assistant_name: '助手名字', + user_name: '用户称呼', + assistant_tone: '助手语气', + }; + const parts = (result.changes || []).map( + c => `${fieldNames[c.field] || c.field}: ${c.old || '(未设置)'} → ${c.new}` + ); + return `已更新: ${parts.join(',')}`; +} + function buildToolContent(result) { if (result.type === 'image' || result.type === 'video') { const payload = { @@ -191,6 +236,9 @@ async function executeTool({ workspace, config, allowMode, toolCall, abortSignal } } else if (name === 'search_workspace') raw = await searchWorkspaceTool(workspace, args); else if (name === 'read_mediafile') raw = readMediafileTool(workspace, args); + else if (name === 'remember') raw = rememberTool(args); + else if (name === 'recall') raw = recallTool(args); + else if (name === 'set_identity') raw = setIdentityTool(args); else raw = { success: false, error: '未知工具' }; let formatted = ''; @@ -201,6 +249,9 @@ async function executeTool({ workspace, config, allowMode, toolCall, abortSignal else if (name === 'extract_webpage') formatted = formatExtractWebpage(raw, args.mode, args.url, args.target_path); else if (name === 'search_workspace') formatted = formatSearchWorkspace(raw); else if (name === 'read_mediafile') formatted = formatReadMediafile(raw); + else if (name === 'remember') formatted = formatRemember(raw); + else if (name === 'recall') formatted = formatRecall(raw); + else if (name === 'set_identity') formatted = formatSetIdentity(raw); else formatted = raw.success ? '' : formatFailure(raw.error || '失败'); const toolContent = buildToolContent(raw); diff --git a/Agent/src/tools/recall.js b/Agent/src/tools/recall.js new file mode 100644 index 0000000..485cbff --- /dev/null +++ b/Agent/src/tools/recall.js @@ -0,0 +1,85 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const MEMORY_DIR = path.join(os.homedir(), 'Library', 'Application Support', 'VoiceInput', 'memory'); +const MEMORY_FILE = path.join(MEMORY_DIR, 'memory.json'); + +function loadMemories() { + if (!fs.existsSync(MEMORY_FILE)) { + return []; + } + try { + const raw = fs.readFileSync(MEMORY_FILE, 'utf8'); + return JSON.parse(raw); + } catch (_) { + return []; + } +} + +function recallTool(args) { + const { + types, + query, + start_date, + end_date, + max_results = 10, + } = args; + + let memories = loadMemories(); + + // 按类型过滤 + if (Array.isArray(types) && types.length > 0) { + memories = memories.filter(m => types.includes(m.type)); + } + + // 按时间区间过滤 + if (start_date) { + const start = new Date(start_date).getTime(); + if (!isNaN(start)) { + memories = memories.filter(m => new Date(m.created_at).getTime() >= start); + } + } + if (end_date) { + const end = new Date(end_date).getTime(); + if (!isNaN(end)) { + memories = memories.filter(m => new Date(m.created_at).getTime() <= end); + } + } + + // 按关键词搜索(在 content 和 keywords 中匹配) + if (query && query.trim()) { + const q = query.trim().toLowerCase(); + memories = memories.filter(m => { + // 匹配内容 + if ((m.content || '').toLowerCase().includes(q)) return true; + // 匹配关键词 + if (Array.isArray(m.keywords)) { + return m.keywords.some(kw => (kw || '').toLowerCase().includes(q)); + } + return false; + }); + } + + // 按更新时间倒序排列 + memories.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()); + + // 限制返回数量 + const total = memories.length; + if (max_results > 0 && memories.length > max_results) { + memories = memories.slice(0, max_results); + } + + return { + success: true, + query: query || null, + types: types || null, + total, + returned: memories.length, + results: memories, + }; +} + +module.exports = { recallTool }; diff --git a/Agent/src/tools/remember.js b/Agent/src/tools/remember.js new file mode 100644 index 0000000..0ddc503 --- /dev/null +++ b/Agent/src/tools/remember.js @@ -0,0 +1,127 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const crypto = require('crypto'); + +const MEMORY_DIR = path.join(os.homedir(), 'Library', 'Application Support', 'VoiceInput', 'memory'); +const MEMORY_FILE = path.join(MEMORY_DIR, 'memory.json'); + +const VALID_TYPES = ['preference', 'habit', 'event', 'todo', 'identity', 'relationship', 'other']; + +function ensureDir() { + if (!fs.existsSync(MEMORY_DIR)) { + fs.mkdirSync(MEMORY_DIR, { recursive: true }); + } +} + +function loadMemories() { + ensureDir(); + if (!fs.existsSync(MEMORY_FILE)) { + return []; + } + try { + const raw = fs.readFileSync(MEMORY_FILE, 'utf8'); + return JSON.parse(raw); + } catch (_) { + return []; + } +} + +function saveMemories(memories) { + ensureDir(); + fs.writeFileSync(MEMORY_FILE, JSON.stringify(memories, null, 2), 'utf8'); +} + +function generateId() { + return crypto.randomUUID().slice(0, 8); +} + +function rememberTool(args) { + const { action, type, content, keywords, id } = args; + + // 校验 action + if (!['add', 'update', 'delete'].includes(action)) { + return { success: false, error: 'action 必须是 add / update / delete' }; + } + + // 校验 type + if (action !== 'delete' && !VALID_TYPES.includes(type)) { + return { success: false, error: `type 必须为: ${VALID_TYPES.join(', ')}` }; + } + + const memories = loadMemories(); + const now = new Date().toISOString(); + + if (action === 'add') { + if (!content || !content.trim()) { + return { success: false, error: 'content 不能为空' }; + } + const entry = { + id: generateId(), + type, + content: content.trim(), + keywords: Array.isArray(keywords) ? keywords.filter(k => k && typeof k === 'string') : [], + created_at: now, + updated_at: now, + }; + memories.push(entry); + saveMemories(memories); + return { + success: true, + action: 'add', + entry, + total: memories.length, + }; + } + + if (action === 'update') { + if (!id) { + return { success: false, error: 'update 需要提供 id' }; + } + const idx = memories.findIndex(m => m.id === id); + if (idx === -1) { + return { success: false, error: `未找到 id=${id} 的记忆` }; + } + if (content !== undefined && content !== null) { + memories[idx].content = String(content).trim(); + } + if (type !== undefined) { + memories[idx].type = type; + } + if (keywords !== undefined) { + memories[idx].keywords = Array.isArray(keywords) ? keywords.filter(k => k && typeof k === 'string') : []; + } + memories[idx].updated_at = now; + saveMemories(memories); + return { + success: true, + action: 'update', + entry: memories[idx], + total: memories.length, + }; + } + + if (action === 'delete') { + if (!id) { + return { success: false, error: 'delete 需要提供 id' }; + } + const idx = memories.findIndex(m => m.id === id); + if (idx === -1) { + return { success: false, error: `未找到 id=${id} 的记忆` }; + } + const removed = memories.splice(idx, 1)[0]; + saveMemories(memories); + return { + success: true, + action: 'delete', + entry: removed, + total: memories.length, + }; + } + + return { success: false, error: '未知 action' }; +} + +module.exports = { rememberTool }; diff --git a/Agent/src/tools/set_identity.js b/Agent/src/tools/set_identity.js new file mode 100644 index 0000000..9be46ce --- /dev/null +++ b/Agent/src/tools/set_identity.js @@ -0,0 +1,63 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const PROFILE_DIR = path.join(os.homedir(), 'Library', 'Application Support', 'VoiceInput'); +const PROFILE_FILE = path.join(PROFILE_DIR, 'user_profile.json'); + +const VALID_FIELDS = ['assistant_name', 'user_name', 'assistant_tone']; + +function ensureDir() { + if (!fs.existsSync(PROFILE_DIR)) { + fs.mkdirSync(PROFILE_DIR, { recursive: true }); + } +} + +function loadProfile() { + ensureDir(); + if (!fs.existsSync(PROFILE_FILE)) { + return {}; + } + try { + return JSON.parse(fs.readFileSync(PROFILE_FILE, 'utf8')); + } catch (_) { + return {}; + } +} + +function saveProfile(profile) { + ensureDir(); + fs.writeFileSync(PROFILE_FILE, JSON.stringify(profile, null, 2), 'utf8'); +} + +function setIdentityTool(args) { + const profile = loadProfile(); + const changes = []; + + for (const field of VALID_FIELDS) { + if (args[field] !== undefined && args[field] !== null) { + const value = String(args[field]).trim(); + if (value) { + const old = profile[field] || ''; + profile[field] = value; + changes.push({ field, old, new: value }); + } + } + } + + if (changes.length === 0) { + return { success: false, error: '请至少提供一个字段:assistant_name / user_name / assistant_tone' }; + } + + saveProfile(profile); + + return { + success: true, + changes, + profile, + }; +} + +module.exports = { setIdentityTool }; diff --git a/Agent/src/ui/tool_display.js b/Agent/src/ui/tool_display.js index 615d084..7f8b047 100644 --- a/Agent/src/ui/tool_display.js +++ b/Agent/src/ui/tool_display.js @@ -16,6 +16,9 @@ function toolNameMap(name) { extract_webpage: '提取网页', search_workspace: '文件搜索', read_mediafile: '读取媒体文件', + remember: '记住', + recall: '回忆', + set_identity: '设置身份', }; return map[name] || name; } @@ -76,6 +79,36 @@ function buildStartLine(name, args) { if (name === 'read_mediafile') { return `${label} ${args.path}`; } + if (name === 'remember') { + const typeNames = { preference: '偏好', habit: '习惯', event: '事件', todo: '待办', identity: '身份', relationship: '关系', other: '其他' }; + const typeLabel = typeNames[args.type] || args.type || ''; + const content = args.content || ''; + const preview = content.length > 40 ? content.slice(0, 40) + '…' : content; + if (args.action === 'delete') { + return `${label} 删除 ${typeLabel ? `[${typeLabel}] ` : ''}${preview}`; + } + if (args.action === 'update') { + return `${label} 更新 ${typeLabel ? `[${typeLabel}] ` : ''}${preview}`; + } + return `${label} ${typeLabel ? `[${typeLabel}] ` : ''}${preview}`; + } + if (name === 'recall') { + const typeNames = { preference: '偏好', habit: '习惯', event: '事件', todo: '待办', identity: '身份', relationship: '关系', other: '其他' }; + const typeLabels = (args.types || []).map(t => typeNames[t] || t).filter(Boolean); + const parts = []; + if (typeLabels.length) parts.push(typeLabels.join('/')); + if (args.query) parts.push(args.query); + const param = parts.length ? parts.join(' · ') : '所有记忆'; + return `${label} ${param}`; + } + if (name === 'set_identity') { + const fieldLabels = { assistant_name: '助手名', user_name: '我的称呼', assistant_tone: '语气' }; + const parts = []; + for (const [key, lbl] of Object.entries(fieldLabels)) { + if (args[key]) parts.push(`${lbl}=${args[key]}`); + } + return `${label} ${parts.join(',') || '更新身份'}`; + } return `${label}`; } @@ -205,6 +238,21 @@ function formatResultLines(name, args, raw) { if (raw.type === 'image') return ['已附加图片']; if (raw.type === 'video') return ['已附加视频']; } + if (name === 'remember') { + if (raw.action === 'delete') return ['已删除']; + if (raw.action === 'update') return ['已更新']; + return ['已记录']; + } + if (name === 'recall') { + const total = raw.total || 0; + const returned = raw.returned || 0; + if (returned === 0) return ['未找到相关记忆']; + if (total > returned) return [`找到 ${total} 条,显示 ${returned} 条`]; + return [`找到 ${returned} 条记忆`]; + } + if (name === 'set_identity') { + return ['已更新身份信息']; + } return ['完成']; } diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift index 67cbdb3..b977eae 100644 --- a/Sources/App/AppDelegate.swift +++ b/Sources/App/AppDelegate.swift @@ -158,18 +158,34 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSSpeechSynthesizerDel private func startRecording() { guard !isRecording else { return } + // 正在朗读 → 停止 + if speechSynthesizer.isSpeaking { + speechSynthesizer.stopSpeaking() + speakingMode = nil + speakAutoHideWorkItem?.cancel() + } + // 如果当前模式不是智能体但刘海弹窗还在 → 关闭 if agentNotch != nil && Config.shared.triggerMode != .agent { hideAgentNotch() } - // 智能体 + 打字输入模式 → 直接显示文本输入刘海 - if Config.shared.triggerMode == .agent && Config.shared.agentInputMode == "text" { - isTextInputActive = true - recordingPanel.updateText("Type your question...") - recordingPanel.showAnimated() - showTextInputNotch() - return + // 智能体模式 → 统一显示刘海弹窗 + if Config.shared.triggerMode == .agent { + agentTracker.resetForInput() + agentTracker.isTextInputMode = true + agentTracker.toolsMode = Config.shared.agentToolsMode + agentTracker.newConversationMode = Config.shared.agentNewConversationMode + showAgentNotchInternal(isTextInput: true) + + // 打字输入:不录音,等待用户输入 + if Config.shared.agentInputMode == "text" { + isTextInputActive = true + recordingPanel.updateText("Type your question...") + recordingPanel.showAnimated() + return + } + // 语音输入:继续录音流程 } isRecording = true @@ -249,7 +265,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSSpeechSynthesizerDel switch Config.shared.triggerMode { case .input: handleInputMode(text: text, config: Config.shared.inputLLM) case .assist: handleAssistMode(text: text, config: Config.shared.assistLLM) - case .agent: handleAgentMode(text: text) + case .agent: + if Config.shared.agentVoiceAutoSend { + handleTextInput(text) + } else { + recordingPanel.hideAnimated() + agentTracker.voiceInputText = text + } } finalTranscription = "" } @@ -329,6 +351,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSSpeechSynthesizerDel } recordingPanel.hideAnimated() + // 新建对话(自动超限 或 手动点击) + let needsNewConversation = agentTracker.autoNewTriggered || agentTracker.manualNewRequested + if needsNewConversation { + agentBridge.stop() + agentActive = false + agentTracker.autoNewTriggered = false + agentTracker.manualNewRequested = false + } + // 如果对话已在进行中 → 追加消息 if agentActive { autoHideWorkItem?.cancel() @@ -365,7 +396,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSSpeechSynthesizerDel } agentBridge.send(message: text, workspace: Config.shared.agentWorkspace, allowMode: Config.shared.agentAllowMode, - modelKey: defaultModel!) + modelKey: defaultModel!, + newConversation: needsNewConversation) } private func hideAgentNotch() { @@ -384,9 +416,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSSpeechSynthesizerDel agentTracker.updateToolResult(tool, resultLines: resultLines) case .assistantChunk(let content): agentTracker.appendChunk(content) - case .done(let content, _): - agentLog("[App] handleEvent done contentLen=\(content.count)") - agentTracker.finish(content: content) + case .done(let content, _, let totalTokens): + agentLog("[App] handleEvent done contentLen=\(content.count) totalTokens=\(totalTokens)") + agentTracker.finish(content: content, totalTokens: totalTokens) let willSpeak = Config.shared.agentSpeakEnabled && !content.isEmpty if willSpeak { speakingMode = .agent @@ -428,14 +460,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSSpeechSynthesizerDel } private func showAgentNotchInternal(isTextInput: Bool) { - agentTracker.isTextInputMode = isTextInput - - // 弹窗已存在 → 复用,只刷新显示 + // 弹窗已存在 → 复用,只刷新显示(不覆盖已有状态) if agentNotch != nil { agentNotch?.show() return } + agentTracker.isTextInputMode = isTextInput + agentTracker.toolsMode = Config.shared.agentToolsMode + agentTracker.newConversationMode = Config.shared.agentNewConversationMode + // 创建新弹窗 let view = AgentNotchView( tracker: agentTracker, @@ -469,6 +503,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSSpeechSynthesizerDel self?.agentActive = false self?.agentIsTextMode = false self?.agentTracker.resetForInput() + self?.agentTracker.manualNewRequested = true }, onSendText: { [weak self] text in self?.handleTextInput(text) diff --git a/Sources/App/MenuBarController.swift b/Sources/App/MenuBarController.swift index 009cbda..5589eea 100644 --- a/Sources/App/MenuBarController.swift +++ b/Sources/App/MenuBarController.swift @@ -19,6 +19,9 @@ final class MenuBarController: NSObject { private let assistSettingsWindow: SettingsWindow private let agentSettingsWindow: SettingsWindow private let agentLLMEnabledItem: NSMenuItem + private let agentVoiceInputItem: NSMenuItem + private let agentTextInputItem: NSMenuItem + private let memoryWindow: MemoryWindow override init() { statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) @@ -38,11 +41,22 @@ final class MenuBarController: NSObject { inputSettingsWindow = SettingsWindow(mode: .input) assistSettingsWindow = SettingsWindow(mode: .assist) agentSettingsWindow = SettingsWindow(mode: .agent) + memoryWindow = MemoryWindow() agentLLMEnabledItem = NSMenuItem( title: "Enable", action: #selector(toggleAgentLLM), keyEquivalent: "" ) + agentVoiceInputItem = NSMenuItem( + title: "🎤 语音输入", + action: #selector(selectAgentVoiceInput), + keyEquivalent: "" + ) + agentTextInputItem = NSMenuItem( + title: "⌨️ 打字输入", + action: #selector(selectAgentTextInput), + keyEquivalent: "" + ) super.init() @@ -199,6 +213,18 @@ final class MenuBarController: NSObject { agentLLMSubmenu.addItem(agentLLMEnabledItem) updateAgentLLMMenuState() + agentLLMSubmenu.addItem(.separator()) + + agentVoiceInputItem.target = self + agentVoiceInputItem.state = Config.shared.agentInputMode == "voice" ? .on : .off + agentLLMSubmenu.addItem(agentVoiceInputItem) + + agentTextInputItem.target = self + agentTextInputItem.state = Config.shared.agentInputMode == "text" ? .on : .off + agentLLMSubmenu.addItem(agentTextInputItem) + + agentLLMSubmenu.addItem(.separator()) + let agentSettingsItem = NSMenuItem( title: "Settings...", action: #selector(openAgentSettings), @@ -215,6 +241,26 @@ final class MenuBarController: NSObject { menu.addItem(.separator()) + // 助手设置 + let assistantSettingsItem = NSMenuItem( + title: "⚙️ 助手设置...", + action: #selector(openAgentSettings), + keyEquivalent: "" + ) + assistantSettingsItem.target = self + menu.addItem(assistantSettingsItem) + + // 记忆管理 + let memoryItem = NSMenuItem( + title: "🧠 记忆管理...", + action: #selector(openMemoryManager), + keyEquivalent: "" + ) + memoryItem.target = self + menu.addItem(memoryItem) + + menu.addItem(.separator()) + // 退出 let quitItem = NSMenuItem( title: "Quit", @@ -328,23 +374,61 @@ final class MenuBarController: NSObject { } @objc private func openInputSettings() { - inputSettingsWindow.makeKeyAndOrderFront(nil) - NSApplication.shared.activate(ignoringOtherApps: true) + showWindowWithDock(inputSettingsWindow) } @objc private func openAssistSettings() { - assistSettingsWindow.makeKeyAndOrderFront(nil) - NSApplication.shared.activate(ignoringOtherApps: true) + showWindowWithDock(assistSettingsWindow) } @objc private func openAgentSettings() { print("[MenuBar] openAgentSettings called") agentSettingsWindow.refreshAgentModelTable() - agentSettingsWindow.makeKeyAndOrderFront(nil) - NSApplication.shared.activate(ignoringOtherApps: true) + showWindowWithDock(agentSettingsWindow) + } + + @objc private func openMemoryManager() { + showWindowWithDock(memoryWindow) + } + + @objc private func selectAgentVoiceInput() { + Config.shared.agentInputMode = "voice" + agentVoiceInputItem.state = .on + agentTextInputItem.state = .off + } + + @objc private func selectAgentTextInput() { + Config.shared.agentInputMode = "text" + agentVoiceInputItem.state = .off + agentTextInputItem.state = .on + } + + /// 显示窗口并临时启用 Dock 图标 + private var dockVisibleWindowCount = 0 + + private func showWindowWithDock(_ window: NSWindow) { + dockVisibleWindowCount += 1 + if NSApp.activationPolicy() != .regular { + NSApp.setActivationPolicy(.regular) + } + window.delegate = self + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) } @objc private func quit() { NSApplication.shared.terminate(nil) } } + +// MARK: - NSWindowDelegate(Dock 回收) + +extension MenuBarController: NSWindowDelegate { + func windowWillClose(_ notification: Notification) { + dockVisibleWindowCount -= 1 + if dockVisibleWindowCount <= 0 { + dockVisibleWindowCount = 0 + NSApp.setActivationPolicy(.accessory) + } + } +} diff --git a/Sources/Core/AgentBridge.swift b/Sources/Core/AgentBridge.swift index 98213a9..7ebe489 100644 --- a/Sources/Core/AgentBridge.swift +++ b/Sources/Core/AgentBridge.swift @@ -7,7 +7,7 @@ enum AgentEvent { case toolStart(tool: String, display: String) case toolEnd(tool: String, resultLines: [String]) case assistantChunk(content: String) - case done(content: String, elapsedMs: Int) + case done(content: String, elapsedMs: Int, totalTokens: Int) case error(message: String) } @@ -125,8 +125,8 @@ final class AgentBridge { } /// 发送对话请求(第一轮) - func send(message: String, workspace: String, allowMode: String = "full_access", modelKey: String? = nil) { - sendInternal(message: message, workspace: workspace, allowMode: allowMode, modelKey: modelKey) + func send(message: String, workspace: String, allowMode: String = "full_access", modelKey: String? = nil, newConversation: Bool = false) { + sendInternal(message: message, workspace: workspace, allowMode: allowMode, modelKey: modelKey, newConversation: newConversation) } /// 追加消息(同一对话) @@ -134,7 +134,7 @@ final class AgentBridge { sendInternal(message: message, workspace: "", allowMode: "", modelKey: nil) } - private func sendInternal(message: String, workspace: String, allowMode: String, modelKey: String?) { + private func sendInternal(message: String, workspace: String, allowMode: String, modelKey: String?, newConversation: Bool = false) { var payload: [String: Any] = [ "action": "chat", "message": message, @@ -143,6 +143,7 @@ final class AgentBridge { if !allowMode.isEmpty { payload["allowMode"] = allowMode } if let mk = modelKey, !mk.isEmpty { payload["modelKey"] = mk } payload["thinkingMode"] = Config.shared.agentThinkingMode + if newConversation { payload["new_conversation"] = true } guard let data = try? JSONSerialization.data(withJSONObject: payload), let json = String(data: data, encoding: .utf8) else { @@ -238,8 +239,9 @@ final class AgentBridge { case "done": let content = json["content"] as? String ?? "" let elapsed = json["elapsed_ms"] as? Int ?? 0 - log(" → done contentLen=\(content.count) elapsedMs=\(elapsed)") - onEvent?(.done(content: content, elapsedMs: elapsed)) + let totalTokens = json["total_tokens"] as? Int ?? 0 + log(" → done contentLen=\(content.count) elapsedMs=\(elapsed) totalTokens=\(totalTokens)") + onEvent?(.done(content: content, elapsedMs: elapsed, totalTokens: totalTokens)) case "error": let message = json["message"] as? String ?? "未知错误" log(" → error message=\(message)") diff --git a/Sources/UI/AgentNotchView.swift b/Sources/UI/AgentNotchView.swift index 2b01faf..dc6c0db 100644 --- a/Sources/UI/AgentNotchView.swift +++ b/Sources/UI/AgentNotchView.swift @@ -28,7 +28,7 @@ struct AgentNotchView: View { thinkingView } - if !tracker.toolEntries.isEmpty { + if !tracker.toolEntries.isEmpty && tracker.toolsMode != "never" { toolProgressList } @@ -51,6 +51,12 @@ struct AgentNotchView: View { .onChange(of: tracker.toolEntries.count) { _, _ in onContentSizeChanged?() } .onChange(of: tracker.isTextInputMode) { _, _ in onContentSizeChanged?() } .onChange(of: tracker.streamingContent.isEmpty) { _, _ in onContentSizeChanged?() } + .onChange(of: tracker.voiceInputText) { _, newValue in + if let text = newValue, !text.isEmpty { + tracker.voiceInputText = nil + textInput = text + } + } } // MARK: - 状态头 @@ -81,18 +87,26 @@ struct AgentNotchView: View { .buttonStyle(.plain) } - if tracker.mode != "thinking" && tracker.mode != "running", - let onNew = onNewConversation { - Button(action: onNew) { - Image(systemName: "plus.bubble") - .font(.system(size: 9, weight: .medium)) - .foregroundColor(.white.opacity(0.5)) - .frame(width: 20, height: 20) - .contentShape(Rectangle()) + if tracker.mode != "thinking" && tracker.mode != "running" { + if tracker.newConversationMode == "auto" { + if tracker.autoNewTriggered { + Text("已自动新建对话") + .font(.system(size: 9)) + .foregroundColor(.white.opacity(0.4)) + } + } else if let onNew = onNewConversation { + // 手动模式(或未知模式默认手动):显示新建按钮 + Button(action: onNew) { + Image(systemName: "plus.bubble") + .font(.system(size: 9, weight: .medium)) + .foregroundColor(.white.opacity(0.5)) + .frame(width: 20, height: 20) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help("新建对话") + .padding(.leading, 2) } - .buttonStyle(.plain) - .help("新建对话") - .padding(.leading, 2) } Button(action: onClose) { diff --git a/Sources/UI/AgentProgressTracker.swift b/Sources/UI/AgentProgressTracker.swift index 4dee226..8e46a60 100644 --- a/Sources/UI/AgentProgressTracker.swift +++ b/Sources/UI/AgentProgressTracker.swift @@ -17,6 +17,12 @@ final class AgentProgressTracker: ObservableObject { @Published var errorMessage: String = "" @Published var streamingContent: String = "" // 实时流式输出 @Published var isTextInputMode: Bool = false // 打字输入模式标记(外部控制) + @Published var autoNewTriggered: Bool = false // 自动新建对话已触发 + @Published var voiceInputText: String? = nil // 语音识别文本(不自动发送时填入输入框) + var toolsMode: String = "all" // all / hide_final / never + var newConversationMode: String = "manual" // manual / auto + var totalTokens: Int = 0 + var manualNewRequested: Bool = false // 手动点击了新建对话 private var timer: Timer? private var startTime: Date = Date() @@ -72,9 +78,17 @@ final class AgentProgressTracker: ObservableObject { } } - func finish(content: String) { + func finish(content: String, totalTokens: Int = 0) { mode = "done" finalContent = content + self.totalTokens = totalTokens + if toolsMode == "hide_final" { + toolEntries = [] + } + // 自动模式:total > 阈值时标记自动新建 + if newConversationMode == "auto" && totalTokens > Config.shared.agentAutoNewTokenThreshold { + autoNewTriggered = true + } timer?.invalidate() timer = nil } diff --git a/Sources/UI/MemoryWindow.swift b/Sources/UI/MemoryWindow.swift new file mode 100644 index 0000000..b33c7b1 --- /dev/null +++ b/Sources/UI/MemoryWindow.swift @@ -0,0 +1,529 @@ +import Cocoa + +// MARK: - 记忆条目(与 memory.json 对应) + +struct MemoryEntry: Codable, Identifiable { + var id: String + var type: String + var content: String + var keywords: [String] + var createdAt: String + var updatedAt: String + + enum CodingKeys: String, CodingKey { + case id, type, content, keywords + case createdAt = "created_at" + case updatedAt = "updated_at" + } + + var typeDisplayName: String { + switch type { + case "preference": return "偏好" + case "habit": return "习惯" + case "event": return "事件" + case "todo": return "待办" + case "identity": return "身份" + case "relationship": return "关系" + default: return "其他" + } + } + + var keywordsString: String { + keywords.joined(separator: ", ") + } + + var dateDisplay: String { + String((updatedAt.isEmpty ? createdAt : updatedAt).prefix(10)) + } +} + +// MARK: - 记忆管理窗口 + +final class MemoryWindow: NSWindow { + + private let searchField: NSTextField + private let typeFilterPopup: NSPopUpButton + private let tableView: NSTableView + private let addButton: NSButton + private let editButton: NSButton + private let deleteButton: NSButton + private let refreshButton: NSButton + private let statusLabel: NSTextField + + private var allEntries: [MemoryEntry] = [] + private var filteredEntries: [MemoryEntry] = [] + + private let memoryPath: String = { + let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + return appSupport.appendingPathComponent("VoiceInput/memory/memory.json").path + }() + + init() { + searchField = NSTextField(frame: .zero) + typeFilterPopup = NSPopUpButton(frame: .zero, pullsDown: false) + tableView = NSTableView(frame: .zero) + addButton = NSButton(title: "+", target: nil, action: nil) + editButton = NSButton(title: "编辑", target: nil, action: nil) + deleteButton = NSButton(title: "删除", target: nil, action: nil) + refreshButton = NSButton(title: "刷新", target: nil, action: nil) + statusLabel = NSTextField(labelWithString: "") + + let rect = NSRect(x: 0, y: 0, width: 680, height: 480) + super.init( + contentRect: rect, + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + + configureWindow() + buildUI() + loadMemories() + } + + private func configureWindow() { + title = "记忆管理" + isReleasedWhenClosed = false + center() + minSize = NSSize(width: 520, height: 360) + } + + override func makeKeyAndOrderFront(_ sender: Any?) { + loadMemories() + super.makeKeyAndOrderFront(sender) + } + + // MARK: - 快捷键 + + override func performKeyEquivalent(with event: NSEvent) -> Bool { + guard event.modifierFlags.contains(.command) else { + return super.performKeyEquivalent(with: event) + } + let selector: Selector? + switch event.charactersIgnoringModifiers?.lowercased() { + case "a": selector = #selector(NSText.selectAll(_:)) + case "c": selector = Selector(("copy:")) + case "v": selector = Selector(("paste:")) + case "x": selector = Selector(("cut:")) + default: selector = nil + } + guard let sel = selector else { + return super.performKeyEquivalent(with: event) + } + if NSApp.sendAction(sel, to: nil, from: self) { return true } + return super.performKeyEquivalent(with: event) + } + + private func makeLabel(_ text: String) -> NSTextField { + let label = NSTextField(labelWithString: text) + label.font = .systemFont(ofSize: 11, weight: .semibold) + label.textColor = .secondaryLabelColor + label.alignment = .right + label.translatesAutoresizingMaskIntoConstraints = false + return label + } + + private func buildUI() { + guard let content = contentView else { return } + + // 搜索区域 + let searchLabel = makeLabel("搜索:") + searchField.placeholderString = "输入关键词搜索记忆..." + searchField.font = .systemFont(ofSize: 13) + searchField.bezelStyle = .roundedBezel + searchField.translatesAutoresizingMaskIntoConstraints = false + searchField.target = self + searchField.action = #selector(searchChanged) + + let filterLabel = makeLabel("类型:") + typeFilterPopup.translatesAutoresizingMaskIntoConstraints = false + typeFilterPopup.addItems(withTitles: ["全部", "偏好", "习惯", "事件", "待办", "身份", "关系", "其他"]) + typeFilterPopup.target = self + typeFilterPopup.action = #selector(filterChanged) + + // 表格 + let scrollView = NSScrollView(frame: .zero) + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.hasVerticalScroller = true + scrollView.borderType = .bezelBorder + + tableView.translatesAutoresizingMaskIntoConstraints = false + tableView.headerView = nil + tableView.selectionHighlightStyle = .regular + tableView.dataSource = self + tableView.delegate = self + tableView.rowHeight = 28 + tableView.intercellSpacing = NSSize(width: 4, height: 2) + tableView.usesAlternatingRowBackgroundColors = true + + let colType = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("type")) + colType.title = "类型"; colType.width = 50; colType.minWidth = 40 + let colContent = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("content")) + colContent.title = "内容"; colContent.width = 280; colContent.minWidth = 150 + let colKeywords = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("keywords")) + colKeywords.title = "关键词"; colKeywords.width = 140; colKeywords.minWidth = 80 + let colDate = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("date")) + colDate.title = "更新时间"; colDate.width = 100; colDate.minWidth = 80 + + tableView.addTableColumn(colType) + tableView.addTableColumn(colContent) + tableView.addTableColumn(colKeywords) + tableView.addTableColumn(colDate) + + scrollView.documentView = tableView + + // 按钮 + addButton.bezelStyle = .rounded + addButton.font = .systemFont(ofSize: 13, weight: .bold) + addButton.translatesAutoresizingMaskIntoConstraints = false + addButton.target = self + addButton.action = #selector(addMemory) + + editButton.bezelStyle = .rounded + editButton.translatesAutoresizingMaskIntoConstraints = false + editButton.target = self + editButton.action = #selector(editMemory) + + deleteButton.bezelStyle = .rounded + deleteButton.translatesAutoresizingMaskIntoConstraints = false + deleteButton.target = self + deleteButton.action = #selector(deleteMemory) + + refreshButton.bezelStyle = .rounded + refreshButton.translatesAutoresizingMaskIntoConstraints = false + refreshButton.target = self + refreshButton.action = #selector(refreshMemories) + + let buttonStack = NSStackView(views: [addButton, editButton, deleteButton, refreshButton]) + buttonStack.translatesAutoresizingMaskIntoConstraints = false + buttonStack.orientation = .horizontal + buttonStack.spacing = 8 + + statusLabel.font = .systemFont(ofSize: 11) + statusLabel.textColor = .secondaryLabelColor + statusLabel.alignment = .center + statusLabel.translatesAutoresizingMaskIntoConstraints = false + + content.addSubview(searchLabel) + content.addSubview(searchField) + content.addSubview(filterLabel) + content.addSubview(typeFilterPopup) + content.addSubview(scrollView) + content.addSubview(buttonStack) + content.addSubview(statusLabel) + + NSLayoutConstraint.activate([ + searchLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), + searchLabel.topAnchor.constraint(equalTo: content.topAnchor, constant: 20), + searchLabel.widthAnchor.constraint(equalToConstant: 40), + + searchField.leadingAnchor.constraint(equalTo: searchLabel.trailingAnchor, constant: 6), + searchField.centerYAnchor.constraint(equalTo: searchLabel.centerYAnchor), + searchField.widthAnchor.constraint(equalToConstant: 240), + + filterLabel.leadingAnchor.constraint(equalTo: searchField.trailingAnchor, constant: 16), + filterLabel.centerYAnchor.constraint(equalTo: searchLabel.centerYAnchor), + filterLabel.widthAnchor.constraint(equalToConstant: 40), + + typeFilterPopup.leadingAnchor.constraint(equalTo: filterLabel.trailingAnchor, constant: 6), + typeFilterPopup.centerYAnchor.constraint(equalTo: searchLabel.centerYAnchor), + typeFilterPopup.widthAnchor.constraint(equalToConstant: 100), + + scrollView.topAnchor.constraint(equalTo: searchField.bottomAnchor, constant: 14), + scrollView.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), + scrollView.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -20), + scrollView.bottomAnchor.constraint(equalTo: buttonStack.topAnchor, constant: -10), + + buttonStack.centerXAnchor.constraint(equalTo: content.centerXAnchor), + buttonStack.bottomAnchor.constraint(equalTo: statusLabel.topAnchor, constant: -8), + + statusLabel.centerXAnchor.constraint(equalTo: content.centerXAnchor), + statusLabel.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -16), + statusLabel.widthAnchor.constraint(equalToConstant: 640) + ]) + } + + // MARK: - 数据加载/保存 + + private func loadMemories() { + guard let data = try? Data(contentsOf: URL(fileURLWithPath: memoryPath)) else { + allEntries = [] + applyFilter() + return + } + let decoder = JSONDecoder() + if let entries = try? decoder.decode([MemoryEntry].self, from: data) { + allEntries = entries.sorted { $0.updatedAt > $1.updatedAt } + } else { + allEntries = [] + } + applyFilter() + } + + private func saveMemories() { + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + guard let data = try? encoder.encode(allEntries) else { return } + let dir = (memoryPath as NSString).deletingLastPathComponent + if !FileManager.default.fileExists(atPath: dir) { + try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + } + try? data.write(to: URL(fileURLWithPath: memoryPath), options: .atomic) + } + + // MARK: - 过滤 + + private func applyFilter() { + let query = searchField.stringValue.trimmingCharacters(in: .whitespaces).lowercased() + let typeIndex = typeFilterPopup.indexOfSelectedItem + let typeMap = ["preference", "habit", "event", "todo", "identity", "relationship", "other"] + + filteredEntries = allEntries.filter { entry in + if typeIndex > 0 && typeIndex <= typeMap.count { + if entry.type != typeMap[typeIndex - 1] { return false } + } + if !query.isEmpty { + let matchContent = entry.content.lowercased().contains(query) + let matchKeywords = entry.keywords.contains { $0.lowercased().contains(query) } + if !matchContent && !matchKeywords { return false } + } + return true + } + tableView.reloadData() + statusLabel.stringValue = "共 \(filteredEntries.count) 条记忆" + } + + @objc private func searchChanged() { applyFilter() } + @objc private func filterChanged() { applyFilter() } + + // MARK: - 操作 + + @objc private func addMemory() { + showEditor(for: nil) + } + + @objc private func editMemory() { + let row = tableView.selectedRow + guard row >= 0, row < filteredEntries.count else { + statusLabel.stringValue = "请先选择一条记忆" + return + } + showEditor(for: filteredEntries[row]) + } + + @objc private func deleteMemory() { + let row = tableView.selectedRow + guard row >= 0, row < filteredEntries.count else { + statusLabel.stringValue = "请先选择一条记忆" + return + } + let entry = filteredEntries[row] + let alert = NSAlert() + alert.messageText = "删除记忆" + alert.informativeText = "确定要删除「\(String(entry.content.prefix(50)))」吗?" + alert.alertStyle = .warning + alert.addButton(withTitle: "删除") + alert.addButton(withTitle: "取消") + if alert.runModal() == .alertFirstButtonReturn { + allEntries.removeAll { $0.id == entry.id } + saveMemories() + applyFilter() + statusLabel.stringValue = "已删除" + } + } + + @objc private func refreshMemories() { + loadMemories() + statusLabel.stringValue = "已刷新,共 \(filteredEntries.count) 条记忆" + statusLabel.textColor = .systemGreen + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in + self?.statusLabel.stringValue = "共 \(self?.filteredEntries.count ?? 0) 条记忆" + self?.statusLabel.textColor = .secondaryLabelColor + } + } + + // MARK: - 编辑器 + + private func showEditor(for entry: MemoryEntry?) { + let isEdit = entry != nil + let panel = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 300), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + panel.title = isEdit ? "编辑记忆" : "添加记忆" + panel.isReleasedWhenClosed = false + panel.center() + + guard let contentView = panel.contentView else { return } + + let typeLabel = makeLabel("类型:") + let typePopup = NSPopUpButton(frame: .zero, pullsDown: false) + typePopup.translatesAutoresizingMaskIntoConstraints = false + typePopup.addItems(withTitles: ["偏好", "习惯", "事件", "待办", "身份", "关系", "其他"]) + let typeMap = ["preference", "habit", "event", "todo", "identity", "relationship", "other"] + if let e = entry, let idx = typeMap.firstIndex(of: e.type) { + typePopup.selectItem(at: idx) + } + + let contentLabel = makeLabel("内容:") + let contentField = NSTextField(frame: .zero) + contentField.placeholderString = "记忆内容..." + contentField.stringValue = entry?.content ?? "" + contentField.font = .systemFont(ofSize: 13) + contentField.bezelStyle = .roundedBezel + contentField.translatesAutoresizingMaskIntoConstraints = false + + let keywordsLabel = makeLabel("关键词:") + let keywordsField = NSTextField(frame: .zero) + keywordsField.placeholderString = "用逗号分隔,如:咖啡, 美式" + keywordsField.stringValue = entry?.keywords.joined(separator: ", ") ?? "" + keywordsField.font = .systemFont(ofSize: 13) + keywordsField.bezelStyle = .roundedBezel + keywordsField.translatesAutoresizingMaskIntoConstraints = false + + let saveBtn = NSButton(title: isEdit ? "保存" : "添加", target: nil, action: nil) + saveBtn.bezelStyle = .rounded + saveBtn.translatesAutoresizingMaskIntoConstraints = false + saveBtn.keyEquivalent = "\r" + + let cancelBtn = NSButton(title: "取消", target: nil, action: nil) + cancelBtn.bezelStyle = .rounded + cancelBtn.translatesAutoresizingMaskIntoConstraints = false + + let btnStack = NSStackView(views: [cancelBtn, saveBtn]) + btnStack.translatesAutoresizingMaskIntoConstraints = false + btnStack.orientation = .horizontal + btnStack.spacing = 12 + btnStack.distribution = .fillEqually + + contentView.addSubview(typeLabel) + contentView.addSubview(typePopup) + contentView.addSubview(contentLabel) + contentView.addSubview(contentField) + contentView.addSubview(keywordsLabel) + contentView.addSubview(keywordsField) + contentView.addSubview(btnStack) + + NSLayoutConstraint.activate([ + typeLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + typeLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 24), + typeLabel.widthAnchor.constraint(equalToConstant: 60), + typePopup.leadingAnchor.constraint(equalTo: typeLabel.trailingAnchor, constant: 8), + typePopup.centerYAnchor.constraint(equalTo: typeLabel.centerYAnchor), + typePopup.widthAnchor.constraint(equalToConstant: 160), + + contentLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + contentLabel.topAnchor.constraint(equalTo: typeLabel.bottomAnchor, constant: 18), + contentLabel.widthAnchor.constraint(equalToConstant: 60), + contentField.leadingAnchor.constraint(equalTo: contentLabel.trailingAnchor, constant: 8), + contentField.centerYAnchor.constraint(equalTo: contentLabel.centerYAnchor), + contentField.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), + + keywordsLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + keywordsLabel.topAnchor.constraint(equalTo: contentLabel.bottomAnchor, constant: 18), + keywordsLabel.widthAnchor.constraint(equalToConstant: 60), + keywordsField.leadingAnchor.constraint(equalTo: keywordsLabel.trailingAnchor, constant: 8), + keywordsField.centerYAnchor.constraint(equalTo: keywordsLabel.centerYAnchor), + keywordsField.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), + + btnStack.topAnchor.constraint(equalTo: keywordsLabel.bottomAnchor, constant: 22), + btnStack.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + btnStack.widthAnchor.constraint(equalToConstant: 200) + ]) + + // 保存/取消动作 + let saveAction = { [weak self] in + guard let self = self else { return } + let typeIdx = typePopup.indexOfSelectedItem + guard typeIdx >= 0, typeIdx < typeMap.count else { return } + let selectedType = typeMap[typeIdx] + let newContent = contentField.stringValue.trimmingCharacters(in: .whitespaces) + guard !newContent.isEmpty else { return } + let keywords = keywordsField.stringValue + .components(separatedBy: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + let now = ISO8601DateFormatter().string(from: Date()) + + if let e = entry { + if let idx = self.allEntries.firstIndex(where: { $0.id == e.id }) { + self.allEntries[idx].type = selectedType + self.allEntries[idx].content = newContent + self.allEntries[idx].keywords = keywords + self.allEntries[idx].updatedAt = now + } + } else { + let newId = UUID().uuidString.prefix(8).lowercased() + let newEntry = MemoryEntry( + id: String(newId), type: selectedType, content: newContent, + keywords: keywords, createdAt: now, updatedAt: now + ) + self.allEntries.append(newEntry) + } + self.saveMemories() + self.applyFilter() + self.statusLabel.stringValue = isEdit ? "已更新" : "已添加" + self.statusLabel.textColor = .systemGreen + panel.close() + } + + // 使用 helper 类绑定 action + let helper = MemoryEditorHelper(panel: panel, onSave: saveAction) + saveBtn.target = helper + saveBtn.action = #selector(MemoryEditorHelper.saveAction) + cancelBtn.target = helper + cancelBtn.action = #selector(MemoryEditorHelper.cancelAction) + objc_setAssociatedObject(saveBtn, "helper", helper, .OBJC_ASSOCIATION_RETAIN) + objc_setAssociatedObject(cancelBtn, "helper", helper, .OBJC_ASSOCIATION_RETAIN) + + panel.makeKeyAndOrderFront(nil) + } +} + +// MARK: - NSTableView + +extension MemoryWindow: NSTableViewDataSource { + func numberOfRows(in tableView: NSTableView) -> Int { filteredEntries.count } +} + +extension MemoryWindow: NSTableViewDelegate { + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + guard row < filteredEntries.count, let column = tableColumn else { return nil } + let entry = filteredEntries[row] + let textField: NSTextField + if let reused = tableView.makeView(withIdentifier: column.identifier, owner: nil) as? NSTextField { + textField = reused + } else { + textField = NSTextField(labelWithString: "") + textField.identifier = column.identifier + textField.font = .systemFont(ofSize: 12) + textField.lineBreakMode = .byTruncatingTail + textField.maximumNumberOfLines = 1 + } + switch column.identifier.rawValue { + case "type": textField.stringValue = entry.typeDisplayName + case "content": textField.stringValue = entry.content + case "keywords": textField.stringValue = entry.keywordsString + case "date": textField.stringValue = entry.dateDisplay + default: textField.stringValue = "" + } + return textField + } +} + +// MARK: - 编辑器 Helper + +private class MemoryEditorHelper: NSObject { + weak var panel: NSPanel? + let onSave: () -> Void + + init(panel: NSPanel, onSave: @escaping () -> Void) { + self.panel = panel + self.onSave = onSave + } + + @objc func saveAction() { onSave() } + @objc func cancelAction() { panel?.close() } +} diff --git a/Sources/UI/SettingsWindow.swift b/Sources/UI/SettingsWindow.swift index 2b3c006..4425c49 100644 --- a/Sources/UI/SettingsWindow.swift +++ b/Sources/UI/SettingsWindow.swift @@ -22,6 +22,23 @@ final class SettingsWindow: NSWindow { // Agent 模式专属:朗读开关 private let agentSpeakCheckbox: NSButton + // Agent 模式专属:语音自动发送开关 + private let voiceAutoSendCheckbox: NSButton + + // Agent 模式专属:助手名字/语气 + private let assistantNameField: NSTextField + private let assistantTonePopup: NSPopUpButton + private let userNameField: NSTextField + + // Agent 模式专属:工具显示模式 + private let toolsModePopup: NSPopUpButton + + // Agent 模式专属:新建对话模式 + private let newConversationModePopup: NSPopUpButton + + // Agent 模式专属:自动新建阈值 + private let autoNewTokenThresholdField: NSTextField + // Agent 模式专属 private let workspaceField: NSTextField private let browseButton: NSButton @@ -51,6 +68,13 @@ final class SettingsWindow: NSWindow { outputModePopup = NSPopUpButton(frame: .zero, pullsDown: false) speakCheckbox = NSButton(checkboxWithTitle: "朗读 AI 回复", target: nil, action: nil) agentSpeakCheckbox = NSButton(checkboxWithTitle: "任务完成时朗读 AI 最终输出", target: nil, action: nil) + voiceAutoSendCheckbox = NSButton(checkboxWithTitle: "语音输入后自动发送", target: nil, action: nil) + assistantNameField = NSTextField(frame: .zero) + assistantTonePopup = NSPopUpButton(frame: .zero, pullsDown: false) + userNameField = NSTextField(frame: .zero) + toolsModePopup = NSPopUpButton(frame: .zero, pullsDown: false) + newConversationModePopup = NSPopUpButton(frame: .zero, pullsDown: false) + autoNewTokenThresholdField = NSTextField(frame: .zero) workspaceField = NSTextField(frame: .zero) browseButton = NSButton(title: "浏览...", target: nil, action: nil) modelPopup = NSPopUpButton(frame: .zero, pullsDown: false) @@ -63,7 +87,7 @@ final class SettingsWindow: NSWindow { editModelButton = NSButton(title: "编辑", target: nil, action: nil) deleteModelButton = NSButton(title: "删除", target: nil, action: nil) - let height: CGFloat = mode == .agent ? 700 : (mode == .assist ? 400 : 320) + let height: CGFloat = mode == .agent ? 860 : (mode == .assist ? 400 : 320) let width: CGFloat = mode == .agent ? 500 : 420 let windowRect = NSRect(x: 0, y: 0, width: width, height: height) super.init( @@ -364,6 +388,7 @@ final class SettingsWindow: NSWindow { } func refreshAgentModelTable() { + Config.shared.syncUserProfile() buildAgentUI() let modelsFile = Config.shared.loadAgentModels() print("[SettingsWindow] loaded models count: \(modelsFile.models.count)") @@ -386,6 +411,19 @@ final class SettingsWindow: NSWindow { tavilyKeyField.stringValue = modelsFile.tavilyApiKey workspaceField.stringValue = Config.shared.agentWorkspace agentSpeakCheckbox.state = Config.shared.agentSpeakEnabled ? .on : .off + voiceAutoSendCheckbox.state = Config.shared.agentVoiceAutoSend ? .on : .off + assistantNameField.stringValue = Config.shared.assistantName + userNameField.stringValue = Config.shared.userName + let tones = ["友好", "专业", "幽默", "简洁", "温暖", "正式"] + let toneIdx = tones.firstIndex(of: Config.shared.assistantTone) ?? 0 + assistantTonePopup.selectItem(at: toneIdx) + let modeMap = ["all", "hide_final", "never"] + let currentMode = Config.shared.agentToolsMode + toolsModePopup.selectItem(at: modeMap.firstIndex(of: currentMode) ?? 0) + let convModeMap = ["manual", "auto"] + let currentConvMode = Config.shared.agentNewConversationMode + newConversationModePopup.selectItem(at: convModeMap.firstIndex(of: currentConvMode) ?? 0) + autoNewTokenThresholdField.stringValue = String(Config.shared.agentAutoNewTokenThreshold) } private func buildAgentUI() { @@ -419,11 +457,65 @@ final class SettingsWindow: NSWindow { thinkingModePopup.target = self thinkingModePopup.action = #selector(agentThinkingModeChanged) + // 工具显示模式 + let toolsModeLabel = makeLabel("工具显示:") + toolsModePopup.translatesAutoresizingMaskIntoConstraints = false + toolsModePopup.addItems(withTitles: ["全部显示", "最后隐藏", "一直不显示"]) + let modeMap = ["all", "hide_final", "never"] + let currentMode = Config.shared.agentToolsMode + toolsModePopup.selectItem(at: modeMap.firstIndex(of: currentMode) ?? 0) + + // 新建对话模式 + let newConvLabel = makeLabel("新建对话:") + newConversationModePopup.translatesAutoresizingMaskIntoConstraints = false + newConversationModePopup.addItems(withTitles: ["手动", "自动"]) + let convModeMap = ["manual", "auto"] + let currentConvMode = Config.shared.agentNewConversationMode + newConversationModePopup.selectItem(at: convModeMap.firstIndex(of: currentConvMode) ?? 0) + + // 自动新建阈值 + let thresholdLabel = makeLabel("自动新建阈值:") + autoNewTokenThresholdField.placeholderString = "40000" + autoNewTokenThresholdField.font = .systemFont(ofSize: 13) + autoNewTokenThresholdField.bezelStyle = .roundedBezel + autoNewTokenThresholdField.translatesAutoresizingMaskIntoConstraints = false + autoNewTokenThresholdField.stringValue = String(Config.shared.agentAutoNewTokenThreshold) + autoNewTokenThresholdField.target = self + autoNewTokenThresholdField.action = #selector(agentAutoNewThresholdChanged) + agentSpeakCheckbox.translatesAutoresizingMaskIntoConstraints = false agentSpeakCheckbox.state = Config.shared.agentSpeakEnabled ? .on : .off agentSpeakCheckbox.target = self agentSpeakCheckbox.action = #selector(agentSpeakToggled) + voiceAutoSendCheckbox.translatesAutoresizingMaskIntoConstraints = false + voiceAutoSendCheckbox.state = Config.shared.agentVoiceAutoSend ? .on : .off + voiceAutoSendCheckbox.target = self + voiceAutoSendCheckbox.action = #selector(agentVoiceAutoSendToggled) + + // 助手名字 + let assistantNameLabel = makeLabel("助手名字:") + assistantNameField.placeholderString = "小助手" + assistantNameField.font = .systemFont(ofSize: 13) + assistantNameField.bezelStyle = .roundedBezel + assistantNameField.translatesAutoresizingMaskIntoConstraints = false + assistantNameField.stringValue = Config.shared.assistantName + + // 用户称呼 + let userNameLabel = makeLabel("我的称呼:") + userNameField.placeholderString = "如 jojo、张总(留空则称「用户」)" + userNameField.font = .systemFont(ofSize: 13) + userNameField.bezelStyle = .roundedBezel + userNameField.translatesAutoresizingMaskIntoConstraints = false + userNameField.stringValue = Config.shared.userName + + // 助手语气 + let assistantToneLabel = makeLabel("助手语气:") + assistantTonePopup.translatesAutoresizingMaskIntoConstraints = false + assistantTonePopup.addItems(withTitles: ["友好", "专业", "幽默", "简洁", "温暖", "正式"]) + let toneIndex = ["友好", "专业", "幽默", "简洁", "温暖", "正式"].firstIndex(of: Config.shared.assistantTone) ?? 0 + assistantTonePopup.selectItem(at: toneIndex) + let tavilyLabel = makeLabel("Tavily Key:") tavilyKeyField.placeholderString = "tvly-...(网络搜索用)" tavilyKeyField.font = .systemFont(ofSize: 13) @@ -515,7 +607,20 @@ final class SettingsWindow: NSWindow { content.addSubview(inputModePopup) content.addSubview(thinkLabel) content.addSubview(thinkingModePopup) + content.addSubview(toolsModeLabel) + content.addSubview(toolsModePopup) + content.addSubview(newConvLabel) + content.addSubview(newConversationModePopup) + content.addSubview(thresholdLabel) + content.addSubview(autoNewTokenThresholdField) content.addSubview(agentSpeakCheckbox) + content.addSubview(voiceAutoSendCheckbox) + content.addSubview(assistantNameLabel) + content.addSubview(assistantNameField) + content.addSubview(userNameLabel) + content.addSubview(userNameField) + content.addSubview(assistantToneLabel) + content.addSubview(assistantTonePopup) content.addSubview(tavilyLabel) content.addSubview(tavilyKeyField) content.addSubview(wsLabel) @@ -557,11 +662,59 @@ final class SettingsWindow: NSWindow { thinkingModePopup.centerYAnchor.constraint(equalTo: thinkLabel.centerYAnchor), thinkingModePopup.widthAnchor.constraint(equalToConstant: 200), + toolsModeLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), + toolsModeLabel.topAnchor.constraint(equalTo: thinkLabel.bottomAnchor, constant: 16), + toolsModeLabel.widthAnchor.constraint(equalToConstant: 85), + toolsModePopup.leadingAnchor.constraint(equalTo: toolsModeLabel.trailingAnchor, constant: 8), + toolsModePopup.centerYAnchor.constraint(equalTo: toolsModeLabel.centerYAnchor), + toolsModePopup.widthAnchor.constraint(equalToConstant: 200), + + newConvLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), + newConvLabel.topAnchor.constraint(equalTo: toolsModeLabel.bottomAnchor, constant: 16), + newConvLabel.widthAnchor.constraint(equalToConstant: 85), + newConversationModePopup.leadingAnchor.constraint(equalTo: newConvLabel.trailingAnchor, constant: 8), + newConversationModePopup.centerYAnchor.constraint(equalTo: newConvLabel.centerYAnchor), + newConversationModePopup.widthAnchor.constraint(equalToConstant: 200), + + thresholdLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), + thresholdLabel.topAnchor.constraint(equalTo: newConvLabel.bottomAnchor, constant: 16), + thresholdLabel.widthAnchor.constraint(equalToConstant: 85), + autoNewTokenThresholdField.leadingAnchor.constraint(equalTo: thresholdLabel.trailingAnchor, constant: 8), + autoNewTokenThresholdField.centerYAnchor.constraint(equalTo: thresholdLabel.centerYAnchor), + autoNewTokenThresholdField.widthAnchor.constraint(equalToConstant: 100), + agentSpeakCheckbox.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), - agentSpeakCheckbox.topAnchor.constraint(equalTo: thinkLabel.bottomAnchor, constant: 10), + agentSpeakCheckbox.topAnchor.constraint(equalTo: thresholdLabel.bottomAnchor, constant: 10), + + voiceAutoSendCheckbox.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), + voiceAutoSendCheckbox.topAnchor.constraint(equalTo: agentSpeakCheckbox.bottomAnchor, constant: 6), + + // 助手名字 + assistantNameLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), + assistantNameLabel.topAnchor.constraint(equalTo: voiceAutoSendCheckbox.bottomAnchor, constant: 14), + assistantNameLabel.widthAnchor.constraint(equalToConstant: 85), + assistantNameField.leadingAnchor.constraint(equalTo: assistantNameLabel.trailingAnchor, constant: 8), + assistantNameField.centerYAnchor.constraint(equalTo: assistantNameLabel.centerYAnchor), + assistantNameField.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -20), + + // 用户称呼 + userNameLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), + userNameLabel.topAnchor.constraint(equalTo: assistantNameLabel.bottomAnchor, constant: 14), + userNameLabel.widthAnchor.constraint(equalToConstant: 85), + userNameField.leadingAnchor.constraint(equalTo: userNameLabel.trailingAnchor, constant: 8), + userNameField.centerYAnchor.constraint(equalTo: userNameLabel.centerYAnchor), + userNameField.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -20), + + // 助手语气 + assistantToneLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), + assistantToneLabel.topAnchor.constraint(equalTo: userNameLabel.bottomAnchor, constant: 14), + assistantToneLabel.widthAnchor.constraint(equalToConstant: 85), + assistantTonePopup.leadingAnchor.constraint(equalTo: assistantToneLabel.trailingAnchor, constant: 8), + assistantTonePopup.centerYAnchor.constraint(equalTo: assistantToneLabel.centerYAnchor), + assistantTonePopup.widthAnchor.constraint(equalToConstant: 200), tavilyLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), - tavilyLabel.topAnchor.constraint(equalTo: agentSpeakCheckbox.bottomAnchor, constant: 14), + tavilyLabel.topAnchor.constraint(equalTo: assistantToneLabel.bottomAnchor, constant: 14), tavilyLabel.widthAnchor.constraint(equalToConstant: 85), tavilyKeyField.leadingAnchor.constraint(equalTo: tavilyLabel.trailingAnchor, constant: 8), tavilyKeyField.centerYAnchor.constraint(equalTo: tavilyLabel.centerYAnchor), @@ -637,6 +790,16 @@ final class SettingsWindow: NSWindow { } } + @objc private func agentVoiceAutoSendToggled() { + Config.shared.agentVoiceAutoSend = voiceAutoSendCheckbox.state == .on + } + + @objc private func agentAutoNewThresholdChanged() { + let val = Int(autoNewTokenThresholdField.stringValue) ?? 40000 + Config.shared.agentAutoNewTokenThreshold = max(1000, val) + autoNewTokenThresholdField.stringValue = String(Config.shared.agentAutoNewTokenThreshold) + } + @objc private func saveAgentConfig() { var modelsFile = Config.shared.loadAgentModels() modelsFile.tavilyApiKey = tavilyKeyField.stringValue.trimmingCharacters(in: .whitespaces) @@ -644,6 +807,27 @@ final class SettingsWindow: NSWindow { modelsFile.models = agentModelList Config.shared.saveAgentModels(modelsFile) Config.shared.agentWorkspace = workspaceField.stringValue + // 保存助手个性化配置 + Config.shared.assistantName = assistantNameField.stringValue.trimmingCharacters(in: .whitespaces) + let tones = ["友好", "专业", "幽默", "简洁", "温暖", "正式"] + let toneIdx = assistantTonePopup.indexOfSelectedItem + if toneIdx >= 0, toneIdx < tones.count { + Config.shared.assistantTone = tones[toneIdx] + } + Config.shared.userName = userNameField.stringValue.trimmingCharacters(in: .whitespaces) + let modeMap = ["all", "hide_final", "never"] + let modeIdx = toolsModePopup.indexOfSelectedItem + if modeIdx >= 0, modeIdx < modeMap.count { + Config.shared.agentToolsMode = modeMap[modeIdx] + } + let convModeMap = ["manual", "auto"] + let convIdx = newConversationModePopup.indexOfSelectedItem + if convIdx >= 0, convIdx < convModeMap.count { + Config.shared.agentNewConversationMode = convModeMap[convIdx] + } + // 保存自动新建阈值 + let thresholdVal = Int(autoNewTokenThresholdField.stringValue) ?? 40000 + Config.shared.agentAutoNewTokenThreshold = max(1000, thresholdVal) refreshAgentModelTable() statusLabel.stringValue = "✅ Agent 配置已保存" statusLabel.textColor = .systemGreen diff --git a/Sources/Utils/Config.swift b/Sources/Utils/Config.swift index 285ac0c..d11785a 100644 --- a/Sources/Utils/Config.swift +++ b/Sources/Utils/Config.swift @@ -306,7 +306,69 @@ final class Config { set { defaults.set(newValue, forKey: "agent_thinking_mode") } } - // MARK: - models.json 路径 + // MARK: - 助手个性化配置 + + var assistantName: String { + get { defaults.string(forKey: "assistant_name") ?? "小助手" } + set { + defaults.set(newValue, forKey: "assistant_name") + saveUserProfile() + } + } + + var assistantTone: String { + get { defaults.string(forKey: "assistant_tone") ?? "友好" } + set { + defaults.set(newValue, forKey: "assistant_tone") + saveUserProfile() + } + } + + var userName: String { + get { defaults.string(forKey: "user_name") ?? "" } + set { + defaults.set(newValue, forKey: "user_name") + saveUserProfile() + } + } + + var agentShowTools: Bool { + get { + if defaults.object(forKey: "agent_show_tools") == nil { return true } + return defaults.bool(forKey: "agent_show_tools") + } + set { defaults.set(newValue, forKey: "agent_show_tools") } + } + + var agentToolsMode: String { + get { defaults.string(forKey: "agent_tools_mode") ?? "all" } + set { defaults.set(newValue, forKey: "agent_tools_mode") } + } + + var agentNewConversationMode: String { + get { defaults.string(forKey: "agent_new_conversation_mode") ?? "manual" } + set { defaults.set(newValue, forKey: "agent_new_conversation_mode") } + } + + /// 自动新建对话的 token 阈值(仅 newConversationMode == "auto" 时生效) + var agentAutoNewTokenThreshold: Int { + get { + let val = defaults.integer(forKey: "agent_auto_new_token_threshold") + return val > 0 ? val : 40000 + } + set { defaults.set(newValue, forKey: "agent_auto_new_token_threshold") } + } + + /// 语音输入后是否自动发送(false 时只填入输入框) + var agentVoiceAutoSend: Bool { + get { + if defaults.object(forKey: "agent_voice_auto_send") == nil { return true } + return defaults.bool(forKey: "agent_voice_auto_send") + } + set { defaults.set(newValue, forKey: "agent_voice_auto_send") } + } + + // MARK: - 用户配置文件(user_profile.json) /// Agent 目录路径:优先查找 bundle Resources,其次逐级往上搜索包含 models.json 的 Agent/ var agentDir: String { @@ -359,6 +421,11 @@ final class Config { userDataDir + "/models.json" } + /// 用户配置文件路径(助手名字/语气) + var userProfilePath: String { + userDataDir + "/user_profile.json" + } + // MARK: - models.json 读写 func loadAgentModels() -> AgentModelsFile { @@ -398,6 +465,35 @@ final class Config { try? data.write(to: URL(fileURLWithPath: path), options: .atomic) } + /// 保存用户个性化配置到 user_profile.json + func saveUserProfile() { + let profile: [String: String] = [ + "assistant_name": assistantName, + "assistant_tone": assistantTone, + "user_name": userName, + ] + let encoder = JSONEncoder() + guard let data = try? encoder.encode(profile) else { return } + try? data.write(to: URL(fileURLWithPath: userProfilePath), options: .atomic) + } + + /// 从 user_profile.json 同步到 UserDefaults(用于窗口打开时刷新) + func syncUserProfile() { + guard let data = try? Data(contentsOf: URL(fileURLWithPath: userProfilePath)), + let profile = try? JSONDecoder().decode([String: String].self, from: data) else { + return + } + if let name = profile["assistant_name"], !name.isEmpty { + defaults.set(name, forKey: "assistant_name") + } + if let tone = profile["assistant_tone"], !tone.isEmpty { + defaults.set(tone, forKey: "assistant_tone") + } + if let userName = profile["user_name"], !userName.isEmpty { + defaults.set(userName, forKey: "user_name") + } + } + /// 有效的模型列表(过滤掉配置不完整的) var validAgentModels: [AgentModelConfig] { loadAgentModels().models.filter { m in