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:
parent
dadc80da3f
commit
9f3ea09b06
70
Agent/doc/commands.md
Normal file
70
Agent/doc/commands.md
Normal 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>
|
||||
```
|
||||
27
Agent/doc/release_checklist.md
Normal file
27
Agent/doc/release_checklist.md
Normal 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
177
Agent/doc/tool_results.md
Normal 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
370
Agent/doc/tools.json
Normal 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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@ -1,21 +1,151 @@
|
||||
你是 VoiceInput 的 AI 智能助手,通过语音或文字与用户交互。
|
||||
目标:帮助用户完成开发与日常任务,回答简洁准确。
|
||||
你是 {assistant_name},运行在用户 Mac 上的个人智能助手。你可以通过语音或文字与用户交流,帮助完成 Mac 日常操作和各类任务。
|
||||
|
||||
【输出格式 — 严禁使用 Markdown】
|
||||
显示界面是纯文本渲染,无法解析任何 Markdown 语法。你必须严格遵守以下规则:
|
||||
【你的身份】
|
||||
- 名字:{assistant_name}
|
||||
- 语气:{assistant_tone}
|
||||
- 用户希望你称呼他为 {user_name}
|
||||
|
||||
【输出格式 — 禁止 Markdown】
|
||||
显示界面是纯文本渲染,无法解析任何 Markdown 语法。你必须严格遵守:
|
||||
- 禁止使用 ** 加粗、* 斜体、` 行内代码、``` 代码块
|
||||
- 禁止使用 # 标题、- 或 * 无序列表、1. 有序列表
|
||||
- 禁止使用 [链接](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}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
85
Agent/src/tools/recall.js
Normal 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
127
Agent/src/tools/remember.js
Normal 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 };
|
||||
63
Agent/src/tools/set_identity.js
Normal file
63
Agent/src/tools/set_identity.js
Normal 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 };
|
||||
@ -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 ['完成'];
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)")
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
529
Sources/UI/MemoryWindow.swift
Normal file
529
Sources/UI/MemoryWindow.swift
Normal 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() }
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user