feat(agent): Better Siri 改造 — 个人助手 + 记忆系统 + 统一弹窗 + 多项优化

=== 记忆系统 ===
- 新增 remember(记录)和 recall(搜索)工具,持久化到 ~/Library/Application Support/VoiceInput/memory/memory.json
- 新增 set_identity 工具,独立管理助手名字/用户称呼/语气
- prompt 重写为个人助手身份,注入 macOS 能力 + 记忆规则
- prompt 中积极调用记忆工具,按需搜索、自然融入回答
- 禁止相对时间,待办必须绝对日期

=== 对话持久化 ===
- 集成 conversation_store,done 前自动写入对话文件
- token 记录:prompt/completion 累加,total 覆盖为最新上下文长度
- 启动时加载最新对话继续(使用 eagent /resume 逻辑)
- 去重:末尾连续 assistant 消息自动合并
- 修复消息加载顺序 bug(旧对话覆盖用户新消息)

=== 新建对话 ===
- 手动/自动两种模式(设置窗口可切换)
- 自动模式:total > 阈值时隐藏按钮,下次输入自动新建
- 自动阈值在设置窗口可配置(默认 40000)

=== 弹窗 UI 统一 ===
- 语音输入按 Fn 即显示刘海弹窗(和打字输入统一)
- 语音识别内容自动填入并发送(可关闭自动发送)
- 工作期间(thinking/running)隐藏输入框,idle/done 时显示
- 工具显示三种模式:全部显示/最后隐藏/一直不显示

=== 设置窗口增强 ===
- 助手名字 / 用户称呼 / 语气可配置
- 工具显示模式 / 新建对话模式 / 自动阈值
- 语音输入后自动发送开关(默认开)
- 记忆管理窗口可视化编辑(Dock 可访问)
- 窗口高度 840→860

=== 菜单增强 ===
- Agent LLM 二级菜单切换语音/打字输入
- 助手设置和记忆管理入口(Dock 可访问)

=== 修复 ===
- Fn 按下时停止正在进行的朗读
- 弹窗已存在时不再覆盖 isTextInputMode
- voiceInputText 加 @Published 以触发 UI 更新
This commit is contained in:
JOJO 2026-04-30 18:16:58 +08:00
parent dadc80da3f
commit 9f3ea09b06
20 changed files with 2252 additions and 64 deletions

70
Agent/doc/commands.md Normal file
View File

@ -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 <conversation_id>` 加载指定对话。
- 例如:
```
/resume 019c99ad-3177-7970-bcf5-0c5893e177a2
```
**作用域限制**`/resume` 只加载**当前工作区路径**下的历史对话。不同路径的工作区对话相互隔离。
---
## /status 输出字段
- 当前模型 + 思考模式
- 工作区 path
- allow 状态(只读/无限制)
- 对话 ID
- Token usage当前上下文长度来自 API 返回并缓存)
---
## /config 输出字段
- base_url
- modelname
- apikey脱敏仅显示前缀+末尾,例如:`sk-xxxxxxw2y`
---
## /compact 行为
- 对当前对话进行压缩。
- 输出示例:
```
压缩完成:<旧token> -> <新token>
```

View File

@ -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 与示例配置在包中可访问

177
Agent/doc/tool_results.md Normal file
View File

@ -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
成功:
```
已替换 <N> 处: <path>
```
失败:
```
失败: <错误信息>
```
---
## 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
返回保存结果的简短确认信息。
成功:
```
已保存: <path> (chars=<N>, bytes=<N>)
```
失败:
```
失败: <错误信息>
```
---
## run_command
仅返回命令输出;如超时/错误则在前缀标注状态。
成功:
```
<stdout/stderr 合并输出>
```
超时:
```
[timeout after <Ns>]
<已捕获输出>
```
失败:
```
[error rc=<code>] <错误信息或输出>
```
---
## read_mediafile
成功(文字提示 + 附加媒体 payload
```
已附加图片: <path>
```
```
已附加视频: <path>
```
失败:
```
失败: <错误信息>
```
---
## search_workspace
### mode=file
```
命中文件(<N>):
1) <path>
2) <path>
```
### mode=content
```
<path>
- L12: <snippet>
- L30: <snippet>
<path>
- L5: <snippet>
```
失败:
```
失败: <错误信息>
```

370
Agent/doc/tools.json Normal file
View File

@ -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": []
}
}
}
]

View File

@ -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=preferencecontent=具体偏好keywords=相关关键词
- 用户透露了生活习惯如「我每天早上8点喝咖啡」
→ type=habit
- 用户分享了重要事件(如「上个月我换了新电脑」)
→ type=event
- 用户提到了待办事项如「明天下午3点开会」
→ type=todocontent 必须包含明确的绝对日期如「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}

View File

@ -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();
}
}
// 持久化对话到磁盘(含 tokenprompt/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;

View File

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

View File

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

85
Agent/src/tools/recall.js Normal file
View File

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

127
Agent/src/tools/remember.js Normal file
View File

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

View File

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

View File

@ -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 ['完成'];
}

View File

@ -158,19 +158,35 @@ 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" {
//
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()
showTextInputNotch()
return
}
//
}
isRecording = true
finalTranscription = ""
@ -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)

View File

@ -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: - NSWindowDelegateDock
extension MenuBarController: NSWindowDelegate {
func windowWillClose(_ notification: Notification) {
dockVisibleWindowCount -= 1
if dockVisibleWindowCount <= 0 {
dockVisibleWindowCount = 0
NSApp.setActivationPolicy(.accessory)
}
}
}

View File

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

View File

@ -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,8 +87,15 @@ struct AgentNotchView: View {
.buttonStyle(.plain)
}
if tracker.mode != "thinking" && tracker.mode != "running",
let onNew = onNewConversation {
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))
@ -94,6 +107,7 @@ struct AgentNotchView: View {
.help("新建对话")
.padding(.leading, 2)
}
}
Button(action: onClose) {
Image(systemName: "xmark")

View File

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

View File

@ -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() }
}

View File

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

View File

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