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. 有序列表
|
- 禁止使用 # 标题、- 或 * 无序列表、1. 有序列表
|
||||||
- 禁止使用 [链接](url)、、> 引用、| 表格
|
- 禁止使用 [链接](url)、、> 引用、| 表格
|
||||||
- 使用纯文本表达:用缩进或空行分隔段落,用「」标注关键术语,用编号 1) 2) 3) 代替列表
|
- 使用纯文本表达:用缩进或空行分隔段落,用「」标注关键术语,用 1) 2) 3) 编号代替列表
|
||||||
- 如需展示代码,直接写出并用「代码:」前缀标明
|
- 如需展示代码或命令,用「代码:」前缀后直接写出
|
||||||
|
|
||||||
【回复风格 — 务必简洁】
|
【回复风格】
|
||||||
- 优先用最少的字把问题说清楚,不要铺垫、不要寒暄、不要冗余解释
|
- 以用户的语言自然交流,不废话但也不冷冰冰
|
||||||
- 用户问什么就答什么,不主动发散无关内容
|
- 如果用户问题简单(如调音量、查时间),直接执行并简短告知结果
|
||||||
- 如果回答较长,把核心结论放在最前面,再补充细节
|
- 如果用户问题复杂或涉及个人偏好,先搜索记忆再回答
|
||||||
|
- 回答时直接给出结论和建议,不要显式说「根据记忆」「我搜索了记忆」
|
||||||
|
- 语音输入可能存在同音字或转录错误,请结合上下文推测用户真实意图。如果实在无法理解,向用户确认而不要猜测
|
||||||
|
|
||||||
重要提示:用户的输入来自语音识别,可能存在同音字、转录错误或模糊表达。请结合上下文推测用户真实意图。如果确实无法理解,应向用户确认而非猜测回复。
|
【macOS 系统能力 — 你拥有以下核心能力】
|
||||||
|
|
||||||
|
系统控制:
|
||||||
|
- 音量:osascript -e 'set volume output volume N'(0-100)
|
||||||
|
- 静音/取消:osascript -e 'set volume output muted true/false'
|
||||||
|
- 锁屏:/System/Library/CoreServices/Menu Extras/User.menu/Contents/Resources/CGSession -suspend(需确认)
|
||||||
|
- 深色模式:osascript -e 'tell app "System Events" to tell appearance preferences to set dark mode to true/false'
|
||||||
|
- 屏保:open -a ScreenSaverEngine
|
||||||
|
- 通知:osascript -e 'display notification "内容" with title "标题"'
|
||||||
|
- 调度中心:osascript -e 'tell app "System Events" to key code 103 using control down'
|
||||||
|
|
||||||
|
应用控制:
|
||||||
|
- 打开应用:open -a "应用名"
|
||||||
|
- 关闭应用:osascript -e 'tell app "应用名" to quit'
|
||||||
|
- 获取前台应用:osascript -e 'tell app "System Events" to get name of first process whose frontmost is true'
|
||||||
|
- 检查应用是否运行:pgrep -x "进程名"
|
||||||
|
|
||||||
|
音乐与媒体:
|
||||||
|
- 播放/暂停:osascript -e 'tell app "Music" to playpause'
|
||||||
|
- 下一曲/上一曲:osascript -e 'tell app "Music" to next track/previous track'
|
||||||
|
- 获取当前歌曲:osascript -e 'tell app "Music" to if running then return name of current track & " - " & artist of current track'
|
||||||
|
|
||||||
|
个人效率:
|
||||||
|
- 创建提醒:osascript -e 'tell app "Reminders" to make new reminder with properties {name:"内容", due date:(current date) + 秒数}'
|
||||||
|
- 创建备忘录:osascript -e 'tell app "Notes" to make new note at folder "Notes" with properties {name:"标题", body:"内容"}'
|
||||||
|
- 朗读文字:say "内容"(中文用 say -v Tingting "内容")
|
||||||
|
- 运行快捷指令:shortcuts run "名称"
|
||||||
|
|
||||||
|
文件与系统信息:
|
||||||
|
- 截图:screencapture ~/Desktop/文件名.png(-i 选区,-C 带鼠标)
|
||||||
|
- 文件搜索:mdfind -name "关键词"
|
||||||
|
- 磁盘空间:df -h /
|
||||||
|
- 电池:pmset -g batt
|
||||||
|
- WiFi:/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport -I
|
||||||
|
- 剪贴板:pbpaste 读取,pbcopy 写入
|
||||||
|
- 打开网页:open "https://..."
|
||||||
|
|
||||||
|
常用快捷键模拟:
|
||||||
|
- 截取选区:osascript -e 'tell app "System Events" to key code 21 using {command down, shift down}'(Cmd+Shift+4)
|
||||||
|
- 切换应用:open -a "应用名"
|
||||||
|
|
||||||
|
以上命令通过 run_command 工具执行。不确定后果的命令(锁屏、强制退出、清空废纸篓)应先向用户确认。
|
||||||
|
|
||||||
|
【身份设置 — set_identity 工具】
|
||||||
|
|
||||||
|
当用户表达了以下意图时,使用 set_identity 工具(而不是 remember):
|
||||||
|
- 让你改名叫什么(如「你叫小A吧」)→ assistant_name
|
||||||
|
- 告诉你 TA 的名字或希望你怎么称呼 TA(如「叫我 jojo」)→ user_name
|
||||||
|
- 希望调整交流语气(如「语气幽默一点」)→ assistant_tone
|
||||||
|
|
||||||
|
这三个配置会立即生效,影响后续所有对话。可以只更新其中一个字段。
|
||||||
|
|
||||||
|
set_identity 和 remember 的本质区别:
|
||||||
|
- set_identity:修改 AI 助手的运行时身份和交互方式,即改即生效
|
||||||
|
- remember:记录用户个人信息,供后续对话参考,不影响当前交互行为
|
||||||
|
举例:
|
||||||
|
「你叫 Jarvis」→ set_identity(改 AI 名字)
|
||||||
|
「我是 iOS 开发者」→ remember(记下身份信息)
|
||||||
|
「叫我张总」→ set_identity(改用户称呼)
|
||||||
|
「我喜欢喝咖啡」→ remember(记下偏好)
|
||||||
|
|
||||||
|
【记忆系统 — 重要,必须严格遵守】
|
||||||
|
|
||||||
|
你有一个长期记忆系统,可以记住关于用户的信息。记住:你是个人助手,不是记录机器。
|
||||||
|
|
||||||
|
1) 何时记录记忆(remember 工具):
|
||||||
|
以下情况应调用 remember 工具 add 操作:
|
||||||
|
- 用户表达了个人偏好(喜欢/不喜欢/觉得什么好用/讨厌什么)
|
||||||
|
→ type=preference,content=具体偏好,keywords=相关关键词
|
||||||
|
- 用户透露了生活习惯(如「我每天早上8点喝咖啡」)
|
||||||
|
→ type=habit
|
||||||
|
- 用户分享了重要事件(如「上个月我换了新电脑」)
|
||||||
|
→ type=event
|
||||||
|
- 用户提到了待办事项(如「明天下午3点开会」)
|
||||||
|
→ type=todo,content 必须包含明确的绝对日期(如「2026年5月1日下午3点开会」),禁止使用「明天」「下周三」「月底」等相对时间
|
||||||
|
→ 当前时间是 {current_time},请据此将相对时间转换为绝对日期
|
||||||
|
- 用户告知了个人身份信息(如「我是iOS开发者」)
|
||||||
|
→ type=identity
|
||||||
|
- 用户提及了人际关系(如「张三是我同事」)
|
||||||
|
→ type=relationship
|
||||||
|
- 其他值得记住的信息
|
||||||
|
→ type=other
|
||||||
|
|
||||||
|
判断标准:如果这个信息以后对话可能会用到,就记住。
|
||||||
|
|
||||||
|
以下情况不要记录:
|
||||||
|
- 用户的普通问候(你好、早上好)
|
||||||
|
- 一次性操作(调高音量、打开应用、问天气)
|
||||||
|
- 纯粹的技术问题(怎么截图、Python怎么用)
|
||||||
|
- 不包含个人信息的闲聊
|
||||||
|
- 任何不属于上述类型中的无意义信息
|
||||||
|
|
||||||
|
2) 何时搜索记忆(recall 工具):
|
||||||
|
当用户提问可能涉及个人偏好、历史或已记录信息时才搜索,例如:
|
||||||
|
- 「我喜欢什么...」「我之前说过...」「帮我查一下我的...」
|
||||||
|
- 涉及个人习惯、喜好、身份的询问
|
||||||
|
- 涉及待办事项或日程
|
||||||
|
|
||||||
|
不需要搜索的情况:
|
||||||
|
- 简单的系统操作(调音量、截图、打开应用)
|
||||||
|
- 纯知识/技能问题(怎么...、什么是...)
|
||||||
|
- 查询时间、天气等实时信息
|
||||||
|
- 用户问「我是谁」「我叫什么」「你叫什么」等身份问题——直接查看 prompt 中【你的身份】部分,那里有用户希望被称呼的名字和你的名字,无需搜索记忆
|
||||||
|
|
||||||
|
搜索时指定相关的 type(可多个),让结果更精准。
|
||||||
|
|
||||||
|
3) 如何使用搜索结果:
|
||||||
|
将搜索结果自然融入回答,就像你本来就记得一样。
|
||||||
|
- 好:「好的,上次你提到喜欢美式咖啡,要帮你记下这家咖啡馆吗?」
|
||||||
|
- 不好:「根据我的记忆,你之前说过喜欢美式咖啡...」
|
||||||
|
|
||||||
|
绝对不要在回答中显式提到「记忆」「搜索」「记录」等词,也不要说「根据我的记忆」。
|
||||||
|
|
||||||
|
【工具使用原则】
|
||||||
|
- 处理用户请求时,优先用 run_command 执行 macOS 命令
|
||||||
|
- 需要外部信息时用 web_search
|
||||||
|
- 如果用户请求需要先了解其偏好/历史,用 recall 搜索记忆
|
||||||
|
- 如果用户在对话中透露值得记住的信息,用 remember 记录
|
||||||
|
- 文件操作工具在需要时使用,不限于开发场景
|
||||||
|
- 重要:工具调用前后的回复顺序必须合理。不要在工具调用前就说出结论或承诺结果。
|
||||||
|
正确做法:工具调用前只做简短过渡(如「好的」),或不回复直接调用工具;等工具返回结果后,再输出完整的最终回复。
|
||||||
|
示例 — 用户说「记住我叫xxx」:
|
||||||
|
错误:先输出「我来帮你记住」,调用 remember,最后说「有什么需要随时说」
|
||||||
|
正确:直接调用 remember,最后输出「已经记住你叫xxx了,有什么需要随时说」
|
||||||
|
|
||||||
|
【系统信息】
|
||||||
- 当前时间:{current_time}
|
- 当前时间:{current_time}
|
||||||
- 当前模型:{model_id}
|
- 当前模型:{model_id}
|
||||||
- 工作区路径:{path}
|
- 工作区路径:{path}
|
||||||
|
|||||||
@ -28,6 +28,7 @@ const { buildSystemPrompt } = require('./core/context');
|
|||||||
const { streamChat } = require('./model/client');
|
const { streamChat } = require('./model/client');
|
||||||
const { executeTool } = require('./tools/dispatcher');
|
const { executeTool } = require('./tools/dispatcher');
|
||||||
const { applyUsage, normalizeTokenUsage, normalizeUsagePayload } = require('./utils/token_usage');
|
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');
|
const { buildStartLine, buildFinalLine, formatResultLines } = require('./ui/tool_display');
|
||||||
|
|
||||||
// --- 配置 ---
|
// --- 配置 ---
|
||||||
@ -110,20 +111,50 @@ async function runAgent(input) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const state = persistentState;
|
const state = persistentState;
|
||||||
|
const workspace = persistentWorkspace || process.cwd();
|
||||||
process.stderr.write(`[bridge] runAgent start, msgs=${state.messages.length}, thinkingMode=${state.thinkingMode}\n`);
|
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 });
|
state.messages.push({ role: 'user', content: input });
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
let continueLoop = true;
|
let continueLoop = true;
|
||||||
let lastAssistantContent = '';
|
let lastAssistantContent = '';
|
||||||
|
let accumulatedUsage = { prompt: 0, completion: 0, total: 0 };
|
||||||
|
|
||||||
while (continueLoop) {
|
while (continueLoop) {
|
||||||
let assistantContent = '';
|
let assistantContent = '';
|
||||||
let fullThinkingBuffer = '';
|
let fullThinkingBuffer = '';
|
||||||
let toolCalls = {};
|
let toolCalls = {};
|
||||||
let usageTotal = null;
|
|
||||||
|
|
||||||
emit({ type: 'status', mode: 'thinking' });
|
emit({ type: 'status', mode: 'thinking' });
|
||||||
|
|
||||||
@ -186,11 +217,11 @@ async function runAgent(input) {
|
|||||||
fullThinkingBuffer += rc;
|
fullThinkingBuffer += rc;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Usage
|
// Usage(累积)
|
||||||
const usage = chunk.usage || choice.usage || delta.usage;
|
const usage = chunk.usage || choice.usage || delta.usage;
|
||||||
const normalized = normalizeUsagePayload(usage);
|
const normalized = normalizeUsagePayload(usage);
|
||||||
if (normalized && Number.isFinite(normalized.total_tokens)) {
|
if (normalized) {
|
||||||
usageTotal = normalized.total_tokens;
|
accumulatedUsage = applyUsage(accumulatedUsage, normalized);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -275,8 +306,34 @@ async function runAgent(input) {
|
|||||||
continueLoop = false;
|
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;
|
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);
|
persistentWorkspace = resolveWorkspace(msg.workspace);
|
||||||
persistentState = createState(config, persistentWorkspace);
|
persistentState = createState(config, persistentWorkspace);
|
||||||
persistentState.allowMode = msg.allowMode || 'full_access';
|
persistentState.allowMode = msg.allowMode || 'full_access';
|
||||||
|
persistentState._forceNew = !!msg.new_conversation; // 强制新对话标记
|
||||||
if (msg.modelKey && config.model_map.has(msg.modelKey)) {
|
if (msg.modelKey && config.model_map.has(msg.modelKey)) {
|
||||||
const model = config.model_map.get(msg.modelKey);
|
const model = config.model_map.get(msg.modelKey);
|
||||||
persistentState.modelKey = model.key;
|
persistentState.modelKey = model.key;
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
const { execSync } = require('child_process');
|
const { execSync } = require('child_process');
|
||||||
|
|
||||||
function getTerminalType() {
|
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) {
|
function buildSystemPrompt(basePrompt, opts) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const tzOffset = now.getTimezoneOffset();
|
const localIso = `${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}`;
|
||||||
const localMs = now.getTime() - tzOffset * 60 * 1000;
|
|
||||||
const localIso = new Date(localMs).toISOString().slice(0, 16);
|
|
||||||
const platform = os.platform();
|
const platform = os.platform();
|
||||||
const systemName = platform === 'darwin' ? 'macos' : platform === 'win32' ? 'windows' : 'linux';
|
const systemName = platform === 'darwin' ? 'macos' : platform === 'win32' ? 'windows' : 'linux';
|
||||||
|
const profile = loadUserProfile();
|
||||||
let prompt = basePrompt;
|
let prompt = basePrompt;
|
||||||
const allowModeValue = opts.allowMode === 'read_only'
|
const allowModeValue = opts.allowMode === 'read_only'
|
||||||
? `${opts.allowMode}\n 已禁用 edit_file、run_command。若用户要求使用,请告知当前无权限需要用户输入 /allow 切换权限。`
|
? `${opts.allowMode}\n 已禁用 edit_file、run_command。若用户要求使用,请告知当前无权限需要用户输入 /allow 切换权限。`
|
||||||
: opts.allowMode;
|
: opts.allowMode;
|
||||||
const replacements = {
|
const replacements = {
|
||||||
|
assistant_name: profile.assistantName,
|
||||||
|
assistant_tone: profile.assistantTone,
|
||||||
|
user_name: profile.userName,
|
||||||
current_time: localIso,
|
current_time: localIso,
|
||||||
path: opts.workspace,
|
path: opts.workspace,
|
||||||
workspace: opts.workspace,
|
workspace: opts.workspace,
|
||||||
|
|||||||
@ -6,6 +6,9 @@ const { runCommandTool } = require('./run_command');
|
|||||||
const { webSearchTool, extractWebpageTool } = require('./web_search');
|
const { webSearchTool, extractWebpageTool } = require('./web_search');
|
||||||
const { searchWorkspaceTool } = require('./search_workspace');
|
const { searchWorkspaceTool } = require('./search_workspace');
|
||||||
const { readMediafileTool } = require('./read_mediafile');
|
const { readMediafileTool } = require('./read_mediafile');
|
||||||
|
const { rememberTool } = require('./remember');
|
||||||
|
const { recallTool } = require('./recall');
|
||||||
|
const { setIdentityTool } = require('./set_identity');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
const MAX_RUN_COMMAND_CHARS = 10000;
|
const MAX_RUN_COMMAND_CHARS = 10000;
|
||||||
@ -122,6 +125,48 @@ function formatReadMediafile(result) {
|
|||||||
return `已附加视频: ${result.path}`;
|
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) {
|
function buildToolContent(result) {
|
||||||
if (result.type === 'image' || result.type === 'video') {
|
if (result.type === 'image' || result.type === 'video') {
|
||||||
const payload = {
|
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 === 'search_workspace') raw = await searchWorkspaceTool(workspace, args);
|
||||||
else if (name === 'read_mediafile') raw = readMediafileTool(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: '未知工具' };
|
else raw = { success: false, error: '未知工具' };
|
||||||
|
|
||||||
let formatted = '';
|
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 === 'extract_webpage') formatted = formatExtractWebpage(raw, args.mode, args.url, args.target_path);
|
||||||
else if (name === 'search_workspace') formatted = formatSearchWorkspace(raw);
|
else if (name === 'search_workspace') formatted = formatSearchWorkspace(raw);
|
||||||
else if (name === 'read_mediafile') formatted = formatReadMediafile(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 || '失败');
|
else formatted = raw.success ? '' : formatFailure(raw.error || '失败');
|
||||||
|
|
||||||
const toolContent = buildToolContent(raw);
|
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: '提取网页',
|
extract_webpage: '提取网页',
|
||||||
search_workspace: '文件搜索',
|
search_workspace: '文件搜索',
|
||||||
read_mediafile: '读取媒体文件',
|
read_mediafile: '读取媒体文件',
|
||||||
|
remember: '记住',
|
||||||
|
recall: '回忆',
|
||||||
|
set_identity: '设置身份',
|
||||||
};
|
};
|
||||||
return map[name] || name;
|
return map[name] || name;
|
||||||
}
|
}
|
||||||
@ -76,6 +79,36 @@ function buildStartLine(name, args) {
|
|||||||
if (name === 'read_mediafile') {
|
if (name === 'read_mediafile') {
|
||||||
return `${label} ${args.path}`;
|
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}`;
|
return `${label}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -205,6 +238,21 @@ function formatResultLines(name, args, raw) {
|
|||||||
if (raw.type === 'image') return ['已附加图片'];
|
if (raw.type === 'image') return ['已附加图片'];
|
||||||
if (raw.type === 'video') 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 ['完成'];
|
return ['完成'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -158,18 +158,34 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSSpeechSynthesizerDel
|
|||||||
private func startRecording() {
|
private func startRecording() {
|
||||||
guard !isRecording else { return }
|
guard !isRecording else { return }
|
||||||
|
|
||||||
|
// 正在朗读 → 停止
|
||||||
|
if speechSynthesizer.isSpeaking {
|
||||||
|
speechSynthesizer.stopSpeaking()
|
||||||
|
speakingMode = nil
|
||||||
|
speakAutoHideWorkItem?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
// 如果当前模式不是智能体但刘海弹窗还在 → 关闭
|
// 如果当前模式不是智能体但刘海弹窗还在 → 关闭
|
||||||
if agentNotch != nil && Config.shared.triggerMode != .agent {
|
if agentNotch != nil && Config.shared.triggerMode != .agent {
|
||||||
hideAgentNotch()
|
hideAgentNotch()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 智能体 + 打字输入模式 → 直接显示文本输入刘海
|
// 智能体模式 → 统一显示刘海弹窗
|
||||||
if Config.shared.triggerMode == .agent && Config.shared.agentInputMode == "text" {
|
if Config.shared.triggerMode == .agent {
|
||||||
isTextInputActive = true
|
agentTracker.resetForInput()
|
||||||
recordingPanel.updateText("Type your question...")
|
agentTracker.isTextInputMode = true
|
||||||
recordingPanel.showAnimated()
|
agentTracker.toolsMode = Config.shared.agentToolsMode
|
||||||
showTextInputNotch()
|
agentTracker.newConversationMode = Config.shared.agentNewConversationMode
|
||||||
return
|
showAgentNotchInternal(isTextInput: true)
|
||||||
|
|
||||||
|
// 打字输入:不录音,等待用户输入
|
||||||
|
if Config.shared.agentInputMode == "text" {
|
||||||
|
isTextInputActive = true
|
||||||
|
recordingPanel.updateText("Type your question...")
|
||||||
|
recordingPanel.showAnimated()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 语音输入:继续录音流程
|
||||||
}
|
}
|
||||||
|
|
||||||
isRecording = true
|
isRecording = true
|
||||||
@ -249,7 +265,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSSpeechSynthesizerDel
|
|||||||
switch Config.shared.triggerMode {
|
switch Config.shared.triggerMode {
|
||||||
case .input: handleInputMode(text: text, config: Config.shared.inputLLM)
|
case .input: handleInputMode(text: text, config: Config.shared.inputLLM)
|
||||||
case .assist: handleAssistMode(text: text, config: Config.shared.assistLLM)
|
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 = ""
|
finalTranscription = ""
|
||||||
}
|
}
|
||||||
@ -329,6 +351,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSSpeechSynthesizerDel
|
|||||||
}
|
}
|
||||||
recordingPanel.hideAnimated()
|
recordingPanel.hideAnimated()
|
||||||
|
|
||||||
|
// 新建对话(自动超限 或 手动点击)
|
||||||
|
let needsNewConversation = agentTracker.autoNewTriggered || agentTracker.manualNewRequested
|
||||||
|
if needsNewConversation {
|
||||||
|
agentBridge.stop()
|
||||||
|
agentActive = false
|
||||||
|
agentTracker.autoNewTriggered = false
|
||||||
|
agentTracker.manualNewRequested = false
|
||||||
|
}
|
||||||
|
|
||||||
// 如果对话已在进行中 → 追加消息
|
// 如果对话已在进行中 → 追加消息
|
||||||
if agentActive {
|
if agentActive {
|
||||||
autoHideWorkItem?.cancel()
|
autoHideWorkItem?.cancel()
|
||||||
@ -365,7 +396,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSSpeechSynthesizerDel
|
|||||||
}
|
}
|
||||||
agentBridge.send(message: text, workspace: Config.shared.agentWorkspace,
|
agentBridge.send(message: text, workspace: Config.shared.agentWorkspace,
|
||||||
allowMode: Config.shared.agentAllowMode,
|
allowMode: Config.shared.agentAllowMode,
|
||||||
modelKey: defaultModel!)
|
modelKey: defaultModel!,
|
||||||
|
newConversation: needsNewConversation)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func hideAgentNotch() {
|
private func hideAgentNotch() {
|
||||||
@ -384,9 +416,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSSpeechSynthesizerDel
|
|||||||
agentTracker.updateToolResult(tool, resultLines: resultLines)
|
agentTracker.updateToolResult(tool, resultLines: resultLines)
|
||||||
case .assistantChunk(let content):
|
case .assistantChunk(let content):
|
||||||
agentTracker.appendChunk(content)
|
agentTracker.appendChunk(content)
|
||||||
case .done(let content, _):
|
case .done(let content, _, let totalTokens):
|
||||||
agentLog("[App] handleEvent done contentLen=\(content.count)")
|
agentLog("[App] handleEvent done contentLen=\(content.count) totalTokens=\(totalTokens)")
|
||||||
agentTracker.finish(content: content)
|
agentTracker.finish(content: content, totalTokens: totalTokens)
|
||||||
let willSpeak = Config.shared.agentSpeakEnabled && !content.isEmpty
|
let willSpeak = Config.shared.agentSpeakEnabled && !content.isEmpty
|
||||||
if willSpeak {
|
if willSpeak {
|
||||||
speakingMode = .agent
|
speakingMode = .agent
|
||||||
@ -428,14 +460,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSSpeechSynthesizerDel
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func showAgentNotchInternal(isTextInput: Bool) {
|
private func showAgentNotchInternal(isTextInput: Bool) {
|
||||||
agentTracker.isTextInputMode = isTextInput
|
// 弹窗已存在 → 复用,只刷新显示(不覆盖已有状态)
|
||||||
|
|
||||||
// 弹窗已存在 → 复用,只刷新显示
|
|
||||||
if agentNotch != nil {
|
if agentNotch != nil {
|
||||||
agentNotch?.show()
|
agentNotch?.show()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
agentTracker.isTextInputMode = isTextInput
|
||||||
|
agentTracker.toolsMode = Config.shared.agentToolsMode
|
||||||
|
agentTracker.newConversationMode = Config.shared.agentNewConversationMode
|
||||||
|
|
||||||
// 创建新弹窗
|
// 创建新弹窗
|
||||||
let view = AgentNotchView(
|
let view = AgentNotchView(
|
||||||
tracker: agentTracker,
|
tracker: agentTracker,
|
||||||
@ -469,6 +503,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSSpeechSynthesizerDel
|
|||||||
self?.agentActive = false
|
self?.agentActive = false
|
||||||
self?.agentIsTextMode = false
|
self?.agentIsTextMode = false
|
||||||
self?.agentTracker.resetForInput()
|
self?.agentTracker.resetForInput()
|
||||||
|
self?.agentTracker.manualNewRequested = true
|
||||||
},
|
},
|
||||||
onSendText: { [weak self] text in
|
onSendText: { [weak self] text in
|
||||||
self?.handleTextInput(text)
|
self?.handleTextInput(text)
|
||||||
|
|||||||
@ -19,6 +19,9 @@ final class MenuBarController: NSObject {
|
|||||||
private let assistSettingsWindow: SettingsWindow
|
private let assistSettingsWindow: SettingsWindow
|
||||||
private let agentSettingsWindow: SettingsWindow
|
private let agentSettingsWindow: SettingsWindow
|
||||||
private let agentLLMEnabledItem: NSMenuItem
|
private let agentLLMEnabledItem: NSMenuItem
|
||||||
|
private let agentVoiceInputItem: NSMenuItem
|
||||||
|
private let agentTextInputItem: NSMenuItem
|
||||||
|
private let memoryWindow: MemoryWindow
|
||||||
|
|
||||||
override init() {
|
override init() {
|
||||||
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||||
@ -38,11 +41,22 @@ final class MenuBarController: NSObject {
|
|||||||
inputSettingsWindow = SettingsWindow(mode: .input)
|
inputSettingsWindow = SettingsWindow(mode: .input)
|
||||||
assistSettingsWindow = SettingsWindow(mode: .assist)
|
assistSettingsWindow = SettingsWindow(mode: .assist)
|
||||||
agentSettingsWindow = SettingsWindow(mode: .agent)
|
agentSettingsWindow = SettingsWindow(mode: .agent)
|
||||||
|
memoryWindow = MemoryWindow()
|
||||||
agentLLMEnabledItem = NSMenuItem(
|
agentLLMEnabledItem = NSMenuItem(
|
||||||
title: "Enable",
|
title: "Enable",
|
||||||
action: #selector(toggleAgentLLM),
|
action: #selector(toggleAgentLLM),
|
||||||
keyEquivalent: ""
|
keyEquivalent: ""
|
||||||
)
|
)
|
||||||
|
agentVoiceInputItem = NSMenuItem(
|
||||||
|
title: "🎤 语音输入",
|
||||||
|
action: #selector(selectAgentVoiceInput),
|
||||||
|
keyEquivalent: ""
|
||||||
|
)
|
||||||
|
agentTextInputItem = NSMenuItem(
|
||||||
|
title: "⌨️ 打字输入",
|
||||||
|
action: #selector(selectAgentTextInput),
|
||||||
|
keyEquivalent: ""
|
||||||
|
)
|
||||||
|
|
||||||
super.init()
|
super.init()
|
||||||
|
|
||||||
@ -199,6 +213,18 @@ final class MenuBarController: NSObject {
|
|||||||
agentLLMSubmenu.addItem(agentLLMEnabledItem)
|
agentLLMSubmenu.addItem(agentLLMEnabledItem)
|
||||||
updateAgentLLMMenuState()
|
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(
|
let agentSettingsItem = NSMenuItem(
|
||||||
title: "Settings...",
|
title: "Settings...",
|
||||||
action: #selector(openAgentSettings),
|
action: #selector(openAgentSettings),
|
||||||
@ -215,6 +241,26 @@ final class MenuBarController: NSObject {
|
|||||||
|
|
||||||
menu.addItem(.separator())
|
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(
|
let quitItem = NSMenuItem(
|
||||||
title: "Quit",
|
title: "Quit",
|
||||||
@ -328,23 +374,61 @@ final class MenuBarController: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc private func openInputSettings() {
|
@objc private func openInputSettings() {
|
||||||
inputSettingsWindow.makeKeyAndOrderFront(nil)
|
showWindowWithDock(inputSettingsWindow)
|
||||||
NSApplication.shared.activate(ignoringOtherApps: true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func openAssistSettings() {
|
@objc private func openAssistSettings() {
|
||||||
assistSettingsWindow.makeKeyAndOrderFront(nil)
|
showWindowWithDock(assistSettingsWindow)
|
||||||
NSApplication.shared.activate(ignoringOtherApps: true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func openAgentSettings() {
|
@objc private func openAgentSettings() {
|
||||||
print("[MenuBar] openAgentSettings called")
|
print("[MenuBar] openAgentSettings called")
|
||||||
agentSettingsWindow.refreshAgentModelTable()
|
agentSettingsWindow.refreshAgentModelTable()
|
||||||
agentSettingsWindow.makeKeyAndOrderFront(nil)
|
showWindowWithDock(agentSettingsWindow)
|
||||||
NSApplication.shared.activate(ignoringOtherApps: true)
|
}
|
||||||
|
|
||||||
|
@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() {
|
@objc private func quit() {
|
||||||
NSApplication.shared.terminate(nil)
|
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 toolStart(tool: String, display: String)
|
||||||
case toolEnd(tool: String, resultLines: [String])
|
case toolEnd(tool: String, resultLines: [String])
|
||||||
case assistantChunk(content: String)
|
case assistantChunk(content: String)
|
||||||
case done(content: String, elapsedMs: Int)
|
case done(content: String, elapsedMs: Int, totalTokens: Int)
|
||||||
case error(message: String)
|
case error(message: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -125,8 +125,8 @@ final class AgentBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 发送对话请求(第一轮)
|
/// 发送对话请求(第一轮)
|
||||||
func send(message: String, workspace: String, allowMode: String = "full_access", modelKey: String? = nil) {
|
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)
|
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)
|
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] = [
|
var payload: [String: Any] = [
|
||||||
"action": "chat",
|
"action": "chat",
|
||||||
"message": message,
|
"message": message,
|
||||||
@ -143,6 +143,7 @@ final class AgentBridge {
|
|||||||
if !allowMode.isEmpty { payload["allowMode"] = allowMode }
|
if !allowMode.isEmpty { payload["allowMode"] = allowMode }
|
||||||
if let mk = modelKey, !mk.isEmpty { payload["modelKey"] = mk }
|
if let mk = modelKey, !mk.isEmpty { payload["modelKey"] = mk }
|
||||||
payload["thinkingMode"] = Config.shared.agentThinkingMode
|
payload["thinkingMode"] = Config.shared.agentThinkingMode
|
||||||
|
if newConversation { payload["new_conversation"] = true }
|
||||||
|
|
||||||
guard let data = try? JSONSerialization.data(withJSONObject: payload),
|
guard let data = try? JSONSerialization.data(withJSONObject: payload),
|
||||||
let json = String(data: data, encoding: .utf8) else {
|
let json = String(data: data, encoding: .utf8) else {
|
||||||
@ -238,8 +239,9 @@ final class AgentBridge {
|
|||||||
case "done":
|
case "done":
|
||||||
let content = json["content"] as? String ?? ""
|
let content = json["content"] as? String ?? ""
|
||||||
let elapsed = json["elapsed_ms"] as? Int ?? 0
|
let elapsed = json["elapsed_ms"] as? Int ?? 0
|
||||||
log(" → done contentLen=\(content.count) elapsedMs=\(elapsed)")
|
let totalTokens = json["total_tokens"] as? Int ?? 0
|
||||||
onEvent?(.done(content: content, elapsedMs: elapsed))
|
log(" → done contentLen=\(content.count) elapsedMs=\(elapsed) totalTokens=\(totalTokens)")
|
||||||
|
onEvent?(.done(content: content, elapsedMs: elapsed, totalTokens: totalTokens))
|
||||||
case "error":
|
case "error":
|
||||||
let message = json["message"] as? String ?? "未知错误"
|
let message = json["message"] as? String ?? "未知错误"
|
||||||
log(" → error message=\(message)")
|
log(" → error message=\(message)")
|
||||||
|
|||||||
@ -28,7 +28,7 @@ struct AgentNotchView: View {
|
|||||||
thinkingView
|
thinkingView
|
||||||
}
|
}
|
||||||
|
|
||||||
if !tracker.toolEntries.isEmpty {
|
if !tracker.toolEntries.isEmpty && tracker.toolsMode != "never" {
|
||||||
toolProgressList
|
toolProgressList
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -51,6 +51,12 @@ struct AgentNotchView: View {
|
|||||||
.onChange(of: tracker.toolEntries.count) { _, _ in onContentSizeChanged?() }
|
.onChange(of: tracker.toolEntries.count) { _, _ in onContentSizeChanged?() }
|
||||||
.onChange(of: tracker.isTextInputMode) { _, _ in onContentSizeChanged?() }
|
.onChange(of: tracker.isTextInputMode) { _, _ in onContentSizeChanged?() }
|
||||||
.onChange(of: tracker.streamingContent.isEmpty) { _, _ 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: - 状态头
|
// MARK: - 状态头
|
||||||
@ -81,18 +87,26 @@ struct AgentNotchView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
|
|
||||||
if tracker.mode != "thinking" && tracker.mode != "running",
|
if tracker.mode != "thinking" && tracker.mode != "running" {
|
||||||
let onNew = onNewConversation {
|
if tracker.newConversationMode == "auto" {
|
||||||
Button(action: onNew) {
|
if tracker.autoNewTriggered {
|
||||||
Image(systemName: "plus.bubble")
|
Text("已自动新建对话")
|
||||||
.font(.system(size: 9, weight: .medium))
|
.font(.system(size: 9))
|
||||||
.foregroundColor(.white.opacity(0.5))
|
.foregroundColor(.white.opacity(0.4))
|
||||||
.frame(width: 20, height: 20)
|
}
|
||||||
.contentShape(Rectangle())
|
} else if let onNew = onNewConversation {
|
||||||
|
// 手动模式(或未知模式默认手动):显示新建按钮
|
||||||
|
Button(action: onNew) {
|
||||||
|
Image(systemName: "plus.bubble")
|
||||||
|
.font(.system(size: 9, weight: .medium))
|
||||||
|
.foregroundColor(.white.opacity(0.5))
|
||||||
|
.frame(width: 20, height: 20)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help("新建对话")
|
||||||
|
.padding(.leading, 2)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
.help("新建对话")
|
|
||||||
.padding(.leading, 2)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Button(action: onClose) {
|
Button(action: onClose) {
|
||||||
|
|||||||
@ -17,6 +17,12 @@ final class AgentProgressTracker: ObservableObject {
|
|||||||
@Published var errorMessage: String = ""
|
@Published var errorMessage: String = ""
|
||||||
@Published var streamingContent: String = "" // 实时流式输出
|
@Published var streamingContent: String = "" // 实时流式输出
|
||||||
@Published var isTextInputMode: Bool = false // 打字输入模式标记(外部控制)
|
@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 timer: Timer?
|
||||||
private var startTime: Date = Date()
|
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"
|
mode = "done"
|
||||||
finalContent = content
|
finalContent = content
|
||||||
|
self.totalTokens = totalTokens
|
||||||
|
if toolsMode == "hide_final" {
|
||||||
|
toolEntries = []
|
||||||
|
}
|
||||||
|
// 自动模式:total > 阈值时标记自动新建
|
||||||
|
if newConversationMode == "auto" && totalTokens > Config.shared.agentAutoNewTokenThreshold {
|
||||||
|
autoNewTriggered = true
|
||||||
|
}
|
||||||
timer?.invalidate()
|
timer?.invalidate()
|
||||||
timer = nil
|
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 模式专属:朗读开关
|
// Agent 模式专属:朗读开关
|
||||||
private let agentSpeakCheckbox: NSButton
|
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 模式专属
|
// Agent 模式专属
|
||||||
private let workspaceField: NSTextField
|
private let workspaceField: NSTextField
|
||||||
private let browseButton: NSButton
|
private let browseButton: NSButton
|
||||||
@ -51,6 +68,13 @@ final class SettingsWindow: NSWindow {
|
|||||||
outputModePopup = NSPopUpButton(frame: .zero, pullsDown: false)
|
outputModePopup = NSPopUpButton(frame: .zero, pullsDown: false)
|
||||||
speakCheckbox = NSButton(checkboxWithTitle: "朗读 AI 回复", target: nil, action: nil)
|
speakCheckbox = NSButton(checkboxWithTitle: "朗读 AI 回复", target: nil, action: nil)
|
||||||
agentSpeakCheckbox = 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)
|
workspaceField = NSTextField(frame: .zero)
|
||||||
browseButton = NSButton(title: "浏览...", target: nil, action: nil)
|
browseButton = NSButton(title: "浏览...", target: nil, action: nil)
|
||||||
modelPopup = NSPopUpButton(frame: .zero, pullsDown: false)
|
modelPopup = NSPopUpButton(frame: .zero, pullsDown: false)
|
||||||
@ -63,7 +87,7 @@ final class SettingsWindow: NSWindow {
|
|||||||
editModelButton = NSButton(title: "编辑", target: nil, action: nil)
|
editModelButton = NSButton(title: "编辑", target: nil, action: nil)
|
||||||
deleteModelButton = 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 width: CGFloat = mode == .agent ? 500 : 420
|
||||||
let windowRect = NSRect(x: 0, y: 0, width: width, height: height)
|
let windowRect = NSRect(x: 0, y: 0, width: width, height: height)
|
||||||
super.init(
|
super.init(
|
||||||
@ -364,6 +388,7 @@ final class SettingsWindow: NSWindow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func refreshAgentModelTable() {
|
func refreshAgentModelTable() {
|
||||||
|
Config.shared.syncUserProfile()
|
||||||
buildAgentUI()
|
buildAgentUI()
|
||||||
let modelsFile = Config.shared.loadAgentModels()
|
let modelsFile = Config.shared.loadAgentModels()
|
||||||
print("[SettingsWindow] loaded models count: \(modelsFile.models.count)")
|
print("[SettingsWindow] loaded models count: \(modelsFile.models.count)")
|
||||||
@ -386,6 +411,19 @@ final class SettingsWindow: NSWindow {
|
|||||||
tavilyKeyField.stringValue = modelsFile.tavilyApiKey
|
tavilyKeyField.stringValue = modelsFile.tavilyApiKey
|
||||||
workspaceField.stringValue = Config.shared.agentWorkspace
|
workspaceField.stringValue = Config.shared.agentWorkspace
|
||||||
agentSpeakCheckbox.state = Config.shared.agentSpeakEnabled ? .on : .off
|
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() {
|
private func buildAgentUI() {
|
||||||
@ -419,11 +457,65 @@ final class SettingsWindow: NSWindow {
|
|||||||
thinkingModePopup.target = self
|
thinkingModePopup.target = self
|
||||||
thinkingModePopup.action = #selector(agentThinkingModeChanged)
|
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.translatesAutoresizingMaskIntoConstraints = false
|
||||||
agentSpeakCheckbox.state = Config.shared.agentSpeakEnabled ? .on : .off
|
agentSpeakCheckbox.state = Config.shared.agentSpeakEnabled ? .on : .off
|
||||||
agentSpeakCheckbox.target = self
|
agentSpeakCheckbox.target = self
|
||||||
agentSpeakCheckbox.action = #selector(agentSpeakToggled)
|
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:")
|
let tavilyLabel = makeLabel("Tavily Key:")
|
||||||
tavilyKeyField.placeholderString = "tvly-...(网络搜索用)"
|
tavilyKeyField.placeholderString = "tvly-...(网络搜索用)"
|
||||||
tavilyKeyField.font = .systemFont(ofSize: 13)
|
tavilyKeyField.font = .systemFont(ofSize: 13)
|
||||||
@ -515,7 +607,20 @@ final class SettingsWindow: NSWindow {
|
|||||||
content.addSubview(inputModePopup)
|
content.addSubview(inputModePopup)
|
||||||
content.addSubview(thinkLabel)
|
content.addSubview(thinkLabel)
|
||||||
content.addSubview(thinkingModePopup)
|
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(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(tavilyLabel)
|
||||||
content.addSubview(tavilyKeyField)
|
content.addSubview(tavilyKeyField)
|
||||||
content.addSubview(wsLabel)
|
content.addSubview(wsLabel)
|
||||||
@ -557,11 +662,59 @@ final class SettingsWindow: NSWindow {
|
|||||||
thinkingModePopup.centerYAnchor.constraint(equalTo: thinkLabel.centerYAnchor),
|
thinkingModePopup.centerYAnchor.constraint(equalTo: thinkLabel.centerYAnchor),
|
||||||
thinkingModePopup.widthAnchor.constraint(equalToConstant: 200),
|
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.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.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),
|
tavilyLabel.widthAnchor.constraint(equalToConstant: 85),
|
||||||
tavilyKeyField.leadingAnchor.constraint(equalTo: tavilyLabel.trailingAnchor, constant: 8),
|
tavilyKeyField.leadingAnchor.constraint(equalTo: tavilyLabel.trailingAnchor, constant: 8),
|
||||||
tavilyKeyField.centerYAnchor.constraint(equalTo: tavilyLabel.centerYAnchor),
|
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() {
|
@objc private func saveAgentConfig() {
|
||||||
var modelsFile = Config.shared.loadAgentModels()
|
var modelsFile = Config.shared.loadAgentModels()
|
||||||
modelsFile.tavilyApiKey = tavilyKeyField.stringValue.trimmingCharacters(in: .whitespaces)
|
modelsFile.tavilyApiKey = tavilyKeyField.stringValue.trimmingCharacters(in: .whitespaces)
|
||||||
@ -644,6 +807,27 @@ final class SettingsWindow: NSWindow {
|
|||||||
modelsFile.models = agentModelList
|
modelsFile.models = agentModelList
|
||||||
Config.shared.saveAgentModels(modelsFile)
|
Config.shared.saveAgentModels(modelsFile)
|
||||||
Config.shared.agentWorkspace = workspaceField.stringValue
|
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()
|
refreshAgentModelTable()
|
||||||
statusLabel.stringValue = "✅ Agent 配置已保存"
|
statusLabel.stringValue = "✅ Agent 配置已保存"
|
||||||
statusLabel.textColor = .systemGreen
|
statusLabel.textColor = .systemGreen
|
||||||
|
|||||||
@ -306,7 +306,69 @@ final class Config {
|
|||||||
set { defaults.set(newValue, forKey: "agent_thinking_mode") }
|
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/
|
/// Agent 目录路径:优先查找 bundle Resources,其次逐级往上搜索包含 models.json 的 Agent/
|
||||||
var agentDir: String {
|
var agentDir: String {
|
||||||
@ -359,6 +421,11 @@ final class Config {
|
|||||||
userDataDir + "/models.json"
|
userDataDir + "/models.json"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 用户配置文件路径(助手名字/语气)
|
||||||
|
var userProfilePath: String {
|
||||||
|
userDataDir + "/user_profile.json"
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - models.json 读写
|
// MARK: - models.json 读写
|
||||||
|
|
||||||
func loadAgentModels() -> AgentModelsFile {
|
func loadAgentModels() -> AgentModelsFile {
|
||||||
@ -398,6 +465,35 @@ final class Config {
|
|||||||
try? data.write(to: URL(fileURLWithPath: path), options: .atomic)
|
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] {
|
var validAgentModels: [AgentModelConfig] {
|
||||||
loadAgentModels().models.filter { m in
|
loadAgentModels().models.filter { m in
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user